diff --git a/.github/workflows/manual-publish-rotom-ng.yml b/.github/workflows/manual-publish-rotom-ng.yml index bfd0375..0158ecf 100644 --- a/.github/workflows/manual-publish-rotom-ng.yml +++ b/.github/workflows/manual-publish-rotom-ng.yml @@ -1,6 +1,6 @@ name: manual-publish-rotom-ng -# Pushes a branch-tagged image for whichever ref this is dispatched on. It runs +# Pushes branch-tagged images for whichever ref this is dispatched on. It runs # the same test + lint gate as the push workflow, so this cannot ship an image # that would have failed CI. on: @@ -15,13 +15,19 @@ jobs: build: needs: [checks] - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.platform.os }} permissions: contents: read packages: write strategy: matrix: - include: + # Both images come from the root Dockerfile, one target apiece, so + # they are matrixed rather than duplicated into a second job. The name + # doubles as the build target and the GHCR repo. 2 x 2 jobs. + image: [rotom-ng, rotom-ng-ui] + # os and arch travel together, so they are one matrix entry rather than + # two dimensions that would cross-product into mismatched pairs. + platform: - os: ubuntu-latest arch: linux/amd64 - os: ubuntu-24.04-arm @@ -31,11 +37,11 @@ jobs: uses: actions/checkout@v6 - name: Prepare run: | - platform=${{ matrix.arch }} + platform=${{ matrix.platform.arch }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: downcase GHCR_REPO run: | - echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/rotom-ng" >>${GITHUB_ENV} + echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/${{ matrix.image }}" >>${GITHUB_ENV} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 @@ -54,8 +60,9 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: apps/rotom-ng/Dockerfile - platforms: ${{ matrix.arch }} + file: Dockerfile + target: ${{ matrix.image }} + platforms: ${{ matrix.platform.arch }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true - name: Export digest @@ -66,7 +73,10 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v4 with: - name: digests-${{ env.PLATFORM_PAIR }} + # Namespaced by image: the merge job below collects one image's + # digests at a time, and an unqualified name would mix them and + # produce a manifest listing the wrong architectures. + name: digests-${{ matrix.image }}-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 @@ -77,6 +87,9 @@ jobs: permissions: contents: read packages: write + strategy: + matrix: + image: [rotom-ng, rotom-ng-ui] steps: - name: Checkout repository uses: actions/checkout@v6 @@ -84,7 +97,7 @@ jobs: uses: actions/download-artifact@v4 with: path: ${{ runner.temp }}/digests - pattern: digests-* + pattern: digests-${{ matrix.image }}-* merge-multiple: true - name: Log in to the Container registry uses: docker/login-action@v3 @@ -96,7 +109,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: downcase GHCR_REPO run: | - echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/rotom-ng" >>${GITHUB_ENV} + echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/${{ matrix.image }}" >>${GITHUB_ENV} - name: Docker meta id: meta uses: docker/metadata-action@v5 diff --git a/.github/workflows/publish-rotom-ng.yml b/.github/workflows/publish-rotom-ng.yml index da75184..971bb09 100644 --- a/.github/workflows/publish-rotom-ng.yml +++ b/.github/workflows/publish-rotom-ng.yml @@ -17,13 +17,19 @@ jobs: build: needs: [checks] - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.platform.os }} permissions: contents: read packages: write strategy: matrix: - include: + # Both images come from the root Dockerfile, one target apiece, so + # they are matrixed rather than duplicated into a second job. The name + # doubles as the build target and the GHCR repo. 2 x 2 jobs. + image: [rotom-ng, rotom-ng-ui] + # os and arch travel together, so they are one matrix entry rather than + # two dimensions that would cross-product into mismatched pairs. + platform: - os: ubuntu-latest arch: linux/amd64 - os: ubuntu-24.04-arm @@ -33,11 +39,11 @@ jobs: uses: actions/checkout@v6 - name: Prepare run: | - platform=${{ matrix.arch }} + platform=${{ matrix.platform.arch }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: downcase GHCR_REPO run: | - echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/rotom-ng" >>${GITHUB_ENV} + echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/${{ matrix.image }}" >>${GITHUB_ENV} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 @@ -56,8 +62,9 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: apps/rotom-ng/Dockerfile - platforms: ${{ matrix.arch }} + file: Dockerfile + target: ${{ matrix.image }} + platforms: ${{ matrix.platform.arch }} labels: ${{ steps.meta.outputs.labels }} outputs: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testing') && format('type=image,"name={0}",push-by-digest=true,name-canonical=true,push=true', env.GHCR_REPO) || 'type=docker' }} - name: Export digest @@ -70,7 +77,10 @@ jobs: if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testing' uses: actions/upload-artifact@v4 with: - name: digests-${{ env.PLATFORM_PAIR }} + # Namespaced by image: the merge job below collects one image's + # digests at a time, and an unqualified name would mix them and + # produce a manifest listing the wrong architectures. + name: digests-${{ matrix.image }}-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 @@ -82,6 +92,9 @@ jobs: permissions: contents: read packages: write + strategy: + matrix: + image: [rotom-ng, rotom-ng-ui] steps: - name: Checkout repository uses: actions/checkout@v6 @@ -89,7 +102,7 @@ jobs: uses: actions/download-artifact@v4 with: path: ${{ runner.temp }}/digests - pattern: digests-* + pattern: digests-${{ matrix.image }}-* merge-multiple: true - name: Log in to the Container registry uses: docker/login-action@v3 @@ -101,7 +114,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: downcase GHCR_REPO run: | - echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/rotom-ng" >>${GITHUB_ENV} + echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/${{ matrix.image }}" >>${GITHUB_ENV} # Branch tag only. Version tags (and releases) are published by release.yml, # which is manual. - name: Docker meta diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd75f52..7d61f5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,13 +86,20 @@ jobs: build: needs: [prepare] - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.platform.os }} permissions: contents: read packages: write strategy: matrix: - include: + # Both images come from the root Dockerfile, one target apiece, at the + # same version, so they are matrixed rather than duplicated into a + # second job. The name doubles as the build target, the GHCR repo, and + # the binary inside the image. 2 x 2 jobs. + image: [rotom-ng, rotom-ng-ui] + # os and arch travel together, so they are one matrix entry rather than + # two dimensions that would cross-product into mismatched pairs. + platform: - os: ubuntu-latest arch: linux/amd64 pair: linux-amd64 @@ -104,7 +111,7 @@ jobs: uses: actions/checkout@v6 - name: downcase GHCR_REPO run: | - echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/rotom-ng" >>${GITHUB_ENV} + echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/${{ matrix.image }}" >>${GITHUB_ENV} - name: Log in to the Container registry uses: docker/login-action@v3 with: @@ -125,8 +132,9 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: apps/rotom-ng/Dockerfile - platforms: ${{ matrix.arch }} + file: Dockerfile + target: ${{ matrix.image }} + platforms: ${{ matrix.platform.arch }} labels: ${{ steps.meta.outputs.labels }} # Attestations can carry build-path metadata; keep the image to just the binary. provenance: false @@ -140,7 +148,10 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v4 with: - name: digests-${{ matrix.pair }} + # Namespaced by image: merge-image collects one image's digests at a + # time, and an unqualified name would mix them into a manifest + # listing the wrong architectures. + name: digests-${{ matrix.image }}-${{ matrix.platform.pair }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 @@ -148,16 +159,19 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: apps/rotom-ng/Dockerfile - platforms: ${{ matrix.arch }} + file: Dockerfile + target: ${{ matrix.image }} + platforms: ${{ matrix.platform.arch }} provenance: false sbom: false outputs: type=local,dest=${{ runner.temp }}/image-out - name: Upload binary uses: actions/upload-artifact@v4 with: - name: rotom-ng-binary-${{ matrix.pair }} - path: ${{ runner.temp }}/image-out/rotom-ng/rotom-ng + # Each target's WORKDIR is named after its binary, so the path + # inside the exported image is /. + name: binary-${{ matrix.image }}-${{ matrix.platform.pair }} + path: ${{ runner.temp }}/image-out/${{ matrix.image }}/${{ matrix.image }} if-no-files-found: error retention-days: 1 @@ -167,12 +181,15 @@ jobs: permissions: contents: read packages: write + strategy: + matrix: + image: [rotom-ng, rotom-ng-ui] steps: - name: Download digests uses: actions/download-artifact@v4 with: path: ${{ runner.temp }}/digests - pattern: digests-* + pattern: digests-${{ matrix.image }}-* merge-multiple: true - name: Log in to the Container registry uses: docker/login-action@v3 @@ -184,7 +201,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: downcase GHCR_REPO run: | - echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/rotom-ng" >>${GITHUB_ENV} + echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/${{ matrix.image }}" >>${GITHUB_ENV} - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -218,7 +235,7 @@ jobs: uses: actions/download-artifact@v4 with: path: ${{ runner.temp }}/binaries - pattern: rotom-ng-binary-* + pattern: binary-* - name: Build tarballs run: | set -eu @@ -227,7 +244,12 @@ jobs: dir="${{ runner.temp }}/stage/rotom-ng-v${VERSION}" rm -rf "${{ runner.temp }}/stage" mkdir -p "$dir" - install -m 755 "${{ runner.temp }}/binaries/rotom-ng-binary-${pair}/rotom-ng" "$dir/rotom-ng" + # Both binaries ship in the one tarball: they are the same version + # built from the same tree, and configs/ already carries an example + # for each, so splitting them would duplicate everything else. + for bin in rotom-ng rotom-ng-ui; do + install -m 755 "${{ runner.temp }}/binaries/binary-${bin}-${pair}/${bin}" "$dir/${bin}" + done # A fresh checkout contains tracked files only, so this cannot pick up # local work-in-progress. logs/ is created empty rather than copied so # its .KEEP placeholder does not ship. @@ -261,13 +283,18 @@ jobs: if [ '${{ needs.prepare.outputs.prerelease }}' = 'true' ]; then prerelease_flag='--prerelease' fi - image="${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}/rotom-ng:v${VERSION}" + repo="${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}" # Built with printf so no leading indentation leaks in and turns the # notes into a markdown code block. notes=$(printf '%s\n' \ "RotomNG ${VERSION}" \ "" \ - "Container image: \`${image}\`") + "Container images:" \ + "" \ + "- \`${repo}/rotom-ng:v${VERSION}\`" \ + "- \`${repo}/rotom-ng-ui:v${VERSION}\` -- multi-instance admin UI, optional" \ + "" \ + "The tarballs carry both binaries.") # --target takes the exact commit this ran on, so the release does not # drift if the branch moves on afterwards. diff --git a/.gitignore b/.gitignore index fd088f4..7ac0f94 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,9 @@ /tmp /out-tsc /rotom-ng +/rotom-ng-ui /apps/rotom-ng/rotom-ng +/apps/rotom-ng-ui-server/rotom-ng-ui # dependencies node_modules diff --git a/CLAUDE.md b/CLAUDE.md index e49bbea..32ab261 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,19 @@ RotomNG is a distributed MITM proxy connection manager. Monorepo with a Go backend (`apps/rotom-ng/`) and React/TypeScript frontend (`apps/rotom-ng-ui/`), sharing libraries in `libs/`. +A second Go service, `apps/rotom-ng-ui-server/`, builds the `rotom-ng-ui` binary: it embeds the **same** UI bundle and reverse-proxies to several rotom-ng instances, acting as a multi-instance admin panel. Note the name collision -- `apps/rotom-ng-ui/` is the React app, `apps/rotom-ng-ui-server/` is the Go service whose binary is named after it. See `docs/RotomNG-UI-Server.md`. + +There is one UI, not two. It detects its mode at runtime from `/api/config`: the admin service always returns an `instances` key (empty list included), a plain rotom-ng never does. Anything the UI gates on config must read `useActiveConfig()` (the selected instance's config), not `useConfig()` (the server's own). + ## Build Commands ```bash -make rotom-ng # Build UI + Go binary (UI must build first) -make rotom-ng-ui # Build React UI only (bun install && bun run build) -make docker # Build Docker image +make # Build both binaries +make rotom-ng # Build UI + rotom-ng binary (UI must build first) +make rotom-ng-ui # Build UI + rotom-ng-ui admin binary +make ui # Build React UI only (bun install && bun run build) +make docker # Build rotom-ng Docker image (Dockerfile --target rotom-ng) +make docker-ui # Build rotom-ng-ui Docker image (--target rotom-ng-ui) make clean # Remove build artifacts and generated files ``` @@ -17,6 +24,7 @@ make clean # Remove build artifacts and generated files - **Go 1.26+**, module path: `github.com/UnownHash/RotomNG` - **Code generation required before build/test**: `go generate ./apps/rotom-ng/... ./libs/...` + - `apps/rotom-ng-ui-server/` has no generate step of its own; it imports rotom-ng's `version` package - Generates protobuf code (`libs/protos/rotom.pb.go`) and version info - Requires `protoc` and `protoc-gen-go` installed - **Test**: `go test -race ./...` @@ -68,12 +76,25 @@ Key linter settings to keep in mind when writing Go code: - **Shared components**: `libs/base-ui/` (Radix UI, Tailwind CSS v4, Lucide icons) - **Path aliases**: `@rotom-ng/base-ui` -> `libs/base-ui/src/index.ts`, `@/*` -> lib src root - **Dev server**: `bun run dev` (port 4201, proxies `/api` to `localhost:7072`) + - `bun run dev:mock` runs against MSW; `?mock=medium,multi` serves the admin service's config so multi-instance mode can be worked on without one, and `?mock=medium,multi,live` flaps an instance to exercise the unreachable states - **Build output**: `libs/rotom_ui/static/` (embedded into Go binary) ## CI Pipeline Every push runs: test (Go) -> lint (golangci-lint) -> build (multi-arch Docker). All must pass. +Both images -- `rotom-ng` and `rotom-ng-ui` -- are built on every push and +published together from the same commit, so a given tag means the same version +of both. The publish, manual-publish, and release workflows each matrix over +image x platform, so adding a third image means one more matrix entry rather +than a new job. + +Both come from the single root `Dockerfile`, one build target apiece, sharing +the bun and Go builder stages. The matrix entry name is the build target, the +GHCR repo, and the binary name all at once, so a new image means adding a +target and listing it. `rotom-ng` is the last stage and therefore the default +target for a bare `docker build .`. + ## Generated Files (do not edit manually) - `libs/protos/rotom.pb.go` -- generated from `libs/protos/rotom.proto` diff --git a/apps/rotom-ng/Dockerfile b/Dockerfile similarity index 51% rename from apps/rotom-ng/Dockerfile rename to Dockerfile index 3970701..95c938b 100644 --- a/apps/rotom-ng/Dockerfile +++ b/Dockerfile @@ -1,3 +1,17 @@ +# Builds both RotomNG binaries. Select one with --target: +# +# docker build --target rotom-ng -t rotom-ng . +# docker build --target rotom-ng-ui -t rotom-ng-ui . +# +# The two images shared all but ten lines when they lived in separate files -- +# the whole bun stage and the entire Go module setup -- so they are one file +# with a target apiece. BuildKit only builds the stages a target depends on, so +# asking for one does not build the other. +# +# rotom-ng is the last stage, and therefore what a bare "docker build ." with no +# --target produces: the connection manager is the primary service, and the +# admin UI is optional. + # Build stage for Node.js frontend FROM oven/bun:1-alpine AS node-builder @@ -27,8 +41,9 @@ RUN if [ "$DEV_MODE" = "true" ]; then \ NODE_ENV=production bun run build; \ fi -# Build stage for Go application -FROM golang:1.26-alpine AS go-builder +# Everything both Go binaries need. Split from the build stages below so that +# building one target does not compile the other. +FROM golang:1.26-alpine AS go-base WORKDIR /app @@ -41,20 +56,29 @@ COPY vendo[r] vendor RUN if [ ! -f vendor/modules.txt ]; then rm -rf vendor && go mod download ; fi COPY libs libs COPY --from=node-builder /app/libs/rotom_ui/static libs/rotom_ui/static +# apps/rotom-ng is needed by both: the admin server imports its version package. COPY apps/rotom-ng apps/rotom-ng COPY .git .git + +FROM go-base AS build-rotom-ng RUN CGO_ENABLED=0 go build -a -ldflags='-s -w' -o rotom-ng ./apps/rotom-ng -# Final runtime stage -FROM alpine +FROM go-base AS build-rotom-ng-ui +COPY apps/rotom-ng-ui-server apps/rotom-ng-ui-server +RUN CGO_ENABLED=0 go build -a -ldflags='-s -w' -o rotom-ng-ui ./apps/rotom-ng-ui-server -# Install ca-certificates for HTTPS requests +# Common runtime. ca-certificates is needed for HTTPS requests. +FROM alpine AS runtime-base RUN apk --no-cache add ca-certificates -WORKDIR /rotom-ng +# The multi-instance admin UI. Optional; see docs/RotomNG-UI-Server.md. +FROM runtime-base AS rotom-ng-ui +WORKDIR /rotom-ng-ui +COPY --from=build-rotom-ng-ui /app/rotom-ng-ui . +CMD ["./rotom-ng-ui"] -# Copy the Go binary from go-builder stage -COPY --from=go-builder /app/rotom-ng . - -# Run the binary +# The connection manager. Last, so it is also the default target. +FROM runtime-base AS rotom-ng +WORKDIR /rotom-ng +COPY --from=build-rotom-ng /app/rotom-ng . CMD ["./rotom-ng"] diff --git a/Makefile b/Makefile index 38b1a8c..efa4120 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -ALL=rotom-ng +ALL=rotom-ng rotom-ng-ui all: $(ALL) @@ -9,17 +9,29 @@ deps: generate-protos: @go generate ./libs/protos/... -rotom-ng: rotom-ng-ui deps +# The React bundle, shared by both binaries: each embeds libs/rotom_ui/static, +# and the UI decides at runtime which service it is talking to. +ui: + @bun install && bun run build + +rotom-ng: ui deps @go generate ./apps/rotom-ng/... @CGO_ENABLED=0 go build -ldflags="-s -w" -o rotom-ng ./apps/rotom-ng @echo rotom-ng has been built. -rotom-ng-ui: - @bun install && bun run build +# The admin UI server: serves the same UI for several rotom-ng instances and +# proxies to them. Shares rotom-ng's version package, hence the same generate. +rotom-ng-ui: ui deps + @go generate ./apps/rotom-ng/... + @CGO_ENABLED=0 go build -ldflags="-s -w" -o rotom-ng-ui ./apps/rotom-ng-ui-server + @echo rotom-ng-ui has been built. docker: - @docker build -f apps/rotom-ng/Dockerfile -t rotom-ng:latest . + @docker build --target rotom-ng -t rotom-ng:latest . + +docker-ui: + @docker build --target rotom-ng-ui -t rotom-ng-ui:latest . clean: - @rm -rf rotom-ng libs/rotom_ui/static + @rm -rf rotom-ng rotom-ng-ui libs/rotom_ui/static @rm -rf .nx node_modules libs/base-ui/node_modules apps/rotom-ng-ui/src/version.ts diff --git a/README.md b/README.md index 0103d66..a5d015d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,13 @@ RotomNG is a service that connects applications to MITM workers. +A single `rotom-ng` is the whole product: it serves its own web UI alongside its +API, and most deployments need nothing else. If you run several, there is an +optional second service that puts one UI in front of all of them. + ## Documentation - [Getting Started](docs/RotomNG-Starting.md) - Configuration and setup guide +- [Multi-instance admin UI](docs/RotomNG-UI-Server.md) - **Optional.** One web UI for several rotom-ng instances; skip it if you run one - [HTTP API Reference](docs/RotomNG-API.md) - REST API endpoints for managing devices, controllers, jobs, and system configuration - [Migration Guide (OG to NG)](docs/RotomNG-Vs-OG.md) - Differences between Rotom OG and Rotom NG for those upgrading diff --git a/apps/rotom-ng-mock-connector/compose.go b/apps/rotom-ng-mock-connector/compose.go index 96cdf46..3fd7c00 100644 --- a/apps/rotom-ng-mock-connector/compose.go +++ b/apps/rotom-ng-mock-connector/compose.go @@ -91,7 +91,8 @@ services: rotom-ng: build: context: ../.. - dockerfile: apps/rotom-ng/Dockerfile + dockerfile: Dockerfile + target: rotom-ng args: DEV_MODE: "true" container_name: rotom-ng-dev diff --git a/apps/rotom-ng-ui-server/app/app.go b/apps/rotom-ng-ui-server/app/app.go new file mode 100644 index 0000000..aea7a42 --- /dev/null +++ b/apps/rotom-ng-ui-server/app/app.go @@ -0,0 +1,319 @@ +// Package app implements the RotomNG admin UI server: one web UI fronting +// several rotom-ng instances, which it proxies to. +package app + +import ( + "context" + "embed" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/gin-gonic/gin" + + "github.com/UnownHash/RotomNG/libs/auth" + "github.com/UnownHash/RotomNG/libs/gitutil" + "github.com/UnownHash/RotomNG/libs/logging" + + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/config" + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/handlers" + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/httpserver" + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/instances" + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/proxy" + "github.com/UnownHash/RotomNG/apps/rotom-ng/app/version" +) + +var gitSHA = gitutil.GetGitBuildSHA() + +const ( + // The admin UI ships the same version as rotom-ng: it is built from the + // same UI sources and tracks the same API. + appVersion = version.AppVersion + userAgent = "RotomNG-UI/" + version.AppVersion +) + +func getInstanceSettings(cfg *config.Config) instances.Settings { + instanceConfigs := make([]instances.InstanceConfig, len(cfg.Instances)) + for idx, instance := range cfg.Instances { + instanceConfigs[idx] = instances.InstanceConfig{ + BaseURL: instance.BaseURL, + APISecret: instance.APISecret, + } + } + return instances.Settings{ + Instances: instanceConfigs, + Interval: cfg.InstanceMonitor.Interval, + Timeout: cfg.InstanceMonitor.Timeout, + } +} + +func getHTTPAPIHandlerSettings(cfg *config.Config) handlers.HTTPAPIHandlerSettings { + return handlers.HTTPAPIHandlerSettings{ + CurrentConfig: *cfg, + } +} + +// FlagConfig holds command-line flag configuration for the application. +type FlagConfig struct { + DebugMode bool + UIPath string + UIDev bool + UIFS *embed.FS + ReloadConfig func() (*config.Config, error) +} + +// App is the main admin application, managing the web server and the instance +// monitor. +type App struct { + cfg *config.Config + flagCfg FlagConfig + logger *slog.Logger + levelVar *slog.LevelVar + closer io.Closer + + shutdownTimeout atomic.Int64 // nanoseconds + + ctx context.Context + cancel context.CancelFunc + + instanceManager *instances.Manager + httpAPIHandlerConfig handlers.HTTPAPIHandlerConfig + httpAuthMiddleware *auth.Middleware + httpServer *httpserver.HTTPServer +} + +// NewApp creates a new App instance with the given configuration. +func NewApp(cfg *config.Config, flagCfg FlagConfig) (*App, error) { + logger, levelVar, closer, err := cfg.GetLogger() + if err != nil { + return nil, err + } + + return &App{ + cfg: cfg, + flagCfg: flagCfg, + logger: logger, + levelVar: levelVar, + closer: closer, + }, nil +} + +// Logger returns the application's logger instance. +func (a *App) Logger() *slog.Logger { + return a.logger +} + +// Cancel cancels the application context, triggering a graceful shutdown. +// This is intended for use by test utilities that need to stop the app +// from outside the app package. +func (a *App) Cancel() { + if a.cancel != nil { + a.cancel() + } +} + +// Init initializes all application dependencies and servers. +func (a *App) Init() error { + if a.flagCfg.DebugMode { + a.levelVar.Set(slog.LevelDebug) + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + hasEmbeddedUI := a.flagCfg.UIFS != nil + if !a.flagCfg.UIDev && (!hasEmbeddedUI || a.flagCfg.UIPath != "") { + indexPath := a.flagCfg.UIPath + "/index.html" + if _, err := os.Stat(indexPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("UI index.html file does not exist at path '%s' (ensure you built the UI or use -ui-path)", indexPath) + } + return fmt.Errorf("UI index.html file is not readable at path '%s' (ensure you built the UI or use -ui-path): %w", indexPath, err) + } + } + + a.logger.LogAttrs(context.Background(), slog.LevelInfo, "starting RotomNG UI", + slog.String("version", appVersion), + slog.String("git_sha", gitSHA), + slog.Int("instances", len(a.cfg.Instances)), + ) + + a.ctx, a.cancel = context.WithCancel(context.Background()) + a.setShutdownTimeout(a.cfg.ShutdownTimeout) + + var err error + a.instanceManager, err = instances.NewManager(instances.ManagerConfig{ + Logger: a.logger.With(slog.String("component", "instances")), + UserAgent: userAgent, + }, getInstanceSettings(a.cfg)) + if err != nil { + return fmt.Errorf("invalid instance settings: %w", err) + } + + a.httpAPIHandlerConfig = handlers.HTTPAPIHandlerConfig{ + Logger: a.logger.With(slog.String("component", "api")), + AppVersion: appVersion, + GitSHA: gitSHA, + Instances: a.instanceManager, + ReloadFn: a.reload, + } + if err := a.httpAPIHandlerConfig.Init(getHTTPAPIHandlerSettings(a.cfg)); err != nil { + return fmt.Errorf("invalid http api handler config: %w", err) + } + + apiProxy := proxy.New(proxy.Config{ + Logger: a.logger.With(slog.String("component", "proxy")), + Resolver: a.instanceManager, + UserAgent: userAgent, + }) + + a.httpAuthMiddleware = auth.NewMiddleware(a.cfg.HTTPListener.Secret) + a.httpAuthMiddleware.SetSessionTTL(a.cfg.HTTPListener.UISessionTTL) + + a.httpServer, err = httpserver.NewHTTPServer( + a.ctx, + a.logger.With(slog.String("component", "http_server")), + httpserver.Config{ + Address: a.cfg.HTTPListener.Address, + Listener: a.cfg.HTTPListener.Listener, + UIPath: a.flagCfg.UIPath, + UIFS: a.flagCfg.UIFS, + DevMode: a.flagCfg.UIDev, + AuthMiddleware: a.httpAuthMiddleware, + APIHandler: handlers.NewHTTPAPIHandler(a.httpAPIHandlerConfig), + ProxyHandler: apiProxy.Handler, + }, + ) + if err != nil { + return fmt.Errorf("failed to setup http server: %w", err) + } + + return nil +} + +// Run starts the application servers and blocks until shutdown. +func (a *App) Run() { + a.logger.LogAttrs(context.Background(), slog.LevelInfo, "Application startup complete") + + var wg sync.WaitGroup + + // reload handling + wg.Go(func() { + reloadChan := make(chan os.Signal, 1) + signal.Notify(reloadChan, syscall.SIGHUP) + for { + select { + case <-a.ctx.Done(): + return + case <-reloadChan: + } + + a.logger.LogAttrs(context.Background(), slog.LevelInfo, "config reload requested") + + if err := a.reload(); err != nil { + a.logger.LogAttrs(context.Background(), slog.LevelError, "failed to reload config", slog.String("error", err.Error())) + continue + } + + a.logger.LogAttrs(context.Background(), slog.LevelInfo, "config reloaded") + } + }) + + wg.Go(func() { + defer a.cancel() + a.httpServer.Run() + }) + + wg.Go(func() { + a.instanceManager.Run(a.ctx) + }) + + // Wait for interrupt + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + select { + case <-c: + a.cancel() + case <-a.ctx.Done(): + } + + a.shutdown(&wg) +} + +func (a *App) setShutdownTimeout(d time.Duration) { + a.shutdownTimeout.Store(int64(d)) +} + +func (a *App) getShutdownTimeout() time.Duration { + return time.Duration(a.shutdownTimeout.Load()) +} + +func (a *App) reload() error { + cfg, err := a.flagCfg.ReloadConfig() + if err != nil { + return err + } + + instanceSettings := getInstanceSettings(cfg) + if err := instanceSettings.Validate(); err != nil { + return err + } + + httpAPIHandlerSettings := getHTTPAPIHandlerSettings(cfg) + if err := httpAPIHandlerSettings.Validate(); err != nil { + return err + } + + // now apply the settings. + if err := a.httpAPIHandlerConfig.PutSettings(httpAPIHandlerSettings); err != nil { + a.logger.LogAttrs(context.Background(), slog.LevelError, "failed to apply settings", slog.String("component", "http_api_handler"), slog.String("error", err.Error())) + } + a.instanceManager.SetSettings(instanceSettings) + a.httpAuthMiddleware.SetSecret(cfg.HTTPListener.Secret) + a.httpAuthMiddleware.SetSessionTTL(cfg.HTTPListener.UISessionTTL) + + a.setShutdownTimeout(cfg.ShutdownTimeout) + + newLevel, err := logging.ParseSlogLevel(cfg.Logging.Level) + if err != nil { + a.logger.LogAttrs(context.Background(), slog.LevelError, "failed to set log level", slog.String("error", err.Error())) + } else { + a.levelVar.Set(newLevel) + } + + return nil +} + +func (a *App) shutdown(wg *sync.WaitGroup) { + a.logger.LogAttrs(context.Background(), slog.LevelInfo, "shutting down") + + shutdownTimeout := a.getShutdownTimeout() + + shutdownCh := make(chan struct{}) + go func() { + defer close(shutdownCh) + timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer timeoutCancel() + + a.httpServer.Shutdown(timeoutCtx) + wg.Wait() + }() + + select { + case <-shutdownCh: + a.logger.LogAttrs(context.Background(), slog.LevelInfo, "shutdown complete") + case <-time.After(shutdownTimeout + (100 * time.Millisecond)): + a.logger.LogAttrs(context.Background(), slog.LevelInfo, "shutdown timed out") + } + + if a.closer != nil { + _ = a.closer.Close() + } +} diff --git a/apps/rotom-ng-ui-server/app/app_test.go b/apps/rotom-ng-ui-server/app/app_test.go new file mode 100644 index 0000000..da1542b --- /dev/null +++ b/apps/rotom-ng-ui-server/app/app_test.go @@ -0,0 +1,549 @@ +package app_test + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/UnownHash/RotomNG/libs/logging" + + uiapp "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app" + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/config" +) + +// waitTimeout bounds the polls below. Conditions normally settle in a few +// milliseconds; this is only a safety net against a wedged test. +const waitTimeout = 10 * time.Second + +var testHTTPClient = &http.Client{ + Transport: &http.Transport{DisableKeepAlives: true}, +} + +// fakeInstance stands in for a rotom-ng server: it answers /api/config the way +// one does, and records what it is asked for. +type fakeInstance struct { + server *httptest.Server + // instanceName is reported as the config's `instance`; empty omits it. + instanceName string + // secret, when set, is required on requests -- as an instance with an api + // secret configured would require. + secret string + down atomic.Bool + // lastStatusSecret records the secret header seen on /api/status, the + // endpoint the tests proxy. + lastStatusSecret atomic.Pointer[string] +} + +func newFakeInstance(t *testing.T, name, secret string) *fakeInstance { + t.Helper() + instance := &fakeInstance{instanceName: name, secret: secret} + mux := http.NewServeMux() + mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { + if !instance.authorize(w, r) { + return + } + if instance.down.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + config := map[string]any{ + "version": "9.9.9", + "sha": "deadbeef", + "tuning": map[string]any{"profiling": false}, + "jobs": map[string]any{"enable": true, "path": "./jobs"}, + } + if instance.instanceName != "" { + config["instance"] = instance.instanceName + } + writeJSON(w, map[string]any{"status": "ok", "config": config}) + }) + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { + seen := r.Header.Get("X-Rotom-Secret") + instance.lastStatusSecret.Store(&seen) + if !instance.authorize(w, r) { + return + } + writeJSON(w, map[string]any{"devices": []any{}, "controllers": []any{}, "from": instance.instanceName}) + }) + instance.server = httptest.NewServer(mux) + t.Cleanup(instance.server.Close) + return instance +} + +func (f *fakeInstance) authorize(w http.ResponseWriter, r *http.Request) bool { + if f.secret != "" && r.Header.Get("X-Rotom-Secret") != f.secret { + w.WriteHeader(http.StatusUnauthorized) + return false + } + return true +} + +func (f *fakeInstance) url() string { return f.server.URL } + +func writeJSON(w http.ResponseWriter, body map[string]any) { + w.Header().Set("Content-Type", "application/json") + encoded, err := json.Marshal(body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = w.Write(encoded) +} + +// testConfig builds a valid admin config wired to ephemeral ports and a temp +// log file, so tests neither collide on ports nor spam the console. +func testConfig(t *testing.T, instances ...config.Instance) *config.Config { + t.Helper() + cfg := &config.Config{ + HTTPListener: &config.HTTPListener{Address: "127.0.0.1:0"}, + Instances: instances, + InstanceMonitor: &config.InstanceMonitor{ + Interval: 20 * time.Millisecond, + Timeout: 2 * time.Second, + }, + Logging: &logging.Config{ + Level: "debug", + NoConsoleLog: true, + File: &logging.FileConfig{Path: filepath.Join(t.TempDir(), "test.log")}, + }, + } + cfg.SetDefaults() + if err := cfg.Validate(); err != nil { + t.Fatalf("test config is invalid: %v", err) + } + return cfg +} + +// uiDir creates a stand-in for the built UI bundle. +func uiDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("rotom ui"), 0o600); err != nil { + t.Fatalf("write index.html: %v", err) + } + return dir +} + +// startApp starts the service on an ephemeral port and returns its address. +func startApp(t *testing.T, cfg *config.Config, reload func() (*config.Config, error)) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + cfg.HTTPListener.Listener = listener + cfg.HTTPListener.Address = listener.Addr().String() + + if reload == nil { + reload = func() (*config.Config, error) { return cfg, nil } + } + + app, err := uiapp.NewApp(cfg, uiapp.FlagConfig{ + UIPath: uiDir(t), + ReloadConfig: reload, + }) + if err != nil { + t.Fatalf("NewApp: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + app.Run() + }() + t.Cleanup(func() { + app.Cancel() + select { + case <-done: + case <-time.After(waitTimeout): + t.Error("app did not shut down") + } + }) + + addr := listener.Addr().String() + waitFor(t, "server to accept connections", func() bool { + response, err := testHTTPClient.Get("http://" + addr + "/api/config") + if err != nil { + return false + } + defer response.Body.Close() + return true + }) + return addr +} + +func waitFor(t *testing.T, what string, condition func() bool) { + t.Helper() + deadline := time.Now().Add(waitTimeout) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +// configReply mirrors the JSON this service returns from /api/config. It is +// declared here rather than imported so a change to the wire format shows up +// as a test failure. +type configReply struct { + Status string `json:"status"` + Config struct { + Version string `json:"version"` + SHA string `json:"sha"` + Instance string `json:"instance"` + Instances *[]struct { + Instance string `json:"instance"` + URL string `json:"url"` + Reachable bool `json:"reachable"` + Config json.RawMessage `json:"config"` + } `json:"instances"` + } `json:"config"` +} + +func getConfig(t *testing.T, addr string) configReply { + t.Helper() + response, body := request(t, http.MethodGet, "http://"+addr+"/api/config", nil) + if response.StatusCode != http.StatusOK { + t.Fatalf("GET /api/config: status %d, body %s", response.StatusCode, body) + } + var reply configReply + if err := json.Unmarshal(body, &reply); err != nil { + t.Fatalf("decode /api/config: %v (body %s)", err, body) + } + return reply +} + +func request(t *testing.T, method, url string, headers map[string]string) (*http.Response, []byte) { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), method, url, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + for name, value := range headers { + req.Header.Set(name, value) + } + response, err := testHTTPClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return response, body +} + +// TestConfigAlwaysReportsInstances pins the contract the UI keys off: this +// service always sends an "instances" list, even an empty one, and rotom-ng +// never does. That presence test is how the UI knows which mode it is in. +func TestConfigAlwaysReportsInstances(t *testing.T) { + addr := startApp(t, testConfig(t), nil) + + response, body := request(t, http.MethodGet, "http://"+addr+"/api/config", nil) + if response.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %s", response.StatusCode, body) + } + + // Checked against the raw JSON: an omitted key and a null both decode to a + // nil slice, and only one of those is acceptable. + var raw struct { + Config map[string]json.RawMessage `json:"config"` + } + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("decode: %v", err) + } + instances, ok := raw.Config["instances"] + if !ok { + t.Fatalf("config has no instances key: %s", body) + } + if string(instances) != "[]" { + t.Errorf("instances = %s, want an empty array", instances) + } +} + +func TestConfigReportsInstanceStateAndConfig(t *testing.T) { + instance := newFakeInstance(t, "east", "instance-secret") + addr := startApp(t, testConfig(t, config.Instance{ + BaseURL: instance.url(), + APISecret: "instance-secret", + }), nil) + + var reply configReply + waitFor(t, "the instance to be probed", func() bool { + reply = getConfig(t, addr) + return reply.Config.Instances != nil && len(*reply.Config.Instances) == 1 && + (*reply.Config.Instances)[0].Reachable + }) + + state := (*reply.Config.Instances)[0] + if state.Instance != "east" { + t.Errorf("instance = %q, want %q", state.Instance, "east") + } + if state.URL != instance.url() { + t.Errorf("url = %q, want %q", state.URL, instance.url()) + } + + // The instance's own config rides along, which is what lets the UI gate + // features (the Jobs tab here) on the instance the operator selected + // rather than on this service. + var instanceConfig struct { + Instance string `json:"instance"` + Jobs struct { + Enable bool `json:"enable"` + } `json:"jobs"` + } + if err := json.Unmarshal(state.Config, &instanceConfig); err != nil { + t.Fatalf("decode instance config: %v", err) + } + if !instanceConfig.Jobs.Enable { + t.Errorf("instance config = %s, want jobs enabled", state.Config) + } + + // The reply's top-level version is this service's own, not the instance's. + if reply.Config.Version == "9.9.9" { + t.Error("top-level version came from the instance, want this service's own") + } +} + +func TestConfigReportsUnreachableInstance(t *testing.T) { + instance := newFakeInstance(t, "east", "") + addr := startApp(t, testConfig(t, config.Instance{BaseURL: instance.url()}), nil) + + waitFor(t, "the instance to become reachable", func() bool { + reply := getConfig(t, addr) + return reply.Config.Instances != nil && (*reply.Config.Instances)[0].Reachable + }) + + instance.down.Store(true) + + waitFor(t, "the instance to become unreachable", func() bool { + reply := getConfig(t, addr) + return !(*reply.Config.Instances)[0].Reachable + }) + + // The last known config survives, so the UI's feature gating does not + // thrash while an instance is briefly down. + reply := getConfig(t, addr) + if len((*reply.Config.Instances)[0].Config) == 0 { + t.Error("config was dropped when the instance went away, want it retained") + } +} + +// TestUnknownAPIPathsAreProxied covers the design decision that makes this +// service forward-compatible: anything it does not serve itself goes upstream, +// so a new rotom-ng endpoint needs no change here. +func TestUnknownAPIPathsAreProxied(t *testing.T) { + east := newFakeInstance(t, "east", "east-secret") + west := newFakeInstance(t, "west", "") + addr := startApp(t, testConfig(t, + config.Instance{BaseURL: east.url(), APISecret: "east-secret"}, + config.Instance{BaseURL: west.url()}, + ), nil) + + waitFor(t, "both instances to be reachable", func() bool { + reply := getConfig(t, addr) + if reply.Config.Instances == nil || len(*reply.Config.Instances) != 2 { + return false + } + return (*reply.Config.Instances)[0].Reachable && (*reply.Config.Instances)[1].Reachable + }) + + t.Run("selected by url", func(t *testing.T) { + response, body := request(t, http.MethodGet, "http://"+addr+"/api/status", + map[string]string{"X-Rotom-Instance": west.url()}) + if response.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %s", response.StatusCode, body) + } + assertFrom(t, body, "west") + }) + + t.Run("selected by name", func(t *testing.T) { + response, body := request(t, http.MethodGet, "http://"+addr+"/api/status", + map[string]string{"X-Rotom-Instance": "east"}) + if response.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %s", response.StatusCode, body) + } + assertFrom(t, body, "east") + // The instance's own secret is what authenticates the hop. + if got := *east.lastStatusSecret.Load(); got != "east-secret" { + t.Errorf("instance saw secret %q, want %q", got, "east-secret") + } + }) + + t.Run("no selection falls back to the first reachable", func(t *testing.T) { + response, body := request(t, http.MethodGet, "http://"+addr+"/api/status", nil) + if response.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %s", response.StatusCode, body) + } + assertFrom(t, body, "east") + }) + + t.Run("unknown instance", func(t *testing.T) { + response, _ := request(t, http.MethodGet, "http://"+addr+"/api/status", + map[string]string{"X-Rotom-Instance": "nope"}) + if response.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", response.StatusCode) + } + }) +} + +func assertFrom(t *testing.T, body []byte, want string) { + t.Helper() + var reply struct { + From string `json:"from"` + } + if err := json.Unmarshal(body, &reply); err != nil { + t.Fatalf("decode: %v (body %s)", err, body) + } + if reply.From != want { + t.Errorf("answered by %q, want %q", reply.From, want) + } +} + +func TestProxyReportsWhenNothingIsReachable(t *testing.T) { + addr := startApp(t, testConfig(t), nil) + + response, body := request(t, http.MethodGet, "http://"+addr+"/api/status", nil) + if response.StatusCode != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503 (body %s)", response.StatusCode, body) + } +} + +// TestAPISecretGuardsBothLocalAndProxiedRoutes matters because the proxied +// routes are reached through gin's NoRoute, outside the authenticated group; +// they have to be just as protected as the routes inside it. +func TestAPISecretGuardsBothLocalAndProxiedRoutes(t *testing.T) { + instance := newFakeInstance(t, "east", "") + cfg := testConfig(t, config.Instance{BaseURL: instance.url()}) + cfg.HTTPListener.Secret = "admin-secret" + addr := startApp(t, cfg, nil) + + waitFor(t, "the instance to be reachable", func() bool { + response, _ := request(t, http.MethodGet, "http://"+addr+"/api/config", + map[string]string{"X-Rotom-Secret": "admin-secret"}) + return response.StatusCode == http.StatusOK + }) + + for _, path := range []string{"/api/config", "/api/status"} { + t.Run("unauthenticated"+path, func(t *testing.T) { + response, _ := request(t, http.MethodGet, "http://"+addr+path, nil) + if response.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", response.StatusCode) + } + }) + + t.Run("authenticated"+path, func(t *testing.T) { + response, body := request(t, http.MethodGet, "http://"+addr+path, + map[string]string{"X-Rotom-Secret": "admin-secret"}) + if response.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200 (body %s)", response.StatusCode, body) + } + }) + } + + // The session probe has to stay reachable without a credential, or the UI + // could never present a login form. + response, _ := request(t, http.MethodGet, "http://"+addr+"/api/auth/me", nil) + if response.StatusCode != http.StatusOK { + t.Errorf("GET /api/auth/me status = %d, want 200", response.StatusCode) + } +} + +func TestUIIsServedForNonAPIPaths(t *testing.T) { + addr := startApp(t, testConfig(t), nil) + + for _, path := range []string{"/", "/devices"} { + response, body := request(t, http.MethodGet, "http://"+addr+path, nil) + if response.StatusCode != http.StatusOK { + t.Errorf("GET %s status = %d, want 200", path, response.StatusCode) + } + if string(body) != "rotom ui" { + t.Errorf("GET %s body = %q, want the UI index", path, body) + } + } +} + +func TestConfigReloadAppliesInstanceChanges(t *testing.T) { + east := newFakeInstance(t, "east", "") + west := newFakeInstance(t, "west", "") + + cfg := testConfig(t, config.Instance{BaseURL: east.url()}) + + var reloaded atomic.Bool + addr := startApp(t, cfg, func() (*config.Config, error) { + if !reloaded.Load() { + return cfg, nil + } + return testConfig(t, + config.Instance{BaseURL: east.url()}, + config.Instance{BaseURL: west.url()}, + ), nil + }) + + waitFor(t, "the first instance to be reachable", func() bool { + reply := getConfig(t, addr) + return reply.Config.Instances != nil && len(*reply.Config.Instances) == 1 && + (*reply.Config.Instances)[0].Reachable + }) + + reloaded.Store(true) + response, body := request(t, http.MethodPut, "http://"+addr+"/api/config/reload", nil) + if response.StatusCode != http.StatusOK { + t.Fatalf("reload status %d, body %s", response.StatusCode, body) + } + + waitFor(t, "the added instance to be reachable", func() bool { + reply := getConfig(t, addr) + if reply.Config.Instances == nil || len(*reply.Config.Instances) != 2 { + return false + } + return (*reply.Config.Instances)[1].Reachable + }) + + // The instance that survived the reload must not have been reset to + // "never contacted" along the way. + reply := getConfig(t, addr) + if !(*reply.Config.Instances)[0].Reachable { + t.Error("the surviving instance lost its reachable state across a reload") + } + + response, body = request(t, http.MethodGet, "http://"+addr+"/api/status", + map[string]string{"X-Rotom-Instance": west.url()}) + if response.StatusCode != http.StatusOK { + t.Fatalf("proxy to the added instance: status %d, body %s", response.StatusCode, body) + } + assertFrom(t, body, "west") +} + +func TestInitRejectsMissingUI(t *testing.T) { + cfg := testConfig(t) + app, err := uiapp.NewApp(cfg, uiapp.FlagConfig{ + UIPath: filepath.Join(t.TempDir(), "does-not-exist"), + ReloadConfig: func() (*config.Config, error) { return cfg, nil }, + }) + if err != nil { + t.Fatalf("NewApp: %v", err) + } + err = app.Init() + if err == nil { + t.Fatal("Init succeeded with no UI files, want an error") + } + if want := "index.html"; !strings.Contains(err.Error(), want) { + t.Errorf("error = %v, want it to mention %s", err, want) + } +} diff --git a/apps/rotom-ng-ui-server/app/config/config.go b/apps/rotom-ng-ui-server/app/config/config.go new file mode 100644 index 0000000..07cbca8 --- /dev/null +++ b/apps/rotom-ng-ui-server/app/config/config.go @@ -0,0 +1,234 @@ +// Package config provides configuration loading and validation for the +// RotomNG admin UI server. +package config + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/url" + "os" + "strings" + "time" + + "github.com/knadh/koanf/parsers/toml" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" + + "github.com/UnownHash/RotomNG/libs/logging" +) + +// Default configuration values. +const ( + DefaultHTTPAddress = ":7073" + DefaultShutdownTimeout = 5 * time.Second + + DefaultLogFilePath = "./logs/rotom-ng-ui.log" + + // DefaultUISessionTTL is how long a web UI login lasts when + // http_listener.ui_session_ttl is not set: one day. Written as 24h because + // Go duration syntax has no day unit. + DefaultUISessionTTL = 24 * time.Hour + + // DefaultMonitorInterval is how often each configured instance is probed + // for reachability when instance_monitor.interval is not set. + DefaultMonitorInterval = 10 * time.Second + + // DefaultMonitorTimeout bounds a single reachability probe when + // instance_monitor.timeout is not set. Kept well under the interval so a + // hung instance cannot delay the next round. + DefaultMonitorTimeout = 5 * time.Second +) + +// HTTPListener holds configuration for the HTTP API listener. It mirrors +// rotom-ng's own http_listener section so a single set of operator habits +// covers both services. +type HTTPListener struct { + Address string `koanf:"address"` + Listener net.Listener `koanf:"-"` + Secret string `koanf:"secret"` + // UISessionTTL is how long a web UI login stays valid (e.g. "30m", "12h"). + // Defaults to DefaultUISessionTTL when unset or <= 0. Only relevant when + // Secret is set, since without a secret the UI never logs in. + UISessionTTL time.Duration `koanf:"ui_session_ttl"` +} + +// Instance identifies one rotom-ng server this service fronts. +type Instance struct { + // BaseURL is the root of the rotom-ng HTTP listener, without the /api + // prefix -- e.g. "http://10.0.0.4:7072". The prefix is appended when + // building upstream request URLs. + BaseURL string `koanf:"base_url"` + // APISecret is that instance's http_listener secret, sent upstream as the + // X-Rotom-Secret header. Empty when the instance has no secret set. + APISecret string `koanf:"api_secret"` +} + +// InstanceMonitor tunes the background reachability probes. +type InstanceMonitor struct { + Interval time.Duration `koanf:"interval"` + Timeout time.Duration `koanf:"timeout"` +} + +// Config is the top-level application configuration. +// +// It deliberately carries none of rotom-ng's device, controller, jobs, or +// rate-limit settings: this service owns no connections, and those settings +// belong to -- and are read from -- the instances it fronts. +type Config struct { + Instance string `koanf:"instance"` + HTTPListener *HTTPListener `koanf:"http_listener"` + Instances []Instance `koanf:"instances"` + InstanceMonitor *InstanceMonitor `koanf:"instance_monitor"` + Logging *logging.Config `koanf:"logging"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` + + defaultsSet bool +} + +// LoadFromFile loads configuration from a TOML file using koanf. +func LoadFromFile(filePath string) (*Config, error) { + k := koanf.New(".") + + if err := k.Load(file.Provider(filePath), toml.Parser()); err != nil { + return nil, fmt.Errorf("load config file %q: %w", filePath, err) + } + + var cfg Config + if err := k.Unmarshal("", &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + cfg.SetDefaults() + + return &cfg, cfg.Validate() +} + +// SetDefaults sets default values for the configuration. +func (cfg *Config) SetDefaults() { + if cfg.ShutdownTimeout <= 0 { + cfg.ShutdownTimeout = DefaultShutdownTimeout + } + + if cfg.HTTPListener == nil { + cfg.HTTPListener = &HTTPListener{} + } + if cfg.HTTPListener.Address == "" { + cfg.HTTPListener.Address = DefaultHTTPAddress + } + if cfg.HTTPListener.UISessionTTL <= 0 { + cfg.HTTPListener.UISessionTTL = DefaultUISessionTTL + } + + if cfg.InstanceMonitor == nil { + cfg.InstanceMonitor = &InstanceMonitor{} + } + if cfg.InstanceMonitor.Interval <= 0 { + cfg.InstanceMonitor.Interval = DefaultMonitorInterval + } + if cfg.InstanceMonitor.Timeout <= 0 { + cfg.InstanceMonitor.Timeout = DefaultMonitorTimeout + } + + // Trailing slashes would produce "//api" once the prefix is appended, and + // they also break the exact-match lookup the proxy does on the base URL, + // so normalise them away before anything else sees the value. + for idx := range cfg.Instances { + cfg.Instances[idx].BaseURL = strings.TrimRight(cfg.Instances[idx].BaseURL, "/") + } + + if cfg.Logging == nil { + cfg.Logging = &logging.Config{} + } + cfg.Logging.SetDefaults() + // File logging is on by default; create FileConfig if not provided + if cfg.Logging.File == nil { + cfg.Logging.File = &logging.FileConfig{} + } + if !cfg.Logging.File.Disable && cfg.Logging.File.Path == "" { + cfg.Logging.File.Path = DefaultLogFilePath + } + + cfg.defaultsSet = true +} + +// Validate validates the configuration after defaults have been applied. +func (cfg *Config) Validate() error { + if !cfg.defaultsSet { + return errors.New("SetDefaults should be called before validing config") + } + + if cfg.HTTPListener == nil { + return errors.New("http_listener configuration is required") + } + if cfg.HTTPListener.Address == "" { + return errors.New("http_listener address is required") + } + + if err := cfg.validateInstances(); err != nil { + return err + } + + if cfg.Logging != nil { + if err := cfg.Logging.Validate(); err != nil { + return err + } + } + + return nil +} + +// GetLogger creates and returns a structured logger based on the logging configuration. +// Returns the logger, a LevelVar for dynamic level changes, and a Closer for shutdown cleanup. +func (cfg *Config) GetLogger() (*slog.Logger, *slog.LevelVar, io.Closer, error) { + if cfg.Logging == nil { + return nil, nil, nil, errors.New("logging configuration is not set") + } + + parsedLevel, err := logging.ParseSlogLevel(cfg.Logging.Level) + if err != nil { + return nil, nil, nil, err + } + + var levelVar slog.LevelVar + levelVar.Set(parsedLevel) + + writer, err := cfg.Logging.GetLoggingWriter(os.Stdout) + if err != nil { + return nil, nil, nil, err + } + + handler := logging.NewSlogHandler(writer, cfg.Logging.Format, &logging.SlogHandlerOptions{Level: &levelVar}) + logger := logging.CreateSlogLogger(handler) + + return logger, &levelVar, writer, nil +} + +// validateInstances checks that every instance has a usable, distinct base +// URL. Duplicates are rejected rather than merged: the UI selects an instance +// by its base URL, so two entries sharing one would be indistinguishable. +func (cfg *Config) validateInstances() error { + seen := make(map[string]struct{}, len(cfg.Instances)) + for idx, instance := range cfg.Instances { + if instance.BaseURL == "" { + return fmt.Errorf("instances[%d]: base_url is required", idx) + } + parsed, err := url.Parse(instance.BaseURL) + if err != nil { + return fmt.Errorf("instances[%d]: invalid base_url %q: %w", idx, instance.BaseURL, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("instances[%d]: base_url %q must be an http:// or https:// URL", idx, instance.BaseURL) + } + if parsed.Host == "" { + return fmt.Errorf("instances[%d]: base_url %q has no host", idx, instance.BaseURL) + } + if _, dup := seen[instance.BaseURL]; dup { + return fmt.Errorf("instances[%d]: duplicate base_url %q", idx, instance.BaseURL) + } + seen[instance.BaseURL] = struct{}{} + } + return nil +} diff --git a/apps/rotom-ng-ui-server/app/config/config_test.go b/apps/rotom-ng-ui-server/app/config/config_test.go new file mode 100644 index 0000000..56cb8e5 --- /dev/null +++ b/apps/rotom-ng-ui-server/app/config/config_test.go @@ -0,0 +1,155 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeConfig(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "rotom-ng-ui.toml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestLoadFromFileDefaults(t *testing.T) { + path := writeConfig(t, "") + + cfg, err := LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + + if cfg.HTTPListener.Address != DefaultHTTPAddress { + t.Errorf("address = %q, want %q", cfg.HTTPListener.Address, DefaultHTTPAddress) + } + if cfg.HTTPListener.UISessionTTL != DefaultUISessionTTL { + t.Errorf("ui_session_ttl = %v, want %v", cfg.HTTPListener.UISessionTTL, DefaultUISessionTTL) + } + if cfg.InstanceMonitor.Interval != DefaultMonitorInterval { + t.Errorf("interval = %v, want %v", cfg.InstanceMonitor.Interval, DefaultMonitorInterval) + } + if cfg.InstanceMonitor.Timeout != DefaultMonitorTimeout { + t.Errorf("timeout = %v, want %v", cfg.InstanceMonitor.Timeout, DefaultMonitorTimeout) + } + if cfg.ShutdownTimeout != DefaultShutdownTimeout { + t.Errorf("shutdown_timeout = %v, want %v", cfg.ShutdownTimeout, DefaultShutdownTimeout) + } + // An operator may legitimately run with none configured yet; that must + // load rather than refuse to start. + if len(cfg.Instances) != 0 { + t.Errorf("instances = %v, want none", cfg.Instances) + } +} + +func TestLoadFromFileFull(t *testing.T) { + path := writeConfig(t, ` +instance = "admin" +shutdown_timeout = "9s" + +[http_listener] +address = ":9999" +secret = "admin-secret" +ui_session_ttl = "30m" + +[instance_monitor] +interval = "3s" +timeout = "1s" + +[[instances]] +base_url = "http://one:7072/" +api_secret = "one-secret" + +[[instances]] +base_url = "http://two:7072" +`) + + cfg, err := LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + + if cfg.Instance != "admin" { + t.Errorf("instance = %q, want %q", cfg.Instance, "admin") + } + if cfg.HTTPListener.Address != ":9999" || cfg.HTTPListener.Secret != "admin-secret" { + t.Errorf("http_listener = %+v", cfg.HTTPListener) + } + if cfg.HTTPListener.UISessionTTL != 30*time.Minute { + t.Errorf("ui_session_ttl = %v, want 30m", cfg.HTTPListener.UISessionTTL) + } + if cfg.ShutdownTimeout != 9*time.Second { + t.Errorf("shutdown_timeout = %v, want 9s", cfg.ShutdownTimeout) + } + if cfg.InstanceMonitor.Interval != 3*time.Second || cfg.InstanceMonitor.Timeout != time.Second { + t.Errorf("instance_monitor = %+v", cfg.InstanceMonitor) + } + + if len(cfg.Instances) != 2 { + t.Fatalf("instances = %d, want 2", len(cfg.Instances)) + } + // The trailing slash must be gone: it would otherwise produce "//api" + // upstream and defeat the base-URL lookup the proxy does. + if cfg.Instances[0].BaseURL != "http://one:7072" { + t.Errorf("instances[0].base_url = %q, want %q", cfg.Instances[0].BaseURL, "http://one:7072") + } + if cfg.Instances[0].APISecret != "one-secret" { + t.Errorf("instances[0].api_secret = %q", cfg.Instances[0].APISecret) + } + if cfg.Instances[1].APISecret != "" { + t.Errorf("instances[1].api_secret = %q, want empty", cfg.Instances[1].APISecret) + } +} + +func TestLoadFromFileRejectsBadInstances(t *testing.T) { + tests := []struct { + name string + contents string + wantErr string + }{ + { + name: "missing base url", + contents: "[[instances]]\napi_secret = \"x\"\n", + wantErr: "base_url is required", + }, + { + name: "not a url", + contents: "[[instances]]\nbase_url = \"one:7072\"\n", + wantErr: "must be an http:// or https:// URL", + }, + { + name: "no host", + contents: "[[instances]]\nbase_url = \"http://\"\n", + wantErr: "has no host", + }, + { + name: "duplicate", + contents: "[[instances]]\nbase_url = \"http://one:7072\"\n[[instances]]\nbase_url = \"http://one:7072/\"\n", + wantErr: "duplicate base_url", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := LoadFromFile(writeConfig(t, test.contents)) + if err == nil { + t.Fatalf("LoadFromFile succeeded, want error containing %q", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Errorf("error = %q, want it to contain %q", err, test.wantErr) + } + }) + } +} + +func TestValidateRequiresDefaults(t *testing.T) { + cfg := &Config{} + if err := cfg.Validate(); err == nil { + t.Fatal("Validate on a config without defaults succeeded, want error") + } +} diff --git a/apps/rotom-ng-ui-server/app/handlers/api_handler.go b/apps/rotom-ng-ui-server/app/handlers/api_handler.go new file mode 100644 index 0000000..b61c0a7 --- /dev/null +++ b/apps/rotom-ng-ui-server/app/handlers/api_handler.go @@ -0,0 +1,126 @@ +// Package handlers provides the HTTP API handlers this service answers +// itself, as opposed to the ones it proxies to an instance. +package handlers + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/UnownHash/RotomNG/libs/settings" + + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/config" + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/instances" +) + +// Response field keys. +const ( + fieldStatus = "status" + fieldError = "error" + statusOK = "ok" + statusError = "error" +) + +// InstanceLister supplies the current state of every configured instance. +type InstanceLister interface { + Snapshot() []instances.State +} + +// HTTPAPIHandlerSettings holds the configuration the handlers read, so a +// config reload can swap it without restarting the server. +type HTTPAPIHandlerSettings struct { + CurrentConfig config.Config +} + +// Validate validates the HTTPAPIHandlerSettings. Currently always returns nil. +func (s HTTPAPIHandlerSettings) Validate() error { + return nil +} + +type apiHandlerSettingsContainer = settings.Container[HTTPAPIHandlerSettings] + +// HTTPAPIHandlerConfig holds configuration for the HTTP API handlers. +type HTTPAPIHandlerConfig struct { + *apiHandlerSettingsContainer + + AppVersion string + GitSHA string + Logger *slog.Logger + Instances InstanceLister + ReloadFn func() error +} + +// Init initializes the settings container with the given settings. +func (cfg *HTTPAPIHandlerConfig) Init(s HTTPAPIHandlerSettings) (err error) { + cfg.apiHandlerSettingsContainer, err = settings.NewContainer(s) + return +} + +// HTTPAPIHandler serves this service's own API endpoints. +type HTTPAPIHandler struct { + logger *slog.Logger + getSettings func() HTTPAPIHandlerSettings + instances InstanceLister + appVersion string + gitSHA string + reloadFn func() error +} + +// NewHTTPAPIHandler creates a new HTTPAPIHandler instance. +func NewHTTPAPIHandler(cfg HTTPAPIHandlerConfig) *HTTPAPIHandler { + return &HTTPAPIHandler{ + logger: cfg.Logger, + getSettings: cfg.GetSettings, + instances: cfg.Instances, + appVersion: cfg.AppVersion, + gitSHA: cfg.GitSHA, + reloadFn: cfg.ReloadFn, + } +} + +// SetupAPIRoutes registers the endpoints this service answers itself. Every +// other /api path is proxied, via the web server's API fallback. +func (ah *HTTPAPIHandler) SetupAPIRoutes(apiGroup *gin.RouterGroup) { + apiGroup.GET("/config", ah.GetConfig) + apiGroup.PUT("/config/reload", ah.ConfigReload) +} + +// GetConfig returns this service's configuration as JSON, on the same path +// rotom-ng serves its own. +// +// The reply carries the fields of rotom-ng's config that make sense for a +// service that holds no devices of its own, plus "instances". That key is what +// tells the UI it is talking to this service rather than to a rotom-ng: it is +// always present here -- an empty list when nothing is configured -- and never +// present in a rotom-ng reply. Per-instance settings the UI gates features on +// live in each entry's own "config", so the UI follows the instance the +// operator has selected. +func (ah *HTTPAPIHandler) GetConfig(c *gin.Context) { + cfg := ah.getSettings().CurrentConfig + + jsonConfig := gin.H{ + "version": ah.appVersion, + "sha": ah.gitSHA, + "instances": ah.instances.Snapshot(), + } + if cfg.Instance != "" { + jsonConfig["instance"] = cfg.Instance + } + + c.JSON(http.StatusOK, gin.H{fieldStatus: statusOK, "config": jsonConfig}) +} + +// ConfigReload handles a request to reload the application configuration. +func (ah *HTTPAPIHandler) ConfigReload(c *gin.Context) { + logger := ah.logger.With(slog.String("remote_addr", c.Request.RemoteAddr)) + logger.LogAttrs(c.Request.Context(), slog.LevelInfo, "config reload requested") + + if err := ah.reloadFn(); err != nil { + ah.logger.LogAttrs(c.Request.Context(), slog.LevelError, "failed to reload config", slog.String(fieldError, err.Error())) + c.JSON(http.StatusInternalServerError, gin.H{fieldStatus: statusError, fieldError: err.Error()}) + return + } + logger.LogAttrs(c.Request.Context(), slog.LevelInfo, "config reloaded") + ah.GetConfig(c) +} diff --git a/apps/rotom-ng-ui-server/app/httpserver/http_server.go b/apps/rotom-ng-ui-server/app/httpserver/http_server.go new file mode 100644 index 0000000..856b792 --- /dev/null +++ b/apps/rotom-ng-ui-server/app/httpserver/http_server.go @@ -0,0 +1,51 @@ +// Package httpserver provides the HTTP/API server for the RotomNG admin UI. +package httpserver + +import ( + "context" + "embed" + "log/slog" + "net" + + "github.com/gin-gonic/gin" + + "github.com/UnownHash/RotomNG/libs/services" +) + +// HTTPAPIHandler defines the interface for the routes this service serves +// itself. +type HTTPAPIHandler interface { + SetupAPIRoutes(apiGroup *gin.RouterGroup) +} + +// Config holds configuration for the HTTP server. +type Config struct { + Address string + Listener net.Listener + UIPath string + UIFS *embed.FS + DevMode bool + AuthMiddleware services.AuthMiddleware + APIHandler HTTPAPIHandler + // ProxyHandler receives every /api request the APIHandler did not claim. + ProxyHandler func(ginContext *gin.Context) +} + +// HTTPServer is a type alias for the generic web server. +type HTTPServer = services.WebServer + +// NewHTTPServer creates a new HTTPServer instance with admin-specific route +// setup. +func NewHTTPServer(ctx context.Context, logger *slog.Logger, cfg Config) (*HTTPServer, error) { + webServerConfig := services.WebServerConfig{ + Address: cfg.Address, + Listener: cfg.Listener, + UIPath: cfg.UIPath, + UIFS: cfg.UIFS, + DevMode: cfg.DevMode, + AuthMiddleware: cfg.AuthMiddleware, + SetupAPIRoutes: cfg.APIHandler.SetupAPIRoutes, + APIFallback: cfg.ProxyHandler, + } + return services.NewWebServer(ctx, logger, webServerConfig) +} diff --git a/apps/rotom-ng-ui-server/app/instances/manager.go b/apps/rotom-ng-ui-server/app/instances/manager.go new file mode 100644 index 0000000..00b280b --- /dev/null +++ b/apps/rotom-ng-ui-server/app/instances/manager.go @@ -0,0 +1,438 @@ +// Package instances tracks the rotom-ng servers this service fronts: their +// reachability, the configuration each one reports, and which upstream a +// request should be sent to. +package instances + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/UnownHash/RotomNG/libs/auth" +) + +// APIPathPrefix is appended to an instance's configured base URL to reach its +// REST API. Every upstream request is built as base_url + APIPathPrefix + rest. +const APIPathPrefix = "/api" + +// configPath is the endpoint probed for reachability. It doubles as the source +// of the instance name and of the configuration the UI gates its features on, +// so one request keeps everything current. +const configPath = APIPathPrefix + "/config" + +var errNoInstances = errors.New("no instances are configured") + +// NewErrNoInstances returns the error reported when the service has no +// instances configured at all. +func NewErrNoInstances() error { return errNoInstances } + +// IsErrNoInstances reports whether err indicates no configured instances. +func IsErrNoInstances(err error) bool { return errors.Is(err, errNoInstances) } + +var errInstanceNotFound = errors.New("instance not found") + +// NewErrInstanceNotFound returns the error reported when a request names an +// instance this service does not have. +func NewErrInstanceNotFound() error { return errInstanceNotFound } + +// IsErrInstanceNotFound reports whether err indicates an unknown instance. +func IsErrInstanceNotFound(err error) bool { return errors.Is(err, errInstanceNotFound) } + +var errNoInstanceReachable = errors.New("no instance is reachable") + +// NewErrNoInstanceReachable returns the error reported when a request did not +// name an instance and none of the configured ones are currently reachable. +func NewErrNoInstanceReachable() error { return errNoInstanceReachable } + +// IsErrNoInstanceReachable reports whether err indicates nothing reachable. +func IsErrNoInstanceReachable(err error) bool { return errors.Is(err, errNoInstanceReachable) } + +// InstanceConfig is one configured upstream. +type InstanceConfig struct { + BaseURL string + APISecret string +} + +// Settings are the parts of the manager's configuration that a config reload +// can change. +type Settings struct { + Instances []InstanceConfig + Interval time.Duration + Timeout time.Duration +} + +// Validate validates the settings. +func (s Settings) Validate() error { + if s.Interval <= 0 { + return errors.New("instance monitor interval must be positive") + } + if s.Timeout <= 0 { + return errors.New("instance monitor timeout must be positive") + } + seen := make(map[string]struct{}, len(s.Instances)) + for _, instance := range s.Instances { + if instance.BaseURL == "" { + return errors.New("instance base url must not be empty") + } + if _, dup := seen[instance.BaseURL]; dup { + return fmt.Errorf("duplicate instance base url %q", instance.BaseURL) + } + seen[instance.BaseURL] = struct{}{} + } + return nil +} + +// State is the public view of one instance, as reported by GET /api/config. +type State struct { + // Instance is the name the upstream reports in its own config. Empty when + // that instance has no instance name set, or has not been reached yet. + Instance string `json:"instance"` + // URL is the configured base URL. It is this service's stable identifier + // for the instance: it is what a client sends back to select one. + URL string `json:"url"` + // Reachable is true only while the most recent probe succeeded, which + // implies Config is populated. + Reachable bool `json:"reachable"` + // Config is the config object from the instance's last successful + // /api/config response, passed through verbatim so the UI reads exactly + // what it would read when pointed at that instance directly. Retained + // while the instance is unreachable, and omitted until first contact. + Config json.RawMessage `json:"config,omitempty"` +} + +// Target is where a proxied request should be sent. +type Target struct { + // BaseURL is the instance root, without the /api prefix. + BaseURL string + // APISecret is sent upstream as X-Rotom-Secret; empty when unset. + APISecret string + // Instance is the upstream's reported name, for logging. + Instance string +} + +// instanceState is the manager's private record for one upstream. +type instanceState struct { + config InstanceConfig + name string + reachable bool + rawConfig json.RawMessage +} + +// ManagerConfig holds the dependencies for a Manager. +type ManagerConfig struct { + Logger *slog.Logger + // HTTPClient probes instances. Defaults to a client with no global + // timeout: each probe carries its own deadline via the request context, + // which is what the configured timeout adjusts. + HTTPClient *http.Client + // UserAgent identifies this service to the instances it probes. + UserAgent string +} + +// Manager tracks instance reachability and resolves proxy targets. +type Manager struct { + logger *slog.Logger + httpClient *http.Client + userAgent string + + mu sync.RWMutex + settings Settings + // order holds base URLs in configuration order, so the list the UI renders + // matches the order the operator wrote them in. + order []string + byURL map[string]*instanceState + + // changed is closed and replaced whenever a probe round alters any + // instance's reachability, so callers can wait for a settled state + // without polling. + changed chan struct{} +} + +// NewManager creates a Manager with the given settings. +func NewManager(cfg ManagerConfig, settings Settings) (*Manager, error) { + if err := settings.Validate(); err != nil { + return nil, err + } + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{} + } + manager := &Manager{ + logger: cfg.Logger, + httpClient: httpClient, + userAgent: cfg.UserAgent, + byURL: make(map[string]*instanceState), + changed: make(chan struct{}), + } + manager.SetSettings(settings) + return manager, nil +} + +// SetSettings replaces the instance list and monitor timings. Instances that +// survive the change keep their cached name, config, and reachability, so a +// reload that only adds or removes entries does not blank out the UI for the +// ones it left alone. +func (m *Manager) SetSettings(settings Settings) { + m.mu.Lock() + defer m.mu.Unlock() + + order := make([]string, 0, len(settings.Instances)) + byURL := make(map[string]*instanceState, len(settings.Instances)) + for _, instanceCfg := range settings.Instances { + state := m.byURL[instanceCfg.BaseURL] + if state == nil { + state = &instanceState{} + } + // The secret may have been rotated in the config file; the cached + // name and config stay valid either way. + state.config = instanceCfg + order = append(order, instanceCfg.BaseURL) + byURL[instanceCfg.BaseURL] = state + } + + m.settings = settings + m.order = order + m.byURL = byURL +} + +// Snapshot returns the current state of every configured instance, in +// configuration order. +func (m *Manager) Snapshot() []State { + m.mu.RLock() + defer m.mu.RUnlock() + + states := make([]State, 0, len(m.order)) + for _, baseURL := range m.order { + state := m.byURL[baseURL] + states = append(states, State{ + Instance: state.name, + URL: baseURL, + Reachable: state.reachable, + Config: state.rawConfig, + }) + } + return states +} + +// Resolve picks the upstream for a request. +// +// key is the client's instance selection: a base URL, or an instance name for +// clients that find that more convenient. Base URLs are matched first because +// they are guaranteed unique, while two instances can report the same name. +// +// An empty key selects the first reachable instance, which is what lets an +// API client that does not care about instance selection work unconfigured. +func (m *Manager) Resolve(key string) (Target, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if len(m.order) == 0 { + return Target{}, errNoInstances + } + + if key == "" { + for _, baseURL := range m.order { + if state := m.byURL[baseURL]; state.reachable { + return targetFor(state), nil + } + } + return Target{}, errNoInstanceReachable + } + + if state, ok := m.byURL[key]; ok { + return targetFor(state), nil + } + for _, baseURL := range m.order { + if state := m.byURL[baseURL]; state.name == key { + return targetFor(state), nil + } + } + return Target{}, fmt.Errorf("%w: %q", errInstanceNotFound, key) +} + +func targetFor(state *instanceState) Target { + return Target{ + BaseURL: state.config.BaseURL, + APISecret: state.config.APISecret, + Instance: state.name, + } +} + +// Changed returns a channel closed the next time a probe round changes any +// instance's reachability. +func (m *Manager) Changed() <-chan struct{} { + m.mu.RLock() + defer m.mu.RUnlock() + return m.changed +} + +// Run probes every instance immediately, then once per configured interval, +// until ctx is cancelled. +func (m *Manager) Run(ctx context.Context) { + for { + m.probeAll(ctx) + + m.mu.RLock() + interval := m.settings.Interval + m.mu.RUnlock() + + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + +// probeAll probes all instances concurrently and returns once they have all +// settled. Probing in parallel keeps one unreachable instance from delaying +// the reachability updates of the rest. +func (m *Manager) probeAll(ctx context.Context) { + m.mu.RLock() + timeout := m.settings.Timeout + targets := make([]InstanceConfig, 0, len(m.order)) + for _, baseURL := range m.order { + targets = append(targets, m.byURL[baseURL].config) + } + m.mu.RUnlock() + + var wg sync.WaitGroup + for _, target := range targets { + wg.Go(func() { + m.probe(ctx, target, timeout) + }) + } + wg.Wait() +} + +func (m *Manager) probe(ctx context.Context, instanceCfg InstanceConfig, timeout time.Duration) { + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + name, rawConfig, err := m.fetchConfig(probeCtx, instanceCfg) + if err != nil { + // A cancelled context means we are shutting down, not that the + // instance went away; leaving the state untouched avoids a spurious + // "unreachable" log line on every restart. + if ctx.Err() != nil { + return + } + m.setUnreachable(ctx, instanceCfg.BaseURL, err) + return + } + m.setReachable(ctx, instanceCfg.BaseURL, name, rawConfig) +} + +// configEnvelope is the shape of a rotom-ng GET /api/config reply. The config +// object is kept raw so this service does not have to track every field +// rotom-ng may add to it. +type configEnvelope struct { + Config json.RawMessage `json:"config"` +} + +// fetchConfig retrieves an instance's config, returning its instance name and +// the raw config object. +func (m *Manager) fetchConfig(ctx context.Context, instanceCfg InstanceConfig) (string, json.RawMessage, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, instanceCfg.BaseURL+configPath, nil) + if err != nil { + return "", nil, fmt.Errorf("build config request: %w", err) + } + if instanceCfg.APISecret != "" { + request.Header.Set(auth.SecretRequestHeader, instanceCfg.APISecret) + } + if m.userAgent != "" { + request.Header.Set("User-Agent", m.userAgent) + } + + response, err := m.httpClient.Do(request) + if err != nil { + return "", nil, fmt.Errorf("request config: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return "", nil, fmt.Errorf("config request returned %s", response.Status) + } + + var envelope configEnvelope + if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil { + return "", nil, fmt.Errorf("decode config response: %w", err) + } + if len(envelope.Config) == 0 { + return "", nil, errors.New("config response has no config object") + } + + // The instance name is optional upstream: rotom-ng omits it entirely when + // unset, so an absent field is normal rather than an error. + var named struct { + Instance string `json:"instance"` + } + if err := json.Unmarshal(envelope.Config, &named); err != nil { + return "", nil, fmt.Errorf("decode config object: %w", err) + } + + return named.Instance, envelope.Config, nil +} + +func (m *Manager) setReachable(ctx context.Context, baseURL, name string, rawConfig json.RawMessage) { + m.mu.Lock() + state, ok := m.byURL[baseURL] + if !ok { + // Removed by a config reload while the probe was in flight. + m.mu.Unlock() + return + } + wasReachable := state.reachable + previousName := state.name + state.reachable = true + state.name = name + state.rawConfig = rawConfig + if !wasReachable || previousName != name { + m.signalChangedLocked() + } + m.mu.Unlock() + + if !wasReachable { + m.logger.LogAttrs(ctx, slog.LevelInfo, "instance is reachable", + slog.String("url", baseURL), slog.String("instance", name)) + } +} + +func (m *Manager) setUnreachable(ctx context.Context, baseURL string, cause error) { + m.mu.Lock() + state, ok := m.byURL[baseURL] + if !ok { + m.mu.Unlock() + return + } + wasReachable := state.reachable + name := state.name + state.reachable = false + if wasReachable { + m.signalChangedLocked() + } + m.mu.Unlock() + + // Only the transition is logged at warn: an instance that is down stays + // down, and one line per probe interval would drown the log. + if wasReachable { + m.logger.LogAttrs(ctx, slog.LevelWarn, "instance is unreachable", + slog.String("url", baseURL), slog.String("instance", name), slog.String("error", cause.Error())) + } else { + m.logger.LogAttrs(ctx, slog.LevelDebug, "instance probe failed", + slog.String("url", baseURL), slog.String("error", cause.Error())) + } +} + +// signalChangedLocked wakes everything waiting on Changed. Callers hold m.mu. +func (m *Manager) signalChangedLocked() { + close(m.changed) + m.changed = make(chan struct{}) +} diff --git a/apps/rotom-ng-ui-server/app/instances/manager_test.go b/apps/rotom-ng-ui-server/app/instances/manager_test.go new file mode 100644 index 0000000..46ce3a5 --- /dev/null +++ b/apps/rotom-ng-ui-server/app/instances/manager_test.go @@ -0,0 +1,441 @@ +package instances + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func testLogger() *slog.Logger { + return slog.New(slog.DiscardHandler) +} + +func testSettings(instances ...InstanceConfig) Settings { + return Settings{ + Instances: instances, + Interval: time.Hour, + Timeout: time.Second, + } +} + +func newTestManager(t *testing.T, settings Settings) *Manager { + t.Helper() + manager, err := NewManager(ManagerConfig{Logger: testLogger(), UserAgent: "test-agent"}, settings) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + return manager +} + +// configServer stands in for a rotom-ng instance's /api/config endpoint. +type configServer struct { + server *httptest.Server + // requests counts config probes, so a test can tell a cached answer from a + // fresh one. + requests atomic.Int64 + // gotSecret records the secret header of the most recent probe. + gotSecret atomic.Pointer[string] + // down makes the endpoint fail, standing in for an instance going away. + down atomic.Bool + // instanceName is the name reported in the config body; empty omits the + // field entirely, as rotom-ng does when no instance name is set. + instanceName atomic.Pointer[string] +} + +func newConfigServer(t *testing.T, instanceName string) *configServer { + t.Helper() + cs := &configServer{} + cs.instanceName.Store(&instanceName) + cs.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cs.requests.Add(1) + secret := r.Header.Get("X-Rotom-Secret") + cs.gotSecret.Store(&secret) + + if r.URL.Path != configPath { + w.WriteHeader(http.StatusNotFound) + return + } + if cs.down.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + + config := map[string]any{"version": "1.2.3", "jobs": map[string]any{"enable": true}} + if name := *cs.instanceName.Load(); name != "" { + config["instance"] = name + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "config": config}) + })) + t.Cleanup(cs.server.Close) + return cs +} + +func (cs *configServer) url() string { return cs.server.URL } + +func TestProbeMarksInstanceReachableAndCachesConfig(t *testing.T) { + upstream := newConfigServer(t, "east") + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: upstream.url(), APISecret: "sekrit"})) + + manager.probeAll(t.Context()) + + states := manager.Snapshot() + if len(states) != 1 { + t.Fatalf("Snapshot returned %d states, want 1", len(states)) + } + state := states[0] + if !state.Reachable { + t.Error("instance is not reachable after a successful probe") + } + if state.Instance != "east" { + t.Errorf("Instance = %q, want %q", state.Instance, "east") + } + if state.URL != upstream.url() { + t.Errorf("URL = %q, want %q", state.URL, upstream.url()) + } + + // The config is passed through verbatim so the UI reads exactly what it + // would read from that instance directly. + var config map[string]any + if err := json.Unmarshal(state.Config, &config); err != nil { + t.Fatalf("cached config is not valid JSON: %v", err) + } + if config["version"] != "1.2.3" { + t.Errorf("cached config = %v, want version 1.2.3", config) + } + + if got := *upstream.gotSecret.Load(); got != "sekrit" { + t.Errorf("upstream saw secret %q, want %q", got, "sekrit") + } +} + +func TestProbeOmittedInstanceNameIsEmpty(t *testing.T) { + upstream := newConfigServer(t, "") + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: upstream.url()})) + + manager.probeAll(t.Context()) + + state := manager.Snapshot()[0] + if !state.Reachable { + t.Fatal("instance is not reachable") + } + if state.Instance != "" { + t.Errorf("Instance = %q, want empty", state.Instance) + } + if got := *upstream.gotSecret.Load(); got != "" { + t.Errorf("upstream saw secret %q, want none sent", got) + } +} + +func TestProbeFailureKeepsLastConfigButClearsReachable(t *testing.T) { + upstream := newConfigServer(t, "east") + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: upstream.url()})) + + manager.probeAll(t.Context()) + upstream.down.Store(true) + manager.probeAll(t.Context()) + + state := manager.Snapshot()[0] + if state.Reachable { + t.Error("instance is still reachable after a failed probe") + } + // Keeping the last config means the UI's feature gating does not thrash + // while an instance is briefly down. + if len(state.Config) == 0 { + t.Error("cached config was dropped on failure, want it retained") + } + if state.Instance != "east" { + t.Errorf("Instance = %q, want it retained as %q", state.Instance, "east") + } +} + +func TestSnapshotBeforeFirstProbe(t *testing.T) { + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: "http://never:7072"})) + + states := manager.Snapshot() + if len(states) != 1 { + t.Fatalf("Snapshot returned %d states, want 1", len(states)) + } + if states[0].Reachable { + t.Error("instance is reachable before any probe ran") + } + if states[0].Config != nil { + t.Errorf("Config = %s, want nil until first contact", states[0].Config) + } + + // An entry with no config yet must serialise without a config key at all, + // so the UI can tell "not contacted" from "contacted, empty config". + encoded, err := json.Marshal(states[0]) + if err != nil { + t.Fatalf("marshal state: %v", err) + } + if want := `{"instance":"","url":"http://never:7072","reachable":false}`; string(encoded) != want { + t.Errorf("state JSON = %s, want %s", encoded, want) + } +} + +func TestSnapshotPreservesConfigOrder(t *testing.T) { + manager := newTestManager(t, testSettings( + InstanceConfig{BaseURL: "http://c:7072"}, + InstanceConfig{BaseURL: "http://a:7072"}, + InstanceConfig{BaseURL: "http://b:7072"}, + )) + + states := manager.Snapshot() + want := []string{"http://c:7072", "http://a:7072", "http://b:7072"} + for idx, url := range want { + if states[idx].URL != url { + t.Errorf("states[%d].URL = %q, want %q", idx, states[idx].URL, url) + } + } +} + +func TestResolve(t *testing.T) { + upstream := newConfigServer(t, "east") + manager := newTestManager(t, testSettings( + InstanceConfig{BaseURL: "http://down:7072", APISecret: "down-secret"}, + InstanceConfig{BaseURL: upstream.url(), APISecret: "up-secret"}, + )) + manager.probeAll(t.Context()) + + t.Run("by base url", func(t *testing.T) { + target, err := manager.Resolve(upstream.url()) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if target.BaseURL != upstream.url() || target.APISecret != "up-secret" { + t.Errorf("target = %+v", target) + } + }) + + t.Run("by instance name", func(t *testing.T) { + target, err := manager.Resolve("east") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if target.BaseURL != upstream.url() { + t.Errorf("target = %+v, want %q", target, upstream.url()) + } + }) + + t.Run("unreachable instance still resolves", func(t *testing.T) { + // Resolving is not gated on reachability: a request that names a + // downed instance should fail against that instance, with its own + // error, rather than be silently answered by a different one. + target, err := manager.Resolve("http://down:7072") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if target.BaseURL != "http://down:7072" { + t.Errorf("target = %+v", target) + } + }) + + t.Run("empty key picks first reachable", func(t *testing.T) { + target, err := manager.Resolve("") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if target.BaseURL != upstream.url() { + t.Errorf("target = %+v, want the reachable instance %q", target, upstream.url()) + } + }) + + t.Run("unknown key", func(t *testing.T) { + _, err := manager.Resolve("http://nope:7072") + if !IsErrInstanceNotFound(err) { + t.Errorf("error = %v, want instance-not-found", err) + } + }) +} + +func TestResolveWithNothingUsable(t *testing.T) { + t.Run("no instances configured", func(t *testing.T) { + manager := newTestManager(t, testSettings()) + if _, err := manager.Resolve(""); !IsErrNoInstances(err) { + t.Errorf("error = %v, want no-instances", err) + } + if _, err := manager.Resolve("anything"); !IsErrNoInstances(err) { + t.Errorf("error = %v, want no-instances", err) + } + }) + + t.Run("none reachable", func(t *testing.T) { + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: "http://down:7072"})) + if _, err := manager.Resolve(""); !IsErrNoInstanceReachable(err) { + t.Errorf("error = %v, want no-instance-reachable", err) + } + }) +} + +func TestSetSettingsPreservesSurvivingInstances(t *testing.T) { + upstream := newConfigServer(t, "east") + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: upstream.url(), APISecret: "old"})) + manager.probeAll(t.Context()) + + // A reload that adds an instance and rotates a secret must not blank out + // the instance it left in place. + manager.SetSettings(testSettings( + InstanceConfig{BaseURL: upstream.url(), APISecret: "rotated"}, + InstanceConfig{BaseURL: "http://new:7072"}, + )) + + states := manager.Snapshot() + if len(states) != 2 { + t.Fatalf("Snapshot returned %d states, want 2", len(states)) + } + if !states[0].Reachable || states[0].Instance != "east" || len(states[0].Config) == 0 { + t.Errorf("surviving instance lost its cached state: %+v", states[0]) + } + if states[1].Reachable { + t.Error("newly added instance is reachable before being probed") + } + + target, err := manager.Resolve(upstream.url()) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if target.APISecret != "rotated" { + t.Errorf("APISecret = %q, want the rotated value", target.APISecret) + } +} + +func TestSetSettingsDropsRemovedInstances(t *testing.T) { + upstream := newConfigServer(t, "east") + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: upstream.url()})) + manager.probeAll(t.Context()) + + manager.SetSettings(testSettings(InstanceConfig{BaseURL: "http://other:7072"})) + + if states := manager.Snapshot(); len(states) != 1 || states[0].URL != "http://other:7072" { + t.Errorf("Snapshot = %+v, want only the remaining instance", states) + } + if _, err := manager.Resolve(upstream.url()); !IsErrInstanceNotFound(err) { + t.Errorf("error = %v, want the removed instance to be unknown", err) + } +} + +func TestChangedSignalsReachabilityTransitions(t *testing.T) { + upstream := newConfigServer(t, "east") + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: upstream.url()})) + + changed := manager.Changed() + manager.probeAll(t.Context()) + select { + case <-changed: + default: + t.Fatal("Changed was not signalled when the instance became reachable") + } + + // A probe that changes nothing must not signal, or a waiter would spin. + changed = manager.Changed() + manager.probeAll(t.Context()) + select { + case <-changed: + t.Error("Changed was signalled by a probe that changed nothing") + default: + } +} + +func TestRunProbesUntilContextCancelled(t *testing.T) { + upstream := newConfigServer(t, "east") + manager := newTestManager(t, Settings{ + Instances: []InstanceConfig{{BaseURL: upstream.url()}}, + Interval: 5 * time.Millisecond, + Timeout: time.Second, + }) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + manager.Run(ctx) + }() + + // Wait for the first probe to land rather than sleeping a fixed amount. + deadline := time.After(5 * time.Second) + for upstream.requests.Load() == 0 { + select { + case <-deadline: + t.Fatal("Run never probed the instance") + case <-time.After(time.Millisecond): + } + } + + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after its context was cancelled") + } +} + +func TestSettingsValidate(t *testing.T) { + tests := []struct { + name string + settings Settings + wantErr bool + }{ + {name: "ok", settings: testSettings(InstanceConfig{BaseURL: "http://a:7072"})}, + {name: "ok with no instances", settings: testSettings()}, + {name: "zero interval", settings: Settings{Timeout: time.Second}, wantErr: true}, + {name: "zero timeout", settings: Settings{Interval: time.Second}, wantErr: true}, + { + name: "empty base url", + settings: testSettings(InstanceConfig{}), + wantErr: true, + }, + { + name: "duplicate base url", + settings: testSettings( + InstanceConfig{BaseURL: "http://a:7072"}, + InstanceConfig{BaseURL: "http://a:7072"}, + ), + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.settings.Validate() + if (err != nil) != test.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} + +func TestFetchConfigRejectsBadResponses(t *testing.T) { + tests := []struct { + name string + body string + code int + }{ + {name: "non-200", body: `{"status":"ok","config":{}}`, code: http.StatusUnauthorized}, + {name: "not json", body: `not json`, code: http.StatusOK}, + {name: "no config object", body: `{"status":"ok"}`, code: http.StatusOK}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(test.code) + _, _ = io.WriteString(w, test.body) + })) + defer server.Close() + + manager := newTestManager(t, testSettings(InstanceConfig{BaseURL: server.URL})) + manager.probeAll(t.Context()) + + if manager.Snapshot()[0].Reachable { + t.Error("instance counted as reachable despite an unusable config response") + } + }) + } +} diff --git a/apps/rotom-ng-ui-server/app/proxy/proxy.go b/apps/rotom-ng-ui-server/app/proxy/proxy.go new file mode 100644 index 0000000..b9c2051 --- /dev/null +++ b/apps/rotom-ng-ui-server/app/proxy/proxy.go @@ -0,0 +1,196 @@ +// Package proxy forwards API requests to the rotom-ng instance the caller +// selected. +package proxy + +import ( + "context" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/UnownHash/RotomNG/libs/auth" + + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/instances" +) + +// InstanceHeader names the instance a request is for. Its value is that +// instance's configured base URL -- unique by construction -- though an +// instance name is accepted too. Absent, the first reachable instance is used, +// so a plain API client that does not care which instance answers still works. +const InstanceHeader = "X-Rotom-Instance" + +// Log/response field keys. +const ( + fieldStatus = "status" + fieldError = "error" + statusError = "error" +) + +// Resolver picks the upstream for an instance key. +type Resolver interface { + Resolve(key string) (instances.Target, error) +} + +// Config holds the dependencies for a Proxy. +type Config struct { + Logger *slog.Logger + Resolver Resolver + // UserAgent identifies this service to the instances it proxies to. Left + // empty, the caller's own User-Agent is passed through untouched. + UserAgent string + // Transport is the round tripper used for upstream requests. Defaults to + // http.DefaultTransport. + Transport http.RoundTripper +} + +// targetContextKey types the context value carrying the resolved target from +// the gin handler into the ReverseProxy rewrite hook. +type targetContextKey struct{} + +// Proxy reverse-proxies API requests to a selected rotom-ng instance. +type Proxy struct { + logger *slog.Logger + resolver Resolver + reverse *httputil.ReverseProxy +} + +// New creates a Proxy. +func New(cfg Config) *Proxy { + p := &Proxy{ + logger: cfg.Logger, + resolver: cfg.Resolver, + } + p.reverse = &httputil.ReverseProxy{ + Transport: cfg.Transport, + Rewrite: func(pr *httputil.ProxyRequest) { + rewrite(pr, cfg.UserAgent) + }, + ErrorHandler: p.handleUpstreamError, + } + return p +} + +// Handler proxies the current request to the selected instance. +// +// It is installed as the web server's API fallback, so it sees every /api +// path this service does not serve itself. That is deliberate: rotom-ng can +// grow endpoints without this service needing to learn about them. +func (p *Proxy) Handler(c *gin.Context) { + key := c.GetHeader(InstanceHeader) + + target, err := p.resolver.Resolve(key) + if err != nil { + p.rejectUnresolved(c, key, err) + return + } + + upstream, err := url.Parse(target.BaseURL) + if err != nil { + // Base URLs are validated at config load, so this is not reachable + // from a valid configuration -- but a 502 beats a panic if it ever is. + p.logger.LogAttrs(c.Request.Context(), slog.LevelError, "instance base url is unusable", + slog.String("url", target.BaseURL), slog.String(fieldError, err.Error())) + c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{ + fieldStatus: statusError, + fieldError: "instance base url is unusable", + }) + return + } + + resolved := &resolvedTarget{upstream: upstream, secret: target.APISecret} + ctx := context.WithValue(c.Request.Context(), targetContextKey{}, resolved) + p.reverse.ServeHTTP(c.Writer, c.Request.WithContext(ctx)) + + p.logger.LogAttrs(c.Request.Context(), slog.LevelDebug, "proxied request", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + slog.String("url", target.BaseURL), + slog.String("instance", target.Instance), + slog.Int(fieldStatus, c.Writer.Status()), + ) +} + +// resolvedTarget is what rewrite needs, precomputed by the handler. +type resolvedTarget struct { + upstream *url.URL + secret string +} + +// rejectUnresolved answers a request that names no usable instance. The three +// causes are distinguished because they mean different things to an operator: +// nothing configured, a name that does not exist, or everything down. +func (p *Proxy) rejectUnresolved(c *gin.Context, key string, err error) { + status := http.StatusServiceUnavailable + if instances.IsErrInstanceNotFound(err) { + status = http.StatusNotFound + } + p.logger.LogAttrs(c.Request.Context(), slog.LevelWarn, "cannot route request to an instance", + slog.String("path", c.Request.URL.Path), + slog.String("requested_instance", key), + slog.String(fieldError, err.Error()), + ) + c.AbortWithStatusJSON(status, gin.H{fieldStatus: statusError, fieldError: err.Error()}) +} + +func (p *Proxy) handleUpstreamError(w http.ResponseWriter, r *http.Request, err error) { + // A cancelled client connection is not an upstream failure and writing to + // the (gone) response writer would only add noise. + if r.Context().Err() != nil { + return + } + p.logger.LogAttrs(r.Context(), slog.LevelWarn, "upstream request failed", + slog.String("method", r.Method), + slog.String("url", r.URL.String()), + slog.String(fieldError, err.Error()), + ) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + // Errors from the request itself are not worth handling: the connection + // is already in a bad way and there is nothing left to fall back to. + _, _ = w.Write([]byte(`{"status":"error","error":"instance request failed"}`)) +} + +// rewrite retargets the outbound request at the selected instance and strips +// the credentials that belong to this service rather than to the instance. +func rewrite(pr *httputil.ProxyRequest, userAgent string) { + resolved, ok := pr.In.Context().Value(targetContextKey{}).(*resolvedTarget) + if !ok { + // Handler always sets the value, so reaching this means a programming + // error rather than a bad request; leaving the URL alone makes the + // transport fail loudly into ErrorHandler. + return + } + + pr.Out.URL.Scheme = resolved.upstream.Scheme + pr.Out.URL.Host = resolved.upstream.Host + // A base URL may carry a path prefix when rotom-ng sits behind a + // path-routing proxy; the incoming path (already /api/...) hangs off it. + pr.Out.URL.Path = strings.TrimRight(resolved.upstream.Path, "/") + pr.In.URL.Path + pr.Out.URL.RawPath = "" + pr.Out.Host = resolved.upstream.Host + + pr.SetXForwarded() + + // This service's own credentials must not travel upstream: the session + // cookie and bearer token are signed with the admin secret and are + // meaningless -- but still sensitive -- to an instance. The instance's own + // secret is what authenticates us. + pr.Out.Header.Del("Cookie") + pr.Out.Header.Del("Authorization") + pr.Out.Header.Del(auth.SessionRequestHeader) + pr.Out.Header.Del(InstanceHeader) + + if resolved.secret == "" { + pr.Out.Header.Del(auth.SecretRequestHeader) + } else { + pr.Out.Header.Set(auth.SecretRequestHeader, resolved.secret) + } + + if userAgent != "" { + pr.Out.Header.Set("User-Agent", userAgent) + } +} diff --git a/apps/rotom-ng-ui-server/app/proxy/proxy_test.go b/apps/rotom-ng-ui-server/app/proxy/proxy_test.go new file mode 100644 index 0000000..bf7cd3d --- /dev/null +++ b/apps/rotom-ng-ui-server/app/proxy/proxy_test.go @@ -0,0 +1,291 @@ +package proxy + +import ( + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/instances" +) + +func testLogger() *slog.Logger { + return slog.New(slog.DiscardHandler) +} + +// stubResolver returns a fixed target, or a fixed error. +type stubResolver struct { + target instances.Target + err error +} + +func (s stubResolver) Resolve(string) (instances.Target, error) { + return s.target, s.err +} + +// recordingResolver records the key it was asked about. +type recordingResolver struct { + target instances.Target + lastKey string +} + +func (r *recordingResolver) Resolve(key string) (instances.Target, error) { + r.lastKey = key + return r.target, nil +} + +// upstreamRecorder stands in for a rotom-ng instance and captures the request +// it was sent. +type upstreamRecorder struct { + server *httptest.Server + request *http.Request + body string +} + +func newUpstream(t *testing.T) *upstreamRecorder { + t.Helper() + rec := &upstreamRecorder{} + rec.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + rec.body = string(body) + rec.request = r.Clone(r.Context()) + w.Header().Set("X-Upstream", "yes") + w.WriteHeader(http.StatusTeapot) + _, _ = io.WriteString(w, `{"status":"ok"}`) + })) + t.Cleanup(rec.server.Close) + return rec +} + +// newTestServer serves the proxy the way the app does: as the fallback for +// every API path no route claimed. It is a real server rather than a +// ResponseRecorder because ReverseProxy takes a different code path when the +// request context has no Done channel, which a synthetic request would not +// exercise the same way. +func newTestServer(t *testing.T, p *Proxy) *httptest.Server { + t.Helper() + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.NoRoute(p.Handler) + server := httptest.NewServer(engine) + t.Cleanup(server.Close) + return server +} + +// result is the part of a response the assertions care about. +type result struct { + status int + header http.Header + body string +} + +func doRequest(t *testing.T, server *httptest.Server, request *http.Request) result { + t.Helper() + request.URL.Scheme = "http" + request.URL.Host = strings.TrimPrefix(server.URL, "http://") + request.RequestURI = "" + + response, err := server.Client().Do(request) + if err != nil { + t.Fatalf("request: %v", err) + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return result{status: response.StatusCode, header: response.Header, body: string(body)} +} + +func TestHandlerProxiesToSelectedInstance(t *testing.T) { + upstream := newUpstream(t) + resolver := &recordingResolver{target: instances.Target{ + BaseURL: upstream.server.URL, + APISecret: "instance-secret", + Instance: "east", + }} + server := newTestServer(t, New(Config{Logger: testLogger(), Resolver: resolver, UserAgent: "RotomNG-UI/test"})) + + request := httptest.NewRequest(http.MethodPut, "/api/device/abc/action/reboot?include_workers=true", strings.NewReader(`{"a":1}`)) + request.Header.Set(InstanceHeader, "east") + response := doRequest(t, server, request) + + if response.status != http.StatusTeapot { + t.Errorf("status = %d, want %d (the upstream's own status)", response.status, http.StatusTeapot) + } + if got := response.header.Get("X-Upstream"); got != "yes" { + t.Errorf("upstream response headers were not passed through: %v", response.header) + } + if response.body != `{"status":"ok"}` { + t.Errorf("body = %q, want the upstream's body", response.body) + } + + if resolver.lastKey != "east" { + t.Errorf("resolver key = %q, want the value of the instance header", resolver.lastKey) + } + if upstream.request == nil { + t.Fatal("upstream received no request") + } + // The /api prefix is part of the path the instance serves, so it must + // survive the hop intact -- along with the method, query, and body. + if got, want := upstream.request.URL.Path, "/api/device/abc/action/reboot"; got != want { + t.Errorf("upstream path = %q, want %q", got, want) + } + if got, want := upstream.request.URL.RawQuery, "include_workers=true"; got != want { + t.Errorf("upstream query = %q, want %q", got, want) + } + if upstream.request.Method != http.MethodPut { + t.Errorf("upstream method = %q, want PUT", upstream.request.Method) + } + if upstream.body != `{"a":1}` { + t.Errorf("upstream body = %q, want the request body", upstream.body) + } +} + +func TestHandlerSwapsCredentials(t *testing.T) { + upstream := newUpstream(t) + resolver := stubResolver{target: instances.Target{BaseURL: upstream.server.URL, APISecret: "instance-secret"}} + server := newTestServer(t, New(Config{Logger: testLogger(), Resolver: resolver})) + + request := httptest.NewRequest(http.MethodGet, "/api/status", nil) + // Everything the admin UI sends to authenticate against THIS service. + request.Header.Set("Cookie", "rotom_session=admin-token") + request.Header.Set("Authorization", "Bearer admin-token") + request.Header.Set("X-Rotom-Session", "1") + request.Header.Set("X-Rotom-Secret", "admin-secret") + request.Header.Set(InstanceHeader, "east") + doRequest(t, server, request) + + if upstream.request == nil { + t.Fatal("upstream received no request") + } + // The admin's own credentials are signed with the admin secret: upstream + // they are useless and leaking them would widen the blast radius of a + // compromised instance. + for _, header := range []string{"Cookie", "Authorization", "X-Rotom-Session", InstanceHeader} { + if got := upstream.request.Header.Get(header); got != "" { + t.Errorf("upstream saw %s = %q, want it stripped", header, got) + } + } + if got := upstream.request.Header.Get("X-Rotom-Secret"); got != "instance-secret" { + t.Errorf("upstream saw secret %q, want the instance's own secret", got) + } +} + +func TestHandlerDropsSecretHeaderForUnsecuredInstance(t *testing.T) { + upstream := newUpstream(t) + resolver := stubResolver{target: instances.Target{BaseURL: upstream.server.URL}} + server := newTestServer(t, New(Config{Logger: testLogger(), Resolver: resolver})) + + request := httptest.NewRequest(http.MethodGet, "/api/status", nil) + request.Header.Set("X-Rotom-Secret", "admin-secret") + doRequest(t, server, request) + + if got := upstream.request.Header.Get("X-Rotom-Secret"); got != "" { + t.Errorf("upstream saw secret %q, want none for an instance with no secret", got) + } +} + +func TestHandlerHonoursBaseURLPathPrefix(t *testing.T) { + upstream := newUpstream(t) + resolver := stubResolver{target: instances.Target{BaseURL: upstream.server.URL + "/rotom"}} + server := newTestServer(t, New(Config{Logger: testLogger(), Resolver: resolver})) + + doRequest(t, server, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + + if got, want := upstream.request.URL.Path, "/rotom/api/status"; got != want { + t.Errorf("upstream path = %q, want %q", got, want) + } +} + +func TestHandlerPassesEmptyKeyWhenNoInstanceHeader(t *testing.T) { + upstream := newUpstream(t) + resolver := &recordingResolver{target: instances.Target{BaseURL: upstream.server.URL}} + server := newTestServer(t, New(Config{Logger: testLogger(), Resolver: resolver})) + + doRequest(t, server, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + + // An empty key is what tells the manager to pick the first reachable + // instance, so a plain API client needs no instance configuration. + if resolver.lastKey != "" { + t.Errorf("resolver key = %q, want empty", resolver.lastKey) + } +} + +func TestHandlerRejectsUnresolvableInstances(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + }{ + {name: "unknown instance", err: instances.NewErrInstanceNotFound(), wantStatus: http.StatusNotFound}, + {name: "none configured", err: instances.NewErrNoInstances(), wantStatus: http.StatusServiceUnavailable}, + {name: "none reachable", err: instances.NewErrNoInstanceReachable(), wantStatus: http.StatusServiceUnavailable}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := newTestServer(t, New(Config{ + Logger: testLogger(), + Resolver: stubResolver{err: test.err}, + })) + + response := doRequest(t, server, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + + if response.status != test.wantStatus { + t.Errorf("status = %d, want %d", response.status, test.wantStatus) + } + if !strings.Contains(response.body, test.err.Error()) { + t.Errorf("body = %q, want it to explain %q", response.body, test.err) + } + }) + } +} + +func TestHandlerReportsUpstreamFailureAsBadGateway(t *testing.T) { + // A URL that parses but cannot be dialled: the instance is configured but + // is not answering. + resolver := stubResolver{target: instances.Target{BaseURL: "http://127.0.0.1:1"}} + server := newTestServer(t, New(Config{Logger: testLogger(), Resolver: resolver})) + + response := doRequest(t, server, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + + if response.status != http.StatusBadGateway { + t.Errorf("status = %d, want %d", response.status, http.StatusBadGateway) + } +} + +func TestHandlerRejectsUnusableBaseURL(t *testing.T) { + resolver := stubResolver{target: instances.Target{BaseURL: "http://[::1]:namedport"}} + server := newTestServer(t, New(Config{Logger: testLogger(), Resolver: resolver})) + + response := doRequest(t, server, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + + if response.status != http.StatusBadGateway { + t.Errorf("status = %d, want %d", response.status, http.StatusBadGateway) + } +} + +func TestHandlerPropagatesResolverErrorsVerbatim(t *testing.T) { + // An error that is none of the three known kinds still has to produce a + // response rather than a panic or an empty body. + server := newTestServer(t, New(Config{ + Logger: testLogger(), + Resolver: stubResolver{err: errors.New("something else went wrong")}, + })) + + response := doRequest(t, server, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + + if response.status != http.StatusServiceUnavailable { + t.Errorf("status = %d, want %d", response.status, http.StatusServiceUnavailable) + } + if !strings.Contains(response.body, "something else went wrong") { + t.Errorf("body = %q", response.body) + } +} diff --git a/apps/rotom-ng-ui-server/main.go b/apps/rotom-ng-ui-server/main.go new file mode 100644 index 0000000..946d79f --- /dev/null +++ b/apps/rotom-ng-ui-server/main.go @@ -0,0 +1,69 @@ +// The RotomNG UI server serves the RotomNG web UI for several rotom-ng +// instances at once, proxying its API calls to whichever one is selected. +package main + +import ( + "flag" + "log" + "os" + + "github.com/UnownHash/RotomNG/libs/gitutil" + "github.com/UnownHash/RotomNG/libs/rotom_ui" + + uiapp "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app" + "github.com/UnownHash/RotomNG/apps/rotom-ng-ui-server/app/config" + "github.com/UnownHash/RotomNG/apps/rotom-ng/app/version" +) + +const defaultConfigFile = "configs/rotom-ng-ui.toml" + +func main() { + var flagCfg uiapp.FlagConfig + uiFS := rotom_ui.GetUIFS() + hasEmbeddedUI := uiFS != nil + + // Parse command line flags + flag.BoolVar(&flagCfg.DebugMode, "debug", false, "Enable debug mode (sets Gin to debug mode)") + if hasEmbeddedUI { + flag.StringVar(&flagCfg.UIPath, "ui-path", "", "Path to the UI static files directory to override embedded UI") + } else { + flag.StringVar(&flagCfg.UIPath, "ui-path", "./libs/rotom_ui/static", "Path to the UI static files directory") + } + flag.BoolVar(&flagCfg.UIDev, "ui-dev", false, "Enable UI development mode (proxy to dev server)") + showVersion := flag.Bool("version", false, "Print version and exit") + flag.Parse() + + if *showVersion { + log.Printf("RotomNG UI %s[%s]", version.AppVersion, gitutil.GetGitBuildSHA()) + os.Exit(0) + } + + flagCfg.UIFS = uiFS + + // Load config - use remaining args for config path + configPath := defaultConfigFile + args := flag.Args() + if len(args) > 0 { + configPath = args[0] + } + + flagCfg.ReloadConfig = func() (*config.Config, error) { + return config.LoadFromFile(configPath) + } + + cfg, err := config.LoadFromFile(configPath) + if err != nil { + log.Fatalf("Failed to load config from '%s': %v", configPath, err) + } + + app, err := uiapp.NewApp(cfg, flagCfg) + if err != nil { + log.Fatalf("Failed to create app: %v", err) + } + + if err := app.Init(); err != nil { + log.Fatalf("failed to initialize app: %v", err) + } + + app.Run() +} diff --git a/apps/rotom-ng-ui/src/app/app.tsx b/apps/rotom-ng-ui/src/app/app.tsx index 3f98366..ba0735a 100644 --- a/apps/rotom-ng-ui/src/app/app.tsx +++ b/apps/rotom-ng-ui/src/app/app.tsx @@ -5,12 +5,13 @@ import { ControllersPage, createAppQueryClient, DevicePage, + InstanceGate, JobsPage, Layout, type NavItem, StatusPage, TooltipProvider, - useConfig, + useActiveConfig, WorkersPage, } from "@rotom-ng/base-ui"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -30,8 +31,10 @@ const baseNavItems: NavItem[] = [ ]; function AppContent() { - const { data: configData } = useConfig(); - const jobsEnabled = configData?.config?.jobs?.enable === true; + // The active instance's config when fronted by the admin service, so the + // nav follows the instance the operator selected rather than the service. + const config = useActiveConfig(); + const jobsEnabled = config?.jobs?.enable === true; const navItems = useMemo(() => { if (jobsEnabled) { @@ -48,14 +51,19 @@ function AppContent() { appVersion={APP_VERSION} navItems={navItems} > - - } /> - } /> - } /> - } /> - {jobsEnabled && } />} - } /> - + {/* Inside Layout so the instance picker stays reachable when the + selected instance is down. Passes through unless the UI is fronted + by the admin service. */} + + + } /> + } /> + } /> + } /> + {jobsEnabled && } />} + } /> + + ); diff --git a/apps/rotom-ng/app/app.go b/apps/rotom-ng/app/app.go index a4bf41c..60880c5 100644 --- a/apps/rotom-ng/app/app.go +++ b/apps/rotom-ng/app/app.go @@ -262,15 +262,8 @@ func (a *App) Init() error { gin.SetMode(gin.ReleaseMode) } - hasEmbeddedUI := a.flagCfg.UIFS != nil - if !a.flagCfg.UIDev && (!hasEmbeddedUI || a.flagCfg.UIPath != "") { - indexPath := a.flagCfg.UIPath + "/index.html" - if _, err := os.Stat(indexPath); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("UI index.html file does not exist at path '%s' (ensure you built the UI or use -ui-path)", indexPath) - } - return fmt.Errorf("UI index.html file is not readable at path '%s' (ensure you built the UI or use -ui-path): %w", indexPath, err) - } + if err := a.checkUIAssets(); err != nil { + return err } a.logger.LogAttrs(context.Background(), slog.LevelInfo, "starting RotomNG", slog.String("version", appVersion), slog.String("git_sha", gitSHA)) @@ -444,6 +437,33 @@ func (a *App) Init() error { return nil } +// checkUIAssets fails startup when the UI is going to be served but its bundle +// is not there, which is otherwise a confusing 404 at first page load. +// +// The assets only have to be present if the UI will actually be served. +// Starting with http_listener.disable_ui set is a supported way to run an +// API-only listener, so a missing bundle is not an error then. Switching the UI +// back on at runtime does not re-check: the static handler simply 404s until +// the assets are in place. +func (a *App) checkUIAssets() error { + if a.cfg.HTTPListener.DisableUI || a.flagCfg.UIDev { + return nil + } + // An embedded bundle is always present, unless overridden by -ui-path. + if a.flagCfg.UIFS != nil && a.flagCfg.UIPath == "" { + return nil + } + + indexPath := a.flagCfg.UIPath + "/index.html" + if _, err := os.Stat(indexPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("UI index.html file does not exist at path '%s' (ensure you built the UI or use -ui-path)", indexPath) + } + return fmt.Errorf("UI index.html file is not readable at path '%s' (ensure you built the UI or use -ui-path): %w", indexPath, err) + } + return nil +} + func (a *App) setShutdownTimeout(d time.Duration) { a.shutdownTimeout.Store(int64(d)) } diff --git a/apps/rotom-ng/app/config/config.go b/apps/rotom-ng/app/config/config.go index 4c598f4..4321e00 100644 --- a/apps/rotom-ng/app/config/config.go +++ b/apps/rotom-ng/app/config/config.go @@ -80,6 +80,14 @@ type HTTPListener struct { // Defaults to DefaultUISessionTTL when unset or <= 0. Only relevant when // Secret is set, since without a secret the UI never logs in. UISessionTTL time.Duration `koanf:"ui_session_ttl"` + // DisableUI withholds the web UI, leaving the REST API as the only thing + // this listener answers. Every non-API path 404s. + // + // For deployments that reach the API from elsewhere -- a controller, or the + // rotom-ng-ui admin service -- and would rather not expose a browser + // surface at all. Applied per request, so a config reload turns it on or + // off without a restart. + DisableUI bool `koanf:"disable_ui"` } // Tuning holds performance tuning options. diff --git a/apps/rotom-ng/app/config/config_test.go b/apps/rotom-ng/app/config/config_test.go index f100a95..7120ad8 100644 --- a/apps/rotom-ng/app/config/config_test.go +++ b/apps/rotom-ng/app/config/config_test.go @@ -413,3 +413,41 @@ func TestLoadFromFileNotFound(t *testing.T) { t.Error("Expected error when loading nonexistent file") } } + +func TestHTTPListenerDisableUI(t *testing.T) { + tests := []struct { + name string + listener string + want bool + }{ + { + name: "absent defaults to serving the UI", + listener: "[http_listener]\naddress = \":7072\"\n", + }, + { + name: "explicit false", + listener: "[http_listener]\ndisable_ui = false\n", + }, + { + name: "explicit true", + listener: "[http_listener]\ndisable_ui = true\n", + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "rotom-ng.toml") + if err := os.WriteFile(path, []byte(test.listener), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + if cfg.HTTPListener.DisableUI != test.want { + t.Errorf("DisableUI = %v, want %v", cfg.HTTPListener.DisableUI, test.want) + } + }) + } +} diff --git a/apps/rotom-ng/app/handlers/api_handler.go b/apps/rotom-ng/app/handlers/api_handler.go index 1ecef5a..8f7d333 100644 --- a/apps/rotom-ng/app/handlers/api_handler.go +++ b/apps/rotom-ng/app/handlers/api_handler.go @@ -84,6 +84,12 @@ func (ah *HTTPAPIHandler) GetConfig(c *gin.Context) { if cfg.Instance != "" { jsonConfig["instance"] = cfg.Instance } + // Only when true, as with disable_worker_stats. Reported so an operator can + // confirm a reload actually landed -- the UI being gone is otherwise the + // only feedback, and that is hard to tell from a broken deployment. + if cfg.HTTPListener != nil && cfg.HTTPListener.DisableUI { + jsonConfig["http_listener"] = gin.H{"disable_ui": true} + } if cfg.Jobs != nil && cfg.Jobs.Enable { jsonConfig["jobs"] = gin.H{ "enable": true, @@ -118,6 +124,13 @@ func (ah *HTTPAPIHandler) GetConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "config": jsonConfig}) } +// GetUIDisabled reports whether the web UI is switched off. Read per request +// by the web server, so a config reload takes effect without a restart. +func (ah *HTTPAPIHandler) GetUIDisabled() bool { + cfg := ah.getSettings().CurrentConfig + return cfg.HTTPListener != nil && cfg.HTTPListener.DisableUI +} + // GetPrometheusEnabled returns whether Prometheus metrics are enabled. func (ah *HTTPAPIHandler) GetPrometheusEnabled() bool { return ah.getSettings().CurrentConfig.Prometheus.Enable diff --git a/apps/rotom-ng/app/httpserver/http_server.go b/apps/rotom-ng/app/httpserver/http_server.go index b9d9d47..8e52486 100644 --- a/apps/rotom-ng/app/httpserver/http_server.go +++ b/apps/rotom-ng/app/httpserver/http_server.go @@ -17,6 +17,7 @@ import ( type HTTPAPIHandler interface { SetupAPIRoutes(apiGroup *gin.RouterGroup) GetPrometheusEnabled() bool + GetUIDisabled() bool GetConfig(ginContext *gin.Context) ConfigReload(ginContext *gin.Context) } @@ -55,6 +56,7 @@ func NewHTTPServer(ctx context.Context, logger *slog.Logger, cfg Config) (*HTTPS DevMode: cfg.DevMode, AuthMiddleware: cfg.AuthMiddleware, StatsRegistrar: cfg.StatsRegistrar, + UIDisabled: apiHandler.GetUIDisabled, SetupAPIRoutes: func(apiGroup *gin.RouterGroup) { apiGroup.GET("/config", apiHandler.GetConfig) apiGroup.PUT("/config/reload", apiHandler.ConfigReload) diff --git a/apps/rotom-ng/app/ui_disable_test.go b/apps/rotom-ng/app/ui_disable_test.go new file mode 100644 index 0000000..aa29fd1 --- /dev/null +++ b/apps/rotom-ng/app/ui_disable_test.go @@ -0,0 +1,142 @@ +package app_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/UnownHash/RotomNG/apps/rotom-ng/app/config" + "github.com/UnownHash/RotomNG/libs/testutil" +) + +// getPath fetches an arbitrary path and returns the status and body. +func getPath(t *testing.T, httpAddr, path string) (int, string) { + t.Helper() + resp, err := testHTTPClient.Get("http://" + httpAddr + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + defer resp.Body.Close() + body := make([]byte, 512) + n, _ := resp.Body.Read(body) + return resp.StatusCode, string(body[:n]) +} + +// TestUI_DisabledWithholdsUIButNotAPI covers http_listener.disable_ui: the +// REST API keeps working while every browser-facing path is refused. +func TestUI_DisabledWithholdsUIButNotAPI(t *testing.T) { + env, err := testutil.NewTestEnv(testutil.WithDisableUI(true)) + if err != nil { + t.Fatalf("NewTestEnv: %v", err) + } + if err := env.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { _ = env.Stop() }) + + status, body := getPath(t, env.HTTPAddr, "/") + if status != http.StatusNotFound { + t.Errorf("GET / status = %d, want 404 (body %s)", status, body) + } + if !strings.Contains(body, "disabled") { + t.Errorf("GET / body = %q, want it to say the UI is disabled", body) + } + + // The API is the whole point of running this way. + if status, body := getPath(t, env.HTTPAddr, "/api/status"); status != http.StatusOK { + t.Errorf("GET /api/status = %d, want 200 (body %s)", status, body) + } + + // Reported back so an operator can confirm the setting took. + status, body = getPath(t, env.HTTPAddr, "/api/config") + if status != http.StatusOK { + t.Fatalf("GET /api/config = %d (body %s)", status, body) + } + var reply struct { + Config struct { + HTTPListener struct { + DisableUI bool `json:"disable_ui"` + } `json:"http_listener"` + } `json:"config"` + } + if err := json.Unmarshal([]byte(body), &reply); err != nil { + t.Fatalf("decode /api/config: %v (body %s)", err, body) + } + if !reply.Config.HTTPListener.DisableUI { + t.Errorf("/api/config did not report disable_ui: %s", body) + } +} + +// TestUI_DisableIsHotReloadable is the property that matters most: the routes +// are registered once at startup, so turning the UI off has to work through a +// config reload rather than needing a restart. +func TestUI_DisableIsHotReloadable(t *testing.T) { + // The reload callback hands back a config whose disable_ui flips when the + // test says so, standing in for an edited config file. + var disabled bool + reload := func() (*config.Config, error) { + cfg, cleanup, err := testutil.NewTestConfig(testutil.WithDisableUI(disabled)) + if err != nil { + return nil, err + } + t.Cleanup(cleanup) + return cfg, nil + } + + env, err := testutil.NewTestEnv(testutil.WithReloadConfig(reload)) + if err != nil { + t.Fatalf("NewTestEnv: %v", err) + } + if err := env.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { _ = env.Stop() }) + + // Started with the UI on. The test env runs in UI dev mode, so a UI path + // is proxied at the (absent) dev server and fails as a bad gateway -- which + // is still proof the request was not refused outright. + if status, body := getPath(t, env.HTTPAddr, "/"); status == http.StatusNotFound { + t.Fatalf("GET / = 404 before disabling; want the request to reach the UI path (body %s)", body) + } + + disabled = true + if status, body := reloadConfig(t, env.HTTPAddr); status != http.StatusOK { + t.Fatalf("reload = %d (body %s)", status, body) + } + + status, body := getPath(t, env.HTTPAddr, "/") + if status != http.StatusNotFound { + t.Errorf("GET / after reload = %d, want 404 (body %s)", status, body) + } + if !strings.Contains(body, "disabled") { + t.Errorf("body = %q, want it to say the UI is disabled", body) + } + + // And back off again, without a restart. + disabled = false + if status, body := reloadConfig(t, env.HTTPAddr); status != http.StatusOK { + t.Fatalf("second reload = %d (body %s)", status, body) + } + if status, body := getPath(t, env.HTTPAddr, "/"); status == http.StatusNotFound { + t.Errorf("GET / after re-enabling = 404, want the UI served again (body %s)", body) + } +} + +// reloadConfig issues PUT /api/config/reload. +func reloadConfig(t *testing.T, httpAddr string) (int, string) { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, + "http://"+httpAddr+"/api/config/reload", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := testHTTPClient.Do(req) + if err != nil { + t.Fatalf("PUT /api/config/reload: %v", err) + } + defer resp.Body.Close() + body := make([]byte, 512) + n, _ := resp.Body.Read(body) + return resp.StatusCode, string(body[:n]) +} diff --git a/configs/rotom-ng-ui.toml.example b/configs/rotom-ng-ui.toml.example new file mode 100644 index 0000000..c3a6d0e --- /dev/null +++ b/configs/rotom-ng-ui.toml.example @@ -0,0 +1,93 @@ +# Example configuration file for the RotomNG UI server (rotom-ng-ui) +# +# ONLY NEEDED IF YOU RUN SEVERAL ROTOM-NG INSTANCES. If you run one, ignore +# this file entirely: that rotom-ng already serves its own web UI on its +# http_listener (:7072 by default), and this service adds nothing. +# +# rotom-ng-ui serves that same web UI for several rotom-ng instances at once, +# with a menu in the header to switch between them. It manages no devices, +# workers, or controllers of its own: every API call the UI makes is proxied to +# whichever instance is selected. Point it at your instances below and browse +# to it instead of to any individual rotom-ng. +# +# Copy this file to rotom-ng-ui.toml and modify as needed. It is separate from +# rotom-ng.toml; each rotom-ng instance keeps its own config, unchanged. +# +# All sections except [[instances]] are optional - if not specified, sensible +# defaults will be used. + +# Optional name for this admin service itself, reported by /api/config. +# The instances it fronts have their own names, set in their own configs. +# instance = "admin" + +# HTTP listener configuration (optional) +# Provides the REST API and the web UI. Same shape as rotom-ng's own +# http_listener section. +[http_listener] +address = ":7073" # Default: ":7073" +# secret = "your-api-secret-here" # Optional authentication secret for API access + # to THIS service. It is unrelated to the + # per-instance secrets below: this one guards + # the admin UI, those authenticate us to each + # rotom-ng. + # API clients send it as the X-Rotom-Secret header. + # The web UI prompts for it and exchanges it for a + # session cookie, so the UI keeps working when set. + # Changing it signs out every active UI session. +# ui_session_ttl = "24h" # Default: "24h" (one day). How long a web UI login + # lasts before the operator must sign in again. + # Note: Go duration syntax has no day unit -- write + # "24h", not "1d". Applies to new logins only; + # shortening it does not cut short existing sessions. + +# The rotom-ng instances this service fronts. Repeat the block once per +# instance. With none configured the UI loads but reports that no instances are +# reachable. +# +# base_url is the root of that instance's http_listener, WITHOUT the /api +# suffix -- "/api" is appended when requests are proxied. It is also how the UI +# identifies an instance when it selects one, so each must be distinct. +# +# api_secret is that instance's own http_listener secret. Leave it out when the +# instance has no secret configured. +# +# The name shown in the UI's instance picker is the instance's own `instance` +# setting, read from its /api/config; instances that do not set one are listed +# by URL. +[[instances]] +base_url = "http://127.0.0.1:7072" +# api_secret = "that-instances-api-secret" + +# [[instances]] +# base_url = "http://127.0.0.1:7082" +# api_secret = "another-instances-api-secret" + +# Instance reachability monitoring (optional) +# Each instance's /api/config is polled on this interval. An instance counts as +# reachable only once that call has succeeded, and the UI will not let an +# operator switch to one that is not reachable. +[instance_monitor] +# interval = "10s" # Default: "10s". How often each instance is probed. +# timeout = "5s" # Default: "5s". Deadline for a single probe. Keep it below + # the interval so a hung instance cannot delay a round. + +# Logging configuration (optional) +[logging] +level = "info" # Log level: panic, fatal, error, warn, warning, info, debug, trace (default: "info") +format = "plain" # Log format: plain, json (default: "plain") +no_console_log = false # Disable console logging, only log to file (default: false) + +# File logging configuration (optional) +# File logging is enabled by default, writing to ./logs/rotom-ng-ui.log +# To disable file logging, set disable = true +[logging.file] +# disable = false # Set to true to disable file logging (default: false) +# path = "./logs/rotom-ng-ui.log" # Full path to log file (default: "./logs/rotom-ng-ui.log") +# max_size_mb = 512 # Maximum size in MB before rotation (default: 0 = no limit) +# max_backups = 30 # Number of old log files to keep (default: 0 = keep all) +# max_age_days = 30 # Maximum age in days to keep log files (default: 0 = no age limit) +# compress = false # Compress rotated log files (default: false) + +# Global shutdown timeout (optional) +# Default: "5s" +shutdown_timeout = "5s" diff --git a/configs/rotom-ng.toml.example b/configs/rotom-ng.toml.example index eee371f..95e3c90 100644 --- a/configs/rotom-ng.toml.example +++ b/configs/rotom-ng.toml.example @@ -3,6 +3,11 @@ # Copy this file to rotom-ng.toml and modify as needed. # # All sections are optional - if not specified, sensible defaults will be used. +# +# This is the only config a rotom-ng needs, including its web UI. The separate +# rotom-ng-ui.toml.example in this directory is for an optional extra service +# that fronts SEVERAL rotom-ng instances with one UI; ignore it unless you run +# more than one. # Device listener configuration (optional) # Handles connections from mitm devices (e.g., RealDeviceMap devices) as well as @@ -37,6 +42,17 @@ address = ":7072" # Default: ":7072" # Note: Go duration syntax has no day unit -- write # "24h", not "1d". Applies to new logins only; # shortening it does not cut short existing sessions. +# disable_ui = false # Default: false, i.e. the web UI is served. Set to + # true to withhold it entirely: every non-API path + # returns 404 and only the REST API is served. + # Most deployments should leave this alone -- the UI + # is how you look at this instance. It is for the + # case where something else reaches the API and no + # one browses here directly: a controller, or the + # optional rotom-ng-ui service fronting several + # instances (see docs/RotomNG-UI-Server.md). + # Applied per request, so a config reload turns it on + # or off without a restart. # Rate limiting configuration (optional) # Controls how frequently a single device's workers can be selected diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 027b76d..0830bd2 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -11,3 +11,35 @@ services: - "7070:7070" - "7071:7071" - "7072:7072" + + # --------------------------------------------------------------------------- + # OPTIONAL: the multi-instance admin UI. Most deployments do not need this. + # + # The rotom-ng above already serves its own web UI on 7072, and for a single + # instance that is all you want. rotom-ng-ui is for running SEVERAL rotom-ng + # instances: it serves one UI for all of them and proxies each API call to + # whichever instance you pick from a menu in the header, so you get one page + # instead of a browser tab per instance. It manages no devices or workers of + # its own. See docs/RotomNG-UI-Server.md. + # + # To use it: uncomment the block below, add a second rotom-ng service (or + # point it at instances running elsewhere), copy + # configs/rotom-ng-ui.toml.example to configs/rotom-ng-ui.toml, and list your + # instances in it. Then browse to port 7073 instead of 7072. + # + # Keep its tag matching rotom-ng's above: both images are built and tagged + # from the same commit. + # + # Instances are listed in configs/rotom-ng-ui.toml by base_url; those URLs are + # resolved from inside this container, so use compose service names + # (http://rotom-ng:7072) rather than localhost. + # + # rotom-ng-ui: + # image: ghcr.io/unownhash/rotomng/rotom-ng-ui:main + # container_name: rotom-ng-ui + # restart: unless-stopped + # volumes: + # - ${PWD}/configs:/rotom-ng-ui/configs + # - ${PWD}/logs:/rotom-ng-ui/logs + # ports: + # - "7073:7073" diff --git a/docs/RotomNG-API.md b/docs/RotomNG-API.md index a63affc..c5bd0a9 100644 --- a/docs/RotomNG-API.md +++ b/docs/RotomNG-API.md @@ -160,6 +160,9 @@ Returns the current system configuration including version, tuning parameters, a "pong_wait": "30s", "registration_timeout": "1m0s", "data_timeout": "2m0s" + }, + "http_listener": { + "disable_ui": true } } } @@ -172,6 +175,7 @@ Returns the current system configuration including version, tuning parameters, a - `device_listener` / `controller_listener`: websocket keep-alive settings. `ping_interval` and `pong_wait` are durations; the read timeout fires if no pong or data message is received within `ping_interval + pong_wait`. The `device_listener` settings also govern MITM worker connections, which connect on the device listener. - `registration_timeout`: max time allowed for a controller to complete registration before the normal ping-based read timeout applies. - `data_timeout`: controller connections only. The connection is considered dead if no data message is received within this period, independent of ping/pong keep-alive activity. Defaults to `"2m"`; set to `"0s"` to disable. +- `http_listener.disable_ui`: only present when the web UI has been switched off (see below). It is reported so an operator can confirm a reload took effect; the UI simply being gone is otherwise hard to tell from a broken deployment. No other `http_listener` field is exposed -- `secret` in particular is never returned. #### Reload Configuration ```http @@ -973,3 +977,25 @@ Rate limiting can be configured per device and applies to worker selection. When ## Configuration The system supports runtime configuration reloading via the `/api/config/reload` endpoint. Configuration changes take effect immediately without requiring a restart. + +### Serving the API without the web UI + +Setting `disable_ui` in `[http_listener]` withholds the web UI: every non-API +path returns `404` with `{"status": "error", "error": "the web ui is disabled"}`, +and only the REST API is served. + +```toml +[http_listener] +address = ":7072" +disable_ui = true +``` + +Useful when the API is reached from elsewhere -- a controller, or the +[multi-instance admin UI](RotomNG-UI-Server.md) -- and there is no reason to +expose a browser surface on this listener. + +It is applied per request, so `/api/config/reload` (or SIGHUP) turns it on and +off without a restart. Starting with it enabled also means the UI assets need +not be present at all; note that switching the UI back on at runtime does not +re-check for them, so a build without the UI bundle will serve 404s until it is +restarted with the assets in place. diff --git a/docs/RotomNG-Starting.md b/docs/RotomNG-Starting.md index 455ff45..c2bd5b7 100644 --- a/docs/RotomNG-Starting.md +++ b/docs/RotomNG-Starting.md @@ -4,6 +4,12 @@ Configuration for RotomNG lives in [configs](../configs). There is an example config file that should be copied to rotom-ng.toml and edited to your liking. All sections are optional and have sensible defaults. +That one file is all a rotom-ng needs, its web UI included. + +(There is a second example, `rotom-ng-ui.toml.example`, for an optional extra +service that fronts several rotom-ng instances with a single UI. Ignore it +unless you run more than one — see +[Multi-instance admin UI](RotomNG-UI-Server.md).) If you are migrating from OG Rotom, there is a conversion script at [configs/rotom-og-to-ng.py](../configs/rotom-og-to-ng.py) that will convert @@ -17,8 +23,15 @@ to `rotom-ng.toml` (for example, `python3 configs/rotom-og-to-ng.py old-config.j ### Image tags -RotomNG images are published to `ghcr.io/unownhash/rotomng/rotom-ng`. -Available tags: +Two images are published, both under `ghcr.io/unownhash/rotomng/`: + +- `rotom-ng` — the connection manager. This is the one you want. +- `rotom-ng-ui` — the optional multi-instance admin UI, which fronts several + `rotom-ng` instances and proxies to them. Skip it unless you run more than + one; see [Multi-instance admin UI](RotomNG-UI-Server.md). + +Both are built and tagged together from the same commit, so a given tag means +the same version of both. Available tags: - `main` — built from every commit on the `main` branch, so it can be ahead of the most recent release. @@ -46,10 +59,11 @@ $ docker compose up -d ### Building locally -If you prefer to build the image yourself instead of pulling from GHCR: +If you prefer to build the images yourself instead of pulling from GHCR: ``` -$ docker build -f apps/rotom-ng/Dockerfile -t rotom-ng . +$ make docker # or: docker build --target rotom-ng -t rotom-ng . +$ make docker-ui # the admin UI image, only if you need it ``` ## Non-docker (local install) @@ -69,7 +83,9 @@ $ make ``` This will install frontend dependencies via Bun, build the UI, and compile -the Go binary. +both Go binaries: `rotom-ng`, and the `rotom-ng-ui` admin server described in +[Multi-instance admin UI](RotomNG-UI-Server.md). `make rotom-ng` builds just +the first, `make rotom-ng-ui` just the second. ### Starting with pm2 @@ -88,3 +104,8 @@ You can also specify a config file path: ``` $ ./rotom-ng /path/to/rotom-ng.toml ``` + +### Running several instances behind one UI + +If you run more than one rotom-ng, `rotom-ng-ui` serves a single web UI that +switches between them. See [Multi-instance admin UI](RotomNG-UI-Server.md). diff --git a/docs/RotomNG-UI-Server.md b/docs/RotomNG-UI-Server.md new file mode 100644 index 0000000..cac1ff1 --- /dev/null +++ b/docs/RotomNG-UI-Server.md @@ -0,0 +1,176 @@ +# RotomNG UI server (multi-instance admin) + +> **This is optional, and most deployments do not need it.** A single +> `rotom-ng` already serves its own web UI on its `http_listener` (`:7072` by +> default). If that is your setup, there is nothing here for you. + +`rotom-ng-ui` is a second, separate binary for operators running **several +rotom-ng instances**. It serves the same web UI for all of them at once, with a +menu in the header to switch between them, so you get one page instead of a +browser tab per instance. + +It manages no devices, workers, or controllers of its own. It serves the UI and +proxies every API call to whichever instance is selected; the instances +themselves are unchanged and unaware of it, and keep working exactly as they do +today whether or not you run this. + +It embeds the same UI bundle `rotom-ng` does — there is one UI, not two. It +decides at runtime which kind of server answered, so adding this service changes +nothing about a single-instance deployment. + +## Do you want this? + +| | | +| --- | --- | +| One rotom-ng | No. Use its own UI on `:7072`. | +| Several, and you are happy with a tab each | No. Nothing breaks either way. | +| Several, and you want one page for all of them | Yes — read on. | + +## Configuration + +Copy [configs/rotom-ng-ui.toml.example](../configs/rotom-ng-ui.toml.example) to +`rotom-ng-ui.toml` and edit it. The minimum is one instance: + +```toml +[http_listener] +address = ":7073" + +[[instances]] +base_url = "http://10.0.0.10:7072" +api_secret = "that-instances-api-secret" + +[[instances]] +base_url = "http://10.0.0.11:7072" +``` + +`[http_listener]` is the same section rotom-ng has, with the same `address`, +`secret`, and `ui_session_ttl` keys, and the same behaviour — see +[the API reference](RotomNG-API.md#authentication). + +### Instances + +Each `[[instances]]` block names one rotom-ng: + +- `base_url` — the root of that instance's `http_listener`, **without** the + `/api` suffix. `/api` is appended when requests are proxied. This is also how + the UI identifies an instance, so each one must be distinct; the service + refuses to start on a duplicate. +- `api_secret` — that instance's own `http_listener.secret`. Omit it when the + instance has no secret configured. + +Two independent secrets are in play, and it is worth keeping them straight: + +| Secret | Guards | Sent by | +| --- | --- | --- | +| `http_listener.secret` | the admin UI itself | the operator's browser, or an API client | +| `instances[].api_secret` | one rotom-ng | this service, on every proxied request | + +The admin service's own credentials — its session cookie and any bearer token — +are stripped from proxied requests and never reach an instance. + +### Instance monitoring + +Each instance's `/api/config` is polled on an interval (default 10s, see +`[instance_monitor]`). An instance is **reachable** only once that call has +succeeded. The UI will not let an operator switch to an unreachable instance, +and says so plainly if the selected one goes down. + +The reply to that poll is also where the service learns each instance's name +and configuration, which is what lets the UI's per-instance features — the Jobs +tab, worker stats — follow the instance the operator selected. An instance that +does not set `instance` in its own config is listed in the picker by URL. + +## `GET /api/config` + +Served on the same path rotom-ng serves its own, and carrying the same fields +that make sense for a service with no connections of its own, plus `instances`: + +```json +{ + "status": "ok", + "config": { + "version": "1.0.1beta1", + "sha": "6e501e2", + "instance": "admin", + "instances": [ + { + "instance": "east", + "url": "http://10.0.0.10:7072", + "reachable": true, + "config": { "version": "1.0.1beta1", "jobs": { "enable": true }, "...": "..." } + }, + { + "instance": "west", + "url": "http://10.0.0.11:7072", + "reachable": false, + "config": { "...": "..." } + }, + { "instance": "", "url": "http://10.0.0.12:7072", "reachable": false } + ] + } +} +``` + +- `instances` is **always present** here, as an empty list when none are + configured, and is **never** present in a rotom-ng reply. That is exactly how + the UI tells the two apart. +- `config` on an entry is that instance's own `/api/config` config object, + passed through verbatim. It is absent until the instance has been reached + once, and is retained while an instance is unreachable so the UI does not + reshape itself every time one restarts. + +## Every other endpoint + +Everything else under `/api` is proxied to the selected instance, unchanged — +see [the API reference](RotomNG-API.md) for what that is. Nothing is +allowlisted, so an endpoint added to rotom-ng works here without a change. + +Select an instance with the `X-Rotom-Instance` header, set to its `base_url` +(an instance name works too, but names can repeat and base URLs cannot): + +``` +$ curl -H 'X-Rotom-Instance: http://10.0.0.10:7072' \ + -H 'X-Rotom-Secret: your-admin-secret' \ + http://localhost:7073/api/status +``` + +Omit the header and the first reachable instance answers, which is convenient +for scripts that do not care which one they get. + +Failures are distinguished, since they mean different things: + +| Status | Meaning | +| --- | --- | +| `404` | the header named an instance that is not configured | +| `502` | the instance was reached for but did not answer | +| `503` | no instances are configured, or none are reachable and none was named | + +## Building and running + +``` +$ make rotom-ng-ui # builds the UI, then the binary +$ ./rotom-ng-ui # reads configs/rotom-ng-ui.toml by default +$ ./rotom-ng-ui /path/to/rotom-ng-ui.toml +``` + +`make` on its own builds both binaries. + +Or with Docker, using the published image: + +``` +$ docker pull ghcr.io/unownhash/rotomng/rotom-ng-ui:latest +``` + +It is tagged alongside `rotom-ng` from the same commit, so matching tags mean +matching versions. To build it yourself instead: + +``` +$ make docker-ui +``` + +## Reloading + +`SIGHUP`, or `PUT /api/config/reload`, re-reads the config file. Instances +added, removed, or given a new secret are picked up without a restart; +instances that survive the reload keep their reachability and cached config, +so a reload does not blank the UI for the ones it left alone. diff --git a/libs/auth/middleware.go b/libs/auth/middleware.go index b39380f..6bc3791 100644 --- a/libs/auth/middleware.go +++ b/libs/auth/middleware.go @@ -38,12 +38,7 @@ func NewMiddleware(expectedSecret string) *Middleware { // Handler is a gin middleware that authenticates the request. func (mw *Middleware) Handler(ginContext *gin.Context) { - secret := mw.currentSecret() - if secret == "" { - ginContext.Next() - return - } - if !mw.authenticate(ginContext, secret) { + if !mw.Allow(ginContext) { ginContext.Status(http.StatusUnauthorized) ginContext.Abort() return @@ -51,6 +46,21 @@ func (mw *Middleware) Handler(ginContext *gin.Context) { ginContext.Next() } +// Allow reports whether the request carries an acceptable credential, without +// touching the response or the handler chain. Handlers reached outside the +// authenticated route group -- gin's NoRoute, for one -- use this to make the +// same decision Handler would. +// +// Returns true when no secret is configured, matching Handler's behaviour of +// letting every request through on an unauthenticated instance. +func (mw *Middleware) Allow(ginContext *gin.Context) bool { + secret := mw.currentSecret() + if secret == "" { + return true + } + return mw.authenticate(ginContext, secret) +} + // SetSessionTTL sets how long newly minted UI sessions stay valid. Values <= 0 // select DefaultSessionTTL. // @@ -109,7 +119,7 @@ func (mw *Middleware) VerifySessionToken(token string) error { // authenticate reports whether the request carries any acceptable credential. func (mw *Middleware) authenticate(ginContext *gin.Context, secret string) bool { - if secretsEqual(ginContext.GetHeader("X-Rotom-Secret"), secret) { + if secretsEqual(ginContext.GetHeader(SecretRequestHeader), secret) { return true } diff --git a/libs/auth/token.go b/libs/auth/token.go index 951d5f2..7705238 100644 --- a/libs/auth/token.go +++ b/libs/auth/token.go @@ -22,6 +22,14 @@ const SessionCookieName = "rotom_session" // if a browser ignores SameSite. const SessionRequestHeader = "X-Rotom-Session" +// SecretRequestHeader carries the shared api secret on machine-client +// requests, the credential Middleware checks before it considers any session +// token. Clients that proxy to another rotom-ng set it to that instance's +// secret. +// +//nolint:gosec // G101: this is the name of a header, not a credential. +const SecretRequestHeader = "X-Rotom-Secret" + // DefaultSessionTTL is how long a UI session stays valid before the operator // has to log in again. Callers can override it per-middleware with // SetSessionTTL; this is the fallback when they do not. diff --git a/libs/base-ui/src/controllers/controllers-table.tsx b/libs/base-ui/src/controllers/controllers-table.tsx index 36f3954..211e648 100644 --- a/libs/base-ui/src/controllers/controllers-table.tsx +++ b/libs/base-ui/src/controllers/controllers-table.tsx @@ -27,6 +27,7 @@ import { TABLE_HEADER_ROW, TABLE_WRAPPER, } from "../lib/aesthetic"; +import { apiFetch } from "../lib/api"; import { Search } from "../search"; import { createControllerSorter, @@ -362,7 +363,7 @@ const ControllersTable = ({ controllers }: { controllers: Controller[] }) => { }, }; - const promise = fetch( + const promise = apiFetch( `/api/controller/${controllerUuid}/action/${action}`, { method: "PUT" }, ).then(async (response) => { diff --git a/libs/base-ui/src/hooks/use-active-config.ts b/libs/base-ui/src/hooks/use-active-config.ts new file mode 100644 index 0000000..38384b7 --- /dev/null +++ b/libs/base-ui/src/hooks/use-active-config.ts @@ -0,0 +1,32 @@ +/** + * Feature gating reads the config of whichever rotom-ng the operator is + * looking at. Pointed at one directly that is just its config; fronted by the + * admin service it is the selected instance's, so the UI's shape follows the + * instance rather than the service proxying to it. + */ + +import type { AppConfig } from "../types"; +import { useConfig } from "./use-config"; +import { useInstances } from "./use-instances"; + +export const useActiveConfig = (): AppConfig | undefined => { + const { data } = useConfig(); + const { multiInstance, selected } = useInstances(); + + if (!multiInstance) { + return data?.config; + } + // Undefined until an instance has been selected and reached, which is + // exactly when `InstanceGate` is showing its message instead of the app. + return selected?.config; +}; + +/** + * Whether the active instance is collecting worker request stats. The server + * only sends `disable_worker_stats` when it is true, so an absent flag — or a + * config that has not loaded yet — means enabled. + */ +export const useWorkerStatsEnabled = (): boolean => { + const config = useActiveConfig(); + return config?.tuning?.disable_worker_stats !== true; +}; diff --git a/libs/base-ui/src/hooks/use-config.ts b/libs/base-ui/src/hooks/use-config.ts index 57d6420..3aa5c48 100644 --- a/libs/base-ui/src/hooks/use-config.ts +++ b/libs/base-ui/src/hooks/use-config.ts @@ -1,14 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { configQuery } from "../lib/query-options"; +/** + * The raw `/api/config` reply. + * + * Fronted by the admin service this describes the *service*, not the rotom-ng + * whose devices are on screen. Feature gating wants `useActiveConfig` instead, + * which follows the selected instance. + */ export const useConfig = () => { return useQuery(configQuery()); }; - -// useWorkerStatsEnabled reports whether the server is collecting worker request -// stats, based on /api/config. The server only sends disable_worker_stats when -// it's true, so an absent flag (or not-yet-loaded config) means enabled. -export const useWorkerStatsEnabled = (): boolean => { - const { data } = useConfig(); - return data?.config?.tuning?.disable_worker_stats !== true; -}; diff --git a/libs/base-ui/src/hooks/use-instances.test.ts b/libs/base-ui/src/hooks/use-instances.test.ts new file mode 100644 index 0000000..1843bc5 --- /dev/null +++ b/libs/base-ui/src/hooks/use-instances.test.ts @@ -0,0 +1,163 @@ +/** + * Rules for the instance name in the header, and for the name shown in the + * instance picker. Both hinge on an instance name being allowed to be empty, + * which is easy to regress and invisible until someone runs an unnamed server. + * + * Run with `bun test`. + */ + +import { describe, expect, test } from "bun:test"; +import type { AppConfig, InstanceInfo } from "../types"; +import { + instanceLabel, + pickInstance, + resolveInstanceLabel, + UNNAMED_INSTANCE, +} from "./use-instances"; + +const instance = (over: Partial = {}): InstanceInfo => ({ + instance: "east", + url: "http://10.0.0.10:7072", + reachable: true, + ...over, +}); + +const rotomNgConfig = (over: Partial = {}): AppConfig => ({ + version: "1.0.0", + sha: "abc1234", + ...over, +}); + +const adminConfig = (instances: InstanceInfo[]): AppConfig => ({ + version: "1.0.0", + sha: "abc1234", + instances, +}); + +describe("instanceLabel", () => { + test("uses the instance's own name", () => { + expect(instanceLabel(instance())).toBe("east"); + }); + + test("falls back to the url when the name is empty", () => { + // A rotom-ng with no `instance` set reports "", and the picker still has to + // tell it apart from its siblings. + expect(instanceLabel(instance({ instance: "" }))).toBe( + "http://10.0.0.10:7072", + ); + }); +}); + +describe("resolveInstanceLabel", () => { + test("is null until the config has loaded, so the header does not flicker", () => { + expect(resolveInstanceLabel(undefined, null)).toBeNull(); + }); + + describe("talking to a rotom-ng directly", () => { + test("uses the configured instance name", () => { + expect( + resolveInstanceLabel(rotomNgConfig({ instance: "scanner-1" }), null), + ).toBe("scanner-1"); + }); + + test("shows for an empty name", () => { + expect(resolveInstanceLabel(rotomNgConfig({ instance: "" }), null)).toBe( + UNNAMED_INSTANCE, + ); + }); + + test("shows when no name is configured at all", () => { + // rotom-ng omits the key entirely rather than sending "", so both the + // absent and the empty case have to land here. + expect(resolveInstanceLabel(rotomNgConfig(), null)).toBe( + UNNAMED_INSTANCE, + ); + }); + }); + + describe("fronted by the admin service", () => { + test("follows the selected instance", () => { + const selected = instance({ instance: "west" }); + expect(resolveInstanceLabel(adminConfig([selected]), selected)).toBe( + "west", + ); + }); + + test("shows the url when the selected instance has no name", () => { + // Not "": here the name also has to identify which of several + // servers is on screen. + const selected = instance({ instance: "" }); + expect(resolveInstanceLabel(adminConfig([selected]), selected)).toBe( + "http://10.0.0.10:7072", + ); + }); + + test("is null when nothing is selected", () => { + expect(resolveInstanceLabel(adminConfig([instance()]), null)).toBeNull(); + }); + + test("is null with no instances configured, not ", () => { + // An empty list still means admin mode, so the single-instance fallback + // must not leak in and name a server that does not exist. + expect(resolveInstanceLabel(adminConfig([]), null)).toBeNull(); + }); + + test("still names an instance that is down while others are up", () => { + // The gate names it too (" is not responding"), so the header agrees + // rather than going blank under a message about that very instance. + const down = instance({ instance: "west", reachable: false }); + expect(resolveInstanceLabel(adminConfig([instance(), down]), down)).toBe( + "west", + ); + }); + + test("names the selection even when the whole fleet is down", () => { + // The gate says "a is not responding"; the header agrees rather than + // going blank underneath a message about that very instance. + const allDown = [ + instance({ instance: "a", url: "http://a:7072", reachable: false }), + instance({ instance: "b", url: "http://b:7072", reachable: false }), + ]; + expect(resolveInstanceLabel(adminConfig(allDown), allDown[0])).toBe("a"); + }); + }); +}); + +describe("pickInstance", () => { + test("prefers a reachable instance over an earlier unreachable one", () => { + // A first visit should land somewhere that works rather than on an error. + const down = instance({ + instance: "a", + url: "http://a:7072", + reachable: false, + }); + const up = instance({ instance: "b", url: "http://b:7072" }); + expect(pickInstance([down, up])?.instance).toBe("b"); + }); + + test("keeps config order among reachable instances", () => { + const first = instance({ instance: "a", url: "http://a:7072" }); + const second = instance({ instance: "b", url: "http://b:7072" }); + expect(pickInstance([first, second])?.instance).toBe("a"); + }); + + test("falls back to the first when none are reachable", () => { + // An all-down fleet still gets a selection, so the UI is about a specific + // instance and can say that one is down. + const a = instance({ + instance: "a", + url: "http://a:7072", + reachable: false, + }); + const b = instance({ + instance: "b", + url: "http://b:7072", + reachable: false, + }); + expect(pickInstance([a, b])?.instance).toBe("a"); + }); + + test("is null only when nothing is configured", () => { + expect(pickInstance([])).toBeNull(); + }); +}); diff --git a/libs/base-ui/src/hooks/use-instances.ts b/libs/base-ui/src/hooks/use-instances.ts new file mode 100644 index 0000000..8f935d1 --- /dev/null +++ b/libs/base-ui/src/hooks/use-instances.ts @@ -0,0 +1,152 @@ +/** + * Multi-instance mode: the UI's view of the rotom-ng servers the admin service + * fronts, and which of them is currently selected. + * + * Mode is detected from the config reply rather than from a build flag, so one + * UI bundle serves both the admin service and a plain rotom-ng: the admin + * service always sends `instances` (an empty list when none are configured) + * and rotom-ng never does. + */ + +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo } from "react"; +import { + setSelectedInstance, + useSelectedInstance, +} from "../lib/instance-store"; +import type { AppConfig, InstanceInfo } from "../types"; +import { useConfig } from "./use-config"; + +export interface InstancesState { + /** True when the UI is talking to the admin service. */ + multiInstance: boolean; + /** Every configured instance, in the order the operator listed them. */ + instances: InstanceInfo[]; + /** + * The selected instance, or null when nothing is selected — either because + * none is configured, or because none has been reachable yet. + */ + selected: InstanceInfo | null; + /** True while the selection points at an instance that is not reachable. */ + selectedUnreachable: boolean; + /** Selects an instance by url. Ignored for an unreachable one. */ + select: (url: string) => void; +} + +/** Display name for an instance: its own name, falling back to its url. */ +export const instanceLabel = (instance: InstanceInfo): string => + instance.instance || instance.url; + +/** + * Stands in for the name of a rotom-ng that has no instance name configured. + * + * Only used in single-instance mode. Fronted by the admin service an unnamed + * instance shows its url instead, because there the name also has to tell one + * server from another -- "" twice over would be useless. + */ +export const UNNAMED_INSTANCE = ""; + +/** + * The instance name for the header, or null when there is nothing to name yet. + * + * Pure so the rules are testable without rendering: + * + * - config not loaded -> null, so the header does not flicker + * - admin service, one selected -> that instance's name, or its url if unnamed + * - admin service, none selected -> null; nothing is being looked at + * - a rotom-ng directly -> its own instance name, or "" + */ +export const resolveInstanceLabel = ( + config: AppConfig | undefined, + selected: InstanceInfo | null, +): string | null => { + if (!config) { + return null; + } + // Presence of `instances` is what marks the admin service; see above. + if (config.instances !== undefined) { + // Named whether or not it is reachable: the gate says " is not + // responding" in that case, so the header agrees with it rather than going + // blank underneath a message about that very instance. + return selected ? instanceLabel(selected) : null; + } + return config.instance || UNNAMED_INSTANCE; +}; + +/** + * The instance to adopt when there is no usable selection -- a first visit, or + * a stored selection whose instance has since gone from the config. + * + * Prefers a reachable one, so a first visit lands somewhere that works rather + * than on an error page, and otherwise takes the first: an all-down fleet still + * ends up with a selection, which is what lets the UI always be about a + * specific instance instead of explaining that it is about none of them. + * + * Null only when nothing is configured. + */ +export const pickInstance = (instances: InstanceInfo[]): InstanceInfo | null => + instances.find((instance) => instance.reachable) ?? instances[0] ?? null; + +export const useInstances = (): InstancesState => { + const { data } = useConfig(); + const selectedUrl = useSelectedInstance(); + const queryClient = useQueryClient(); + + const config = data?.config; + const instances = useMemo(() => config?.instances ?? [], [config?.instances]); + const multiInstance = config?.instances !== undefined; + + const selected = useMemo( + () => instances.find((instance) => instance.url === selectedUrl) ?? null, + [instances, selectedUrl], + ); + + const select = useCallback( + (url: string) => { + const target = instances.find((instance) => instance.url === url); + if (!target?.reachable) { + return; + } + setSelectedInstance(url); + // Every cached page belongs to the instance it was fetched from, so it + // all has to go: leaving it would show one instance's devices under + // another's name until the next poll landed. + queryClient.removeQueries({ + predicate: (query) => + query.queryKey[0] !== "auth" && query.queryKey[0] !== "config", + }); + }, + [instances, queryClient], + ); + + // Adopt a selection whenever there is not a usable one: a first visit, or a + // stored selection whose instance has since gone from the config. Either way + // it ends with something selected, so the UI is always about a specific + // instance and never has to explain that it is about none of them. + // + // Deliberately does NOT move off a selection that has merely gone + // unreachable: switching servers under an operator mid-task is worse than + // telling them the one they chose is down. + useEffect(() => { + if (!multiInstance || selected !== null) { + return; + } + const adopted = pickInstance(instances); + setSelectedInstance(adopted ? adopted.url : null); + }, [multiInstance, selected, instances]); + + return { + multiInstance, + instances, + selected, + selectedUnreachable: selected !== null && !selected.reachable, + select, + }; +}; + +/** The instance name to show in the header. See resolveInstanceLabel. */ +export const useInstanceLabel = (): string | null => { + const { data } = useConfig(); + const { selected } = useInstances(); + return resolveInstanceLabel(data?.config, selected); +}; diff --git a/libs/base-ui/src/index.ts b/libs/base-ui/src/index.ts index 214f49b..f4eb57f 100644 --- a/libs/base-ui/src/index.ts +++ b/libs/base-ui/src/index.ts @@ -41,10 +41,25 @@ export { } from "./components/ui/tooltip"; export { ControllersPage } from "./controllers/controllers-page"; export { DevicePage } from "./devices/device-page"; -export { useConfig, useWorkerStatsEnabled } from "./hooks/use-config"; +export { + useActiveConfig, + useWorkerStatsEnabled, +} from "./hooks/use-active-config"; +export { useConfig } from "./hooks/use-config"; +export { + type InstancesState, + instanceLabel, + pickInstance, + resolveInstanceLabel, + UNNAMED_INSTANCE, + useInstanceLabel, + useInstances, +} from "./hooks/use-instances"; export { useTablePagination } from "./hooks/use-table-pagination"; export { JobsPage } from "./jobs/jobs-page"; export { Box } from "./layout/box"; +export { InstanceGate } from "./layout/instance-gate"; +export { InstanceSwitcher } from "./layout/instance-switcher"; export { Layout, type LayoutProps, type NavItem } from "./layout/layout"; export { NavLink } from "./layout/nav-link"; export { @@ -69,6 +84,12 @@ export { logout, } from "./lib/api"; export { formatMemory } from "./lib/format-memory"; +export { + getSelectedInstance, + INSTANCE_HEADER, + setSelectedInstance, + useSelectedInstance, +} from "./lib/instance-store"; export { createAppQueryClient } from "./lib/query-client"; export { configQuery, diff --git a/libs/base-ui/src/jobs/execute-job-modal.tsx b/libs/base-ui/src/jobs/execute-job-modal.tsx index 3ef663b..efa5b66 100644 --- a/libs/base-ui/src/jobs/execute-job-modal.tsx +++ b/libs/base-ui/src/jobs/execute-job-modal.tsx @@ -18,6 +18,7 @@ import { TableRow, } from "../components/ui/table"; import { TABLE_BODY_ROW, TABLE_HEADER_ROW } from "../lib/aesthetic"; +import { apiFetch } from "../lib/api"; import { Search } from "../search"; import { compareAlphanumeric } from "../sorting"; import type { Device } from "../types"; @@ -65,7 +66,7 @@ export const ExecuteJobModal: React.FC = ({ const executeJob = useCallback( async ({ deviceIds }: { deviceIds: string[] | number[] }) => { - const promise = fetch(`/api/job/${jobId}/run`, { + const promise = apiFetch(`/api/job/${jobId}/run`, { method: "PUT", headers: { "Content-Type": "application/json", diff --git a/libs/base-ui/src/jobs/jobs-page.tsx b/libs/base-ui/src/jobs/jobs-page.tsx index 1734ba0..e5bece1 100644 --- a/libs/base-ui/src/jobs/jobs-page.tsx +++ b/libs/base-ui/src/jobs/jobs-page.tsx @@ -16,6 +16,7 @@ import { AESTHETIC_CARD_HEADER, AESTHETIC_DANGER_BUTTON, } from "../lib/aesthetic"; +import { apiFetch } from "../lib/api"; import { jobInstancesQuery, jobsQuery, @@ -55,7 +56,7 @@ export const JobsPage = () => { const cancel = new AbortController(); const timer = setTimeout(() => cancel.abort(), 5000); try { - const res = await fetch("/api/job-instance/-/clear", { + const res = await apiFetch("/api/job-instance/-/clear", { method: "PUT", signal: cancel.signal, }); @@ -77,7 +78,7 @@ export const JobsPage = () => { const cancel = new AbortController(); const timer = setTimeout(() => cancel.abort(), 5000); try { - const res = await fetch("/api/job/-/reload", { + const res = await apiFetch("/api/job/-/reload", { method: "PUT", signal: cancel.signal, }); diff --git a/libs/base-ui/src/layout/instance-gate.test.ts b/libs/base-ui/src/layout/instance-gate.test.ts new file mode 100644 index 0000000..4780a56 --- /dev/null +++ b/libs/base-ui/src/layout/instance-gate.test.ts @@ -0,0 +1,97 @@ +/** + * Priority order for the multi-instance gate. This is the substance of the + * component, and it is easy to get subtly wrong: a misordered branch produces a + * message that is true but misleading rather than an obvious break. + * + * Run with `bun test`. + */ + +import { describe, expect, test } from "bun:test"; +import type { InstancesState } from "../hooks/use-instances"; +import type { InstanceInfo } from "../types"; +import { resolveInstanceGate } from "./instance-gate"; + +const instance = (over: Partial = {}): InstanceInfo => ({ + instance: "east", + url: "http://10.0.0.10:7072", + reachable: true, + ...over, +}); + +/** Builds the state useInstances would produce for a given list + selection. */ +const state = ( + instances: InstanceInfo[], + selectedUrl: string | null = null, +): InstancesState => { + const selected = instances.find((entry) => entry.url === selectedUrl) ?? null; + return { + multiInstance: true, + instances, + selected, + selectedUnreachable: selected !== null && !selected.reachable, + select: () => undefined, + }; +}; + +describe("resolveInstanceGate", () => { + test("passes through when not fronted by the admin service", () => { + expect( + resolveInstanceGate({ ...state([]), multiInstance: false }).kind, + ).toBe("ready"); + }); + + test("says so when none are configured", () => { + expect(resolveInstanceGate(state([])).kind).toBe("none-configured"); + }); + + test("shows the pages once a reachable instance is selected", () => { + const up = instance(); + expect(resolveInstanceGate(state([up], up.url)).kind).toBe("ready"); + }); + + test("waits quietly while a selection is being adopted", () => { + // Reachable but nothing chosen yet: one frame, so no message. + expect(resolveInstanceGate(state([instance()])).kind).toBe("pending"); + }); + + test("reports the selected instance being down while others are up", () => { + const down = instance({ + instance: "west", + url: "http://down:7072", + reachable: false, + }); + const gate = resolveInstanceGate(state([instance(), down], down.url)); + expect(gate.kind).toBe("selected-unreachable"); + expect(gate).toHaveProperty("label", "west"); + }); + + test("names an unnamed instance by url when it is the one that is down", () => { + const down = instance({ + instance: "", + url: "http://down:7072", + reachable: false, + }); + const gate = resolveInstanceGate(state([instance(), down], down.url)); + expect(gate).toHaveProperty("label", "http://down:7072"); + }); + + describe("when nothing at all is reachable", () => { + const allDown = [ + instance({ instance: "a", url: "http://a:7072", reachable: false }), + instance({ instance: "b", url: "http://b:7072", reachable: false }), + ]; + + // No separate all-down state: a selection is always adopted, so this is + // just the selected instance being down, said once about the instance the + // operator is actually on. + test("reports the selected instance, not the fleet", () => { + const gate = resolveInstanceGate(state(allDown, "http://a:7072")); + expect(gate.kind).toBe("selected-unreachable"); + expect(gate).toHaveProperty("label", "a"); + }); + + test("waits quietly in the frame before one is adopted", () => { + expect(resolveInstanceGate(state(allDown)).kind).toBe("pending"); + }); + }); +}); diff --git a/libs/base-ui/src/layout/instance-gate.tsx b/libs/base-ui/src/layout/instance-gate.tsx new file mode 100644 index 0000000..a5c9e25 --- /dev/null +++ b/libs/base-ui/src/layout/instance-gate.tsx @@ -0,0 +1,110 @@ +import { ServerCrash, ServerOff } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { + type InstancesState, + instanceLabel, + useInstances, +} from "@/hooks/use-instances"; + +export interface InstanceGateProps { + children: ReactNode; +} + +/** What the gate should show. See resolveInstanceGate. */ +export type InstanceGateState = + | { kind: "ready" } + | { kind: "pending" } + | { kind: "none-configured" } + | { kind: "selected-unreachable"; label: string }; + +/** The fields the decision depends on. */ +type GateInput = Pick< + InstancesState, + "multiInstance" | "instances" | "selected" | "selectedUnreachable" +>; + +/** + * Decides what the gate shows, in priority order. Pure so the ordering is + * testable -- it is the whole substance of this component, and getting it wrong + * produces a message that is true but misleading rather than an obvious break. + * + * There is no separate "none of them are reachable" state. A selection is + * always adopted when there is one to be had, so an all-down fleet is just the + * selected instance being down, said once about the instance the operator is + * actually on. + */ +export const resolveInstanceGate = (state: GateInput): InstanceGateState => { + if (!state.multiInstance) { + return { kind: "ready" }; + } + if (state.instances.length === 0) { + return { kind: "none-configured" }; + } + if (state.selected === null) { + // Something is reachable but nothing is chosen yet: the frame between the + // config landing and the effect that adopts a selection. Transient, so it + // gets no message -- one would only ever be seen as a flash. + return { kind: "pending" }; + } + if (state.selectedUnreachable) { + return { + kind: "selected-unreachable", + label: instanceLabel(state.selected), + }; + } + return { kind: "ready" }; +}; + +/** + * Holds back the pages when the selected instance cannot answer for them. + * + * Every page polls `/api/status` and friends, which the admin service can only + * answer by asking an instance. Without this the operator would get a wall of + * error toasts from queries that never had a chance; a single sentence saying + * what is wrong is both truer and quieter. + * + * Rendered inside `Layout`, so the instance picker stays reachable — an + * operator whose instance went down can still switch to one that is up. + * + * Passes children straight through outside multi-instance mode. + */ +export const InstanceGate: FC = ({ children }) => { + const gate = resolveInstanceGate(useInstances()); + + switch (gate.kind) { + case "ready": + return <>{children}; + case "pending": + return null; + case "none-configured": + return ( + } + title="No instances configured" + detail="Add one or more [[instances]] entries to this service's config, then reload it." + /> + ); + case "selected-unreachable": + return ( + } + title="Current instance not reachable" + detail={`${gate.label} is not responding. It will come back on its own once it does; you can also pick another instance from the header.`} + /> + ); + } +}; + +interface GateMessageProps { + icon: ReactNode; + title: string; + detail: string; +} + +const GateMessage: FC = ({ icon, title, detail }) => ( +
+
{icon}
+

{title}

+

{detail}

+
+); diff --git a/libs/base-ui/src/layout/instance-switcher.tsx b/libs/base-ui/src/layout/instance-switcher.tsx new file mode 100644 index 0000000..6963e42 --- /dev/null +++ b/libs/base-ui/src/layout/instance-switcher.tsx @@ -0,0 +1,106 @@ +import { Check, Database, Server } from "lucide-react"; +import type { FC } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { instanceLabel, useInstances } from "@/hooks/use-instances"; +import { cn } from "@/lib/utils"; +import { Button } from "../components/ui/button"; + +export interface InstanceSwitcherProps { + /** Renders full width with a visible label, for the mobile drawer. */ + block?: boolean; + /** Called after a selection is made, so the drawer can close itself. */ + onSelect?: () => void; +} + +/** + * Picks which rotom-ng the UI is pointed at. + * + * Renders nothing outside multi-instance mode, so the header is unchanged when + * the UI is served by a rotom-ng directly. + * + * Unreachable instances stay listed but are not selectable: hiding them would + * make an instance silently vanish from the picker whenever it restarted, and + * leaving them selectable would put the operator on a server that cannot + * answer. Listed-but-disabled says "it exists, it's down" in one glance. + */ +export const InstanceSwitcher: FC = ({ + block, + onSelect, +}) => { + const { multiInstance, instances, selected, select } = useInstances(); + + if (!multiInstance) { + return null; + } + + const currentLabel = selected ? instanceLabel(selected) : "No instance"; + + return ( + + + + + + Instances + {instances.length === 0 ? ( +
+ None configured +
+ ) : ( + instances.map((instance) => { + const label = instanceLabel(instance); + const isCurrent = instance.url === selected?.url; + return ( + { + select(instance.url); + onSelect?.(); + }} + > + + + {label} + {instance.reachable ? null : ( + + unreachable + + )} + + + + ); + }) + )} +
+
+ ); +}; diff --git a/libs/base-ui/src/layout/layout.tsx b/libs/base-ui/src/layout/layout.tsx index e94170b..6c18549 100644 --- a/libs/base-ui/src/layout/layout.tsx +++ b/libs/base-ui/src/layout/layout.tsx @@ -9,7 +9,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useInstanceLabel } from "@/hooks/use-instances"; import { cn } from "@/lib/utils"; +import { InstanceSwitcher } from "./instance-switcher"; import { NavLink } from "./nav-link"; export interface NavItem { @@ -37,6 +39,7 @@ export const Layout: FC = ({ // configured. Either way there is no session to end, so no button. const auth = useAuthOptional(); const showLogout = auth?.authRequired ?? false; + const instanceName = useInstanceLabel(); return (
= ({ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }} className="sticky w-full top-0 z-40 flex h-14 items-center justify-between border-b border-border/60 bg-background/80 px-4 backdrop-blur" > -
- {appName} - - {appName} - {appVersion ? ` v${appVersion}` : ""} - +
+ {appName} +
+ + {appName} + {appVersion ? ` v${appVersion}` : ""} + + {instanceName ? ( + // Which server the page is actually about. Fronted by the admin + // service this follows the selection, so it doubles as + // confirmation that a switch took effect. + + Instance: {instanceName} + + ) : null} +