diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83ffb02 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_call: + schedule: + - cron: "0 4 * * 1" + +concurrency: + # Literal "ci-", never ${{ github.workflow }}: inside a reusable workflow that + # expression resolves to the *caller's* name, so the `uses: ./.github/workflows/ci.yml` + # leg of release.yml would compute "Release-refs/tags/vX" while release.yml's own + # group is "release-refs/tags/vX" -- the same group, because GitHub matches + # concurrency names case-insensitively, but with the two halves disagreeing about + # cancel-in-progress (true here, false there). A literal keeps this group's meaning + # fixed no matter who calls the workflow. Do not "restore" the expression. + group: ci-${{ github.ref }} + # Pull requests and pushes to main only care about the newest commit; superseded + # runs are wasted minutes. Tagged releases are serialised by release.yml's own + # group instead, which is where cancel-in-progress: false belongs. + cancel-in-progress: true + +permissions: + contents: read + +jobs: + boundary: + name: HTTP boundary intact + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + - run: ./scripts/check-module-boundary.sh + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 + with: + version: v2.11.4 + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./... + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + - run: go mod verify + - run: go vet ./... + - run: go test -race ./... + - run: go build ./cmd/ferro + + integration: + name: Contract vs AI Gateway ${{ matrix.gateway_ref }} + runs-on: ubuntu-latest + # The pinned v1.4.2 leg is the required signal; "main" moves upstream of + # this repo and release.yml depends on ci via `needs: ci`, so a failure + # there must not block merges or tagged releases — only report it. + continue-on-error: ${{ matrix.gateway_ref == 'main' }} + strategy: + fail-fast: false + matrix: + gateway_ref: ["v1.4.2", "main"] + steps: + - name: Check out gateway-cli + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: gateway-cli + persist-credentials: false + - name: Check out AI Gateway + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ferro-labs/ai-gateway + ref: ${{ matrix.gateway_ref }} + path: ai-gateway + persist-credentials: false + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: gateway-cli/go.mod + - run: ./scripts/with-gateway.sh + working-directory: gateway-cli + env: + FERRO_GATEWAY_SOURCE: ${{ github.workspace }}/ai-gateway diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..db266e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +on: + push: + tags: ["v*"] + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +jobs: + ci: + permissions: + contents: read + uses: ./.github/workflows/ci.yml + + release: + name: GoReleaser + needs: ci + runs-on: ubuntu-latest + permissions: + # GoReleaser publishes the GitHub release and uploads archives. + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + # A restored cache can carry entries from other refs; this job + # publishes release artifacts, so a poisoned cache is user-facing. + cache: false + # .goreleaser.yaml's sboms block shells out to syft, which is not present on + # GitHub-hosted runners; without this step the first real tag fails at the + # SBOM stage, after the binaries are already built. + - uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + with: + distribution: goreleaser + version: "2.17.1" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index aaadf73..93b83c9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Local builds +/ferro +/fakegw +/dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..09c08a1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,60 @@ +version: "2" + +run: + timeout: 5m + +linters: + enable: + - bodyclose + - copyloopvar + - depguard + - dupl + - errcheck + - errorlint + - goconst + - gocritic + - gocyclo + - gosec + - govet + - ineffassign + - maintidx + - misspell + - noctx + - nolintlint + - prealloc + - revive + - staticcheck + - unconvert + - unparam + - unused + + settings: + depguard: + rules: + main: + deny: + - pkg: gopkg.in/yaml.v3 + desc: use go.yaml.in/yaml/v3 + # depguard prefix-matches, so the module root also denies every + # current and future subpackage. + - pkg: github.com/ferro-labs/ai-gateway + desc: gateway-cli communicates with AI Gateway over HTTP only + dupl: + threshold: 150 + gocyclo: + min-complexity: 30 + nolintlint: + require-explanation: true + require-specific: true + allow-unused: false + + exclusions: + rules: + - path: _test\.go + linters: [dupl, errorlint, goconst, unparam] + - path: _test\.go + linters: [staticcheck] + text: SA5011 + - path: internal/command/output\.go + linters: [gosec] + text: G115 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..51a4301 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,66 @@ +# GoReleaser for the standalone ferro operator console. +version: 2 +project_name: ferro +dist: dist + +builds: + - id: ferro + main: ./cmd/ferro + binary: ferro + env: [CGO_ENABLED=0] + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + # Reproducibility: rebuilding a tag must yield the same bytes, or a user has + # no way to check that a published binary was built from the source it names. + # mod_timestamp pins the file mtimes goreleaser stamps into archives, and + # .CommitDate replaces the build wall clock below -- both are needed, since + # either one alone still leaves the build time baked in. + mod_timestamp: "{{ .CommitTimestamp }}" + # -trimpath is the third leg: without it the binary embeds the absolute build + # directory, so a rebuild anywhere but the CI runner's own path differs even + # with the timestamps pinned -- and the published binary leaks that path. + flags: [-trimpath] + ldflags: + - -s -w + - -X github.com/ferro-labs/gateway-cli/internal/version.Version={{.Version}} + - -X github.com/ferro-labs/gateway-cli/internal/version.Commit={{.ShortCommit}} + - -X github.com/ferro-labs/gateway-cli/internal/version.Date={{.CommitDate}} + +# cmd/fakegw is a development tool (a fake gateway for testing the CLI without +# a server). It is excluded by omission — builds are listed explicitly above. +# Do not add a build entry for it. + +archives: + - formats: [tar.gz] + name_template: "ferro_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] + files: + - src: README.md + dst: README.md + - src: CHANGELOG.md + dst: CHANGELOG.md + - src: LICENSE + dst: LICENSE + +checksum: + name_template: checksums.txt + +# One SBOM per archive (syft's default, SPDX JSON), so a consumer can answer +# "is this build affected by CVE-x" without unpacking and re-scanning it. +# Requires syft on the runner -- release.yml installs it, and goreleaser fails +# the release rather than skipping the SBOM if it is missing. +sboms: + - artifacts: archive + +release: + name_template: "Ferro CLI v{{ .Version }}" + +changelog: + use: git + filters: + include: + - "^feat" + - "^fix" + - "^perf" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8bb6e0c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,74 @@ +# Changelog + +All notable changes to **Ferro Operator Console**, the scriptable CLI and +interactive TUI for Ferro Labs AI Gateway. The binary it installs is `ferro`. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## v0.1.0 — 2026-08-14 + +First release. One binary, `ferro`, with two surfaces over a single HTTP client: +scriptable commands for automation, and a full-screen console for operators. +Requires AI Gateway v1.4.2 or later. + +### Added + +- **Commands** — `status`, `models`, `providers`, `mcp`, `plugins`, `services`, + `sessions`, `audit`, `keys list|get|create|rotate|revoke`, + `logs list|stats|tail`, `chat`, and `version`. Every one supports + `--format table|json|yaml`. +- **Console** — run bare `ferro` at a TTY for a full-screen operations view with + four screens: Home, Request logs, Keys, and Playground. Includes a command + composer with completion, history, and reverse search (`ctrl+r`). +- **Request-log tail** — follow live traffic in `logs tail` or the console, with + filters for time, model, provider, stage, and credential. +- **Key management** — list, create, rotate, and revoke API keys, with derived + `active` / `expired` / `revoked` state. New secrets are printed once, to + stdout, and never enter the console transcript or history. `rotate` and + `revoke` are irreversible, so at a terminal each asks for the key id typed + back before it runs; `--yes` skips the prompt, and is required when stdin is + not a terminal rather than the verb blocking or proceeding unasked. +- **Playground** — streaming chat against the gateway with `/model` and + `/clear`, showing token usage plus route and cost when available. +- **Connection profiles** at `os.UserConfigDir()/ferro/config.yaml`. URL + resolution: `--gateway-url` > `FERRO_URL` > profile > `http://localhost:8080`. + Key resolution: `FERRO_API_KEY` > profile `api_key_env` > `MASTER_KEY`, the + last of which applies only when the gateway URL is loopback. `MASTER_KEY` is + the gateway server's own variable and is not chosen for ferro by anyone, so + it is the one source that could otherwise be forwarded to a remote host the + operator named on the command line. Keys are never passed as flags, so they + stay out of shell history. +- **Global flags** `--gateway-url`, `--profile`, `--format`, `--ascii`, plus + `NO_COLOR`, TTY, and `TERM=dumb` detection. + +### Output contract + +- stdout carries data; narration, warnings, and errors go to stderr — so + `--format json` is always safe to pipe. +- `status` exits 1 only when the gateway is unreachable. A reachable but + degraded gateway exits 0 and reports its degraded state. +- Missing measurements render `-`, never `0`. Endpoints the gateway does not + serve disable that panel with a hint instead of failing the command. +- Gateway-supplied text is data, never layout or terminal control. Control + characters in a provider name, an upstream error message, or a model's answer + are neutralized on every surface, so a table keeps its columns, a pane keeps + its line count, and nothing upstream can drive the terminal. + +### Distribution + +- Release binaries are reproducible: `-trimpath`, and both the build stamp and + the archive timestamps come from the tagged commit rather than the build + clock, so two builds of one tag are byte-identical. +- An SPDX SBOM ships beside each archive. +- `go install` builds, which carry no linker stamp, now report their module + version and commit from the embedded build info instead of `dev`/`none`. + +### Known limitations + +- The gateway does not report its version over HTTP, so the console's version + slot renders `—`. +- The traffic panel and route attribution come from the request log, and show as + unavailable when no request-log store is configured. +- Not yet shipped: config viewer / history / rollback, provider capability + matrix, doctor checklist, and init wizard. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..16eed70 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +Ferro Operator Console is a standalone Go module. Keep the runtime boundary +with AI Gateway HTTP-only; do not import packages from the gateway module. + +Before opening a change, run: + +```bash +./scripts/check-module-boundary.sh +go vet ./... +go test -race ./... +golangci-lint run ./... +``` + +For contract changes, run `FERRO_GATEWAY_SOURCE=/path/to/ai-gateway make itest`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cb45450 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: build test lint fake itest smoke +build: ; go build -o ferro ./cmd/ferro +test: ; go test -race ./... +lint: ; golangci-lint run ./... +fake: ; go run ./cmd/fakegw +itest: ; ./scripts/with-gateway.sh +smoke: build ; ./scripts/smoke.sh diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..783c26f --- /dev/null +++ b/NOTICE @@ -0,0 +1,8 @@ +Ferro Operator Console (ferro-cli) +Copyright 2026 Ferro Labs + +This product includes software developed at Ferro Labs +(https://github.com/ferro-labs). + +Licensed under the Apache License, Version 2.0; see the LICENSE file for the +full terms. diff --git a/README.md b/README.md index b2f9c72..9fefd1c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,239 @@ -# ai-gateway-cli -Operator Console - a scriptable CLI and interactive TUI for Ferro Labs AI Gateway +
+ + + + + + +
Ferro Labs Gateway CLI

Ferro Labs Gateway CLI

Operator Console for the Ferro Labs AI Gateway
+ +**A scriptable CLI and interactive TUI for the [Ferro Labs AI Gateway](https://github.com/ferro-labs/ai-gateway). One static binary — health, request logs, API keys, and a live playground, without opening a browser.** + +[![Go](https://img.shields.io/badge/go-1.25+-00ADD8.svg)](https://go.dev) +[![Go Reference](https://pkg.go.dev/badge/github.com/ferro-labs/gateway-cli.svg)](https://pkg.go.dev/github.com/ferro-labs/gateway-cli) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![CI](https://github.com/ferro-labs/gateway-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/ferro-labs/gateway-cli/actions/workflows/ci.yml) + +📖 **Gateway documentation:** [docs.ferrolabs.ai](https://docs.ferrolabs.ai) · this README is the reference for `ferro` itself + +ferro-cli console demo + +
+ +--- + +`ferro-cli` ships a single binary named **`ferro`**. Run it with a verb and it +behaves like any other Unix tool — `ferro status`, `ferro keys list`, +`ferro logs tail` — with clean stdout you can pipe into `jq`. Run it bare on a +terminal and it opens the console above. + +Every console view has a scriptable twin. The console is a lens, never the only +door. + +## Install + +```bash +go install github.com/ferro-labs/gateway-cli/cmd/ferro@v0.1.0 +``` + +Or download a release archive — `ferro___.tar.gz` (`.zip` on +Windows) — from the releases titled **Ferro CLI vX.Y.Z**, with `checksums.txt` +alongside. Linux, macOS, and Windows on amd64 and arm64. No runtime, no +dependencies: it is a single static binary. + +Works with **AI Gateway v1.4.2 or later**. + +> A withdrawn nested module also used v0.1.0 at +> `github.com/ferro-labs/ai-gateway/cli`. This standalone module has a distinct +> version namespace. Install only from `github.com/ferro-labs/gateway-cli`. + +| Console release | Distribution | AI Gateway compatibility | +|---|---|---| +| v0.1.x | `github.com/ferro-labs/gateway-cli` | v1.4.2+ | + +`ferro` is optional operator tooling for a running gateway. It is not part of +the gateway runtime and does not replace `ferrogw serve`, `ferrogw init`, or +offline `ferrogw validate`. + +## Quickstart + +```bash +export FERRO_URL=http://localhost:8080 +export FERRO_API_KEY=fgw_... # or let it fall back to MASTER_KEY + +ferro status # health in one line +ferro # the console (needs a terminal) +ferro status --format json | jq . # stdout is always pure machine output +``` + +## Commands + +Every list and get honours `--format json|yaml`. + +| Command | What it does | +|---|---| +| `ferro` | Opens the console. On a pipe it refuses and points at `--help`. | +| `ferro status` | State, URL, latency, targets, providers, models, MCP, auth. | +| `ferro models` | Models the gateway routes — id, owner, mode, context window, capabilities. | +| `ferro providers` | Providers with status, circuit state, model count, and message. | +| `ferro mcp` | MCP tool servers — ready, required, last error. | +| `ferro plugins` | Configured plugins merged with the build's catalog, including fail-open. | +| `ferro services` | MCP, plugin, session, and audit availability in one report. | +| `ferro sessions` | Active operator sessions — subject, scopes, created, last seen, expires. | +| `ferro audit` | Audit trail. `--action --actor --outcome --since --limit`. | +| `ferro keys list` | Keys — name, masked secret, scopes, expiry, last used, uses, state. | +| `ferro keys get ` | One key, as structured output. | +| `ferro keys create` | `--name` (required), `--scope admin\|read_only`, `--expires-in`. | +| `ferro keys rotate ` | Mint a new secret; the previous one stops authenticating at once. Confirms first; `--yes` to skip. | +| `ferro keys revoke ` | Immediate and irreversible. Confirms first; `--yes` to skip. | +| `ferro logs list` | Request log — time, trace, provider, model, stage, duration, cost, tokens. | +| `ferro logs stats` | Totals, errors, tokens, cost, and latency percentiles over a window. | +| `ferro logs tail` | Follow the log. Poll-based, deduped, plain lines; Ctrl-C exits clean. | +| `ferro chat "" --model ` | Streaming completion. Answer to stdout, usage to stderr. | +| `ferro version` | Version, commit, build date. Works with no gateway. | + +**Persistent flags:** `--gateway-url`, `--profile`, `--format table|json|yaml`, +`--ascii` (use `[OK] [X] [!] [-]` instead of `✓ ✗ ! ·`), and +`--insecure-http` (explicitly allow plaintext HTTP to a non-loopback gateway). + +## Configuration + +| Variable | Effect | +|---|---| +| `FERRO_URL` | Gateway base URL. Default `http://localhost:8080`. | +| `FERRO_API_KEY` | Bearer credential sent to the gateway. | +| `MASTER_KEY` | Last-resort credential fallback, **only when the gateway URL is loopback**. It is the gateway server's own variable, so it is sent to `localhost` and `127.0.0.1` alone — never to a remote host that merely happens to be on the command line. | +| `NO_COLOR` | Any value suppresses ANSI. (Also off under `TERM=dumb` or a non-TTY stdout.) | + +```text +URL: --gateway-url > FERRO_URL > profile.url > http://localhost:8080 +key: FERRO_API_KEY > profile.api_key_env deref > MASTER_KEY (loopback only) +``` + +**There is deliberately no credential flag.** Command-line arguments show up in +process listings and shell history, so credentials are environment-only. +Remote gateways require HTTPS by default because admin data and bearer +credentials must not cross a network in plaintext. Plain HTTP remains enabled +for loopback development; private-network HTTP requires `--insecure-http` on +each invocation. + +### Profiles + +`ferro-cli` stores **connection profiles only**, never gateway data, at +`os.UserConfigDir()/ferro/config.yaml` (`~/.config/ferro/config.yaml` on Linux). +A missing file is not an error. + +```yaml +current_profile: prod +profiles: + - name: prod + url: https://gw.example.com + api_key_env: PROD_KEY # the NAME of an env var, never the secret + - name: local + url: http://localhost:8080 +``` + +Select one with `--profile local`. A profile names the environment variable +holding its credential; the credential itself never touches the file. + +## The console + +`↵ run · tab complete · ↑ history · ctrl+r search · ? help · esc home · ctrl+c quit` + +Four persistent regions: a header carrying identity and connection state, a left +rail answering *what is connected*, a main frame answering *what happened*, and a +command composer that is the only navigation surface — there is no tab row. + +Type ordinary commands (`status`, `logs --since 15m --model claude-*`, +`keys create`) or slash aliases (`/logs`, `/keys`, `/playground`). `?` on an +empty composer prints the verb tree without destroying the transcript. + +| Screen | Contents | +|---|---| +| **Home** | Bounded command transcript, rendered by the same formatting code the scriptable verbs use. | +| **Request logs** | Live tail with filters and a per-row detail pane that resolves a credential id to its key name. | +| **Keys** | Key table with derived state, a create wizard, and typed-confirmation rotate and revoke. | +| **Playground** | Streaming chat with `/model` and `/clear`, and a metadata line carrying route, latency, tokens, and cost. | + +Layout adapts to width: ≥110 columns gets the mark, rail, and panels; 80–109 +collapses the rail into a compact status frame; below 80 leaves output and the +composer. Nothing is ever clipped mid-border. + +## Contracts worth relying on + +These are tested behaviours, not aspirations. + +- **stdout is the machine channel.** Narrative, warnings, and errors go to + stderr, so `ferro --format json | jq` only ever sees JSON. +- **Exit codes are the API.** `ferro status` exits **1 only when the gateway is + unreachable**. A reachable-but-degraded gateway exits **0** and prints its + degraded state — so `ferro status || alert` pages on outages, not brownouts. +- **Secrets are shown exactly once.** `keys create` and `keys rotate` print the + new secret once, on stdout, because the gateway stores only a hash. It never + reaches the console transcript or the command history. +- **Missing is not zero.** An absent measurement renders `-`, never `0`. When + `/readyz` reports no targets, you get `-`, because "none configured" and + "yours are dead" are different answers and the gateway only sent one of them. +- **Redirects are refused, never followed.** A bearer token is never replayed to + whatever host a redirect names; the target is surfaced as a hint instead. +- **A missing feature degrades one screen.** A 404 or 501 — no request-log + store, sessions disabled — disables that panel with a note rather than + ending the command or the app. +- **Colour never carries state alone.** Every status also has a glyph or text, + so the full ladder holds: console → `NO_COLOR` → non-TTY plain text → + `--format json`. +- **Nothing is cached on disk.** Everything re-derives from the API on each poll. + +### Known gaps in v0.1.0 + +Stated plainly, because the console shows them rather than inventing values: + +- The header reads `gateway —`: the gateway does not serve its own version over + HTTP yet. +- The traffic panel renders `—` without a request-log store, since it derives + from log statistics. +- Streaming responses carry no provider on the wire, so a chat's route is + resolved afterwards by matching the response's request id against the request + log — and is absent when no log store is configured. Usage still renders. +- The playground renders answers as word-wrapped plain text rather than + formatted markdown. Streaming, batching, and the metadata line are unaffected. +- Not in this release: the config viewer, the capability matrix, the doctor + checklist, and the init wizard. Their gateway endpoints exist; the screens + do not. + +## Development + +This repository versions independently from AI Gateway. HTTP is the only +runtime boundary: this module imports no gateway package and does not depend on +the gateway module. Wire shapes are copied, never imported, and CI enforces the +boundary. + +```bash +make fake # fake gateway (cmd/fakegw) on 127.0.0.1:8080 +make build # go build -o ferro ./cmd/ferro +make test # go test -race ./... +make lint # golangci-lint run ./... +make itest # contract suite against ../ai-gateway or FERRO_GATEWAY_SOURCE +``` + +`make fake` is the loop to use almost always. It serves the gateway's HTTP +contract from fixtures, so the whole CLI and console can be developed with **no +gateway, no provider credentials, and no network**: + +```bash +make fake & +FERRO_URL=http://localhost:8080 FERRO_API_KEY=fgw_test go run ./cmd/ferro +``` + +`cmd/fakegw` takes `--addr`, `--degraded`, `--no-providers`, `--no-log-store`, +and `--no-auth` to reproduce failure states a healthy gateway will not show you +on demand. It is a development tool and is never in a release build. + +`make itest` is the acceptance loop: it builds `ferrogw` from a separate AI +Gateway checkout, boots it, runs the contract suite, and tears it down. The fake +proves the CLI is internally correct; only this proves the contract is real. + +## License + +Copyright 2026 Ferro Labs. Apache 2.0 — see [`LICENSE`](LICENSE) and +[`NOTICE`](NOTICE). diff --git a/cmd/fakegw/main.go b/cmd/fakegw/main.go new file mode 100644 index 0000000..8b5cda8 --- /dev/null +++ b/cmd/fakegw/main.go @@ -0,0 +1,64 @@ +// Command fakegw serves deterministic gateway responses for local CLI use. +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/ferro-labs/gateway-cli/internal/fixture" +) + +func main() { + // Loopback-only: --no-auth accepts unauthenticated admin requests, so a + // wildcard bind would expose them to anything else on the network. + addr := flag.String("addr", "127.0.0.1:8080", "listen address") + degraded := flag.Bool("degraded", false, "healthy gateway in trouble: 200 with a half-open circuit and one unroutable target") + noProviders := flag.Bool("no-providers", false, "no credential configured: /health 503 no_providers, /readyz 503 not_ready") + noAuth := flag.Bool("no-auth", false, "accept unauthenticated admin requests") + noLogs := flag.Bool("no-log-store", false, "501 the log endpoints, as a gateway without a store does") + flag.Parse() + + s := fixture.Default() + // Two independent axes, as on the real gateway: a circuit does not make + // /health non-200, and only an empty provider set does. + s.Degraded, s.NoProviders = *degraded, *noProviders + s.NoLogStore, s.ChatDelay = *noLogs, 60*time.Millisecond + s.RequireAuth = !*noAuth + + fmt.Fprintf(os.Stderr, "fakegw on %s — key: %s\n", *addr, s.AcceptKey) + // A bare ":8080" needs a host prepended; "127.0.0.1:8101" already has one. + host := *addr + if strings.HasPrefix(host, ":") { + host = "localhost" + host + } + fmt.Fprintf(os.Stderr, " FERRO_URL=http://%s FERRO_API_KEY=%s go run ./cmd/ferro\n", host, s.AcceptKey) + srv := &http.Server{ + Addr: *addr, + Handler: fixture.Handler(s), + // ReadHeaderTimeout is the Slowloris bound — a client that opens a + // connection and dribbles headers forever is cut off here — and + // ReadTimeout bounds the body after it. Neither can cut a stream + // short: net/http clears the read deadline the moment the request + // body is consumed (connReader.startBackgroundRead), which is before + // the first SSE frame is written. + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + // WriteTimeout is deliberately zero. It bounds the WHOLE response, and + // this server's chat surface is SSE: a finite value would cut a stream + // off mid-flight and, worse, do it silently — the client sees a + // truncated stream with no error frame and no [DONE], which is exactly + // the failure the playground dev loop exists to make visible. Nothing + // is given up by leaving it off, because the Slowloris vector + // ReadHeaderTimeout covers is the one G114 is about. + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } + if err := srv.ListenAndServe(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/ferro/main.go b/cmd/ferro/main.go new file mode 100644 index 0000000..f4df0f3 --- /dev/null +++ b/cmd/ferro/main.go @@ -0,0 +1,34 @@ +// Command ferro is the operator console for the Ferro Labs AI Gateway. +// +// It talks to the gateway only over HTTP and does not import gateway packages. +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/charmbracelet/fang" + + "github.com/ferro-labs/gateway-cli/internal/command" +) + +func main() { + if err := run(); err != nil { + os.Exit(1) // fang has already rendered the error + } +} + +// run owns every deferred cleanup, because os.Exit runs none of them. With the +// signal context built directly in main, the os.Exit(1) on a failed command +// skipped `defer stop()` entirely, so the SIGINT handler signal.NotifyContext +// installs was never released on the error path. +func run() error { + // SIGINT cancels in-flight requests and long-running tails cleanly rather + // than killing the process mid-write. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + return fang.Execute(ctx, command.NewRoot()) +} diff --git a/docs/console.gif b/docs/console.gif new file mode 100644 index 0000000..9dd231f Binary files /dev/null and b/docs/console.gif differ diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..f3ca42d Binary files /dev/null and b/docs/logo.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c1dc4b --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/ferro-labs/gateway-cli + +go 1.25.13 + +require ( + charm.land/bubbletea/v2 v2.0.8 + charm.land/lipgloss/v2 v2.0.5 + github.com/charmbracelet/fang v1.0.0 + github.com/charmbracelet/x/ansi v0.11.7 + github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/term v0.45.0 +) + +require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..850f70b --- /dev/null +++ b/go.sum @@ -0,0 +1,76 @@ +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= +charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..8fbe7d9 --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,294 @@ +// Package api provides the HTTP, JSON, and SSE client used by ferro. +// It centralizes redirect refusal, non-2xx errors unless an endpoint tolerates +// them, empty-response refusal when a payload is expected, and uniform error +// envelope decoding. +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "slices" + "strings" + "time" + "unicode/utf8" +) + +const ( + // DefaultTimeout bounds a single request/response exchange. SSE uses a + // separate no-timeout client with context cancellation instead. + DefaultTimeout = 15 * time.Second + maxBody = 4 << 20 +) + +// Client is a gateway HTTP client. Every endpoint method in this package +// funnels through the unexported do method, which owns the safety contracts. +type Client struct { + base *url.URL + apiKey string + hc *http.Client + userAgent string + streamIdleTimeout time.Duration + allowInsecureHTTP bool +} + +// Option customizes a Client at construction time. +type Option func(*Client) + +// WithTimeout overrides DefaultTimeout for this client's requests, including +// the response-header bound enforced by the shared transport. +func WithTimeout(d time.Duration) Option { + return func(c *Client) { + c.hc.Timeout = d + if tr, ok := c.hc.Transport.(*http.Transport); ok { + clone := tr.Clone() + clone.ResponseHeaderTimeout = d + c.hc.Transport = clone + } + } +} + +// WithStreamIdleTimeout overrides the maximum time a chat stream may remain +// silent. A non-positive duration disables the client-side idle bound. +func WithStreamIdleTimeout(d time.Duration) Option { + return func(c *Client) { c.streamIdleTimeout = d } +} + +// WithUserAgent overrides the User-Agent header sent on every request. +func WithUserAgent(ua string) Option { return func(c *Client) { c.userAgent = ua } } + +// WithInsecureHTTP permits plaintext HTTP to a non-loopback gateway. It must +// be an explicit operator choice because bearer credentials and admin data are +// otherwise exposed to the network. +func WithInsecureHTTP() Option { return func(c *Client) { c.allowInsecureHTTP = true } } + +// New builds a Client for the gateway at rawURL, authenticating with apiKey +// (empty means send no Authorization header). +func New(rawURL, apiKey string, opts ...Option) (*Client, error) { + u, err := url.Parse(strings.TrimRight(rawURL, "/")) + if err != nil || u.Scheme == "" || u.Host == "" || u.Hostname() == "" { + return nil, fmt.Errorf("invalid gateway URL %q (want e.g. http://localhost:8080)", rawURL) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("invalid gateway URL %q: scheme must be http or https", rawURL) + } + if u.User != nil { + return nil, fmt.Errorf("invalid gateway URL %q: credentials belong in environment variables, not URL userinfo", rawURL) + } + // do builds every request's path and query from this base, so a query + // string here is either silently dropped or silently replaced, and a + // fragment never leaves the process at all. Neither reading produces the + // request the operator wrote — the same reason the gateway refuses a query + // or fragment in a provider base URL. + if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return nil, fmt.Errorf("invalid gateway URL %q: a gateway URL carries no query string or fragment (each request builds its own)", rawURL) + } + c := &Client{ + base: u, + apiKey: apiKey, + hc: &http.Client{ + Timeout: DefaultTimeout, + Transport: func() http.RoundTripper { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.ResponseHeaderTimeout = DefaultTimeout + return tr + }(), + // Redirects are surfaced, never followed: a bearer token must not + // replay to whatever host a redirect names. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + userAgent: "ferro-cli", + streamIdleTimeout: DefaultStreamIdleTimeout, + } + for _, o := range opts { + o(c) + } + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) && !c.allowInsecureHTTP { + return nil, fmt.Errorf("refusing plaintext HTTP to non-loopback gateway %q; use HTTPS or explicitly pass --insecure-http", u.Host) + } + return c, nil +} + +func isLoopbackHost(host string) bool { + host = strings.TrimSuffix(strings.ToLower(host), ".") + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// BaseURL returns the gateway URL this client talks to. +func (c *Client) BaseURL() string { return c.base.String() } + +// resolveURL builds the absolute URL for endpoint path p against c.base. It +// is the one join every request goes through — do and StreamChat both call +// it — so a path-prefixed base cannot resolve to two different URLs for the +// same endpoint depending on which caller built the request. +func (c *Client) resolveURL(p string) *url.URL { + u := *c.base + u.Path = path.Join(c.base.Path, p) + return &u +} + +// Error is a non-2xx (or refused-redirect) response from the gateway. +// +// The name follows net/url's Error: a package-level struct named Error that +// carries an Error() method is the standard shape for this, and it reads at +// the call site as api.Error rather than the stuttering api.APIError. +type Error struct { + Status int + Message string + Type string + Code string + RedirectTo string +} + +func (e *Error) Error() string { + if e.RedirectTo != "" { + return fmt.Sprintf("HTTP %d redirect to %s refused — credentials are never replayed to another host (usual cause: an ingress upgrading http to https; point --gateway-url at the target)", e.Status, e.RedirectTo) + } + if e.Code != "" { + return fmt.Sprintf("HTTP %d %s: %s", e.Status, e.Code, e.Message) + } + return fmt.Sprintf("HTTP %d: %s", e.Status, e.Message) +} + +// IsNotSupported reports whether an optional endpoint is absent on this +// gateway build (older version or feature disabled): screens degrade, the +// app never terminates on it. +func IsNotSupported(err error) bool { + var ae *Error + return errors.As(err, &ae) && (ae.Status == http.StatusNotFound || ae.Status == http.StatusNotImplemented) +} + +// IsUnauthorized reports whether the gateway refused the credential, as +// opposed to failing for any other reason. +// +// It exists so a caller degrading on an admin-only endpoint can say which +// happened. "No admin credential accepted" is a specific claim, and answering +// it to a timeout or a 500 sends the reader to check a credential that was +// never the problem. +func IsUnauthorized(err error) bool { + var ae *Error + return errors.As(err, &ae) && + (ae.Status == http.StatusUnauthorized || ae.Status == http.StatusForbidden) +} + +type doOpts struct { + tolerate []int // status codes decoded as success (e.g. 503 for /health) +} + +func (c *Client) do(ctx context.Context, method, p string, q url.Values, in, out any, o doOpts) (int, error) { + u := c.resolveURL(p) + if q != nil { + u.RawQuery = q.Encode() + } + var body io.Reader + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return 0, fmt.Errorf("encode request: %w", err) + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.userAgent) + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.hc.Do(req) + if err != nil { + return 0, fmt.Errorf("gateway unreachable at %s: %w", c.BaseURL(), err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return resp.StatusCode, &Error{ + Status: resp.StatusCode, + Message: "redirect refused", + RedirectTo: resp.Header.Get("Location"), + } + } + // Read one byte past maxBody: LimitReader itself is silent about a + // truncation, and a truncated JSON body would otherwise fail inside + // json.Unmarshal as "decode response" — a report that sends an operator + // looking for a malformed payload instead of an oversized one. + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1)) + if err != nil { + return resp.StatusCode, fmt.Errorf("read response: %w", err) + } + if len(raw) > maxBody { + return resp.StatusCode, fmt.Errorf("response exceeded the %d-byte limit", maxBody) + } + ok := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !ok && !slices.Contains(o.tolerate, resp.StatusCode) { + return resp.StatusCode, decodeAPIError(resp.StatusCode, raw) + } + if out == nil { + return resp.StatusCode, nil + } + if len(raw) == 0 { + return resp.StatusCode, &Error{Status: resp.StatusCode, + Message: fmt.Sprintf("HTTP %d with an empty response body, expected a payload", resp.StatusCode)} + } + if err := json.Unmarshal(raw, out); err != nil { + return resp.StatusCode, fmt.Errorf("decode response: %w", err) + } + return resp.StatusCode, nil +} + +// maxErrMessage bounds the fallback error message built from a raw, +// non-envelope body. Without a cap, a proxy's HTML error page or a stack +// trace flows into terminal output and logs unbounded. +const maxErrMessage = 2048 + +// boundMessage caps an operator-facing message at maxErrMessage. Both of +// decodeAPIError's sources go through it: capping only the raw body left the +// bound trivially bypassable, because a gateway answering with a well-formed +// envelope replaces that message with an unbounded one. +func boundMessage(s string) string { + if len(s) <= maxErrMessage { + return s + } + // Back up to a rune boundary before slicing: a cut through the middle of a + // multi-byte rune renders as U+FFFD, which reads as corruption in the + // gateway's reply rather than as ferro's own truncation. + cut := maxErrMessage + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "… (truncated)" +} + +func decodeAPIError(status int, raw []byte) *Error { + e := &Error{Status: status, Message: boundMessage(strings.TrimSpace(string(raw)))} + var env struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` + } + if json.Unmarshal(raw, &env) == nil && env.Error.Message != "" { + e.Message, e.Type, e.Code = boundMessage(env.Error.Message), env.Error.Type, env.Error.Code + } + if e.Message == "" { + e.Message = http.StatusText(status) + } + return e +} diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..abc7ecf --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,249 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" +) + +func TestBearerHeaderSentAndOmitted(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + writeJSON(t, w, http.StatusOK, `{}`) + })) + defer srv.Close() + + c := testClient(t, srv.URL, "fgw_secret") + var out map[string]any + if _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{}); err != nil { + t.Fatal(err) + } + if got != "Bearer fgw_secret" { + t.Fatalf("want bearer header, got %q", got) + } + + c2 := testClient(t, srv.URL, "") + if _, err := c2.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{}); err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("empty key must send no Authorization header, got %q", got) + } +} + +func TestRedirectRefusedNeverFollowed(t *testing.T) { + var followed atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + followed.Add(1) + })) + defer target.Close() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/steal", http.StatusFound) + })) + defer srv.Close() + + c := testClient(t, srv.URL, "fgw_secret") + var out map[string]any + _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{}) + var apiErr *Error + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusFound { + t.Fatalf("want api.Error 302, got %v", err) + } + if !strings.Contains(apiErr.RedirectTo, target.URL) { + t.Fatalf("error must surface the redirect target, got %+v", apiErr) + } + if followed.Load() != 0 { + t.Fatal("redirect was followed — bearer token would have replayed") + } +} + +func TestEmpty2xxWithExpectedPayloadIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) // no body + })) + defer srv.Close() + c := testClient(t, srv.URL, "") + var out map[string]any + _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{}) + if err == nil || !strings.Contains(err.Error(), "empty response body") { + t.Fatalf("want empty-2xx refusal, got %v", err) + } +} + +func Test204WithNilDestIsSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + c := testClient(t, srv.URL, "") + if _, err := c.do(context.Background(), http.MethodDelete, "/x", nil, nil, nil, doOpts{}); err != nil { + t.Fatalf("204 with nil dest must succeed: %v", err) + } +} + +func TestErrorEnvelopeDecoded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, + `{"error":{"message":"bad key","type":"authentication_error","code":"unauthorized"}}`) + })) + defer srv.Close() + c := testClient(t, srv.URL, "fgw_bad") + var out map[string]any + _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{}) + var apiErr *Error + if !errors.As(err, &apiErr) || apiErr.Message != "bad key" || apiErr.Type != "authentication_error" { + t.Fatalf("envelope not decoded: %v", err) + } +} + +func TestTolerated503DecodesBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusServiceUnavailable, + `{"status":"not_ready","reason":"no routable targets"}`) + })) + defer srv.Close() + c := testClient(t, srv.URL, "") + var out struct { + Status string `json:"status"` + Reason string `json:"reason"` + } + status, err := c.do(context.Background(), http.MethodGet, "/readyz", nil, nil, &out, doOpts{tolerate: []int{503}}) + if err != nil || status != 503 || out.Reason != "no routable targets" { + t.Fatalf("tolerated 503 must decode: status=%d out=%+v err=%v", status, out, err) + } +} + +func TestNewRejectsUnusableGatewayURLs(t *testing.T) { + // A query or a fragment is refused rather than dropped: do builds the path + // and the query of every request, so neither could survive, and deleting + // the part that cannot work is the same defect one step quieter. + for _, raw := range []string{ + "", "localhost:8080", "http://", "https://:8443", "://nope", + "ftp://localhost:8080", + "http://localhost:8080?token=abc", + "http://localhost:8080/v1?a=b", + "http://localhost:8080#frag", + "http://localhost:8080/?", + "https://u:p@gw.example.com", + "http://gw.example.com:8080", + } { + if c, err := New(raw, ""); err == nil { + t.Fatalf("New(%q) must fail, got a client for %q", raw, c.BaseURL()) + } + } + // Local HTTP is safe for development; remote gateways must use TLS unless + // the operator explicitly acknowledges the risk. + for _, raw := range []string{ + "http://localhost:8080", "http://localhost.:8080", + "http://127.0.0.1:8080", "http://[::1]:8080", + "https://gw.example.com/ferro/", + } { + if _, err := New(raw, ""); err != nil { + t.Fatalf("New(%q) must succeed: %v", raw, err) + } + } + if _, err := New("http://gateway.internal:8080", "fgw_secret", WithInsecureHTTP()); err != nil { + t.Fatalf("explicit insecure HTTP opt-in must succeed: %v", err) + } +} + +// TestNewNormalizesBaseURL pins the exact form BaseURL() returns for every +// accepted input. do and StreamChat both build requests by joining an +// endpoint path onto this string (do via resolveURL's path.Join, StreamChat +// via the same resolveURL call), so a trailing slash surviving here is what +// would put a double slash on every request path, streaming included. +func TestNewNormalizesBaseURL(t *testing.T) { + tests := []struct { + raw string + want string + }{ + {"http://localhost:8080", "http://localhost:8080"}, + {"http://localhost:8080/", "http://localhost:8080"}, + // The load-bearing case: a path-prefixed base must lose its trailing + // slash, or concatenating "/v1/chat/completions" onto it doubles up. + {"https://gw.example.com/ferro/", "https://gw.example.com/ferro"}, + {"https://gw.example.com/ferro", "https://gw.example.com/ferro"}, + } + for _, tt := range tests { + c, err := New(tt.raw, "") + if err != nil { + t.Fatalf("New(%q): %v", tt.raw, err) + } + if got := c.BaseURL(); got != tt.want { + t.Errorf("New(%q).BaseURL() = %q, want %q", tt.raw, got, tt.want) + } + if strings.HasSuffix(c.BaseURL(), "/") { + t.Errorf("New(%q).BaseURL() = %q must never carry a trailing slash", tt.raw, c.BaseURL()) + } + } +} + +func TestIsNotSupported(t *testing.T) { + if !IsNotSupported(&Error{Status: 501}) || !IsNotSupported(&Error{Status: 404}) || IsNotSupported(&Error{Status: 500}) { + t.Fatal("IsNotSupported must be exactly {404, 501}") + } +} + +func TestWithTimeoutUpdatesClientAndHeaderBounds(t *testing.T) { + c, err := New("http://localhost:8080", "", WithTimeout(3*time.Second)) + if err != nil { + t.Fatal(err) + } + tr, ok := c.hc.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport type = %T", c.hc.Transport) + } + if c.hc.Timeout != 3*time.Second || tr.ResponseHeaderTimeout != 3*time.Second { + t.Fatalf("timeouts diverged: client=%s header=%s", c.hc.Timeout, tr.ResponseHeaderTimeout) + } +} + +func TestDecodeAPIErrorTruncatesOnARuneBoundary(t *testing.T) { + // The multi-byte runes straddle the cut, so a byte slice would keep half of + // one and print U+FFFD — a corruption the operator would read as the + // gateway's, not ferro's. + raw := []byte(strings.Repeat("a", maxErrMessage-1) + strings.Repeat("é", 8)) + e := decodeAPIError(http.StatusBadGateway, raw) + if !strings.HasSuffix(e.Message, "… (truncated)") { + t.Fatalf("oversized body must be marked truncated, got %d bytes ending %q", + len(e.Message), e.Message[max(0, len(e.Message)-20):]) + } + if strings.ContainsRune(e.Message, utf8.RuneError) || !utf8.ValidString(e.Message) { + t.Fatal("truncation split a rune") + } +} + +// The envelope path replaces the message the raw-body cap just bounded, so it +// needs the same cap. Without one, a gateway answering with a well-formed +// {"error":{"message":...}} sends unbounded text into terminal output and logs +// while the constant that exists to prevent that reads as enforced. +func TestDecodeAPIErrorBoundsTheEnvelopeMessageToo(t *testing.T) { + body := `{"error":{"message":"` + strings.Repeat("z", maxErrMessage*4) + `","code":"upstream"}}` + e := decodeAPIError(http.StatusBadGateway, []byte(body)) + + if e.Code != "upstream" { + t.Fatalf("envelope must still be decoded, got code %q", e.Code) + } + if len(e.Message) > maxErrMessage+len("… (truncated)") { + t.Errorf("envelope message escaped the %d-byte bound: got %d bytes", maxErrMessage, len(e.Message)) + } + if !strings.HasSuffix(e.Message, "… (truncated)") { + t.Error("an oversized envelope message must be marked truncated") + } +} + +// A short envelope message is not truncated, so the bound cannot be read as +// "every structured error loses its tail". +func TestDecodeAPIErrorLeavesAShortEnvelopeMessageWhole(t *testing.T) { + e := decodeAPIError(http.StatusNotFound, []byte(`{"error":{"message":"no such model"}}`)) + if e.Message != "no such model" { + t.Fatalf("got %q", e.Message) + } +} diff --git a/internal/api/endpoints.go b/internal/api/endpoints.go new file mode 100644 index 0000000..9451c49 --- /dev/null +++ b/internal/api/endpoints.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "net/http" +) + +// Health reads the unauthenticated GET /health. A 503 is a report, not a +// failure — the body names which providers are down — so it is decoded and the +// HTTP status is returned alongside for the caller to act on. +func (c *Client) Health(ctx context.Context) (*HealthReport, int, error) { + var out HealthReport + status, err := c.do(ctx, http.MethodGet, "/health", nil, nil, &out, + doOpts{tolerate: []int{http.StatusServiceUnavailable}}) + if err != nil { + return nil, status, err + } + return &out, status, nil +} + +// Ready reads the unauthenticated GET /readyz. As with Health, a 503 carries +// the reason and is decoded rather than raised. +func (c *Client) Ready(ctx context.Context) (*ReadyReport, int, error) { + var out ReadyReport + status, err := c.do(ctx, http.MethodGet, "/readyz", nil, nil, &out, + doOpts{tolerate: []int{http.StatusServiceUnavailable}}) + if err != nil { + return nil, status, err + } + return &out, status, nil +} + +// AdminHealth reads GET /admin/health, which answers 200 for any authenticated +// caller. A 401/403 therefore means the credential, not the gateway, is the +// problem — callers use that to decide what they may display. +func (c *Client) AdminHealth(ctx context.Context) (*AdminHealth, error) { + var out AdminHealth + if _, err := c.do(ctx, http.MethodGet, "/admin/health", nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return &out, nil +} + +// Models lists GET /v1/models, unwrapping the {"object":"list","data":[…]} +// envelope. +func (c *Client) Models(ctx context.Context) ([]Model, error) { + var out struct { + Data []Model `json:"data"` + } + if _, err := c.do(ctx, http.MethodGet, "/v1/models", nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return out.Data, nil +} diff --git a/internal/api/endpoints_test.go b/internal/api/endpoints_test.go new file mode 100644 index 0000000..95b8c72 --- /dev/null +++ b/internal/api/endpoints_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// testClient builds a Client against a stub, failing the test on a bad URL. +func testClient(t *testing.T, base, key string) *Client { + t.Helper() + c, err := New(base, key) + if err != nil { + t.Fatalf("New(%q): %v", base, err) + } + return c +} + +// writeJSON is the stub-side body writer; status 0 means 200. +func writeJSON(t *testing.T, w http.ResponseWriter, status int, body string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if status != 0 && status != http.StatusOK { + w.WriteHeader(status) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Errorf("stub write: %v", err) + } +} + +// gatewayStub is the healthy-gateway fixture shared by the endpoint and status +// tests. admin=false makes /admin/health answer 401 for every caller. +func gatewayStub(t *testing.T, admin bool) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ok","providers":[ + {"name":"anthropic","status":"available","circuit":"closed","models":412}, + {"name":"openai","status":"available","circuit":"half_open","models":1104}]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ready", + "providers":[{"name":"anthropic","circuit":"closed"}], + "targets":[{"name":"anthropic-primary","routable":true},{"name":"openai-primary","routable":false}], + "mcp_servers":[{"name":"filesystem","ready":true,"required":false},{"name":"search","ready":false,"required":false}]}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, r *http.Request) { + if !admin || r.Header.Get("Authorization") != "Bearer fgw_ok" { + writeJSON(t, w, http.StatusUnauthorized, + `{"error":{"message":"unauthorized","type":"authentication_error","code":"unauthorized"}}`) + return + } + writeJSON(t, w, 200, `{"status":"healthy", + "providers":[{"name":"anthropic","status":"healthy","models":412}, + {"name":"openai","status":"degraded","models":1104,"message":"rate limited"}], + "components":[{"name":"API","status":"healthy"}], + "mcp_servers":[{"name":"search","ready":false,"required":false,"last_error":"dial tcp: refused"}], + "scopes":["admin"]}`) + }) + mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"unauthorized","type":"authentication_error"}}`) + return + } + writeJSON(t, w, 200, `{"object":"list","data":[ + {"id":"claude-sonnet-4-6","object":"model","created":0,"owned_by":"anthropic","capabilities":["streaming","vision"],"context_window":200000}, + {"id":"gpt-5.1","object":"model","created":0,"owned_by":"openai"}]}`) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestHealthDecodesAndPath(t *testing.T) { + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + writeJSON(t, w, 200, `{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"open","models":7}]}`) + })) + defer srv.Close() + + h, status, err := testClient(t, srv.URL, "").Health(context.Background()) + if err != nil { + t.Fatal(err) + } + if path != "/health" { + t.Fatalf("want GET /health, got %q", path) + } + if status != 200 || h.Status != "ok" || len(h.Providers) != 1 || + h.Providers[0].Circuit != "open" || h.Providers[0].Models != 7 { + t.Fatalf("bad decode: status=%d %+v", status, h) + } +} + +func TestHealthTolerates503(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"no_providers","providers":[]}`) + })) + defer srv.Close() + + h, status, err := testClient(t, srv.URL, "").Health(context.Background()) + if err != nil { + t.Fatalf("503 must decode, not error: %v", err) + } + if status != http.StatusServiceUnavailable || h.Status != "no_providers" { + t.Fatalf("bad tolerated 503: status=%d %+v", status, h) + } +} + +func TestReadyDecodesNotReady503(t *testing.T) { + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"not_ready","reason":"no routable targets"}`) + })) + defer srv.Close() + + rep, status, err := testClient(t, srv.URL, "").Ready(context.Background()) + if err != nil { + t.Fatalf("503 readyz must decode: %v", err) + } + if path != "/readyz" { + t.Fatalf("want GET /readyz, got %q", path) + } + if status != http.StatusServiceUnavailable || rep.Status != "not_ready" || rep.Reason != "no routable targets" { + t.Fatalf("bad decode: status=%d %+v", status, rep) + } + // Optional collections stay nil rather than becoming empty non-nil slices. + if rep.Targets != nil || rep.MCPServers != nil || rep.Providers != nil { + t.Fatalf("absent optional fields must stay nil: %+v", rep) + } +} + +func TestReadyDecodesFullBody(t *testing.T) { + srv := gatewayStub(t, true) + rep, status, err := testClient(t, srv.URL, "").Ready(context.Background()) + if err != nil || status != 200 { + t.Fatalf("status=%d err=%v", status, err) + } + if len(rep.Targets) != 2 || rep.Targets[0].Name != "anthropic-primary" || !rep.Targets[0].Routable || + rep.Targets[1].Routable { + t.Fatalf("targets decode: %+v", rep.Targets) + } + if len(rep.MCPServers) != 2 || !rep.MCPServers[0].Ready || rep.MCPServers[1].Ready { + t.Fatalf("mcp decode: %+v", rep.MCPServers) + } + if len(rep.Providers) != 1 || rep.Providers[0].Circuit != "closed" { + t.Fatalf("providers decode: %+v", rep.Providers) + } +} + +func TestAdminHealthDecodesAndPropagates401(t *testing.T) { + srv := gatewayStub(t, true) + + ah, err := testClient(t, srv.URL, "fgw_ok").AdminHealth(context.Background()) + if err != nil { + t.Fatal(err) + } + if ah.Status != "healthy" || len(ah.Providers) != 2 || ah.Providers[1].Message != "rate limited" || + len(ah.Components) != 1 || len(ah.Scopes) != 1 || ah.Scopes[0] != "admin" { + t.Fatalf("bad decode: %+v", ah) + } + if len(ah.MCPServers) != 1 || ah.MCPServers[0].LastError == "" { + t.Fatalf("admin mcp servers carry last_error: %+v", ah.MCPServers) + } + + _, err = testClient(t, srv.URL, "fgw_bad").AdminHealth(context.Background()) + var ae *Error + if !errors.As(err, &ae) || ae.Status != http.StatusUnauthorized { + t.Fatalf("401 must propagate as api.Error: %v", err) + } +} + +func TestModelsUnwrapsEnvelope(t *testing.T) { + var path string + srv := gatewayStub(t, true) + c := testClient(t, srv.URL, "fgw_ok") + c.hc.Transport = pathRecorder{&path} + + models, err := c.Models(context.Background()) + if err != nil { + t.Fatal(err) + } + if path != "/v1/models" { + t.Fatalf("want GET /v1/models, got %q", path) + } + if len(models) != 2 || models[0].ID != "claude-sonnet-4-6" || models[0].OwnedBy != "anthropic" || + len(models[0].Capabilities) != 2 || models[0].ContextWindow != 200000 { + t.Fatalf("bad decode: %+v", models) + } + // Absent optional fields decode to zero values, not errors. + if models[1].ContextWindow != 0 || models[1].Capabilities != nil || models[1].Deprecated { + t.Fatalf("optional model fields must be zero: %+v", models[1]) + } +} + +func TestModelsUnauthorized(t *testing.T) { + srv := gatewayStub(t, true) + _, err := testClient(t, srv.URL, "").Models(context.Background()) + if !errors.As(err, new(*Error)) { + t.Fatalf("want api.Error, got %v", err) + } +} + +// pathRecorder captures the request path without changing transport behavior. +type pathRecorder struct{ path *string } + +func (p pathRecorder) RoundTrip(r *http.Request) (*http.Response, error) { + *p.path = r.URL.Path + return http.DefaultTransport.RoundTrip(r) +} diff --git a/internal/api/keys.go b/internal/api/keys.go new file mode 100644 index 0000000..0a6e9aa --- /dev/null +++ b/internal/api/keys.go @@ -0,0 +1,134 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +// Key is one /admin/keys row. Key is masked (head8…tail4) on every read; +// the full secret appears exactly once, in the CreateKey and RotateKey +// responses, and the gateway then keeps only a hash of it. +type Key struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + RotatedAt *time.Time `json:"rotated_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + UsageCount int64 `json:"usage_count"` + Active bool `json:"active"` +} + +// KeyCreateRequest is the POST /admin/keys body. +// +// Scopes is always sent explicitly. A scope-less request is NOT granted admin: +// the gateway's key store applies defaultScopes and mints a least-privilege +// read_only key. ferro sends one anyway so the scope a key carries is the +// operator's stated choice rather than whatever the server's default happens +// to be in the version it is talking to. +type KeyCreateRequest struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// The three states KeyState derives, and the value the gateway confirms a +// revocation with. +const ( + KeyStateActive = "active" + KeyStateExpired = "expired" + KeyStateRevoked = "revoked" +) + +// KeyState derives the state column the gateway does not serve: there is no +// state or revoked field on the wire, only revoked_at, expires_at and active. +// +// Expiry must be read from expires_at, never from active. The gateway clears +// active in exactly one place — Revoke, which stamps revoked_at in the same +// write, on both the memory and SQL stores — so a key that has merely expired +// is still served as active:true with revoked_at:null and a past expires_at. +// Deriving "expired" from !active therefore never fired for a real expired +// key, and reported it as active: the one answer that matters here, on a +// surface an operator reads to decide whether a credential still works. +// model.KeyIsUsable is the authority and checks all three independently. +func KeyState(k Key) string { + switch { + case k.RevokedAt != nil: + return KeyStateRevoked + case k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt): + return KeyStateExpired + case !k.Active: + // Not reachable from a gateway that only clears active in Revoke, but + // if one ever serves it, the key authenticates nothing — and "revoked" + // is the administrative reading, where "expired" would name a deadline + // that has not passed. + return KeyStateRevoked + default: + return KeyStateActive + } +} + +// Keys lists GET /admin/keys, which answers with a bare array — no {"data":…} +// envelope, unlike every other admin list. +func (c *Client) Keys(ctx context.Context) ([]Key, error) { + var out []Key + if _, err := c.do(ctx, http.MethodGet, "/admin/keys", nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return out, nil +} + +// Key reads one key by id. +func (c *Client) Key(ctx context.Context, id string) (*Key, error) { + var out Key + if _, err := c.do(ctx, http.MethodGet, keyPath(id), nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return &out, nil +} + +// CreateKey creates a key. The returned Key carries the full secret in Key, +// and this is the only response that ever will. +func (c *Client) CreateKey(ctx context.Context, req KeyCreateRequest) (*Key, error) { + var out Key + if _, err := c.do(ctx, http.MethodPost, "/admin/keys", nil, req, &out, doOpts{}); err != nil { + return nil, err + } + return &out, nil +} + +// RotateKey issues a new secret for an existing key. The previous secret stops +// authenticating immediately, and the new one is returned once. +func (c *Client) RotateKey(ctx context.Context, id string) (*Key, error) { + var out Key + if _, err := c.do(ctx, http.MethodPost, keyPath(id)+"/rotate", nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return &out, nil +} + +// RevokeKey revokes a key immediately and irreversibly. The gateway confirms +// with {"status":"revoked"}; anything else is reported as a failure rather +// than assumed to have worked. +func (c *Client) RevokeKey(ctx context.Context, id string) error { + var out struct { + Status string `json:"status"` + } + if _, err := c.do(ctx, http.MethodPost, keyPath(id)+"/revoke", nil, nil, &out, doOpts{}); err != nil { + return err + } + if out.Status != KeyStateRevoked { + return fmt.Errorf("gateway did not confirm revocation of %s (status %q)", id, out.Status) + } + return nil +} + +// keyPath escapes the id so a caller-supplied value cannot climb out of +// /admin/keys via path traversal. +func keyPath(id string) string { return "/admin/keys/" + url.PathEscape(id) } diff --git a/internal/api/keys_test.go b/internal/api/keys_test.go new file mode 100644 index 0000000..54cb055 --- /dev/null +++ b/internal/api/keys_test.go @@ -0,0 +1,230 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// keysStub serves the /admin/keys surface and records the last request body. +func keysStub(t *testing.T, lastBody *string, lastPath *string) *httptest.Server { + t.Helper() + record := func(r *http.Request) { + *lastPath = r.URL.Path + if r.Body == nil { + return + } + b, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + *lastBody = string(b) + } + mux := http.NewServeMux() + // Deliberately a BARE ARRAY — /admin/keys is the one list endpoint with no + // {"data":…} envelope. + mux.HandleFunc("GET /admin/keys", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, 200, `[ + {"id":"k1","key":"fgw_head8...il4","name":"ci","scopes":["read_only"], + "created_at":"2026-08-01T10:00:00Z","expires_at":"2026-09-01T10:00:00Z", + "last_used_at":"2026-08-08T09:00:00Z","usage_count":42,"active":true}, + {"id":"k2","key":"fgw_dead8...ad4","name":"old","scopes":["admin"], + "created_at":"2026-07-01T10:00:00Z","revoked_at":"2026-07-20T10:00:00Z", + "usage_count":0,"active":false}]`) + }) + mux.HandleFunc("GET /admin/keys/{id}", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, 200, `{"id":"k1","key":"fgw_head8...il4","name":"ci","scopes":["read_only"], + "created_at":"2026-08-01T10:00:00Z","usage_count":42,"active":true}`) + }) + mux.HandleFunc("POST /admin/keys", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, http.StatusCreated, `{"id":"k9","key":"fgw_fullsecret000","name":"ci", + "scopes":["read_only"],"created_at":"2026-08-08T10:00:00Z","usage_count":0,"active":true}`) + }) + mux.HandleFunc("POST /admin/keys/{id}/rotate", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, 200, `{"id":"k1","key":"fgw_rotatedsecret","name":"ci","scopes":["read_only"], + "created_at":"2026-08-01T10:00:00Z","rotated_at":"2026-08-08T10:00:00Z","usage_count":42,"active":true}`) + }) + mux.HandleFunc("POST /admin/keys/{id}/revoke", func(w http.ResponseWriter, r *http.Request) { + record(r) + if r.PathValue("id") == "k404" { + writeJSON(t, w, http.StatusNotFound, `{"error":{"message":"key not found","type":"not_found_error"}}`) + return + } + if r.PathValue("id") == "kweird" { + writeJSON(t, w, 200, `{"status":"queued"}`) + return + } + writeJSON(t, w, 200, `{"status":"revoked"}`) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestKeysDecodesBareArray(t *testing.T) { + var body, path string + srv := keysStub(t, &body, &path) + + keys, err := testClient(t, srv.URL, "fgw_ok").Keys(context.Background()) + if err != nil { + t.Fatal(err) + } + if path != "/admin/keys" { + t.Fatalf("want GET /admin/keys, got %q", path) + } + if len(keys) != 2 { + t.Fatalf("want 2 keys, got %d", len(keys)) + } + k := keys[0] + if k.ID != "k1" || k.Name != "ci" || k.UsageCount != 42 || !k.Active || + len(k.Scopes) != 1 || k.Scopes[0] != "read_only" { + t.Fatalf("bad decode: %+v", k) + } + if k.ExpiresAt == nil || !k.ExpiresAt.Equal(time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)) { + t.Fatalf("expires_at must decode to a pointer: %+v", k.ExpiresAt) + } + if k.RevokedAt != nil || k.RotatedAt != nil { + t.Fatalf("absent timestamps must stay nil: %+v", k) + } + if keys[1].RevokedAt == nil { + t.Fatal("revoked_at must decode") + } +} + +func TestKeyStateIsDerived(t *testing.T) { + ts := time.Now() + past := ts.Add(-24 * time.Hour) + future := ts.Add(24 * time.Hour) + cases := []struct { + name string + key Key + want string + }{ + {"active", Key{Active: true}, "active"}, + {"active with an expiry still ahead", Key{Active: true, ExpiresAt: &future}, "active"}, + // The shape a real expired key actually has. The gateway clears active + // only in Revoke, so an expired-but-not-revoked key is served + // active:true with revoked_at:null — this case is the whole reason + // KeyState reads expires_at, and it reported "active" until it did. + {"expired", Key{Active: true, ExpiresAt: &past}, "expired"}, + {"revoked", Key{Active: false, RevokedAt: &ts}, "revoked"}, + // revoked wins even if the gateway still reports the key active. + {"revoked beats active", Key{Active: true, RevokedAt: &ts}, "revoked"}, + // A key can be both; revoked is the more specific answer. + {"revoked beats expired", Key{Active: false, RevokedAt: &ts, ExpiresAt: &past}, "revoked"}, + // Not a shape the gateway serves, but it authenticates nothing, and + // "expired" would name a deadline that has not passed. + {"inactive without a revocation", Key{Active: false}, "revoked"}, + } + for _, tc := range cases { + if got := KeyState(tc.key); got != tc.want { + t.Errorf("%s: KeyState = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestKeyGetByID(t *testing.T) { + var body, path string + srv := keysStub(t, &body, &path) + + k, err := testClient(t, srv.URL, "fgw_ok").Key(context.Background(), "k1") + if err != nil { + t.Fatal(err) + } + if path != "/admin/keys/k1" { + t.Fatalf("want GET /admin/keys/k1, got %q", path) + } + if k.ID != "k1" { + t.Fatalf("bad decode: %+v", k) + } +} + +func TestCreateKeyAlwaysSendsScopes(t *testing.T) { + var body, path string + srv := keysStub(t, &body, &path) + + exp := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC) + k, err := testClient(t, srv.URL, "fgw_ok").CreateKey(context.Background(), + KeyCreateRequest{Name: "ci", Scopes: []string{"read_only"}, ExpiresAt: &exp}) + if err != nil { + t.Fatal(err) + } + if path != "/admin/keys" { + t.Fatalf("want POST /admin/keys, got %q", path) + } + var sent map[string]any + if err := json.Unmarshal([]byte(body), &sent); err != nil { + t.Fatalf("request body is not JSON: %q", body) + } + scopes, ok := sent["scopes"].([]any) + if !ok || len(scopes) == 0 { + // The gateway defaults a scope-less request to read_only, not admin. + // ferro still always sends one, so the scope is the operator's choice + // rather than a server default that can differ between versions. + t.Fatalf("scopes must always be sent: %q", body) + } + if got, _ := sent["expires_at"].(string); got != "2026-09-08T10:00:00Z" { + t.Fatalf("expires_at must be RFC3339: %q", body) + } + if k.Key != "fgw_fullsecret000" { + t.Fatalf("create must return the full secret once: %+v", k) + } +} + +func TestCreateKeyOmitsAbsentExpiry(t *testing.T) { + var body, path string + srv := keysStub(t, &body, &path) + + if _, err := testClient(t, srv.URL, "fgw_ok").CreateKey(context.Background(), + KeyCreateRequest{Name: "ci", Scopes: []string{"admin"}}); err != nil { + t.Fatal(err) + } + if strings.Contains(body, "expires_at") { + t.Fatalf("no expiry means the field is omitted, not null: %q", body) + } +} + +func TestRotateKeyReturnsNewSecret(t *testing.T) { + var body, path string + srv := keysStub(t, &body, &path) + + k, err := testClient(t, srv.URL, "fgw_ok").RotateKey(context.Background(), "k1") + if err != nil { + t.Fatal(err) + } + if path != "/admin/keys/k1/rotate" { + t.Fatalf("want POST /admin/keys/k1/rotate, got %q", path) + } + if k.Key != "fgw_rotatedsecret" || k.RotatedAt == nil { + t.Fatalf("rotate must return the new secret and rotated_at: %+v", k) + } +} + +func TestRevokeKey(t *testing.T) { + var body, path string + srv := keysStub(t, &body, &path) + c := testClient(t, srv.URL, "fgw_ok") + + if err := c.RevokeKey(context.Background(), "k1"); err != nil { + t.Fatal(err) + } + if path != "/admin/keys/k1/revoke" { + t.Fatalf("want POST /admin/keys/k1/revoke, got %q", path) + } + // A 200 that does not say "revoked" is not a revocation. + if err := c.RevokeKey(context.Background(), "kweird"); err == nil { + t.Fatal("an unexpected status must not be reported as success") + } + if err := c.RevokeKey(context.Background(), "k404"); err == nil { + t.Fatal("a 404 must propagate") + } +} diff --git a/internal/api/logs.go b/internal/api/logs.go new file mode 100644 index 0000000..48d62ec --- /dev/null +++ b/internal/api/logs.go @@ -0,0 +1,264 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "slices" + "strconv" + "time" +) + +// LogEntry is one row of GET /admin/logs. The three measurements are pointers +// because the gateway sends null for "not measured" — a request that failed +// before it reached a provider, or one served by a provider with no price in +// the catalog. Collapsing null into 0 would render as a real zero-cost, +// zero-latency request. +type LogEntry struct { + TraceID string `json:"trace_id"` + Stage string `json:"stage"` + Model string `json:"model"` + APIKeyID string `json:"api_key_id"` + Provider string `json:"provider"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + ErrorMessage string `json:"error_message"` + CreatedAt time.Time `json:"created_at"` + DurationMs *float64 `json:"duration_ms"` + TTFTMs *float64 `json:"ttft_ms"` + CostUSD *float64 `json:"cost_usd"` +} + +// LogsQuery filters GET /admin/logs. Only non-zero fields are sent: an empty +// value is not the same as an absent one on every parameter, and sending one +// can narrow the listing server-side. +// +// APIKeyID accepts the gateway's "none" sentinel (rows with no credential), +// which is passed through untouched. +type LogsQuery struct { + Limit int + Offset int + Since time.Time + Model string + Provider string + Stage string + APIKeyID string +} + +func (q LogsQuery) values() url.Values { + v := url.Values{} + if q.Limit > 0 { + v.Set("limit", strconv.Itoa(q.Limit)) + } + if q.Offset > 0 { + v.Set("offset", strconv.Itoa(q.Offset)) + } + if !q.Since.IsZero() { + v.Set("since", q.Since.UTC().Format(time.RFC3339)) + } + for k, s := range map[string]string{ + "model": q.Model, "provider": q.Provider, "stage": q.Stage, "api_key_id": q.APIKeyID, + } { + if s != "" { + v.Set(k, s) + } + } + return v +} + +// LogsPage is one page of GET /admin/logs. +type LogsPage struct { + Data []LogEntry `json:"data"` + Summary struct { + TotalEntries int `json:"total_entries"` + ReturnedEntries int `json:"returned_entries"` + } `json:"summary"` +} + +// Percentiles is one latency distribution. The gateway sends null for the +// whole block when nothing was measured, so callers hold it by pointer. +type Percentiles struct { + P50 float64 `json:"p50"` + P95 float64 `json:"p95"` + P99 float64 `json:"p99"` + Max float64 `json:"max"` + Mean float64 `json:"mean"` + Count int `json:"count"` +} + +// LogStats is GET /admin/logs/stats. The by_* breakdowns stay raw: their value +// shape is not part of any contract ferro renders, and decoding them would +// invent one. +type LogStats struct { + Summary struct { + TotalEntries int `json:"total_entries"` + ErrorEntries int `json:"error_entries"` + TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + CostUSD float64 `json:"cost_usd"` + UnpricedRequests int `json:"unpriced_requests"` + } `json:"summary"` + LatencyMs *Percentiles `json:"latency_ms"` + TTFTMs *Percentiles `json:"ttft_ms"` + ByProvider map[string]json.RawMessage `json:"by_provider"` + ByModel map[string]json.RawMessage `json:"by_model"` + TopErrors []struct { + Message string `json:"message"` + Count int `json:"count"` + } `json:"top_errors"` +} + +// Logs reads a page of the request log. A gateway with no log store configured +// answers 501, for which IsNotSupported reports true. +func (c *Client) Logs(ctx context.Context, q LogsQuery) (*LogsPage, error) { + var out LogsPage + if _, err := c.do(ctx, http.MethodGet, "/admin/logs", q.values(), nil, &out, doOpts{}); err != nil { + return nil, err + } + return &out, nil +} + +// LogStats reads the pre-aggregated request-log statistics: totals, error +// counts and latency percentiles the CLI would otherwise have to compute from +// a full page walk. The stats endpoint supports time, model, provider, and +// stage filters; paging and credential filters are intentionally omitted. +func (c *Client) LogStats(ctx context.Context, filter LogsQuery) (*LogStats, error) { + var out LogStats + q := LogsQuery{Since: filter.Since, Model: filter.Model, Provider: filter.Provider, Stage: filter.Stage}.values() + if _, err := c.do(ctx, http.MethodGet, "/admin/logs/stats", q, nil, &out, doOpts{}); err != nil { + return nil, err + } + return &out, nil +} + +// ParseSince reads a --since value: a Go duration ("15m", "2h") meaning that +// long ago, or an absolute RFC3339 timestamp. A signed duration is read as the +// same span into the past — a future cursor is never what was meant. +func ParseSince(s string) (time.Time, error) { + if d, err := time.ParseDuration(s); err == nil { + if d < 0 { + d = -d + } + return time.Now().Add(-d), nil + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, fmt.Errorf("invalid since %q: want a duration (15m, 2h) or an RFC3339 timestamp", s) + } + return t, nil +} + +// followDedupeWindow bounds the ring of already-emitted row keys. +// +// The window is row-count based rather than time based. It covers the maximum +// bounded page drain so a boundary row cannot be evicted during one poll. +const ( + maxFollowPages = 32 + maxFollowLimit = 200 + followDedupeWindow = maxFollowPages * maxFollowLimit +) + +// Follower turns the paged /admin/logs listing into a tail: each Poll advances +// a since-cursor and returns only rows not seen before. +// +// It is not safe for concurrent use — one Follower belongs to one tail loop. +type Follower struct { + c *Client + q LogsQuery + cursor time.Time + seen map[string]struct{} + order []string // FIFO eviction for the dedupe ring +} + +// NewFollower starts a tail at q.Since (zero means the gateway's own default +// window). +func NewFollower(c *Client, q LogsQuery) *Follower { + if q.Limit <= 0 { + q.Limit = 100 + } else if q.Limit > maxFollowLimit { + q.Limit = maxFollowLimit + } + return &Follower{c: c, q: q, cursor: q.Since, seen: make(map[string]struct{})} +} + +// dedupeKey identifies a row. A trace has one row per pipeline stage, so the +// stage is part of the identity; the timestamp separates a retried trace id. +func dedupeKey(e LogEntry) string { + return e.TraceID + "/" + e.Stage + "/" + e.CreatedAt.UTC().Format(time.RFC3339Nano) +} + +// ErrFollowTruncated reports that one poll drained maxFollowPages without +// reaching the end of its window. The rows returned alongside it are complete +// and the cursor has advanced past them, so the next poll resumes instead of +// re-draining the same window: a sustained burst slows the tail down, it never +// stops it. What is lost is the older end of that one window — the gateway +// lists newest first, so the pages the bound cut off are the oldest ones. +var ErrFollowTruncated = fmt.Errorf( + "request-log poll exceeded the %d-page safety bound; older rows in this window were skipped — narrow the filters or use a shorter interval", + maxFollowPages) + +// Poll fetches rows since the cursor and returns the unseen ones in ascending +// created_at order. +// +// On a fetch error the cursor is left untouched, so a transient failure +// re-fetches the same window rather than skipping the rows that landed during +// it. The cursor is also second-granular while created_at is not, so the +// boundary row is re-fetched by design and dropped by the dedupe ring — the +// safe direction. +// +// ErrFollowTruncated is the one error returned with rows: they are valid and +// the cursor moved, so a caller that renders them loses nothing. +func (f *Follower) Poll(ctx context.Context) ([]LogEntry, error) { + q := f.q + q.Since = f.cursor + rows := make([]LogEntry, 0, q.Limit) + baseOffset := q.Offset + drained := false + for pageNo := 0; pageNo < maxFollowPages; pageNo++ { + q.Offset = baseOffset + pageNo*q.Limit + page, err := f.c.Logs(ctx, q) + if err != nil { + return nil, err + } + rows = append(rows, page.Data...) + // A short page is the end-of-window signal every gateway gives. The + // total is only the second one when the gateway actually sent it: an + // absent summary.total_entries decodes as 0, which read as "the window + // ended here" on the first FULL page and capped the tail at one page — + // silently, because drained was set and ErrFollowTruncated never fired. + // With no usable total the drain runs to the page bound instead, and + // the stated loss that produces beats a silent one. + if total := page.Summary.TotalEntries; len(page.Data) < q.Limit || + (total > 0 && q.Offset+len(page.Data) >= total) { + drained = true + break + } + } + slices.SortStableFunc(rows, func(a, b LogEntry) int { return a.CreatedAt.Compare(b.CreatedAt) }) + + fresh := rows[:0] + for _, e := range rows { + k := dedupeKey(e) + if _, dup := f.seen[k]; dup { + continue + } + f.seen[k] = struct{}{} + f.order = append(f.order, k) + if len(f.order) > followDedupeWindow { + delete(f.seen, f.order[0]) + f.order = f.order[1:] + } + fresh = append(fresh, e) + if e.CreatedAt.After(f.cursor) { + f.cursor = e.CreatedAt + } + } + if !drained { + return fresh, ErrFollowTruncated + } + return fresh, nil +} diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go new file mode 100644 index 0000000..60d62ae --- /dev/null +++ b/internal/api/logs_test.go @@ -0,0 +1,537 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func TestLogsDecodesNullMeasurements(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"data":[ + {"trace_id":"t1","stage":"after_request","model":"gpt-5.1","api_key_id":"k1","provider":"openai", + "prompt_tokens":10,"completion_tokens":20,"total_tokens":30,"error_message":"", + "created_at":"2026-08-08T10:00:01Z","duration_ms":812.5,"ttft_ms":210.25,"cost_usd":0.0042}, + {"trace_id":"t2","stage":"on_error","model":"gpt-5.1","api_key_id":"","provider":"", + "prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"error_message":"upstream unavailable", + "created_at":"2026-08-08T10:00:02Z","duration_ms":null,"ttft_ms":null,"cost_usd":null}], + "summary":{"total_entries":2,"returned_entries":2}}`) + })) + defer srv.Close() + + page, err := testClient(t, srv.URL, "fgw_ok").Logs(context.Background(), LogsQuery{}) + if err != nil { + t.Fatal(err) + } + if page.Summary.TotalEntries != 2 || page.Summary.ReturnedEntries != 2 || len(page.Data) != 2 { + t.Fatalf("bad page: %+v", page) + } + a := page.Data[0] + if a.DurationMs == nil || *a.DurationMs != 812.5 || a.TTFTMs == nil || a.CostUSD == nil || *a.CostUSD != 0.0042 { + t.Fatalf("measured row lost its values: %+v", a) + } + if a.PromptTokens != 10 || a.TotalTokens != 30 || a.Provider != "openai" { + t.Fatalf("bad decode: %+v", a) + } + b := page.Data[1] + // null is "not measured" — it must NOT collapse into 0, which would render + // as a real zero-cost, zero-latency request. + if b.DurationMs != nil || b.TTFTMs != nil || b.CostUSD != nil { + t.Fatalf("null measurements must decode to nil: %+v", b) + } + if b.ErrorMessage != "upstream unavailable" { + t.Fatalf("bad decode: %+v", b) + } +} + +func TestLogsQueryEncoding(t *testing.T) { + var got url.Values + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, path = r.URL.Query(), r.URL.Path + writeJSON(t, w, 200, `{"data":[],"summary":{"total_entries":0,"returned_entries":0}}`) + })) + defer srv.Close() + c := testClient(t, srv.URL, "fgw_ok") + + since := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC) + if _, err := c.Logs(context.Background(), LogsQuery{ + Limit: 50, Offset: 100, Since: since, + Model: "gpt-5.1", Provider: "openai", Stage: "all", APIKeyID: "none", + }); err != nil { + t.Fatal(err) + } + if path != "/admin/logs" { + t.Fatalf("want GET /admin/logs, got %q", path) + } + want := url.Values{ + "limit": {"50"}, "offset": {"100"}, "since": {"2026-08-08T10:00:00Z"}, + "model": {"gpt-5.1"}, "provider": {"openai"}, "stage": {"all"}, + // "none" is the gateway's sentinel for credential-less rows: passed + // through untouched, never interpreted. + "api_key_id": {"none"}, + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("query = %v, want %v", got, want) + } + + // A zero query sends no filters at all — an empty value would narrow the + // listing server-side on some parameters. + if _, err := c.Logs(context.Background(), LogsQuery{}); err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("zero query must send no parameters, got %v", got) + } +} + +func TestLogs501IsNotSupported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusNotImplemented, + `{"error":{"message":"request log store not configured","type":"server_error"}}`) + })) + defer srv.Close() + + _, err := testClient(t, srv.URL, "fgw_ok").Logs(context.Background(), LogsQuery{}) + if err == nil || !IsNotSupported(err) { + t.Fatalf("a 501 must be detectable as unsupported, got %v", err) + } +} + +func TestLogStatsDecodes(t *testing.T) { + var got url.Values + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, path = r.URL.Query(), r.URL.Path + writeJSON(t, w, 200, `{ + "summary":{"total_entries":1200,"error_entries":13,"total_tokens":90000,"prompt_tokens":60000, + "completion_tokens":30000,"cost_usd":1.2345,"unpriced_requests":4}, + "latency_ms":{"p50":210,"p95":980.5,"p99":1400,"max":2200,"mean":320.25,"count":1200}, + "ttft_ms":null, + "by_stage":{"after_request":{"count":1187}}, + "by_provider":{"openai":{"count":900}}, + "by_model":{"gpt-5.1":{"count":900}}, + "top_errors":[{"message":"upstream unavailable","count":9}], + "series":{}}`) + })) + defer srv.Close() + + since := time.Date(2026, 8, 8, 9, 55, 0, 0, time.UTC) + st, err := testClient(t, srv.URL, "fgw_ok").LogStats(context.Background(), LogsQuery{ + Since: since, Model: "gpt-5.1", Provider: "openai", Stage: "on_error", APIKeyID: "key_ignored", + }) + if err != nil { + t.Fatal(err) + } + if path != "/admin/logs/stats" { + t.Fatalf("want GET /admin/logs/stats, got %q", path) + } + if got.Get("since") != "2026-08-08T09:55:00Z" || got.Get("model") != "gpt-5.1" || + got.Get("provider") != "openai" || got.Get("stage") != "on_error" || got.Has("api_key_id") { + t.Fatalf("bad query: %v", got) + } + if st.Summary.TotalEntries != 1200 || st.Summary.ErrorEntries != 13 || st.Summary.CostUSD != 1.2345 || + st.Summary.UnpricedRequests != 4 { + t.Fatalf("bad summary: %+v", st.Summary) + } + if st.LatencyMs == nil || st.LatencyMs.P95 != 980.5 || st.LatencyMs.Count != 1200 { + t.Fatalf("latency percentiles: %+v", st.LatencyMs) + } + // A null percentile block means "nothing measured" and must stay nil. + if st.TTFTMs != nil { + t.Fatalf("null ttft_ms must decode to nil, got %+v", st.TTFTMs) + } + if len(st.TopErrors) != 1 || st.TopErrors[0].Count != 9 { + t.Fatalf("top errors: %+v", st.TopErrors) + } + if string(st.ByProvider["openai"]) != `{"count":900}` || len(st.ByModel) != 1 { + t.Fatalf("breakdowns stay raw: %v", st.ByProvider) + } +} + +func TestParseSince(t *testing.T) { + now := time.Now() + got, err := ParseSince("15m") + if err != nil { + t.Fatal(err) + } + if d := now.Add(-15 * time.Minute).Sub(got); d > time.Second || d < -time.Second { + t.Fatalf("15m must mean 15 minutes ago, got %v (off by %v)", got, d) + } + // A signed duration means the same thing — nobody wants a future cursor. + // Bound the difference on both sides: an upper bound alone passes a + // timestamp two hours in the future, which is the bug being guarded. + got, err = ParseSince("-2h") + if err != nil { + t.Fatalf("-2h must parse: %v", err) + } + if d := now.Add(-2 * time.Hour).Sub(got); d > time.Second || d < -time.Second { + t.Fatalf("-2h must also mean two hours ago, got %v (off by %v)", got, d) + } + want := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC) + if got, err = ParseSince("2026-08-08T10:00:00Z"); err != nil || !got.Equal(want) { + t.Fatalf("RFC3339 must parse: %v %v", got, err) + } + if _, err = ParseSince("yesterday"); err == nil { + t.Fatal("an unparseable value must be an error, not a silent zero time") + } + if _, err = ParseSince(""); err == nil { + t.Fatal("empty must be an error") + } +} + +// followStub serves pages keyed by the `since` parameter and records every +// since value it was asked for. +type followStub struct { + mu sync.Mutex + since []string + pages []string // body per request index + statuses []int // 0 means 200 +} + +func (s *followStub) handler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + i := len(s.since) + s.since = append(s.since, r.URL.Query().Get("since")) + body, status := "", 0 + if i < len(s.pages) { + body = s.pages[i] + } + if i < len(s.statuses) { + status = s.statuses[i] + } + s.mu.Unlock() + if status != 0 && status != 200 { + writeJSON(t, w, status, `{"error":{"message":"boom","type":"server_error"}}`) + return + } + writeJSON(t, w, 200, body) + } +} + +func (s *followStub) sinceAt(i int) string { + s.mu.Lock() + defer s.mu.Unlock() + if i >= len(s.since) { + return "" + } + return s.since[i] +} + +func logRow(trace, stage, created string) string { + return fmt.Sprintf(`{"trace_id":%q,"stage":%q,"model":"m","api_key_id":"","provider":"openai", + "prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"error_message":"", + "created_at":%q,"duration_ms":null,"ttft_ms":null,"cost_usd":null}`, trace, stage, created) +} + +func logPage(rows ...string) string { + return fmt.Sprintf(`{"data":[%s],"summary":{"total_entries":%d,"returned_entries":%d}}`, + strings.Join(rows, ","), len(rows), len(rows)) +} + +func TestFollowerCursorAndDedupe(t *testing.T) { + a, b, cc := "2026-08-08T10:00:01Z", "2026-08-08T10:00:02Z", "2026-08-08T10:00:03Z" + stub := &followStub{pages: []string{ + // The gateway serves newest first; the follower must not. + logPage(logRow("B", "after_request", b), logRow("A", "after_request", a)), + // since >= B: the boundary row comes back, plus the new one. + logPage(logRow("C", "after_request", cc), logRow("B", "after_request", b)), + }} + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + + f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}) + rows, err := f.Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].TraceID != "A" || rows[1].TraceID != "B" { + t.Fatalf("first poll must be ascending [A B], got %v", traceIDs(rows)) + } + if s := stub.sinceAt(0); s != "" { + t.Fatalf("first poll has no cursor yet, sent since=%q", s) + } + + rows, err = f.Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].TraceID != "C" { + t.Fatalf("B must be deduped, want [C], got %v", traceIDs(rows)) + } + if s := stub.sinceAt(1); s != b { + t.Fatalf("cursor must advance to the newest row, sent since=%q want %q", s, b) + } +} + +func TestFollowerDrainsEveryPageBeforeAdvancingCursor(t *testing.T) { + created := []string{ + "2026-08-08T10:00:04Z", "2026-08-08T10:00:03Z", + "2026-08-08T10:00:02Z", "2026-08-08T10:00:01Z", + } + var mu sync.Mutex + var offsets []int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + mu.Lock() + offsets = append(offsets, offset) + mu.Unlock() + end := min(offset+limit, len(created)) + rows := make([]string, 0, end-offset) + for i := offset; i < end; i++ { + rows = append(rows, logRow(fmt.Sprintf("T%d", i), "after_request", created[i])) + } + writeJSON(t, w, http.StatusOK, fmt.Sprintf( + `{"data":[%s],"summary":{"total_entries":%d,"returned_entries":%d}}`, + strings.Join(rows, ","), len(created), len(rows))) + })) + defer srv.Close() + + got, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 2}).Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != 4 { + t.Fatalf("all pages must be returned oldest first: %v", traceIDs(got)) + } + for i := 1; i < len(got); i++ { + if got[i].CreatedAt.Before(got[i-1].CreatedAt) { + t.Fatalf("all pages must be returned oldest first: %v", traceIDs(got)) + } + } + mu.Lock() + defer mu.Unlock() + if fmt.Sprint(offsets) != "[0 2]" { + t.Fatalf("page offsets = %v, want [0 2]", offsets) + } +} + +func TestFollowerTruncatedPollKeepsRowsAndAdvances(t *testing.T) { + base := time.Date(2026, 8, 8, 10, 10, 0, 0, time.UTC) + var mu sync.Mutex + var sinces []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + sinces = append(sinces, r.URL.Query().Get("since")) + mu.Unlock() + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + // Newest first, and a total the drain can never reach: the sustained + // burst that used to stall the tail forever. + row := logRow(fmt.Sprintf("T%d", offset), "after_request", + base.Add(-time.Duration(offset)*time.Second).Format(time.RFC3339)) + writeJSON(t, w, http.StatusOK, + `{"data":[`+row+`],"summary":{"total_entries":100000,"returned_entries":1}}`) + })) + defer srv.Close() + + f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 1}) + rows, err := f.Poll(context.Background()) + if !errors.Is(err, ErrFollowTruncated) { + t.Fatalf("a poll that hits the page bound must say so: %v", err) + } + if len(rows) != maxFollowPages { + t.Fatalf("rows already fetched must be kept, got %d want %d", len(rows), maxFollowPages) + } + if _, err := f.Poll(context.Background()); !errors.Is(err, ErrFollowTruncated) { + t.Fatalf("the second poll is still truncated: %v", err) + } + mu.Lock() + defer mu.Unlock() + // The advancing cursor is the whole fix: discarding it made every poll + // re-drain the same window, so the operator never saw a row. + if first, second := sinces[0], sinces[maxFollowPages]; first != "" || second != base.Format(time.RFC3339) { + t.Fatalf("cursor must advance past the rows returned: poll1 since=%q poll2 since=%q", first, second) + } +} + +// Not every gateway populates summary.total_entries. An absent one decodes as +// 0, which used to satisfy the end-of-window test on the very first FULL page: +// the tail stopped after one page and ErrFollowTruncated — the only mechanism +// that tells the operator rows were skipped — never fired. A tail that cannot +// see the end of its window must drain to the bound and then SAY so. +func TestFollowerWithoutTotalEntriesPagesOnAndReportsTruncation(t *testing.T) { + base := time.Date(2026, 8, 8, 10, 20, 0, 0, time.UTC) + var mu sync.Mutex + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests++ + mu.Unlock() + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + // Newest first, one full page every time, and no total_entries at all. + row := logRow(fmt.Sprintf("T%d", offset), "after_request", + base.Add(-time.Duration(offset)*time.Second).Format(time.RFC3339)) + writeJSON(t, w, http.StatusOK, `{"data":[`+row+`],"summary":{"returned_entries":1}}`) + })) + defer srv.Close() + + rows, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 1}).Poll(context.Background()) + if !errors.Is(err, ErrFollowTruncated) { + t.Fatalf("a drain that never reached the end of its window must state the loss, got %v", err) + } + if len(rows) != maxFollowPages { + t.Fatalf("full pages must keep paging to the bound, got %d rows want %d", len(rows), maxFollowPages) + } + mu.Lock() + defer mu.Unlock() + if requests != maxFollowPages { + t.Fatalf("the tail stopped after %d pages, want %d", requests, maxFollowPages) + } +} + +// The other half of that branch: a SHORT page still ends the drain on a gateway +// that sends no total, so the fix cannot have turned every quiet poll into a +// 32-page walk. +func TestFollowerWithoutTotalEntriesStopsOnAShortPage(t *testing.T) { + row := logRow("A", "after_request", "2026-08-08T10:00:01Z") + stub := &followStub{pages: []string{`{"data":[` + row + `],"summary":{"returned_entries":1}}`}} + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + + rows, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}).Poll(context.Background()) + if err != nil { + t.Fatalf("a short page is the end of the window whatever the summary says: %v", err) + } + if len(rows) != 1 { + t.Fatalf("want the one row, got %v", traceIDs(rows)) + } +} + +func TestFollowerErrorKeepsCursor(t *testing.T) { + a, b, cc := "2026-08-08T10:00:01Z", "2026-08-08T10:00:02Z", "2026-08-08T10:00:03Z" + stub := &followStub{ + pages: []string{ + logPage(logRow("A", "after_request", a), logRow("B", "after_request", b)), + "", + logPage(logRow("C", "after_request", cc)), + }, + statuses: []int{200, http.StatusInternalServerError, 200}, + } + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + + f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}) + if _, err := f.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := f.Poll(context.Background()); err == nil { + t.Fatal("a 500 must be reported") + } + rows, err := f.Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].TraceID != "C" { + t.Fatalf("want [C] after recovery, got %v", traceIDs(rows)) + } + // The cursor is what guarantees no row is skipped after a transient + // failure: it must be unchanged by the failed poll. + if s2, s3 := stub.sinceAt(1), stub.sinceAt(2); s2 != b || s3 != b { + t.Fatalf("cursor must survive the failure: poll2 since=%q poll3 since=%q want %q", s2, s3, b) + } +} + +func TestFollowerSameStageDifferentTraceNotDeduped(t *testing.T) { + ts := "2026-08-08T10:00:01Z" + stub := &followStub{pages: []string{ + logPage(logRow("A", "after_request", ts), logRow("A", "on_error", ts), logRow("B", "after_request", ts)), + }} + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + + rows, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}).Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + // Dedupe is trace+stage+time: one trace's two stages are two rows. + if len(rows) != 3 { + t.Fatalf("want 3 distinct rows, got %d: %v", len(rows), traceIDs(rows)) + } +} + +func TestFollowerDedupeRingStaysBounded(t *testing.T) { + base := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC) + initial := make([]string, 0, followDedupeWindow) + for i := range followDedupeWindow { + initial = append(initial, logRow(fmt.Sprintf("t%d", i), "after_request", + base.Add(time.Duration(i)*time.Second).UTC().Format(time.RFC3339))) + } + const freshRows = 100 + next := make([]string, 0, freshRows+1) + next = append(next, initial[len(initial)-1]) // inclusive cursor boundary + for i := range freshRows { + next = append(next, logRow(fmt.Sprintf("new%d", i), "after_request", + base.Add(time.Duration(followDedupeWindow+i)*time.Second).UTC().Format(time.RFC3339))) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rows := initial + if r.URL.Query().Get("since") != "" { + rows = next + } + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + end := min(offset+limit, len(rows)) + pageRows := rows[offset:end] + writeJSON(t, w, http.StatusOK, fmt.Sprintf( + `{"data":[%s],"summary":{"total_entries":%d,"returned_entries":%d}}`, + strings.Join(pageRows, ","), len(rows), len(pageRows))) + })) + defer srv.Close() + + f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 1000}) + got, err := f.Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != followDedupeWindow { + t.Fatalf("first poll = %d rows, want %d", len(got), followDedupeWindow) + } + got, err = f.Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != 100 { + t.Fatalf("boundary row must dedupe while 100 new rows pass, got %d", len(got)) + } + // A long tail session must not grow a set of every row it ever saw. + if len(f.seen) > followDedupeWindow || len(f.order) > followDedupeWindow { + t.Fatalf("dedupe ring unbounded: seen=%d order=%d", len(f.seen), len(f.order)) + } +} + +func TestNewFollowerNormalizesNegativeLimit(t *testing.T) { + f := NewFollower(nil, LogsQuery{Limit: -1}) + if f.q.Limit != 100 { + t.Fatalf("negative limit must use the safe default, got %d", f.q.Limit) + } +} + +func TestFollowerPropagates501(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusNotImplemented, `{"error":{"message":"no log store","type":"server_error"}}`) + })) + defer srv.Close() + + _, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}).Poll(context.Background()) + if !IsNotSupported(err) { + t.Fatalf("the tail must be able to degrade one screen on 501: %v", err) + } +} + +func traceIDs(rows []LogEntry) []string { + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.TraceID+"/"+r.Stage) + } + return out +} diff --git a/internal/api/services.go b/internal/api/services.go new file mode 100644 index 0000000..a0ab1bb --- /dev/null +++ b/internal/api/services.go @@ -0,0 +1,135 @@ +package api + +import ( + "context" + "net/http" + "net/url" + "strconv" + "time" +) + +// Session is one dashboard session. There is no device or IP field on the +// wire — Subject is the credential's name. +type Session struct { + ID string `json:"id"` + CredentialID string `json:"credential_id"` + Subject string `json:"subject"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + ExpiresAt time.Time `json:"expires_at"` +} + +// AuditEntry is one row of the audit trail. +type AuditEntry struct { + OccurredAt time.Time `json:"occurred_at"` + Action string `json:"action"` + Actor string `json:"actor"` + ActorID string `json:"actor_id,omitempty"` + TargetID string `json:"target_id,omitempty"` + Outcome string `json:"outcome"` // ok|denied|error + Detail string `json:"detail,omitempty"` + SourceIP string `json:"source_ip,omitempty"` + TraceID string `json:"trace_id,omitempty"` +} + +// AuditQuery filters GET /admin/audit. Only non-zero fields are sent — +// Outcome in particular must be ok|denied|error or the gateway answers 400, +// so an empty one is omitted rather than sent blank. +type AuditQuery struct { + Action string + ActorID string + Outcome string + Since time.Time + Limit int + Offset int +} + +func (q AuditQuery) values() url.Values { + v := url.Values{} + for k, s := range map[string]string{"action": q.Action, "actor_id": q.ActorID, "outcome": q.Outcome} { + if s != "" { + v.Set(k, s) + } + } + if !q.Since.IsZero() { + v.Set("since", q.Since.UTC().Format(time.RFC3339)) + } + if q.Limit > 0 { + v.Set("limit", strconv.Itoa(q.Limit)) + } + if q.Offset > 0 { + v.Set("offset", strconv.Itoa(q.Offset)) + } + return v +} + +// AuditPage is one page of GET /admin/audit. +type AuditPage struct { + Data []AuditEntry `json:"data"` + Summary struct { + TotalEntries int `json:"total_entries"` + ReturnedEntries int `json:"returned_entries"` + } `json:"summary"` +} + +// PluginInfo is a plugin this gateway has configured. +type PluginInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` +} + +// BuiltinPlugin is a plugin this gateway build ships. FailsOpen lives here +// only, which is why the plugins table merges the two listings by name. +type BuiltinPlugin struct { + Name string `json:"name"` + Type string `json:"type"` + Summary string `json:"summary"` + Settings []string `json:"settings"` + FailsOpen bool `json:"fails_open"` +} + +// Sessions lists active dashboard sessions, unwrapping the {"data":…} +// envelope. A gateway with sessions disabled answers 501, for which +// IsNotSupported reports true. +func (c *Client) Sessions(ctx context.Context) ([]Session, error) { + var out struct { + Data []Session `json:"data"` + } + if _, err := c.do(ctx, http.MethodGet, "/admin/sessions", nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return out.Data, nil +} + +// Audit reads a page of the audit trail. +func (c *Client) Audit(ctx context.Context, q AuditQuery) (*AuditPage, error) { + var out AuditPage + if _, err := c.do(ctx, http.MethodGet, "/admin/audit", q.values(), nil, &out, doOpts{}); err != nil { + return nil, err + } + return &out, nil +} + +// Plugins lists the configured plugins. Like /admin/keys and /admin/providers, +// this one answers with a bare array. +func (c *Client) Plugins(ctx context.Context) ([]PluginInfo, error) { + var out []PluginInfo + if _, err := c.do(ctx, http.MethodGet, "/admin/plugins", nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return out, nil +} + +// PluginCatalog lists the plugins this gateway build ships, unwrapping the +// {"data":…} envelope. +func (c *Client) PluginCatalog(ctx context.Context) ([]BuiltinPlugin, error) { + var out struct { + Data []BuiltinPlugin `json:"data"` + } + if _, err := c.do(ctx, http.MethodGet, "/admin/plugins/catalog", nil, nil, &out, doOpts{}); err != nil { + return nil, err + } + return out.Data, nil +} diff --git a/internal/api/services_test.go b/internal/api/services_test.go new file mode 100644 index 0000000..fae40d5 --- /dev/null +++ b/internal/api/services_test.go @@ -0,0 +1,190 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +// servicesStub serves the sessions/audit/plugins surface and records the last +// path and query it was asked for. +func servicesStub(t *testing.T, path *string, query *url.Values) *httptest.Server { + t.Helper() + record := func(r *http.Request) { + *path = r.URL.Path + q := r.URL.Query() + *query = q + } + mux := http.NewServeMux() + mux.HandleFunc("GET /admin/sessions", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, 200, `{"data":[ + {"id":"s1","credential_id":"k1","subject":"ci","scopes":["read_only"], + "created_at":"2026-08-08T09:00:00Z","last_seen_at":"2026-08-08T09:30:00Z", + "expires_at":"2026-08-09T09:00:00Z"}, + {"id":"s2","credential_id":"k2","subject":"ops","scopes":["admin"], + "created_at":"2026-08-08T08:00:00Z","expires_at":"2026-08-09T08:00:00Z"}]}`) + }) + mux.HandleFunc("GET /admin/audit", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, 200, `{"data":[ + {"occurred_at":"2026-08-08T09:00:00Z","action":"key.create","actor":"ops","actor_id":"k2", + "target_id":"k9","outcome":"ok","detail":"scopes=read_only","source_ip":"10.0.0.1","trace_id":"t1"}, + {"occurred_at":"2026-08-08T09:01:00Z","action":"session.create","actor":"unknown", + "outcome":"denied"}], + "summary":{"total_entries":2,"returned_entries":2}}`) + }) + // A BARE ARRAY, unlike /admin/plugins/catalog one path segment down. + mux.HandleFunc("GET /admin/plugins", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, 200, `[{"name":"request-logger","type":"logging","enabled":true}, + {"name":"word-filter","type":"guardrail","enabled":false}]`) + }) + mux.HandleFunc("GET /admin/plugins/catalog", func(w http.ResponseWriter, r *http.Request) { + record(r) + writeJSON(t, w, 200, `{"data":[ + {"name":"request-logger","type":"logging","summary":"Logs requests","settings":["persist"],"fails_open":true}, + {"name":"word-filter","type":"guardrail","summary":"Blocks words","settings":["blocked_words"],"fails_open":false}]}`) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestSessionsUnwrapsDataEnvelope(t *testing.T) { + var path string + var query url.Values + srv := servicesStub(t, &path, &query) + + sessions, err := testClient(t, srv.URL, "fgw_ok").Sessions(context.Background()) + if err != nil { + t.Fatal(err) + } + if path != "/admin/sessions" { + t.Fatalf("want GET /admin/sessions, got %q", path) + } + if len(sessions) != 2 { + t.Fatalf("want 2 sessions, got %d", len(sessions)) + } + s := sessions[0] + if s.ID != "s1" || s.CredentialID != "k1" || s.Subject != "ci" || len(s.Scopes) != 1 { + t.Fatalf("bad decode: %+v", s) + } + if s.LastSeenAt == nil || !s.LastSeenAt.Equal(time.Date(2026, 8, 8, 9, 30, 0, 0, time.UTC)) { + t.Fatalf("last_seen_at must decode: %+v", s.LastSeenAt) + } + if !s.ExpiresAt.Equal(time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)) { + t.Fatalf("expires_at must decode: %+v", s.ExpiresAt) + } + // A session that has never been used has no last_seen_at at all. + if sessions[1].LastSeenAt != nil { + t.Fatalf("absent last_seen_at must stay nil: %+v", sessions[1]) + } +} + +func TestSessions501IsNotSupported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusNotImplemented, + `{"error":{"message":"sessions are disabled","type":"server_error"}}`) + })) + defer srv.Close() + + _, err := testClient(t, srv.URL, "fgw_ok").Sessions(context.Background()) + if err == nil || !IsNotSupported(err) { + t.Fatalf("sessions disabled must degrade one screen, got %v", err) + } +} + +func TestAuditDecodesAndEncodesQuery(t *testing.T) { + var path string + var query url.Values + srv := servicesStub(t, &path, &query) + c := testClient(t, srv.URL, "fgw_ok") + + since := time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC) + page, err := c.Audit(context.Background(), AuditQuery{ + Action: "key.create", ActorID: "k2", Outcome: "ok", Since: since, Limit: 50, Offset: 10, + }) + if err != nil { + t.Fatal(err) + } + if path != "/admin/audit" { + t.Fatalf("want GET /admin/audit, got %q", path) + } + want := url.Values{ + "action": {"key.create"}, "actor_id": {"k2"}, "outcome": {"ok"}, + "since": {"2026-08-08T00:00:00Z"}, "limit": {"50"}, "offset": {"10"}, + } + if fmt.Sprint(query) != fmt.Sprint(want) { + t.Fatalf("query = %v, want %v", query, want) + } + if page.Summary.TotalEntries != 2 || len(page.Data) != 2 { + t.Fatalf("bad page: %+v", page) + } + e := page.Data[0] + if e.Action != "key.create" || e.Actor != "ops" || e.ActorID != "k2" || e.TargetID != "k9" || + e.Outcome != "ok" || e.Detail == "" || e.SourceIP != "10.0.0.1" || e.TraceID != "t1" { + t.Fatalf("bad decode: %+v", e) + } + if !e.OccurredAt.Equal(time.Date(2026, 8, 8, 9, 0, 0, 0, time.UTC)) { + t.Fatalf("occurred_at: %v", e.OccurredAt) + } + if page.Data[1].ActorID != "" || page.Data[1].Outcome != "denied" { + t.Fatalf("optional fields: %+v", page.Data[1]) + } + + // A zero query sends nothing: an empty outcome is a 400 server-side. + if _, err := c.Audit(context.Background(), AuditQuery{}); err != nil { + t.Fatal(err) + } + if len(query) != 0 { + t.Fatalf("zero query must send no parameters, got %v", query) + } +} + +func TestPluginsDecodesBareArray(t *testing.T) { + var path string + var query url.Values + srv := servicesStub(t, &path, &query) + + plugins, err := testClient(t, srv.URL, "fgw_ok").Plugins(context.Background()) + if err != nil { + t.Fatal(err) + } + if path != "/admin/plugins" { + t.Fatalf("want GET /admin/plugins, got %q", path) + } + if len(plugins) != 2 || plugins[0].Name != "request-logger" || plugins[0].Type != "logging" || + !plugins[0].Enabled || plugins[1].Enabled { + t.Fatalf("bad decode: %+v", plugins) + } +} + +func TestPluginCatalogUnwrapsDataEnvelope(t *testing.T) { + var path string + var query url.Values + srv := servicesStub(t, &path, &query) + + catalog, err := testClient(t, srv.URL, "fgw_ok").PluginCatalog(context.Background()) + if err != nil { + t.Fatal(err) + } + if path != "/admin/plugins/catalog" { + t.Fatalf("want GET /admin/plugins/catalog, got %q", path) + } + if len(catalog) != 2 { + t.Fatalf("want 2 builtins, got %d", len(catalog)) + } + // fails_open lives only in the catalog — it is what the plugins table + // merges in by name. + if !catalog[0].FailsOpen || catalog[1].FailsOpen { + t.Fatalf("fails_open must decode: %+v", catalog) + } + if catalog[0].Summary == "" || len(catalog[0].Settings) != 1 { + t.Fatalf("bad decode: %+v", catalog[0]) + } +} diff --git a/internal/api/sse.go b/internal/api/sse.go new file mode 100644 index 0000000..3c25458 --- /dev/null +++ b/internal/api/sse.go @@ -0,0 +1,404 @@ +package api + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// ChatMessage is one turn of a chat request. +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// ChatRequest is the subset of POST /v1/chat/completions ferro sends. +// +// Stream is forced true by StreamChat: the CLI has no non-streaming path. The +// cost is that a streamed body carries no provider — that is resolved from the +// request log instead, by TraceAttribution. +type ChatRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + Stream bool `json:"stream"` +} + +// Usage is the token accounting the gateway sends in a chunk of its own before +// [DONE]. It arrives whether or not the client asked for it — the gateway +// strips usage only on an explicit stream_options.include_usage:false — so a +// stream that ends without one ended abnormally. +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` +} + +// MessageDelta is the incremental piece of one choice. +// +// ToolCalls is carried raw and unrendered until v0.2: decoding into a typed +// shape ferro does not display would make a tool-calling stream fail to parse +// for no benefit, and dropping the field would make it unrecoverable. +type MessageDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` +} + +// StreamChoice is one candidate's delta within a chunk. FinishReason is a +// pointer because every chunk carries the field and only the last one carries +// a value. +type StreamChoice struct { + Index int `json:"index"` + Delta MessageDelta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +// ChatChunk is one `data:` frame of a streamed completion. Choices may be +// empty — the usage chunk is exactly that shape — but the gateway never sends +// null for it. +type ChatChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []StreamChoice `json:"choices"` + Usage *Usage `json:"usage,omitempty"` +} + +// StreamEvent is one thing that happened on a stream: exactly one of the three +// fields is set. +// +// Err and Done are both terminal and mutually exclusive. A stream that fails +// mid-flight ends with an error frame and NO [DONE], so a reader that waits for +// Done after an Err waits forever; read until the channel closes instead. +type StreamEvent struct { + Chunk *ChatChunk // a decoded content/usage frame + Err *Error // mid-stream failure — the stream is over + Done bool // data: [DONE] +} + +// ChatStream is a live streamed completion. +// +// RequestID is the X-Request-ID response header, which equals the trace_id of +// the matching /admin/logs row. It is the only way to attribute a streamed +// request: no chunk names a provider. +// +// Cancel is always safe to call, safe to call more than once, and never blocks +// — including while the reader has stopped reading Events. Call it (defer it) +// even after a clean Done: it is what releases the reader goroutine and the +// connection. +type ChatStream struct { + RequestID string + Events <-chan StreamEvent + Cancel context.CancelFunc +} + +// Stream error codes. Only the first is the gateway's own, sent inside an +// error frame; the other two are ferro's, synthesized locally. Reading +// stream_timeout as the gateway's verdict is what put "the gateway closed it" +// into two operator-facing messages for a bound the gateway never saw. +const ( + CodeStreamError = "stream_error" // gateway: the upstream or the pipeline failed + CodeStreamTimeout = "stream_timeout" // client: this client's idle bound elapsed + CodeStreamIncomplete = "stream_incomplete" // client: connection ended without [DONE] + + // DefaultStreamIdleTimeout prevents a connected-but-silent upstream from + // leaving a CLI command or console turn waiting forever. + DefaultStreamIdleTimeout = 2 * time.Minute + + // maxStreamFrameBytes bounds one SSE frame, which is one line. It is a + // memory bound on a stream nothing else limits, so it stays where it is: + // raising it only moves the wall, and a frame this size is a broken or + // hostile upstream rather than an answer. + maxStreamFrameBytes = 1 << 20 + + // maxStreamBytes bounds all accepted JSON payloads in one response. A + // per-frame limit alone still allows an unending sequence of valid frames + // to exhaust the CLI or console. + maxStreamBytes = 4 << 20 +) + +type scanResult struct { + line string + err error + done bool +} + +// StreamChat opens a streamed chat completion. +// +// A non-2xx arriving before the stream starts is returned as an *Error and +// no ChatStream. Once streaming has begun every failure is delivered on the +// channel instead, because the HTTP status is already 200 by then. +// +// The caller owns the returned stream's lifetime: read Events until it closes, +// and call Cancel. +func (c *Client) StreamChat(ctx context.Context, req ChatRequest) (*ChatStream, error) { + req.Stream = true // a value copy: the caller's request is not mutated + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(ctx) + // resolveURL is the same join c.do uses for every other endpoint, so a + // path-prefixed --gateway-url (e.g. https://gw.example.com/ferro) cannot + // resolve to a different URL here than it would through do. + reqURL := c.resolveURL("/v1/chat/completions").String() + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body)) + if err != nil { + cancel() + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("User-Agent", c.userAgent) + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + // A dedicated client AND a dedicated transport: the shared client carries + // DefaultTimeout and its transport carries the same value as + // ResponseHeaderTimeout, so escaping only the first still cut every stream + // off at the header phase. A gateway or ingress that withholds response + // headers until the first token is ordinary, which is what + // DefaultStreamIdleTimeout is sized for. The assertion falls back to the + // transport as-is because a test double is not an *http.Transport and + // carries no bound to set. The redirect refusal is not optional — a bearer token must not + // replay to whatever host a redirect names, on this surface as much as any + // other. + streamTransport := c.hc.Transport + if tr, ok := streamTransport.(*http.Transport); ok { + clone := tr.Clone() + // Widened to the idle bound, not removed. Do runs before the idle timer + // below exists and this client carries no Timeout, so zeroing it left a + // peer that accepts the connection and then withholds headers blocking + // StreamChat for as long as the caller's context allows — which for the + // CLI's signal context and the console's is forever. The idle timeout is + // already the answer to "how long may this stream say nothing"; the + // header phase is the first stretch of exactly that silence. A caller + // who disables the idle bound disables this one with it. + clone.ResponseHeaderTimeout = c.streamIdleTimeout + streamTransport = clone + } + streamClient := &http.Client{ + Transport: streamTransport, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + resp, err := streamClient.Do(httpReq) + if err != nil { + cancel() + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer func() { _ = resp.Body.Close() }() + defer cancel() + if resp.StatusCode < 400 { + return nil, &Error{ + Status: resp.StatusCode, + Message: "redirect refused", + RedirectTo: resp.Header.Get("Location"), + } + } + raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + return nil, decodeAPIError(resp.StatusCode, raw) + } + + events := make(chan StreamEvent) + st := &ChatStream{RequestID: resp.Header.Get("X-Request-ID"), Events: events, Cancel: cancel} + + go func() { + defer close(events) + defer cancel() + defer func() { _ = resp.Body.Close() }() + + lines := make(chan scanResult) + go scanStream(ctx, resp.Body, lines) + + var idle <-chan time.Time + var timer *time.Timer + if c.streamIdleTimeout > 0 { + timer = time.NewTimer(c.streamIdleTimeout) + idle = timer.C + defer timer.Stop() + } + resetIdle := func() { + if timer == nil { + return + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(c.streamIdleTimeout) + } + + var streamBytes int + for { + select { + case <-ctx.Done(): + return + case <-idle: + sendEvent(ctx, events, StreamEvent{Err: &Error{ + Status: http.StatusGatewayTimeout, Type: CodeStreamError, Code: CodeStreamTimeout, + Message: "stream produced no events before the idle timeout", + }}) + cancel() + return + case result := <-lines: + if result.done { + if ctx.Err() != nil { + return + } + msg := "stream ended without a terminating frame" + switch { + case errors.Is(result.err, bufio.ErrTooLong): + // The stream really is incomplete, so the code is right, + // but "read failed: token too long" names a Go type and + // sends the reader looking at the network. Say what the + // gateway sent instead. + msg = fmt.Sprintf("a single stream frame exceeded the %d-byte limit; the answer is truncated", maxStreamFrameBytes) + case result.err != nil: + msg = "stream read failed: " + result.err.Error() + } + sendEvent(ctx, events, StreamEvent{Err: &Error{ + Status: http.StatusBadGateway, Type: CodeStreamError, Code: CodeStreamIncomplete, Message: msg, + }}) + return + } + line := result.line + if !strings.HasPrefix(line, "data:") { + continue // blank keep-alives, ": comments", and any field ferro does not read + } + // TrimSpace absorbs both the optional space after the colon and + // the \r of a CRLF stream. + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + + if payload == "[DONE]" { + resetIdle() + sendEvent(ctx, events, StreamEvent{Done: true}) + return + } + if e := decodeErrorFrame(payload); e != nil { + resetIdle() + // An error frame is the last frame: no [DONE] follows it, so + // this goroutine must not go back to waiting for one. + sendEvent(ctx, events, StreamEvent{Err: e}) + return + } + // Accounted before the decode, never after: an undecodable + // frame was still read off the wire, and charging only the ones + // that parsed let an endless run of garbage frames walk straight + // past the bound this constant exists to enforce. + streamBytes += len(payload) + if streamBytes > maxStreamBytes { + sendEvent(ctx, events, StreamEvent{Err: &Error{ + Status: http.StatusBadGateway, Type: CodeStreamError, Code: CodeStreamIncomplete, + Message: fmt.Sprintf("aggregate stream payload exceeded the %d-byte limit; the answer is truncated", maxStreamBytes), + }}) + return + } + var chunk ChatChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + continue // one malformed frame never kills a live stream + } + resetIdle() + if !sendEvent(ctx, events, StreamEvent{Chunk: &chunk}) { + return + } + } + } + }() + return st, nil +} + +func scanStream(ctx context.Context, r io.Reader, out chan<- scanResult) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), maxStreamFrameBytes) + for sc.Scan() { + select { + case out <- scanResult{line: sc.Text()}: + case <-ctx.Done(): + return + } + } + select { + case out <- scanResult{err: sc.Err(), done: true}: + case <-ctx.Done(): + } +} + +// decodeErrorFrame reports whether payload is a mid-stream error frame, and +// decodes it. A content chunk carries no "error" key, so this cannot false-fire +// on one. +func decodeErrorFrame(payload string) *Error { + var probe struct { + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` + } + if json.Unmarshal([]byte(payload), &probe) != nil || probe.Error == nil { + return nil + } + // 502: the HTTP response was already 200 by the time this arrived, so the + // status is synthesized to say "the upstream failed mid-answer". + return &Error{ + Status: http.StatusBadGateway, + Message: probe.Error.Message, + Type: probe.Error.Type, + Code: probe.Error.Code, + } +} + +// sendEvent delivers ev unless ctx is cancelled first, and reports whether it +// was delivered. Every send goes through it: an unbuffered channel plus a +// reader that stopped reading is exactly the deadlock Cancel exists to break. +func sendEvent(ctx context.Context, ch chan<- StreamEvent, ev StreamEvent) bool { + select { + case ch <- ev: + return true + case <-ctx.Done(): + return false + } +} + +// TraceAttribution resolves which provider served a request and what it cost, +// by matching a response's X-Request-ID against /admin/logs trace_id. Streamed +// chunks carry no provider, so this is the only attribution path. +// +// It answers (nil, nil) rather than an error for every "cannot know yet" +// case — no trace id, no matching row (the log write lags the response), or a +// gateway with no log store (501). Attribution is a detail shown beside an +// answer that already arrived; failing the command over its absence would turn +// a cosmetic gap into a broken chat. +func (c *Client) TraceAttribution(ctx context.Context, traceID string, since time.Time) (*LogEntry, error) { + if traceID == "" { + return nil, nil + } + page, err := c.Logs(ctx, LogsQuery{Limit: 50, Since: since}) + if err != nil { + if IsNotSupported(err) { + return nil, nil + } + return nil, err + } + for _, e := range page.Data { + if e.TraceID == traceID { + return &e, nil + } + } + return nil, nil +} diff --git a/internal/api/sse_test.go b/internal/api/sse_test.go new file mode 100644 index 0000000..fff8027 --- /dev/null +++ b/internal/api/sse_test.go @@ -0,0 +1,637 @@ +package api + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// frame wraps one JSON payload in the gateway's framing: `data: \n\n`, +// no event name. +func frame(payload string) string { return "data: " + payload + "\n\n" } + +const doneFrame = "data: [DONE]\n\n" + +// writeSSE writes frames verbatim, flushing between them, so the client reads +// them as a stream rather than in one buffered shot. +func writeSSE(w http.ResponseWriter, requestID string, frames ...string) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Request-ID", requestID) + w.WriteHeader(http.StatusOK) + f, ok := w.(http.Flusher) + for _, fr := range frames { + // A failed frame write means the client hung up mid-stream, which the + // cancellation and early-close tests do on purpose. It is discarded + // rather than reported: failing the test here would make those cases + // flaky, and the client-side assertions already cover what was read. + _, _ = io.WriteString(w, fr) + if ok { + f.Flush() + } + } +} + +// drain reads until the channel closes, failing rather than hanging forever if +// it never does. +func drain(t *testing.T, st *ChatStream) []StreamEvent { + t.Helper() + var got []StreamEvent + deadline := time.After(5 * time.Second) + for { + select { + case ev, open := <-st.Events: + if !open { + return got + } + got = append(got, ev) + case <-deadline: + t.Fatal("stream never closed its Events channel") + } + } +} + +func content(events []StreamEvent) string { + var b strings.Builder + for _, ev := range events { + if ev.Chunk == nil { + continue + } + for _, ch := range ev.Chunk.Choices { + b.WriteString(ch.Delta.Content) + } + } + return b.String() +} + +func TestStreamChatHappyPath(t *testing.T) { + // The handler runs in its own goroutine; the streamed response body is + // what the test goroutine synchronizes on to read the events, not these + // captured request facts, so they need their own guard. + var mu sync.Mutex + var gotBody, gotAccept, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + mu.Lock() + gotBody, gotAccept, gotAuth = string(raw), r.Header.Get("Accept"), r.Header.Get("Authorization") + mu.Unlock() + writeSSE(w, "trace-abc", + frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`), + frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[{"index":0,"delta":{"content":"Hello "},"finish_reason":null}]}`), + frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[{"index":0,"delta":{"content":"world"},"finish_reason":"stop"}]}`), + frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}}`), + doneFrame, + ) + })) + defer srv.Close() + + c, _ := New(srv.URL, "fgw_secret") + st, err := c.StreamChat(context.Background(), ChatRequest{ + Model: "m", + Messages: []ChatMessage{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + defer st.Cancel() + + events := drain(t, st) + if len(events) != 5 { + t.Fatalf("want 4 chunks + Done, got %d events: %+v", len(events), events) + } + for i, ev := range events[:4] { + if ev.Chunk == nil || ev.Err != nil || ev.Done { + t.Fatalf("event %d must be a chunk, got %+v", i, ev) + } + } + if !events[4].Done || events[4].Chunk != nil || events[4].Err != nil { + t.Fatalf("last event must be Done, got %+v", events[4]) + } + if got := content(events); got != "Hello world" { + t.Fatalf("content = %q, want %q", got, "Hello world") + } + if fr := events[2].Chunk.Choices[0].FinishReason; fr == nil || *fr != "stop" { + t.Fatalf("finish_reason not decoded: %v", fr) + } + usage := events[3].Chunk.Usage + if usage == nil || usage.TotalTokens != 12 || usage.PromptTokens != 9 { + t.Fatalf("usage chunk not decoded: %+v", usage) + } + if len(events[3].Chunk.Choices) != 0 { + t.Fatalf("usage chunk carries an empty choices array, got %+v", events[3].Chunk.Choices) + } + if st.RequestID != "trace-abc" { + t.Fatalf("RequestID = %q, want the X-Request-ID header", st.RequestID) + } + // The request itself: stream is forced on, and the SSE surface is asked for. + mu.Lock() + body, accept, auth := gotBody, gotAccept, gotAuth + mu.Unlock() + if !strings.Contains(body, `"stream":true`) { + t.Fatalf("StreamChat must force stream:true, body was %s", body) + } + if accept != "text/event-stream" || auth != "Bearer fgw_secret" { + t.Fatalf("accept=%q auth=%q", accept, auth) + } +} + +func TestStreamChatDoneIgnoresFollowingBufferedFrames(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, "trace-done", doneFrame, + frame(`{"id":"late","choices":[{"index":0,"delta":{"content":"must be ignored"}}]}`)) + })) + defer srv.Close() + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatal(err) + } + defer st.Cancel() + events := drain(t, st) + if len(events) != 1 || !events[0].Done { + t.Fatalf("[DONE] must terminate immediately: %+v", events) + } +} + +func TestStreamChatErrorFrameEndsStreamWithoutDone(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, "trace-err", + frame(`{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant"}}]}`), + frame(`{"error":{"message":"boom","type":"stream_error","code":"stream_error"}}`), + // No [DONE]: that asymmetry is the gateway's real behavior. + ) + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + defer st.Cancel() + + events := drain(t, st) + var errs, dones int + for _, ev := range events { + if ev.Err != nil { + errs++ + } + if ev.Done { + dones++ + } + } + if errs != 1 || dones != 0 { + t.Fatalf("want exactly one Err and no Done, got %d/%d: %+v", errs, dones, events) + } + last := events[len(events)-1] + if last.Err.Code != CodeStreamError || last.Err.Message != "boom" || last.Err.Type != "stream_error" { + t.Fatalf("error frame not decoded: %+v", last.Err) + } + if !strings.Contains(last.Err.Error(), "boom") { + t.Fatalf("api.Error must read usefully: %q", last.Err.Error()) + } +} + +func TestStreamChatToleratesCRLFAndComments(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, "trace-crlf", + ": keep-alive\r\n\r\n", + "data: "+`{"id":"c1","choices":[{"index":0,"delta":{"content":"ok"}}]}`+"\r\n\r\n", + // The space after the colon is optional in the SSE grammar. The + // gateway always writes one; a proxy that re-frames the stream + // need not, and dropping the frame would lose content silently. + "data:"+`{"id":"c1","choices":[{"index":0,"delta":{"content":"!"}}]}`+"\r\n\r\n", + "\r\n", + "data: [DONE]\r\n\r\n", + ) + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + defer st.Cancel() + + events := drain(t, st) + if got := content(events); got != "ok!" { + t.Fatalf("content = %q, want %q (events %+v)", got, "ok!", events) + } + if !events[len(events)-1].Done { + t.Fatalf("a CRLF [DONE] must terminate the stream: %+v", events) + } +} + +func TestStreamChatSkipsMalformedFrame(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, "trace-junk", + frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"a"}}]}`), + frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":`), // truncated JSON + frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"b"}}]}`), + doneFrame, + ) + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + defer st.Cancel() + + events := drain(t, st) + if got := content(events); got != "ab" { + t.Fatalf("one bad frame must not kill the stream: content = %q, events %+v", got, events) + } + if !events[len(events)-1].Done { + t.Fatalf("stream must still reach Done: %+v", events) + } +} + +func TestStreamChatTruncatedStreamReportsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // A well-formed frame, then the connection ends: no [DONE], no error + // frame. A silently closed channel would render as a complete answer. + writeSSE(w, "trace-cut", frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"half"}}]}`)) + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + defer st.Cancel() + + events := drain(t, st) + last := events[len(events)-1] + if last.Err == nil || last.Err.Code != CodeStreamIncomplete { + t.Fatalf("a truncated stream must surface an Err, got %+v", events) + } +} + +func TestStreamChatOversizedFrameNamesItsOwnCause(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, "trace-big", frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"`+ + strings.Repeat("x", maxStreamFrameBytes)+`"}}]}`)) + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + defer st.Cancel() + + last := drain(t, st) + e := last[len(last)-1].Err + if e == nil || e.Code != CodeStreamIncomplete { + t.Fatalf("an oversized frame still ends the stream incomplete: %+v", last) + } + // The stream is genuinely incomplete, so the code is right — but the + // message must name the frame size, not a scanner internal, or the reader + // goes looking at the network for a limit the client applied. + if !strings.Contains(e.Message, "exceeded") || strings.Contains(e.Message, "token too long") { + t.Fatalf("the message must name the frame limit, got %q", e.Message) + } +} + +func TestStreamChatSilentConnectionTimesOut(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() + })) + defer srv.Close() + + c, err := New(srv.URL, "", WithStreamIdleTimeout(25*time.Millisecond)) + if err != nil { + t.Fatal(err) + } + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatal(err) + } + defer st.Cancel() + events := drain(t, st) + if len(events) != 1 || events[0].Err == nil || events[0].Err.Code != CodeStreamTimeout { + t.Fatalf("silent connection must end with stream_timeout: %+v", events) + } +} + +func TestStreamChatMalformedFramesDoNotDefeatIdleTimeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + f, _ := w.(http.Flusher) + for { + if _, err := io.WriteString(w, "data: {not-json}\n\n"); err != nil { + return + } + if f != nil { + f.Flush() + } + select { + case <-r.Context().Done(): + return + case <-time.After(10 * time.Millisecond): + } + } + })) + defer srv.Close() + + c, err := New(srv.URL, "", WithStreamIdleTimeout(35*time.Millisecond)) + if err != nil { + t.Fatal(err) + } + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatal(err) + } + defer st.Cancel() + events := drain(t, st) + if len(events) != 1 || events[0].Err == nil || events[0].Err.Code != CodeStreamTimeout { + t.Fatalf("malformed frames must not keep a broken stream alive: %+v", events) + } +} + +func TestStreamChatAggregatePayloadIsBounded(t *testing.T) { + payload := `{"id":"c1","choices":[{"index":0,"delta":{"content":"` + + strings.Repeat("x", 32<<10) + `"}}]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + frames := make([]string, 0, maxStreamBytes/(32<<10)+2) + for range maxStreamBytes/(32<<10) + 1 { + frames = append(frames, frame(payload)) + } + writeSSE(w, "trace-aggregate", frames...) + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatal(err) + } + defer st.Cancel() + events := drain(t, st) + last := events[len(events)-1] + if last.Err == nil || last.Err.Code != CodeStreamIncomplete || + !strings.Contains(last.Err.Message, "aggregate") { + t.Fatalf("aggregate stream limit must terminate visibly: %+v", last) + } +} + +func TestStreamChatUndecodableFramesCountTowardAggregateBound(t *testing.T) { + // Nothing here parses, so every frame takes the malformed-frame path — the + // path that used to skip the accounting and hand a hostile or broken + // upstream an unbounded stream. + const size = 512 << 10 + payload := strings.Repeat("x", size) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + frames := make([]string, 0, maxStreamBytes/size+2) + for range maxStreamBytes/size + 1 { + frames = append(frames, frame(payload)) + } + // [DONE] last: before the fix the stream reached it and reported a + // complete answer after 4 MiB of garbage. + writeSSE(w, "trace-garbage", append(frames, doneFrame)...) + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatal(err) + } + defer st.Cancel() + events := drain(t, st) + last := events[len(events)-1] + if last.Done { + t.Fatalf("undecodable frames must not ride past the aggregate bound to Done: %+v", events) + } + if last.Err == nil || last.Err.Code != CodeStreamIncomplete || + !strings.Contains(last.Err.Message, "aggregate") { + t.Fatalf("aggregate stream limit must terminate visibly: %+v", last) + } +} + +func TestStreamChatSurvivesSlowResponseHeaders(t *testing.T) { + // Well past the client's request timeout: a gateway that buffers until the + // upstream's first token, or an ingress in front of one, looks exactly like + // this, and the idle timer — not the header bound — is what should judge it. + const headerDelay = 300 * time.Millisecond + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(headerDelay) + writeSSE(w, "trace-slow-headers", + frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"late"}}]}`), doneFrame) + })) + defer srv.Close() + + c, err := New(srv.URL, "", WithTimeout(40*time.Millisecond)) + if err != nil { + t.Fatal(err) + } + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("a slow response header must not kill a stream: %v", err) + } + defer st.Cancel() + events := drain(t, st) + if got := content(events); got != "late" { + t.Fatalf("content = %q, want %q (events: %+v)", got, "late", events) + } + if last := events[len(events)-1]; !last.Done { + t.Fatalf("stream must reach Done: %+v", events) + } +} + +func TestStreamChatCancelMidStreamDoesNotDeadlock(t *testing.T) { + released := make(chan struct{}) + tick := frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"tick"}}]}`) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(released) + // Several frames, so the reader goroutine is blocked handing over a + // later one — an unbuffered channel with a reader that walked away — + // at the moment Cancel lands. + writeSSE(w, "trace-cancel", tick, tick, tick) + <-r.Context().Done() // never sends [DONE]: the client hangs up first + })) + defer srv.Close() + + c, _ := New(srv.URL, "") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + + select { + case ev := <-st.Events: + if ev.Chunk == nil { + t.Fatalf("want the first chunk, got %+v", ev) + } + case <-time.After(5 * time.Second): + t.Fatal("no first chunk") + } + + st.Cancel() + drain(t, st) // must close promptly, with no sender left blocked + st.Cancel() // idempotent: always safe to call again + <-released // the server saw the hang-up, so the connection is not leaked + + // This asserts that the channel closes, not that no goroutine leaked: leak + // detection under httptest means goroutine counting, which is flaky. Add it + // if a leak is ever suspected. +} + +func TestStreamChatPreStreamErrorReturnsAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, + `{"error":{"message":"bad key","type":"authentication_error","code":"unauthorized"}}`) + })) + defer srv.Close() + + c, _ := New(srv.URL, "fgw_bad") + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if st != nil { + t.Fatal("a pre-stream failure must return no ChatStream") + } + var apiErr *Error + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusUnauthorized || apiErr.Message != "bad key" { + t.Fatalf("want a decoded 401 envelope, got %v", err) + } +} + +func TestStreamChatURLMatchesDoResolutionForPathPrefixedBase(t *testing.T) { + // StreamChat builds its URL through the same c.resolveURL join c.do uses + // (see client.go), so a path-prefixed --gateway-url cannot land the + // request on a different path than every other endpoint — in particular + // it cannot produce a double slash on the streaming path. + var gotPath atomic.Pointer[string] + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + gotPath.Store(&p) + writeSSE(w, "trace-prefix", doneFrame) + })) + defer srv.Close() + + for _, suffix := range []string{"/ferro/", "/ferro"} { + c, err := New(srv.URL+suffix, "") + if err != nil { + t.Fatalf("New: %v", err) + } + st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + drain(t, st) + st.Cancel() + + want := "/ferro/v1/chat/completions" + var got string + if p := gotPath.Load(); p != nil { + got = *p + } + if got != want { + t.Fatalf("base %q: request path = %q, want %q (no double slash)", srv.URL+suffix, got, want) + } + if strings.Contains(got, "//") { + t.Fatalf("base %q: request path %q contains a double slash", srv.URL+suffix, got) + } + } +} + +func TestTraceAttribution(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/admin/logs" { + t.Errorf("attribution must read /admin/logs, got %s", r.URL.Path) + } + writeJSON(t, w, http.StatusOK, `{"data":[ + {"trace_id":"other","provider":"openai","cost_usd":9.99}, + {"trace_id":"trace-abc","stage":"after_request","model":"m","provider":"anthropic","total_tokens":12,"cost_usd":0.0012} + ],"summary":{"total_entries":2,"returned_entries":2}}`) + })) + defer srv.Close() + c, _ := New(srv.URL, "fgw_k") + + row, err := c.TraceAttribution(context.Background(), "trace-abc", time.Time{}) + if err != nil { + t.Fatalf("TraceAttribution: %v", err) + } + if row == nil || row.Provider != "anthropic" || row.CostUSD == nil || *row.CostUSD != 0.0012 { + t.Fatalf("want the matching row, got %+v", row) + } + + row, err = c.TraceAttribution(context.Background(), "not-in-the-page", time.Time{}) + if row != nil || err != nil { + t.Fatalf("a missing row is (nil, nil) — log writes lag: got %+v, %v", row, err) + } + + before := calls.Load() + if row, err = c.TraceAttribution(context.Background(), "", time.Time{}); row != nil || err != nil || calls.Load() != before { + t.Fatalf("an empty trace id must answer (nil, nil) without a request: %+v %v calls=%d", row, err, calls.Load()-before) + } +} + +func TestTraceAttributionNoLogStoreIsNotAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusNotImplemented, + `{"error":{"message":"request logging is not configured","type":"server_error","code":"not_implemented"}}`) + })) + defer srv.Close() + + c, _ := New(srv.URL, "fgw_k") + row, err := c.TraceAttribution(context.Background(), "trace-abc", time.Now().Add(-time.Minute)) + if row != nil || err != nil { + t.Fatalf("a 501 log store must degrade to (nil, nil), got %+v, %v", row, err) + } +} + +// A peer that accepts the connection and then withholds response headers must +// not hold StreamChat open indefinitely. Do runs before the idle timer exists +// and the stream client carries no Timeout, so the transport's header bound is +// the only thing standing between a silent peer and a CLI that never returns — +// the caller's context has no deadline in either the CLI or the console. +func TestStreamChatBoundsAPeerThatWithholdsHeaders(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + <-release // never sends headers until the test is done with it + })) + t.Cleanup(func() { close(release); srv.Close() }) + + const idle = 150 * time.Millisecond + c, err := New(srv.URL, "", WithStreamIdleTimeout(idle)) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + // context.Background(): exactly what the console passes, and what the + // CLI's signal context amounts to when nobody presses ctrl+c. + _, streamErr := c.StreamChat(context.Background(), ChatRequest{Model: "m"}) + done <- streamErr + }() + + select { + case streamErr := <-done: + if streamErr == nil { + t.Fatal("a peer that sent no headers must not yield a usable stream") + } + case <-time.After(10 * idle): + t.Fatalf("StreamChat did not return within %s of a %s idle bound: the header phase is unbounded", 10*idle, idle) + } +} diff --git a/internal/api/status.go b/internal/api/status.go new file mode 100644 index 0000000..f821309 --- /dev/null +++ b/internal/api/status.go @@ -0,0 +1,210 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +// The three values StatusReport.State takes. They are part of the frozen +// `ferro status --format json` contract, so consumers match on these strings +// rather than on a Go type. +const ( + StateConnected = "connected" + StateDegraded = "degraded" + StateUnreachable = "unreachable" +) + +// The values StatusReport.Auth takes: the two scopes a gateway credential can +// carry, and the marker for one the gateway refused. Part of the same frozen +// contract as the states above. +const ( + ScopeAdmin = "admin" + ScopeReadOnly = "read_only" + AuthUnauthorized = "unauthorized" +) + +// TargetSummary counts configured routing targets and how many are reachable. +type TargetSummary struct { + Total int `json:"total"` + Routable int `json:"routable"` +} + +// CircuitSummary counts providers whose breaker is not closed. +type CircuitSummary struct { + Open int `json:"open"` + HalfOpen int `json:"half_open"` +} + +// MCPSummary counts MCP tool servers and how many completed their handshake. +type MCPSummary struct { + Ready int `json:"ready"` + Total int `json:"total"` +} + +// StatusReport is the frozen `ferro status --format json` contract and the +// shape the TUI header renders. Adding a field is compatible; renaming or +// removing one is not. +type StatusReport struct { + State string `json:"state"` // connected|degraded|unreachable + URL string `json:"url"` + LatencyMs int64 `json:"latency_ms"` + // Providers and Models are nil when the gateway did not tell us, for the + // same reason Targets is: each is populated only from a call that can + // fail on its own. /health failing leaves a reachable, degraded gateway + // reporting no provider count, and /v1/models is skipped entirely without + // a credential or answered 404 by a build that does not serve it. A real + // zero and an unanswered question are different facts, and only a pointer + // can carry both. + Providers *int `json:"providers,omitempty"` + Models *int `json:"models,omitempty"` + // Targets is nil when the gateway did not tell us. /readyz answers a 503 + // with only {status, reason} — no targets array — so a zero count there + // would mean "none configured" when the truth is "yours are dead", which + // is the wrong answer during exactly the outage this command is run in. + // Absent is honest; 0 is a claim we cannot support. + Targets *TargetSummary `json:"targets,omitempty"` + Circuits CircuitSummary `json:"circuits"` + MCP *MCPSummary `json:"mcp,omitempty"` + Auth string `json:"auth,omitempty"` // admin|read_only|unauthorized|"" + Warnings []string `json:"warnings,omitempty"` +} + +// Status assembles the report both `ferro status` and the TUI header render. +// /health and /readyz are unauthenticated ground truth; admin and models +// enrich it best-effort. An auth problem degrades the report, it never fails +// it — only an unreachable gateway returns an error, and it returns a report +// alongside so callers can still say which URL was tried. +func (c *Client) Status(ctx context.Context) (*StatusReport, error) { + r := &StatusReport{State: StateUnreachable, URL: c.BaseURL()} + + start := time.Now() + health, healthStatus, err := c.Health(ctx) + r.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + return r, err + } + r.State = StateConnected + applyHealth(r, health, healthStatus) + + readyStatus := 0 + ready, status, readyErr := c.Ready(ctx) + if readyErr == nil { + readyStatus = status + applyReady(r, ready, status) + } else { + r.Warnings = append(r.Warnings, "readiness unavailable: "+readyErr.Error()) + } + + if c.applyAuth(ctx, r) { + if models, err := c.Models(ctx); err == nil { + r.Models = Count(len(models)) + } else if !IsNotSupported(err) { + r.Warnings = append(r.Warnings, "models unavailable: "+err.Error()) + } + } + + unroutable := r.Targets != nil && r.Targets.Routable < r.Targets.Total + // A credential the gateway refused is a degraded gateway from where this + // operator sits: /health and /readyz are fine, and every authenticated API + // is still shut. Presenting no credential at all is not a fault — nothing + // was rejected, so that case reports unauthorized without degrading. + credentialRejected := r.Auth == AuthUnauthorized && c.apiKey != "" + if healthStatus == http.StatusServiceUnavailable || readyStatus == http.StatusServiceUnavailable || + readyErr != nil || r.Circuits.Open > 0 || r.Circuits.HalfOpen > 0 || unroutable || credentialRejected { + r.State = StateDegraded + } + return r, nil +} + +// Count records a count the gateway actually supplied. A nil *int in a +// StatusReport means the question went unanswered; Count(0) means the gateway +// answered zero. Callers building a report by hand — tests, fixtures — use it +// so that "answered zero" cannot be written accidentally as "never asked". +func Count(n int) *int { return &n } + +// applyHealth folds the /health body into the report: the provider count, the +// per-provider circuit counts with a warning naming each non-closed one, and a +// warning carrying the body's own status when /health answered 503. +func applyHealth(r *StatusReport, health *HealthReport, status int) { + r.Providers = Count(len(health.Providers)) + for _, p := range health.Providers { + switch p.Circuit { + case "open": + r.Circuits.Open++ + r.Warnings = append(r.Warnings, fmt.Sprintf("%s circuit open", p.Name)) + case "half_open": + r.Circuits.HalfOpen++ + r.Warnings = append(r.Warnings, fmt.Sprintf("%s circuit half-open", p.Name)) + } + } + if status == http.StatusServiceUnavailable { + r.Warnings = append(r.Warnings, "health: "+health.Status) + } +} + +// applyReady folds a successfully read /readyz body into the report. +// +// A not_ready body carries no targets array at all, so only count them when the +// gateway actually listed some. Leaving Targets nil renders as "—" rather than +// asserting a zero the server never reported; the same holds for MCP. +func applyReady(r *StatusReport, ready *ReadyReport, status int) { + if ready.Targets != nil { + t := &TargetSummary{Total: len(ready.Targets)} + for _, target := range ready.Targets { + if target.Routable { + t.Routable++ + } + } + r.Targets = t + } + if ready.MCPServers != nil { + m := &MCPSummary{Total: len(ready.MCPServers)} + for _, s := range ready.MCPServers { + if s.Ready { + m.Ready++ + } + } + r.MCP = m + } + if status == http.StatusServiceUnavailable && ready.Reason != "" { + r.Warnings = append(r.Warnings, "not ready: "+ready.Reason) + } +} + +// applyAuth probes /admin/health and records the scope the credential carries, +// preferring admin over read_only. It reports whether the caller authenticated +// at all, which is what decides whether the model count can be enriched. A +// 401/403 is recorded as unauthorized and degrades the report — an auth problem +// is never an error here. +// +// Every other failure — a 500, a timeout, an undecodable body — is recorded as +// a warning instead. Without one the probe's outcome is indistinguishable from +// a gateway that authenticated fine and serves no models. +func (c *Client) applyAuth(ctx context.Context, r *StatusReport) bool { + ah, err := c.AdminHealth(ctx) + if err != nil { + var ae *Error + if errors.As(err, &ae) && (ae.Status == http.StatusUnauthorized || ae.Status == http.StatusForbidden) { + r.Auth = AuthUnauthorized + if c.apiKey != "" { + r.Warnings = append(r.Warnings, "credential rejected: "+err.Error()) + } + return false + } + r.Warnings = append(r.Warnings, "admin health unavailable: "+err.Error()) + return false + } + for _, scope := range ah.Scopes { + if scope == ScopeAdmin { + r.Auth = scope + break + } + if scope == ScopeReadOnly && r.Auth == "" { + r.Auth = scope + } + } + return true +} diff --git a/internal/api/status_test.go b/internal/api/status_test.go new file mode 100644 index 0000000..77a21ec --- /dev/null +++ b/internal/api/status_test.go @@ -0,0 +1,347 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// cnt reads a supplied count, and answers -1 for one the gateway never gave — +// a value no real count can take, so "absent" can never satisfy an assertion +// about a number. +func cnt(n *int) int { + if n == nil { + return -1 + } + return *n +} + +func TestStatusConnectedWithAdmin(t *testing.T) { + srv := gatewayStub(t, true) + r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + // A half-open circuit and an unroutable target both degrade the report. + if r.State != "degraded" || cnt(r.Providers) != 2 || cnt(r.Models) != 2 || r.Targets == nil || + r.Targets.Total != 2 || r.Targets.Routable != 1 || + r.Circuits.HalfOpen != 1 || r.Circuits.Open != 0 || + r.MCP == nil || r.MCP.Ready != 1 || r.MCP.Total != 2 || + r.Auth != "admin" || len(r.Warnings) == 0 { + t.Fatalf("bad report: %+v", r) + } + if r.URL != strings.TrimRight(srv.URL, "/") { + t.Fatalf("report must name the gateway: %q", r.URL) + } + if !strings.Contains(strings.Join(r.Warnings, ";"), "openai") { + t.Fatalf("the half-open provider must be named: %v", r.Warnings) + } + if r.LatencyMs < 0 { + t.Fatal("latency must be measured") + } +} + +func TestStatusUnauthorizedStillReports(t *testing.T) { + srv := gatewayStub(t, true) + // No key: /health and /readyz still answer; admin and models do not. + r, err := testClient(t, srv.URL, "").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + // Models is nil, not 0: without an accepted credential /v1/models is never + // asked, and reporting zero would state a fact the CLI does not have. + if r.Auth != "unauthorized" || r.Models != nil || cnt(r.Providers) != 2 { + t.Fatalf("unauth status must degrade gracefully: %+v", r) + } + if r.Targets == nil || r.Targets.Total != 2 || r.MCP == nil { + t.Fatalf("unauthenticated ground truth must still be read: %+v", r) + } +} + +func TestStatusUnreachable(t *testing.T) { + c := testClient(t, "http://127.0.0.1:1", "") + r, err := c.Status(context.Background()) + if err == nil || r == nil || r.State != "unreachable" { + t.Fatalf("want unreachable report + error, got %+v, %v", r, err) + } + if r.URL == "" { + t.Fatal("an unreachable report still names the URL it tried") + } +} + +func TestStatusDegradedOn503(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"no_providers","providers":[]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"not_ready","reason":"no routable targets"}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"no_providers","providers":[],"components":[],"scopes":["read_only"]}`) + }) + mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"object":"list","data":[]}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background()) + if err != nil { + t.Fatalf("503 is a report, not a failure: %v", err) + } + joined := strings.Join(r.Warnings, ";") + if r.State != "degraded" || !strings.Contains(joined, "no_providers") || !strings.Contains(joined, "no routable targets") { + t.Fatalf("bad 503 report: %+v", r) + } + if r.Auth != "read_only" { + t.Fatalf("scope must be reported: %+v", r) + } +} + +func TestStatusConnectedWhenAllHealthy(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"closed","models":3}]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ready","targets":[{"name":"openai-primary","routable":true}]}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"healthy","providers":[],"components":[]}`) + }) + mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"object":"list","data":[{"id":"gpt-5.1","object":"model","created":0,"owned_by":"openai"}]}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.State != "connected" || len(r.Warnings) != 0 || r.MCP != nil { + t.Fatalf("healthy gateway must report connected with no warnings: %+v", r) + } + // No scopes in the body: authenticated, but the scope is unknown — models + // are still enriched because the probe did not fail. + if r.Auth != "" || cnt(r.Models) != 1 { + t.Fatalf("scope-less admin health: %+v", r) + } +} + +func TestStatusDegradedWhenReadinessCannotBeRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ok","providers":[]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{not-json`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"nope"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r, err := testClient(t, srv.URL, "").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.State != "degraded" || !strings.Contains(strings.Join(r.Warnings, ";"), "readiness") { + t.Fatalf("a failed readiness probe must degrade the report: %+v", r) + } +} + +func TestStatusPreservesReportedEmptyTargets(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ok","providers":[]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ready","targets":[]}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"nope"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r, err := testClient(t, srv.URL, "").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.Targets == nil || r.Targets.Total != 0 { + t.Fatalf("a reported empty target list is known zero, not absent: %+v", r.Targets) + } +} + +func TestStatusMeasuresRealLatency(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(25 * time.Millisecond) + writeJSON(t, w, 200, `{"status":"ok","providers":[]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ready"}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"nope","type":"authentication_error"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r, err := testClient(t, srv.URL, "").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.LatencyMs < 20 { + t.Fatalf("latency must be the measured /health RTT, got %dms", r.LatencyMs) + } +} + +// healthyExceptAdmin serves a gateway whose unauthenticated ground truth is +// perfect, so nothing but the admin probe can degrade the report. +func healthyExceptAdmin(t *testing.T, adminStatus int, adminBody string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"closed","models":3}]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ready","targets":[{"name":"openai-primary","routable":true}]}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, adminStatus, adminBody) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestStatusDegradesWhenCredentialIsRejected(t *testing.T) { + srv := healthyExceptAdmin(t, http.StatusUnauthorized, `{"error":{"message":"key expired"}}`) + + // A credential was presented and refused: healthy probes must not hide it. + r, err := testClient(t, srv.URL, "fgw_expired").Status(context.Background()) + if err != nil { + t.Fatalf("an auth failure degrades, it never fails: %v", err) + } + if r.State != StateDegraded || r.Auth != AuthUnauthorized { + t.Fatalf("a rejected credential must degrade: %+v", r) + } + if !strings.Contains(strings.Join(r.Warnings, ";"), "credential rejected") { + t.Fatalf("the report must say why it degraded: %v", r.Warnings) + } + + // No credential at all is not a fault — nothing was rejected. + r, err = testClient(t, srv.URL, "").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.State != StateConnected || r.Auth != AuthUnauthorized || len(r.Warnings) != 0 { + t.Fatalf("an unauthenticated probe must report connected: %+v", r) + } +} + +func TestStatusWarnsWhenAdminProbeFailsForANonAuthReason(t *testing.T) { + srv := healthyExceptAdmin(t, http.StatusInternalServerError, `{"error":{"message":"boom"}}`) + + r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + // Without the warning a broken probe is indistinguishable from a gateway + // that authenticated fine and serves no models. + if !strings.Contains(strings.Join(r.Warnings, ";"), "admin health unavailable") { + t.Fatalf("a non-auth probe failure must be visible: %+v", r) + } + if r.Auth != "" { + t.Fatalf("a 500 says nothing about the credential: %+v", r) + } +} + +func TestStatusPreservesEmptyMCPAndSelectsKnownScope(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ok","providers":[]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ready","targets":[],"mcp_servers":[]}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"healthy","providers":[],"components":[],"scopes":["custom","read_only","admin"]}`) + }) + mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusInternalServerError, `{"error":{"message":"catalog failed"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.MCP == nil || r.MCP.Total != 0 || r.Auth != "admin" { + t.Fatalf("empty-present MCP and scope precedence were lost: %+v", r) + } + if !strings.Contains(strings.Join(r.Warnings, ";"), "models unavailable") { + t.Fatalf("model enrichment failure must be visible: %v", r.Warnings) + } +} + +// A gateway that answers zero and one that never answered are different facts. +// Before Providers/Models became pointers both rendered as a real 0, so a +// /health that failed on a still-reachable gateway reported "0 providers" — +// and the status table printed "0" for providers beside "-" for models under +// exactly the same condition. +func TestSuppliedZeroIsNotAbsent(t *testing.T) { + mux := http.NewServeMux() + // /health answers, and honestly reports no providers at all. + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ok","providers":[]}`) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"ready","targets":[]}`) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, 200, `{"status":"healthy","providers":[],"components":[],"scopes":["admin"]}`) + }) + // /v1/models is not served by this build at all — never answered. + mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusNotImplemented, `{"error":{"message":"not supported"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.Providers == nil || *r.Providers != 0 { + t.Fatalf("a reported empty provider list is a known zero, not absent: %v", cnt(r.Providers)) + } + if r.Models != nil { + t.Fatalf("an endpoint this build does not serve leaves the count absent, got %v", cnt(r.Models)) + } +} + +// The frozen --format json contract: an absent count is omitted entirely +// rather than serialized as 0, so a consumer can tell the two apart too. +func TestAbsentCountIsOmittedFromJSON(t *testing.T) { + zero := 0 + body, err := json.Marshal(&StatusReport{State: "degraded", Providers: &zero}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"providers":0`) { + t.Fatalf("a supplied zero must serialize as 0: %s", body) + } + if strings.Contains(string(body), `"models"`) { + t.Fatalf("an absent count must be omitted, not sent as 0: %s", body) + } +} diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..e05069f --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,88 @@ +package api + +// Wire shapes for the gateway's read endpoints. Every json tag here is the +// gateway's own struct tag, verbatim — the gateway is the schema, this file is +// only a mirror of it, so nothing is renamed for taste. + +// ProviderHealth is one row of the unauthenticated /health provider list. +type ProviderHealth struct { + Name string `json:"name"` + Status string `json:"status"` + Circuit string `json:"circuit"` // closed|open|half_open + Models int `json:"models"` +} + +// HealthReport is GET /health: 200 when serving, 503 when no provider is up. +type HealthReport struct { + Status string `json:"status"` // ok|no_providers + Providers []ProviderHealth `json:"providers"` +} + +// ReadyTarget is a configured routing target and whether a request can reach it. +type ReadyTarget struct { + Name string `json:"name"` + Routable bool `json:"routable"` +} + +// ReadyProvider is the circuit-only provider view served by /readyz. +type ReadyProvider struct { + Name string `json:"name"` + Circuit string `json:"circuit"` +} + +// MCPServer is one MCP tool server. LastError is served by /admin/health only: +// /readyz is unauthenticated and the reason can quote a URL or a token. +type MCPServer struct { + Name string `json:"name"` + Ready bool `json:"ready"` + Required bool `json:"required"` + LastError string `json:"last_error,omitempty"` +} + +// ReadyReport is GET /readyz: 200 ready, or 503 with Reason set. +type ReadyReport struct { + Status string `json:"status"` // ready|not_ready + Reason string `json:"reason,omitempty"` + Providers []ReadyProvider `json:"providers,omitempty"` + Targets []ReadyTarget `json:"targets,omitempty"` + MCPServers []MCPServer `json:"mcp_servers,omitempty"` +} + +// AdminProviderHealth is one row of the authenticated provider list, which +// carries a message a failing provider explains itself with. +type AdminProviderHealth struct { + Name string `json:"name"` + Status string `json:"status"` // healthy|degraded|disabled|unavailable|available + Models int `json:"models"` + Message string `json:"message,omitempty"` +} + +// AdminComponent is a subsystem (API, key store, log store, …) and its status. +type AdminComponent struct { + Name string `json:"name"` + Status string `json:"status"` +} + +// AdminHealth is GET /admin/health, which always answers 200 when the caller +// is authenticated — so it doubles as ferro's auth and scope probe. +type AdminHealth struct { + Status string `json:"status"` + Providers []AdminProviderHealth `json:"providers"` + Components []AdminComponent `json:"components"` + MCPServers []MCPServer `json:"mcp_servers,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +// Model is one entry of the OpenAI-shaped GET /v1/models listing. +type Model struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Mode string `json:"mode,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Status string `json:"status,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` +} diff --git a/internal/command/chat.go b/internal/command/chat.go new file mode 100644 index 0000000..ae8a0c0 --- /dev/null +++ b/internal/command/chat.go @@ -0,0 +1,286 @@ +package command + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" +) + +const ( + // maxPromptBytes prevents piped input from consuming memory without bound. + // Command-line arguments already have a much smaller OS-level limit. + maxPromptBytes = 1 << 20 + + // attributionRetryDelay is how long ferro waits before its single retry of + // the trace lookup. The request-log row is written after the stream ends, + // so the first lookup routinely races it and finds nothing. + attributionRetryDelay = 500 * time.Millisecond + + // attributionSkew widens the log window backwards past the moment the + // request started. created_at is stamped by the *gateway's* clock and the + // window is filtered against it, so without a margin a client running a + // second fast would never match its own row. + // It is a fixed margin, not clock synchronization — the row is then matched + // by exact trace id, so a wider window costs nothing but rows scanned. + // Matches internal/tui's attributionSlack. The TUI found empirically that a + // one-minute margin was not always enough, and two callers of the same endpoint + // disagreeing about clock tolerance is a bug waiting for a slow gateway. + attributionSkew = 2 * time.Minute +) + +// stdinIsTTY reports whether stdin is a terminal, which is how `ferro chat` +// with no arguments tells "the operator forgot the prompt" from "the prompt is +// being piped in". It is a variable so a test can state which of the two it is +// exercising; nothing else reassigns it. +var stdinIsTTY = isTerminalStdin + +func isTerminalStdin() bool { return isTTY(os.Stdin) } + +// chatResult is the frozen --format json contract. Provider and cost are +// omitted rather than zeroed when attribution found nothing: a consumer must be +// able to tell "served by nobody knows what" from "served by a provider named +// empty string", and $0.0000 is a real price some requests genuinely have. +type chatResult struct { + Model string `json:"model"` + Content string `json:"content"` + Usage *api.Usage `json:"usage"` + RequestID string `json:"request_id"` + Provider string `json:"provider,omitempty"` + CostUSD *float64 `json:"cost_usd,omitempty"` +} + +func newChatCmd() *cobra.Command { + var model, system string + cmd := &cobra.Command{ + Use: "chat [flags] ...", + Short: "Send one prompt through the gateway and stream the answer", + Long: "chat sends a single prompt to a model through the gateway and streams the\n" + + "answer to stdout as it arrives. The prompt is the arguments joined, or\n" + + "stdin when there are none and stdin is not a terminal.\n\n" + + "stdout carries the answer and nothing else — no ANSI, no markdown\n" + + "rendering — so `ferro chat ... > answer.txt` captures exactly what the\n" + + "model said. What served it, what it cost, and any failure go to stderr.\n" + + "A stream that fails or ends mid-answer exits 1: a truncated answer that\n" + + "exited 0 would be indistinguishable from a complete one.", + RunE: func(cmd *cobra.Command, args []string) error { + // Validated here rather than left to the gateway: an empty value + // satisfies cobra's required-flag check, which only asks whether the + // flag was set. + model = strings.TrimSpace(model) + if model == "" { + return errors.New("--model is required and must name a model this gateway serves (ferro models)") + } + prompt, err := readPrompt(cmd, args) + if err != nil { + return err + } + return runChat(cmd, model, system, prompt) + }, + } + f := cmd.Flags() + f.StringVar(&model, "model", "", "model to send the prompt to (required)") + f.StringVar(&system, "system", "", "system message sent ahead of the prompt") + _ = cmd.MarkFlagRequired("model") + return cmd +} + +// readPrompt takes the prompt from the arguments, or from stdin when there are +// none. Reading a terminal would block forever on input the operator does not +// know is wanted, so that case is an error naming both ways to supply one. +func readPrompt(cmd *cobra.Command, args []string) (string, error) { + if len(args) > 0 { + return strings.Join(args, " "), nil + } + if stdinIsTTY() { + return "", errors.New(`no prompt: pass one as arguments (ferro chat "explain X" --model M) or pipe it on stdin`) + } + b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), maxPromptBytes+1)) + if err != nil { + return "", fmt.Errorf("read prompt from stdin: %w", err) + } + if len(b) > maxPromptBytes { + return "", fmt.Errorf("prompt exceeds the %d-byte stdin limit", maxPromptBytes) + } + prompt := strings.TrimSpace(string(b)) + if prompt == "" { + return "", errors.New("no prompt: stdin was empty") + } + return prompt, nil +} + +func runChat(cmd *cobra.Command, model, system, prompt string) error { + d := deps(cmd) + ctx := cmd.Context() + + msgs := make([]api.ChatMessage, 0, 2) + if system != "" { + msgs = append(msgs, api.ChatMessage{Role: "system", Content: system}) + } + msgs = append(msgs, api.ChatMessage{Role: "user", Content: prompt}) + + started := time.Now().Add(-attributionSkew) + st, err := d.Client.StreamChat(ctx, api.ChatRequest{Model: model, Messages: msgs}) + if err != nil { + return err + } + // Cancel releases the reader goroutine and the connection even after a + // clean end, and is safe to call twice. + defer st.Cancel() + + // Table format is the streaming, human-facing mode; json and yaml collect + // the whole answer and emit one document, so nothing but that document may + // touch stdout. + streaming := d.Printer.Format == FormatTable + + res := chatResult{Model: model, RequestID: st.RequestID} + var content strings.Builder + var wroteContent, endedWithNewline bool + var streamErr *api.Error + done := false + + for ev := range st.Events { + switch { + case ev.Err != nil: + streamErr = ev.Err + case ev.Done: + done = true + case ev.Chunk != nil: + // The gateway names the model that actually served the request; + // fall back to the requested one only when it does not. + if ev.Chunk.Model != "" { + res.Model = ev.Chunk.Model + } + if ev.Chunk.Usage != nil { + res.Usage = ev.Chunk.Usage + } + for _, ch := range ev.Chunk.Choices { + // reasoning_content and tool_calls are deliberately not + // printed — stdout is the answer, and anything else on it + // corrupts the one channel a pipeline reads. + if ch.Delta.Content == "" { + continue + } + if streaming { + // In production Out is os.Stdout, an *os.File: every write + // is a syscall, so deltas appear as they arrive with no + // flushing of ours to arrange. + _, _ = fmt.Fprint(d.Printer.Out, ch.Delta.Content) + wroteContent = true + endedWithNewline = strings.HasSuffix(ch.Delta.Content, "\n") + } else { + content.WriteString(ch.Delta.Content) + } + } + } + } + res.Content = content.String() + + if streaming && wroteContent && !endedWithNewline { + // Models rarely end on a newline; without this the shell prompt lands + // mid-answer. It is a terminator, not a rendering. + _, _ = fmt.Fprintln(d.Printer.Out) + } + + // Checked before the error paths: an interrupt can surface either as a + // silent close or as a truncation the reader noticed first, and both mean + // the same thing — the operator ended their own request. + if !done && ctx.Err() != nil { + d.Printer.Warn("interrupted — output is truncated") + if !streaming { + if err := d.printStructured(res); err != nil { + return err + } + return errors.New("chat interrupted — structured output is truncated") + } + return ctx.Err() + } + if streamErr != nil { + return streamFailure(streamErr) + } + if !done { + // Belt and braces: internal/api synthesizes stream_incomplete for this, + // so arriving here means even that frame could not be delivered. Same + // fact, same non-zero exit. + return streamFailure(&api.Error{Code: api.CodeStreamIncomplete}) + } + + if entry := attribute(ctx, d.Client, st.RequestID, started); entry != nil { + res.Provider = entry.Provider + res.CostUSD = entry.CostUSD + } + if !streaming { + return d.printStructured(res) + } + printChatMeta(d, res) + return nil +} + +// streamFailure turns a terminal stream code into the sentence an operator can +// act on. All three exit non-zero: the answer on stdout is incomplete in every +// one of them, and only the exit code can say so to a pipeline. +func streamFailure(e *api.Error) error { + switch e.Code { + case api.CodeStreamTimeout: + // The bound is ferro's own client-side idle timer (api. + // DefaultStreamIdleTimeout) — the gateway takes no part in the + // decision, so the duration is a fact this process knows, not a guess. + return fmt.Errorf("stream idled out — the gateway sent no events for %s, so ferro closed the connection", + api.DefaultStreamIdleTimeout) + case api.CodeStreamIncomplete: + return errors.New("stream ended mid-answer — output is truncated") + default: + if e.Message == "" { + return fmt.Errorf("stream failed: %s", e.Error()) + } + return fmt.Errorf("stream failed: %s", e.Message) + } +} + +// attribute resolves which provider served the answer and what it cost. A +// streamed body names neither, so the only path is the request log — which is +// written after the stream ends, hence the one retry. +// +// Every failure answers nil: attribution is a detail printed beside an answer +// the operator already has, and failing the command over its absence would turn +// a cosmetic gap into a broken chat. +func attribute(ctx context.Context, c *api.Client, traceID string, since time.Time) *api.LogEntry { + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil + case <-time.After(attributionRetryDelay): + } + } + entry, err := c.TraceAttribution(ctx, traceID, since) + if err != nil || entry != nil { + return entry + } + } + return nil +} + +// printChatMeta writes the one narrative line a chat adds, to stderr. Each +// segment is omitted when the fact behind it is absent rather than rendered as +// a zero or an empty name — an invented provider is worse than a shorter line. +func printChatMeta(d *runtimeDeps, res chatResult) { + seg := []string{res.Model} + if res.Provider != "" { + seg = append(seg, res.Provider) + } + if res.Usage != nil { + seg = append(seg, fmt.Sprintf("%d in / %d out", res.Usage.PromptTokens, res.Usage.CompletionTokens)) + } + if res.CostUSD != nil { + seg = append(seg, fmtCost(res.CostUSD)) + } + _, _ = fmt.Fprintln(d.Printer.Err, strings.Join(seg, " "+d.Printer.glyph("·", "|")+" ")) +} diff --git a/internal/command/chat_test.go b/internal/command/chat_test.go new file mode 100644 index 0000000..cee848f --- /dev/null +++ b/internal/command/chat_test.go @@ -0,0 +1,416 @@ +package command + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "go.yaml.in/yaml/v3" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/fixture" +) + +// fullReply is what the fixture streams back, one delta per word. The command +// must reproduce it byte for byte: stdout is the answer, not a rendering of it. +const fullReply = "Ferro routed this through the fake gateway." + +// recorder captures what ferro put on the wire, so a test can assert both what +// was sent and — for the flag-validation case — that nothing was sent at all. +type recorder struct { + mu sync.Mutex + requests int + chatBody []byte +} + +func (r *recorder) record(path string, body []byte) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests++ + if path == "/v1/chat/completions" { + r.chatBody = body + } +} + +func (r *recorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.requests +} + +func (r *recorder) chat() []byte { + r.mu.Lock() + defer r.mu.Unlock() + return r.chatBody +} + +// recordingGateway is the fixture with a tap on it. The fixture stays the one +// definition of the wire contract; this only observes the traffic. +func recordingGateway(t *testing.T, s fixture.State) (*httptest.Server, *recorder) { + t.Helper() + rec := &recorder{} + inner := fixture.Handler(s) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + rec.record(r.URL.Path, body) + inner.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv, rec +} + +// runChatWith runs one invocation with control over the three things a chat +// cares about that the shared `run` helper hides: stdin, the stdout writer, and +// the context that an interrupt cancels. +func runChatWith(ctx context.Context, t *testing.T, srv *httptest.Server, stdin io.Reader, stdout io.Writer, args ...string) (stderr string, err error) { + t.Helper() + // This builds its own root instead of going through execute(), so it does not + // inherit execute's config isolation -- see root_test.go. Without this line a + // developer's real ~/.config/ferro/config.yaml resolves into PersistentPreRunE + // and steers every test below it. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("FERRO_API_KEY", "fgw_test") + root := NewRoot() + var errb bytes.Buffer + if stdin != nil { + root.SetIn(stdin) + } + root.SetOut(stdout) + root.SetErr(&errb) + root.SetArgs(append(args, "--gateway-url", srv.URL)) + // Executed on its own line: `return errb.String(), root.Execute…` would read + // the buffer before the command that fills it has run. + err = root.ExecuteContext(ctx) + return errb.String(), err +} + +func TestChatStreamsContentToStdoutVerbatim(t *testing.T) { + srv := gateway(t, fixture.Default()) + + stdout, _, err := run(t, srv, "chat", "hello", "there", "--model", "claude-sonnet-4-6") + if err != nil { + t.Fatalf("a complete stream must exit 0: %v", err) + } + if stdout != fullReply+"\n" { + t.Fatalf("stdout must carry the answer verbatim, got %q want %q", stdout, fullReply+"\n") + } + if strings.Contains(stdout, "\x1b") { + t.Fatalf("stdout must be free of ANSI — it is the pipeable channel: %q", stdout) + } +} + +// The answer belongs on stdout; everything ferro knows *about* the answer +// belongs on stderr, or `ferro chat ... > answer.txt` captures the narrative too. +func TestChatMetaLineGoesToStderrNotStdout(t *testing.T) { + srv := gateway(t, fixture.Default()) + + stdout, stderr, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"claude-sonnet-4-6", "anthropic", "24 in / 7 out", "$0.0031"} { + if !strings.Contains(stderr, want) { + t.Fatalf("meta line missing %q, stderr was %q", want, stderr) + } + } + if stdout != fullReply+"\n" { + t.Fatalf("meta leaked into stdout: %q", stdout) + } +} + +func TestChatJSONIsExactlyOneDocument(t *testing.T) { + srv := gateway(t, fixture.Default()) + + stdout, _, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6", "--format", "json") + if err != nil { + t.Fatal(err) + } + doc := oneJSONDoc(t, stdout) + if doc["content"] != fullReply { + t.Fatalf("json content must be the whole answer: %v", doc["content"]) + } + for _, k := range []string{"model", "content", "usage", "request_id", "provider", "cost_usd"} { + if _, ok := doc[k]; !ok { + t.Fatalf("json document missing %q: %v", k, doc) + } + } + if strings.Contains(stdout, fullReply+"\n"+fullReply) { + t.Fatalf("json mode must not also stream prose: %q", stdout) + } +} + +// The model is not a detail the gateway can supply a default for, and a request +// missing it is a wasted round trip against a live gateway. +func TestChatWithoutModelFailsBeforeAnyRequest(t *testing.T) { + srv, rec := recordingGateway(t, fixture.Default()) + + _, err := runChatWith(context.Background(), t, srv, nil, io.Discard, "chat", "hi") + if err == nil { + t.Fatal("a chat with no --model must fail") + } + if !strings.Contains(err.Error(), "model") { + t.Fatalf("the error must name the missing flag, got %v", err) + } + if n := rec.count(); n != 0 { + t.Fatalf("nothing may reach the gateway before --model is validated, saw %d requests", n) + } +} + +// An empty value passes cobra's required-flag check (the flag *was* set), so +// the guard has to be ferro's own. +func TestChatEmptyModelFailsBeforeAnyRequest(t *testing.T) { + srv, rec := recordingGateway(t, fixture.Default()) + + _, err := runChatWith(context.Background(), t, srv, nil, io.Discard, "chat", "hi", "--model", " ") + if err == nil { + t.Fatal("a blank --model must fail") + } + if n := rec.count(); n != 0 { + t.Fatalf("a blank --model must not reach the gateway, saw %d requests", n) + } +} + +func TestChatSendsSystemAndUserMessages(t *testing.T) { + srv, rec := recordingGateway(t, fixture.Default()) + + if _, err := runChatWith(context.Background(), t, srv, nil, io.Discard, + "chat", "how", "are", "you", "--model", "claude-sonnet-4-6", "--system", "be terse"); err != nil { + t.Fatal(err) + } + var sent struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.chat(), &sent); err != nil { + t.Fatalf("chat body: %v (%s)", err, rec.chat()) + } + if sent.Model != "claude-sonnet-4-6" || !sent.Stream { + t.Fatalf("model/stream wrong: %+v", sent) + } + if len(sent.Messages) != 2 { + t.Fatalf("want system + user, got %+v", sent.Messages) + } + if sent.Messages[0].Role != "system" || sent.Messages[0].Content != "be terse" { + t.Fatalf("system message: %+v", sent.Messages[0]) + } + if sent.Messages[1].Role != "user" || sent.Messages[1].Content != "how are you" { + t.Fatalf("prompt must be the args joined: %+v", sent.Messages[1]) + } +} + +// A half-written answer that exits 0 is indistinguishable from a complete one +// to anything consuming this command — so a mid-stream failure is exit 1 with +// the reason on stderr. +func TestChatMidStreamErrorExitsNonZero(t *testing.T) { + s := fixture.Default() + s.ChatFails = true + srv := gateway(t, s) + + stdout, stderr, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6") + if err == nil { + t.Fatal("a mid-stream error frame must exit non-zero") + } + if !strings.Contains(stderr, "upstream error from provider anthropic") { + t.Fatalf("stderr must carry the gateway's reason, got %q", stderr) + } + if !strings.Contains(stdout, "Ferro routed") { + t.Fatalf("the part that did arrive still belongs on stdout: %q", stdout) + } +} + +// A gateway with no request-log store cannot attribute anything. That is a +// missing detail beside an answer that arrived, not a failed chat. +func TestChatWithoutLogStoreSucceedsAndOmitsProvider(t *testing.T) { + s := fixture.Default() + s.NoLogStore = true + srv := gateway(t, s) + + stdout, stderr, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6") + if err != nil { + t.Fatalf("absent attribution must not fail the chat: %v", err) + } + if stdout != fullReply+"\n" { + t.Fatalf("the answer must still arrive: %q", stdout) + } + if !strings.Contains(stderr, "24 in / 7 out") { + t.Fatalf("usage comes from the stream, not the log: %q", stderr) + } + if strings.Contains(stderr, "anthropic") || strings.Contains(stderr, "$") { + t.Fatalf("an unattributed answer must not name a provider or a cost: %q", stderr) + } +} + +// The other two terminal codes cannot be produced by the fixture — one is the +// gateway's idle bound elapsing, the other a dropped connection — so their +// wording is asserted here. All three are errors, which is what makes them +// exit non-zero. +func TestStreamFailureWording(t *testing.T) { + cases := []struct { + name string + err api.Error + want string + }{ + {"timeout", api.Error{Code: api.CodeStreamTimeout}, "stream idled out — the gateway sent no events for 2m0s, so ferro closed the connection"}, + {"incomplete", api.Error{Code: api.CodeStreamIncomplete}, "stream ended mid-answer — output is truncated"}, + {"error", api.Error{Code: api.CodeStreamError, Message: "upstream exploded"}, "stream failed: upstream exploded"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := streamFailure(&tc.err) + if got == nil { + t.Fatal("every terminal stream code must be an error, or a truncated answer exits 0") + } + if got.Error() != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +// cancelingWriter cancels as soon as the first byte of the answer lands, so +// "the operator pressed ctrl-c mid-stream" is a deterministic event rather than +// a race against a sleep. +type cancelingWriter struct { + mu sync.Mutex + buf strings.Builder + cancel context.CancelFunc +} + +func (w *cancelingWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + n, err := w.buf.Write(p) + if w.cancel != nil { + w.cancel() + w.cancel = nil + } + return n, err +} + +func (w *cancelingWriter) String() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.buf.String() +} + +// A partial answer must never exit zero: shell consumers have no other way to +// distinguish it from a complete response. +func TestChatInterruptedFailsAndSaysSo(t *testing.T) { + s := fixture.Default() + s.ChatDelay = 40 * time.Millisecond + srv := gateway(t, s) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + out := &cancelingWriter{cancel: cancel} + + stderr, err := runChatWith(ctx, t, srv, nil, out, "chat", "hi", "--model", "claude-sonnet-4-6") + if !errors.Is(err, context.Canceled) { + t.Fatalf("an interrupted chat must return cancellation, got: %v", err) + } + if !strings.Contains(stderr, "interrupted") { + t.Fatalf("an interrupted chat must say the output is truncated: %q", stderr) + } + if got := out.String(); got == "" || strings.Contains(got, "gateway.") { + t.Fatalf("want the partial answer, got %q", got) + } +} + +func TestChatStructuredInterruptPrintsPartialDocumentAndFails(t *testing.T) { + for _, format := range []string{"json", "yaml"} { + t.Run(format, func(t *testing.T) { + s := fixture.Default() + s.ChatDelay = 40 * time.Millisecond + srv := gateway(t, s) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + timer := time.AfterFunc(150*time.Millisecond, cancel) + defer timer.Stop() + var out bytes.Buffer + stderr, err := runChatWith(ctx, t, srv, nil, &out, + "chat", "hi", "--model", "claude-sonnet-4-6", "--format", format) + if err == nil || !strings.Contains(err.Error(), "structured output is truncated") { + t.Fatalf("structured interruption must exit non-zero: %v", err) + } + if !strings.Contains(stderr, "interrupted") { + t.Fatalf("interruption warning missing: %q", stderr) + } + var doc map[string]any + if format == "json" { + if err := json.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("partial JSON must remain one valid document: %v (%q)", err, out.String()) + } + } else if err := yaml.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("partial YAML must remain one valid document: %v (%q)", err, out.String()) + } + if _, ok := doc["content"]; !ok { + t.Fatalf("partial document lost its content field: %v", doc) + } + }) + } +} + +func TestChatReadsPromptFromStdinWhenNotATTY(t *testing.T) { + srv, rec := recordingGateway(t, fixture.Default()) + stdinIsTTY = func() bool { return false } + t.Cleanup(func() { stdinIsTTY = isTerminalStdin }) + + if _, err := runChatWith(context.Background(), t, srv, strings.NewReader("summarize this\n"), io.Discard, + "chat", "--model", "claude-sonnet-4-6"); err != nil { + t.Fatal(err) + } + var sent struct { + Messages []struct{ Content string } `json:"messages"` + } + if err := json.Unmarshal(rec.chat(), &sent); err != nil { + t.Fatal(err) + } + if len(sent.Messages) != 1 || sent.Messages[0].Content != "summarize this" { + t.Fatalf("prompt must come from stdin: %+v", sent.Messages) + } +} + +func TestChatRejectsOversizedStdinPrompt(t *testing.T) { + srv, rec := recordingGateway(t, fixture.Default()) + stdinIsTTY = func() bool { return false } + t.Cleanup(func() { stdinIsTTY = isTerminalStdin }) + + _, err := runChatWith(context.Background(), t, srv, + strings.NewReader(strings.Repeat("x", maxPromptBytes+1)), io.Discard, + "chat", "--model", "claude-sonnet-4-6") + if err == nil || !strings.Contains(err.Error(), "prompt exceeds") { + t.Fatalf("oversized stdin must fail clearly, got %v", err) + } + if n := rec.count(); n != 0 { + t.Fatalf("oversized stdin must not reach the gateway, saw %d requests", n) + } +} + +// With no args and a terminal on stdin there is no prompt and nothing to wait +// for: say so rather than block forever on a read that will never return. +func TestChatWithNoPromptAndATTYFailsWithGuidance(t *testing.T) { + srv, rec := recordingGateway(t, fixture.Default()) + stdinIsTTY = func() bool { return true } + t.Cleanup(func() { stdinIsTTY = isTerminalStdin }) + + _, err := runChatWith(context.Background(), t, srv, nil, io.Discard, "chat", "--model", "claude-sonnet-4-6") + if err == nil || !strings.Contains(err.Error(), "prompt") { + t.Fatalf("want a prompt-shaped error, got %v", err) + } + if n := rec.count(); n != 0 { + t.Fatalf("a promptless chat must not reach the gateway, saw %d requests", n) + } +} diff --git a/internal/command/keys.go b/internal/command/keys.go new file mode 100644 index 0000000..9c5badd --- /dev/null +++ b/internal/command/keys.go @@ -0,0 +1,254 @@ +package command + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +func newKeysCmd() *cobra.Command { + keys := &cobra.Command{ + Use: "keys", + Short: "Manage gateway API keys", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, + } + keys.AddCommand(newKeysListCmd(), newKeysGetCmd(), newKeysCreateCmd(), + newKeysRotateCmd(), newKeysRevokeCmd()) + return keys +} + +func newKeysListCmd() *cobra.Command { + return &cobra.Command{ + Use: verbList, + Short: "List API keys with their derived state", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + rows, err := d.Client.Keys(cmd.Context()) + if err != nil { + return err + } + if d.Printer.Format != FormatTable { + return d.printStructured(rows) + } + cells := make([][]string, 0, len(rows)) + for _, k := range rows { + cells = append(cells, []string{ + // ID leads because rotate and revoke take one, and the + // listing is the only place to find it. + k.ID, + k.Name, + // Already masked on the wire; ferro never unmasks. + k.Key, + strings.Join(k.Scopes, ","), + table.FmtTimePtr(k.ExpiresAt), + table.FmtTimePtr(k.LastUsedAt), + fmt.Sprintf("%d", k.UsageCount), + api.KeyState(k), + }) + } + d.Printer.Table([]string{"ID", colName, "KEY", colScopes, colExpires, "LAST USED", "USES", colState}, cells) + return nil + }, + } +} + +func newKeysGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show one key", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d := deps(cmd) + k, err := d.Client.Key(cmd.Context(), args[0]) + if err != nil { + // A gateway build without GET /admin/keys/{id} still serves the + // row in the list, so fall back rather than fail. + if !api.IsNotSupported(err) { + return err + } + if k, err = findKey(cmd, args[0]); err != nil { + return err + } + } + return d.printStructured(k) + }, + } +} + +// findKey resolves one key out of the full listing. +func findKey(cmd *cobra.Command, id string) (*api.Key, error) { + rows, err := deps(cmd).Client.Keys(cmd.Context()) + if err != nil { + return nil, err + } + for i := range rows { + if rows[i].ID == id { + return &rows[i], nil + } + } + return nil, fmt.Errorf("no key with id %q on this gateway", id) +} + +func newKeysCreateCmd() *cobra.Command { + create := &cobra.Command{ + Use: "create", + Short: "Create an API key (the secret is shown exactly once)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + name, _ := cmd.Flags().GetString("name") + scope, _ := cmd.Flags().GetString("scope") + expiresIn, _ := cmd.Flags().GetDuration("expires-in") + + req, err := keyCreateRequest(name, scope, expiresIn) + if err != nil { + return err + } + k, err := d.Client.CreateKey(cmd.Context(), req) + if err != nil { + return err + } + return printSecretOnce(d, k, "secret shown once — the gateway stores only a hash") + }, + } + create.Flags().String("name", "", "key name (required)") + create.Flags().String("scope", api.ScopeReadOnly, + api.ScopeAdmin+"|"+api.ScopeReadOnly+" (always sent explicitly)") + create.Flags().Duration("expires-in", 720*time.Hour, "lifetime from now; 0 for no expiry") + return create +} + +// keyCreateRequest validates client-side so a bad flag costs no request. +func keyCreateRequest(name, scope string, expiresIn time.Duration) (api.KeyCreateRequest, error) { + // These messages lead with a word rather than a flag: the styled error + // renderer capitalises the first character, which mangles "--name". + if name == "" { + return api.KeyCreateRequest{}, errors.New( + "a key name is required, so pass --name (a log row names the key, and an unnamed one is unattributable once it is revoked)") + } + // The two scope names come from internal/api, which derives the same + // vocabulary from the wire; a second copy here could drift from it. + if scope != api.ScopeAdmin && scope != api.ScopeReadOnly { + return api.KeyCreateRequest{}, fmt.Errorf( + "scope %q is not valid: --scope must be %s|%s — ferro always sends one explicitly rather than relying on the gateway's default", + scope, api.ScopeAdmin, api.ScopeReadOnly) + } + if expiresIn < 0 { + return api.KeyCreateRequest{}, errors.New("expiry must be 0 for no expiry or a positive duration") + } + req := api.KeyCreateRequest{Name: name, Scopes: []string{scope}} + if expiresIn > 0 { + t := time.Now().Add(expiresIn).UTC() + req.ExpiresAt = &t + } + return req, nil +} + +func newKeysRotateCmd() *cobra.Command { + // Rotation is as destructive as revocation from the outside: every client + // still holding the current secret stops authenticating the moment it + // lands, and no flag brings the old one back. + rotate := &cobra.Command{ + Use: "rotate ", + Short: "Rotate a key (the previous secret dies immediately)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d := deps(cmd) + if err := confirmDestructive(cmd, "rotate", args[0]); err != nil { + return err + } + k, err := d.Client.RotateKey(cmd.Context(), args[0]) + if err != nil { + return err + } + return printSecretOnce(d, k, + "previous secret stopped authenticating; the new one is shown once") + }, + } + addYesFlag(rotate) + return rotate +} + +// printSecretOnce is the one deliberate credential display: the secret goes to +// stdout so it can be piped into a secret store, and every word about it goes +// to stderr so the pipe stays clean. +func printSecretOnce(d *runtimeDeps, k *api.Key, warning string) error { + d.Printer.Warn("%s", warning) + if d.Printer.Format != FormatTable { + return d.printStructured(k) + } + _, err := fmt.Fprintln(d.Printer.Out, k.Key) + return err +} + +func newKeysRevokeCmd() *cobra.Command { + revoke := &cobra.Command{ + Use: "revoke ", + Short: "Revoke a key (immediate and irreversible)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d := deps(cmd) + if err := confirmDestructive(cmd, "revoke", args[0]); err != nil { + return err + } + if err := d.Client.RevokeKey(cmd.Context(), args[0]); err != nil { + return err + } + d.Printer.OK("revoked %s", args[0]) + return nil + }, + } + addYesFlag(revoke) + return revoke +} + +// flagYes is the one spelling of "I have read the blast radius", shared by both +// destructive key verbs so a script that learnt it on one keeps it on the other. +const flagYes = "yes" + +func addYesFlag(cmd *cobra.Command) { + cmd.Flags().Bool(flagYes, false, "skip the confirmation prompt (required when there is no terminal)") +} + +// confirmDestructive gates rotate and revoke on the operator typing the key id +// back. Typing the id rather than "y" is the console's discipline -- its modal +// demands the key's exact name before it will revoke (internal/tui's +// openRevoke) -- and the id is what the CLI has on the command line, so it is +// the thing that can actually be checked against a typo in the argument. +// +// The prompt goes to stderr and the answer comes from cmd's stdin: stdout stays +// the payload channel even here, where there is no payload. +func confirmDestructive(cmd *cobra.Command, verb, id string) error { + if yes, _ := cmd.Flags().GetBool(flagYes); yes { + return nil + } + // A pipe, a CI job, or a cron line has nobody to answer. Blocking on a read + // that will never return is the worst of the three possible behaviours, and + // proceeding unasked is the second worst. + if !stdinIsTTY() || !isTTY(os.Stderr) { + return fmt.Errorf( + "%s %s is irreversible and there is no terminal to confirm on: pass --yes to run it non-interactively", + verb, id) + } + deps(cmd).Printer.Warn("%s %s is irreversible — type the key id to confirm:", verb, id) + answer, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + // EOF (a closed terminal) is a valid end to the answer, not a read failure. + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("read confirmation: %w", err) + } + if strings.TrimSpace(answer) != id { + return fmt.Errorf("aborted: the id typed did not match %q, so nothing was %sd", id, verb) + } + return nil +} diff --git a/internal/command/keys_test.go b/internal/command/keys_test.go new file mode 100644 index 0000000..a6cf8a0 --- /dev/null +++ b/internal/command/keys_test.go @@ -0,0 +1,289 @@ +package command + +import ( + "bytes" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/fixture" +) + +// runInteractive is run() with the terminal faked and an answer already waiting +// on stdin. Faking is the only way in: isTTY asks real descriptors, and a test +// binary's stdin is never one of them. +func runInteractive(t *testing.T, srv *httptest.Server, answer string, args ...string) (stdout, stderr string, err error) { + t.Helper() + t.Setenv("FERRO_API_KEY", "fgw_test") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + oldTTY, oldStdin := isTTY, stdinIsTTY + isTTY = func(*os.File) bool { return true } + stdinIsTTY = func() bool { return true } + t.Cleanup(func() { isTTY, stdinIsTTY = oldTTY, oldStdin }) + + root := NewRoot() + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetIn(strings.NewReader(answer)) + root.SetArgs(append(args, "--gateway-url", srv.URL)) + err = root.Execute() + return out.String(), errb.String(), err +} + +// keyIsLive reports whether the fixture still serves id as an active key. The +// fixture's key store is stateful, so this is what tells an aborted revoke +// apart from one that ran and printed an error afterwards. +func keyIsLive(t *testing.T, srv *httptest.Server, id string) bool { + t.Helper() + stdout, _, err := run(t, srv, "keys", "get", id) + if err != nil { + t.Fatalf("keys get %s: %v", id, err) + } + var got map[string]any + if err := decodeOne(stdout, &got); err != nil { + t.Fatalf("keys get %s: %v (%q)", id, err, stdout) + } + return got["revoked_at"] == nil +} + +func TestKeysListDerivesStateAndNeverPrintsASecret(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "keys", "list") + if err != nil { + t.Fatal(err) + } + // ID is here and not in the plan's column set on purpose: rotate and revoke + // take an id, and a table listing that omits it cannot start that workflow. + for _, col := range []string{"ID", "NAME", "KEY", "SCOPES", "EXPIRES", "LAST USED", "USES", "STATE"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + // The gateway serves no state field; all three states are derived from + // revoked_at / active, and the seeded store carries one of each. + for _, state := range []string{"active", "revoked", "expired"} { + if !strings.Contains(stdout, state) { + t.Fatalf("derived STATE %q missing: %q", state, stdout) + } + } + // The wire masks reads as head8...tail4; anything longer would mean the + // listing had grown a full secret. + for _, line := range strings.Split(stdout, "\n") { + for _, f := range strings.Fields(line) { + if strings.HasPrefix(f, "fgw_") && !strings.Contains(f, "...") { + t.Fatalf("list printed an unmasked credential: %q", f) + } + } + } +} + +func TestKeysListJSONIsOneDocument(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "keys", "list", "--format", "json") + if err != nil { + t.Fatal(err) + } + var rows []map[string]any + if err := decodeOne(stdout, &rows); err != nil { + t.Fatalf("stdout must be one JSON document: %v (%q)", err, stdout) + } + if len(rows) == 0 { + t.Fatalf("expected the seeded keys: %q", stdout) + } +} + +func TestKeysCreateSecretOnStdoutWarningOnStderr(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, stderr, err := run(t, srv, "keys", "create", "--name", "ci", "--scope", "read_only", "--expires-in", "720h") + if err != nil { + t.Fatal(err) + } + secret := strings.TrimSpace(stdout) + if !strings.HasPrefix(secret, "fgw_") || strings.Contains(secret, "...") { + t.Fatalf("the full secret must go to stdout so it can be piped, got %q", stdout) + } + if !strings.Contains(stderr, "shown once") { + t.Fatalf("the shown-once warning belongs on stderr, got %q", stderr) + } + if strings.Contains(stdout, "shown once") { + t.Fatalf("narrative leaked into the pipeable channel: %q", stdout) + } +} + +func TestKeysCreateRejectsBadScopeBeforeAnyRequest(t *testing.T) { + _, _, err := execute(t, "keys", "create", "--name", "x", "--scope", "root", + "--gateway-url", "http://127.0.0.1:1") + if err == nil || !strings.Contains(err.Error(), "admin|read_only") { + t.Fatalf("scope must be validated client-side, naming the valid values: %v", err) + } + if !strings.Contains(err.Error(), "always sends one explicitly") { + t.Fatalf("the error must explain why ferro always sends a scope: %v", err) + } + // The gateway defaults a scope-less create to read_only, so the message + // must not claim the opposite — it said "granted admin server-side" while + // defaultScopes was minting least privilege. + if strings.Contains(err.Error(), "server-side") { + t.Fatalf("the message must not restate the old backwards claim: %v", err) + } +} + +func TestKeysCreateRequiresName(t *testing.T) { + _, _, err := execute(t, "keys", "create", "--gateway-url", "http://127.0.0.1:1") + if err == nil || !strings.Contains(err.Error(), "--name") { + t.Fatalf("a nameless key is unattributable later: %v", err) + } +} + +func TestKeysCreateRejectsNegativeExpiry(t *testing.T) { + _, err := keyCreateRequest("ci", "read_only", -time.Hour) + if err == nil || !strings.Contains(err.Error(), "positive duration") { + t.Fatalf("negative expiry must not become a non-expiring key: %v", err) + } +} + +func TestKeysRotateShowsNewSecretOnce(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, stderr, err := run(t, srv, "keys", "rotate", "key_02", "--yes") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(strings.TrimSpace(stdout), "fgw_") { + t.Fatalf("rotate must print the new secret to stdout: %q", stdout) + } + if !strings.Contains(stderr, "shown once") || !strings.Contains(stderr, "stopped authenticating") { + t.Fatalf("stderr must say the old secret is dead and the new one is shown once: %q", stderr) + } +} + +func TestKeysRevokeConfirmsOnStderrAndPrintsNothingToStdout(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, stderr, err := run(t, srv, "keys", "revoke", "key_01", "--yes") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("revoke has no payload; stdout must stay empty: %q", stdout) + } + if !strings.Contains(stderr, "key_01") { + t.Fatalf("the confirmation belongs on stderr: %q", stderr) + } +} + +func TestKeysGetResolvesOneKey(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "keys", "get", "key_01") + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := decodeOne(stdout, &got); err != nil { + t.Fatalf("keys get renders a structured document: %v (%q)", err, stdout) + } + if got["id"] != "key_01" { + t.Fatalf("wrong key: %v", got) + } +} + +func TestKeysGetUnknownIDFails(t *testing.T) { + srv := gateway(t, fixture.Default()) + if _, _, err := run(t, srv, "keys", "get", "key_nope"); err == nil { + t.Fatal("an unknown key id must be an error, not an empty success") + } +} + +// The operator-facing symptom of the KeyState bug: a key whose expiry has +// passed is served active:true with revoked_at:null, and STATE showed +// "active" — the one answer that matters on a surface read to decide whether +// a credential still works. +func TestKeysListReportsAnExpiredKeyAsExpired(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "keys", "list") + if err != nil { + t.Fatal(err) + } + var row string + for _, l := range strings.Split(stdout, "\n") { + if strings.Contains(l, "demo-temp") { + row = l + } + } + if row == "" { + t.Fatalf("expected the seeded expired key: %q", stdout) + } + if !strings.Contains(row, api.KeyStateExpired) { + t.Fatalf("a key past its expiry must report %q, got %q", api.KeyStateExpired, row) + } + if strings.Contains(row, api.KeyStateRevoked) { + t.Fatalf("an expired key was never revoked: %q", row) + } +} + +// The pipeline case: no terminal to prompt on, so the only two acceptable +// outcomes are --yes or a refusal. Hanging on a read nobody will answer would +// wedge a CI job, and revoking unasked is what this gate exists to stop. +func TestKeysDestructiveVerbsRefuseWithoutTerminalOrYes(t *testing.T) { + for _, verb := range []string{"rotate", "revoke"} { + t.Run(verb, func(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "keys", verb, "key_01") + if err == nil { + t.Fatal("a non-interactive destructive verb must refuse, not proceed") + } + if !strings.Contains(err.Error(), "--yes") { + t.Fatalf("the refusal must name the way through it: %v", err) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("nothing ran, so stdout must be empty: %q", stdout) + } + if !keyIsLive(t, srv, "key_01") { + t.Fatal("the refused verb reached the gateway anyway") + } + }) + } +} + +func TestKeysRevokeProceedsWhenTheIDIsTypedBack(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, stderr, err := runInteractive(t, srv, "key_01\n", "keys", "revoke", "key_01") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stderr, "type the key id") { + t.Fatalf("the prompt belongs on stderr: %q", stderr) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("neither prompt nor result is a payload: %q", stdout) + } + if keyIsLive(t, srv, "key_01") { + t.Fatal("a confirmed revoke must actually revoke") + } +} + +// "y" is exactly the answer this prompt refuses to accept: the console makes +// the operator type the key's name for the same reason. +func TestKeysRevokeAbortsOnAnythingButTheID(t *testing.T) { + srv := gateway(t, fixture.Default()) + _, _, err := runInteractive(t, srv, "y\n", "keys", "revoke", "key_01") + if err == nil || !strings.Contains(err.Error(), "aborted") { + t.Fatalf("a wrong answer must abort: %v", err) + } + if !keyIsLive(t, srv, "key_01") { + t.Fatal("an aborted revoke revoked the key anyway") + } +} + +// Ctrl-D at the prompt: an empty answer is a wrong answer, not a default yes. +func TestKeysRotateAbortsOnEOFAndMintsNoSecret(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := runInteractive(t, srv, "", "keys", "rotate", "key_02") + if err == nil || !strings.Contains(err.Error(), "aborted") { + t.Fatalf("EOF at the prompt must abort: %v", err) + } + if strings.Contains(stdout, "fgw_") { + t.Fatalf("an aborted rotate must mint nothing: %q", stdout) + } +} diff --git a/internal/command/logs.go b/internal/command/logs.go new file mode 100644 index 0000000..b5a4894 --- /dev/null +++ b/internal/command/logs.go @@ -0,0 +1,330 @@ +package command + +import ( + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +const ( + // tailInterval is the poll cadence of `logs tail`; tailMaxInterval caps the + // ×2 backoff a failing gateway earns. + tailInterval = 2 * time.Second + tailMaxInterval = 30 * time.Second + + // metricCostUSD is the one spelling of the cost total: `logs stats` prints + // it as a row name and the JSON form of the same report carries it as a + // field, so a script greps one name whichever --format it asked for. + metricCostUSD = "cost_usd" + + // tailLookback is the window the first poll covers when no --since was + // given. It is a cursor, not a report: enough history that a row written + // while the command was starting is not missed, little enough that a bare + // tail does not replay the gateway's whole default window as though it had + // just happened. + tailLookback = 2 * time.Minute +) + +func newLogsCmd() *cobra.Command { + logs := &cobra.Command{ + Use: "logs", + Short: "Read the gateway request log", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, + } + logs.AddCommand(newLogsListCmd(), newLogsStatsCmd(), newLogsTailCmd()) + return logs +} + +// addLogFilterFlags installs the filter set `list` and `tail` share, so a flag +// cannot mean one thing in one verb and another in the next. +func addLogFilterFlags(cmd *cobra.Command) { + f := cmd.Flags() + f.String("since", "", "duration (15m, 2h) or an RFC3339 timestamp") + f.String("model", "", "only rows for this model") + f.String("provider", "", "only rows served by this provider") + f.String("stage", "", "pipeline stage; 'all' for every stage (default: terminal stages only)") + f.String("api-key-id", "", "only rows for this credential id ('none' for credential-less rows)") +} + +func addLogStatsFilterFlags(cmd *cobra.Command) { + f := cmd.Flags() + f.String("since", "", "duration (15m, 2h) or an RFC3339 timestamp") + f.String("model", "", "only rows for this model") + f.String("provider", "", "only rows served by this provider") + f.String("stage", "", "only rows for this pipeline stage") +} + +func logsQueryFromFlags(cmd *cobra.Command) (api.LogsQuery, error) { + f := cmd.Flags() + q := api.LogsQuery{} + q.Model, _ = f.GetString("model") + q.Provider, _ = f.GetString("provider") + q.Stage, _ = f.GetString("stage") + q.APIKeyID, _ = f.GetString("api-key-id") + + if since, _ := f.GetString("since"); since != "" { + t, err := api.ParseSince(since) + if err != nil { + return q, err + } + q.Since = t + } + // list-only paging flags; absent on tail, where they make no sense. + if f.Lookup("limit") != nil { + q.Limit, _ = f.GetInt("limit") + } + if f.Lookup("offset") != nil { + q.Offset, _ = f.GetInt("offset") + } + return q, nil +} + +// noStore turns a 501 into the sentence an operator can act on. The raw error +// stays wrapped for anyone who needs the status code. +func noStore(err error) error { + if api.IsNotSupported(err) { + return fmt.Errorf("this gateway has no request-log store configured, so there are no request logs to read (set REQUEST_LOG_STORE_BACKEND and REQUEST_LOG_STORE_DSN): %w", err) + } + return err +} + +func newLogsListCmd() *cobra.Command { + list := &cobra.Command{ + Use: verbList, + Short: "List request-log rows", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + q, err := logsQueryFromFlags(cmd) + if err != nil { + return err + } + page, err := d.Client.Logs(cmd.Context(), q) + if err != nil { + return noStore(err) + } + if d.Printer.Format != FormatTable { + return d.printStructured(page) + } + rows := make([][]string, 0, len(page.Data)) + for _, e := range page.Data { + rows = append(rows, []string{ + table.FmtTime(e.CreatedAt), e.TraceID, table.OrDash(e.Provider), table.OrDash(e.Model), + e.Stage, fmtMillis(e.DurationMs), fmtCost(e.CostUSD), + fmt.Sprintf("%d", e.TotalTokens), + }) + } + d.Printer.Table([]string{colTime, "TRACE", colProvider, "MODEL", "STAGE", "MS", "COST", "TOKENS"}, rows) + return nil + }, + } + addLogFilterFlags(list) + list.Flags().Int("limit", 50, "rows to return (gateway caps at 200)") + list.Flags().Int("offset", 0, "rows to skip") + return list +} + +func newLogsStatsCmd() *cobra.Command { + stats := &cobra.Command{ + Use: "stats", + Short: "Aggregate request-log statistics (totals, errors, latency percentiles)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + q, err := logsQueryFromFlags(cmd) + if err != nil { + return err + } + s, err := d.Client.LogStats(cmd.Context(), q) + if err != nil { + return noStore(err) + } + if d.Printer.Format != FormatTable { + return d.printStructured(s) + } + sum := s.Summary + rows := slices.Concat( + [][]string{ + {"requests", fmt.Sprintf("%d", sum.TotalEntries)}, + {"errors", fmt.Sprintf("%d", sum.ErrorEntries)}, + {"error_rate", fmtRate(sum.ErrorEntries, sum.TotalEntries)}, + {"tokens", fmt.Sprintf("%d", sum.TotalTokens)}, + {"prompt_tokens", fmt.Sprintf("%d", sum.PromptTokens)}, + {"completion_tokens", fmt.Sprintf("%d", sum.CompletionTokens)}, + {metricCostUSD, fmt.Sprintf("%.4f", sum.CostUSD)}, + {"unpriced_requests", fmt.Sprintf("%d", sum.UnpricedRequests)}, + }, + percentileRows("latency", s.LatencyMs), + percentileRows("ttft", s.TTFTMs), + ) + d.Printer.Table([]string{"METRIC", "VALUE"}, rows) + return nil + }, + } + addLogStatsFilterFlags(stats) + return stats +} + +// percentileRows renders one distribution. A nil block means nothing was +// measured, which is a "-" and not a zero-latency gateway. +func percentileRows(prefix string, p *api.Percentiles) [][]string { + // The unmeasured table, which is also the answer when p is nil. + rows := [][]string{ + {prefix + "_p50_ms", "-"}, + {prefix + "_p95_ms", "-"}, + {prefix + "_p99_ms", "-"}, + {prefix + "_max_ms", "-"}, + } + if p == nil { + return rows + } + for i, v := range []float64{p.P50, p.P95, p.P99, p.Max} { + rows[i][1] = fmt.Sprintf("%.1f", v) + } + return rows +} + +func newLogsTailCmd() *cobra.Command { + tail := &cobra.Command{ + Use: "tail", + Short: "Follow the request log (one plain line per new row)", + Long: "tail polls the request log every 2s and prints each new row as a line.\n" + + "A failing poll backs off ×2 to 30s and keeps the last cursor, so no row\n" + + "is skipped. Interrupting a tail is a clean exit, not a failure.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + q, err := logsQueryFromFlags(cmd) + if err != nil { + return err + } + if q.Since.IsZero() { + q.Since = time.Now().Add(-tailLookback) + } + f := api.NewFollower(d.Client, q) + delay := tailInterval + for { + rows, err := f.Poll(cmd.Context()) + // A truncated poll is progress, not a failure: its rows are + // complete and its cursor moved past them, so they are printed, + // the cadence stays, and only the older end it could not reach + // is reported — on stderr, where every loss is stated. + truncated := errors.Is(err, api.ErrFollowTruncated) + switch { + case err == nil || truncated: + delay = tailInterval + if truncated { + d.Printer.Warn("%v", err) + } + for _, e := range rows { + _, _ = fmt.Fprintln(d.Printer.Out, formatLogLine(e)) + } + case cmd.Context().Err() != nil: + // The failing poll is the interrupt itself, not a fault. + return nil + case api.IsNotSupported(err): + // A feature that is absent will not appear by retrying. + return noStore(err) + default: + // Backed off before it is reported: the warning names the + // wait the operator is about to sit through, not the one + // that has already elapsed. + delay = backoff(delay) + d.Printer.Warn("poll failed (%v) — retrying in %s", err, delay) + } + select { + case <-cmd.Context().Done(): + return nil // an interrupted tail is a clean exit + case <-time.After(delay): + } + } + }, + } + addLogFilterFlags(tail) + return tail +} + +func backoff(d time.Duration) time.Duration { + return min(d*2, tailMaxInterval) +} + +// formatLogLine is the tail's one-row-per-line rendering. Fields are +// space-separated and normalized to one physical line, so `awk` still works; +// the free-form error message remains last. Every fixed-width field goes +// through oneField, not only the error message: a Provider, Model or Stage +// the gateway sent with an embedded space or newline would otherwise widen +// into an extra column, and a newline in any field would break the +// one-physical-line contract `awk` depends on. +func formatLogLine(e api.LogEntry) string { + fields := []string{ + oneField(table.FmtTime(e.CreatedAt)), + oneField(e.TraceID), + oneField(table.OrDash(e.Provider)), + oneField(table.OrDash(e.Model)), + oneField(e.Stage), + oneField(fmtMillis(e.DurationMs)), + oneField(fmtCost(e.CostUSD)), + fmt.Sprintf("%d", e.TotalTokens), + } + line := strings.Join(fields, " ") + if e.ErrorMessage != "" { + line += " " + oneLine(e.ErrorMessage) + } + return line +} + +// oneField collapses s to a single whitespace-free token, joining any +// embedded words with "_" rather than a space, so the value can never widen +// into more than the one column it occupies in formatLogLine's fixed-width +// prefix. A value that normalizes to nothing (empty, or all whitespace) +// becomes "-": an empty column still occupies a position in the joined +// string but disappears under an `awk`-style whitespace split, silently +// shifting every column after it. +func oneField(s string) string { + f := strings.Fields(table.SanitizeCell(s)) + if len(f) == 0 { + return "-" + } + return strings.Join(f, "_") +} + +// oneLine collapses s's internal whitespace runs to single spaces. Used only +// for the trailing, free-form error message, which may keep its internal +// spaces because it is always the last thing on the row -- past every column +// a script indexes into by position -- and only a literal newline or tab +// inside it can break the one-line contract. +func oneLine(s string) string { + return strings.Join(strings.Fields(table.SanitizeCell(s)), " ") +} + +// fmtMillis and fmtCost keep the nullable-measurement rule in one place: the +// gateway sends null for "not measured", and a rendered 0 would claim a real +// instantaneous or free request. +func fmtMillis(v *float64) string { + if v == nil { + return "-" + } + return fmt.Sprintf("%.1f", *v) +} + +func fmtCost(v *float64) string { + if v == nil { + return "-" + } + return fmt.Sprintf("$%.4f", *v) +} + +func fmtRate(part, total int) string { + if total == 0 { + return "-" + } + return fmt.Sprintf("%.2f%%", 100*float64(part)/float64(total)) +} diff --git a/internal/command/logs_test.go b/internal/command/logs_test.go new file mode 100644 index 0000000..b058ff5 --- /dev/null +++ b/internal/command/logs_test.go @@ -0,0 +1,374 @@ +package command + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/fixture" +) + +// executeCtx is execute() with a caller-owned context, which is what a tail +// needs: cancelling it is how SIGINT reaches the poll loop. +func executeCtx(ctx context.Context, t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + // See execute's identical guard in root_test.go: without it, a real ferro + // config on the host machine could resolve into these tests' connection + // setup. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + root := NewRoot() + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetArgs(args) + err = root.ExecuteContext(ctx) + return out.String(), errb.String(), err +} + +func TestLogsListRendersDashForNullMeasurements(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "logs", "list") + if err != nil { + t.Fatal(err) + } + for _, col := range []string{"TIME", "TRACE", "PROVIDER", "MODEL", "STAGE", "MS", "COST", "TOKENS"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + // The seeded gpt-4o-mini row has null duration_ms, ttft_ms and cost_usd. + // Rendering those as 0 would claim a free, instantaneous request. + var row string + for _, l := range strings.Split(stdout, "\n") { + if strings.Contains(l, "gpt-4o-mini") { + row = l + } + } + if row == "" { + t.Fatalf("expected the seeded gpt-4o-mini row: %q", stdout) + } + // TIME TRACE PROVIDER MODEL STAGE MS COST TOKENS — MS and COST are the two + // nullable ones on this row, and both must be a dash rather than a zero + // that would claim a free, instantaneous request. + fields := strings.Fields(row) + if len(fields) != 8 { + t.Fatalf("want 8 whitespace-free columns, got %d in %q", len(fields), row) + } + if fields[5] != "-" || fields[6] != "-" { + t.Fatalf("null duration_ms/cost_usd must render as -, got ms=%q cost=%q", fields[5], fields[6]) + } + if fields[7] != "856" { + t.Fatalf("a measured token count must survive: %q", row) + } +} + +func TestLogsListSendsSinceAndFilters(t *testing.T) { + var got atomic.Pointer[url.Values] + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + got.Store(&q) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[],"summary":{"total_entries":0,"returned_entries":0}}`)) + })) + defer srv.Close() + + if _, _, err := execute(t, "logs", "list", "--since", "15m", "--provider", "anthropic", + "--stage", "all", "--limit", "7", "--gateway-url", srv.URL); err != nil { + t.Fatal(err) + } + q := got.Load() + if q == nil { + t.Fatal("no request reached the gateway") + } + since := q.Get("since") + if since == "" { + t.Fatalf("--since must reach the wire: %v", *q) + } + ts, err := time.Parse(time.RFC3339, since) + if err != nil { + t.Fatalf("since must be RFC3339, got %q", since) + } + if d := time.Since(ts); d < 14*time.Minute || d > 16*time.Minute { + t.Fatalf("15m must mean 15 minutes ago, got %s", d) + } + if q.Get("provider") != "anthropic" || q.Get("stage") != "all" || q.Get("limit") != "7" { + t.Fatalf("filters lost on the way to the wire: %v", *q) + } +} + +func TestLogsListRejectsBadSince(t *testing.T) { + _, _, err := execute(t, "logs", "list", "--since", "yesterday", "--gateway-url", "http://127.0.0.1:1") + if err == nil || !strings.Contains(err.Error(), "since") { + t.Fatalf("an unparseable --since must be reported before any request: %v", err) + } +} + +func TestLogsStatsRendersPercentiles(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "logs", "stats") + if err != nil { + t.Fatal(err) + } + // Read the rendered rows rather than matching literal totals. The fixture + // aggregates its own seeded rows now, so a hardcoded "1240" pinned this + // test to that data and failed the moment the fixture started answering + // honestly — which is a change to the fixture, not to this renderer. + got := map[string]string{} + for _, line := range strings.Split(stdout, "\n") { + if f := strings.Fields(line); len(f) == 2 { + got[f[0]] = f[1] + } + } + for _, want := range []string{ + "requests", "error_rate", "cost_usd", + "latency_p50_ms", "latency_p95_ms", "latency_p99_ms", "ttft_p50_ms", + } { + if got[want] == "" { + t.Fatalf("stats must render %q:\n%s", want, stdout) + } + } + // Percentiles are non-decreasing by construction, so this holds for any + // data — and it fails if the renderer ever transposes or mislabels the + // columns, which matching one value in isolation cannot detect. + p50, p95, p99 := statNum(t, got, "latency_p50_ms"), statNum(t, got, "latency_p95_ms"), statNum(t, got, "latency_p99_ms") + if p50 > p95 || p95 > p99 { + t.Fatalf("latency percentiles must be non-decreasing, got %v/%v/%v:\n%s", p50, p95, p99, stdout) + } +} + +func statNum(t *testing.T, rows map[string]string, name string) float64 { + t.Helper() + v, err := strconv.ParseFloat(strings.TrimSuffix(rows[name], "%"), 64) + if err != nil { + t.Fatalf("%s must render as a number, got %q", name, rows[name]) + } + return v +} + +func TestLogsStatsExposesOnlySupportedFilters(t *testing.T) { + srv := gateway(t, fixture.Default()) + if _, _, err := run(t, srv, "logs", "stats", "--stage", "on_error"); err != nil { + t.Fatalf("stage is supported by the stats endpoint: %v", err) + } + if _, _, err := run(t, srv, "logs", "stats", "--api-key-id", "key_01"); err == nil { + t.Fatal("stats must not advertise the list-only api-key-id filter") + } +} + +func TestFormatLogLineKeepsErrorsOnOneLine(t *testing.T) { + line := formatLogLine(api.LogEntry{Stage: "on_error", ErrorMessage: "first\nsecond\tthird"}) + if strings.ContainsAny(line, "\n\r\t") || !strings.HasSuffix(line, "first second third") { + t.Fatalf("tail rows must remain one line: %q", line) + } +} + +// A Provider, Model or Stage carrying a space or newline must not widen into +// an extra column or break the one-physical-line contract: every fixed-width +// field is normalized, not only ErrorMessage. +func TestFormatLogLineNormalizesEveryField(t *testing.T) { + line := formatLogLine(api.LogEntry{ + TraceID: "trace 1", + Provider: "open\nai rogue", + Model: "gpt 4\to", + Stage: "custom\nstage", + }) + if strings.ContainsAny(line, "\n\r\t") { + t.Fatalf("a field carrying whitespace must not break the one-line contract: %q", line) + } + fields := strings.Fields(line) + if len(fields) != 8 { + t.Fatalf("want 8 whitespace-delimited columns even with dirty fields, got %d in %q", len(fields), line) + } + if fields[1] != "trace_1" { + t.Fatalf("trace id's embedded space must not split into a second column: %q", line) + } + if fields[2] != "open_ai_rogue" || fields[3] != "gpt_4_o" || fields[4] != "custom_stage" { + t.Fatalf("provider/model/stage must stay one column each: %q", line) + } +} + +// A gateway with no log store answers 501. That is a feature that is absent, +// not a crash: the message names the missing store instead of the status code. +func TestLogsDegradeWithAHintWhenNoStore(t *testing.T) { + s := fixture.Default() + s.NoLogStore = true + srv := gateway(t, s) + + for _, verb := range []string{"list", "stats", "tail"} { + _, _, err := run(t, srv, "logs", verb) + if err == nil { + t.Fatalf("logs %s must fail when the store is absent", verb) + } + if !strings.Contains(err.Error(), "request-log store") { + t.Fatalf("logs %s must name the missing store, got %v", verb, err) + } + } +} + +func TestLogsTailPrintsRowsAndExitsCleanlyOnCancel(t *testing.T) { + srv := gateway(t, fixture.Default()) + t.Setenv("FERRO_API_KEY", "fgw_test") + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancel() + + stdout, _, err := executeCtx(ctx, t, "logs", "tail", "--since", "1h", + "--gateway-url", srv.URL) + if err != nil { + t.Fatalf("a tail the operator interrupted is not a failure: %v", err) + } + if !strings.Contains(stdout, "anthropic") { + t.Fatalf("tail must print each new row as a line: %q", stdout) + } + if strings.Contains(stdout, "TRACE") { + t.Fatalf("tail emits lines, not a re-drawn table header: %q", stdout) + } +} + +func TestLogsTailBacksOffAndKeepsGoingOnFailure(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"message":"boom","type":"server_error","code":"server_error"}}`)) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + + stdout, stderr, err := executeCtx(ctx, t, "logs", "tail", + "--gateway-url", srv.URL) + if err != nil { + t.Fatalf("a poll failure is a warning, not an exit: %v", err) + } + if calls.Load() == 0 { + t.Fatal("the tail never polled") + } + if !strings.Contains(stderr, "retrying") { + t.Fatalf("the failure and the retry delay belong on stderr: %q", stderr) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("a failed poll must not write to the machine channel: %q", stdout) + } +} + +// A poll that hits the follower's page bound keeps the rows it fetched and +// moves its cursor past them, so it is progress: the rows belong on stdout, the +// gap belongs on stderr, and the cadence must not back off as it does for a +// poll that failed and fetched nothing. +func TestLogsTailReportsTruncationWithoutBackingOff(t *testing.T) { + // The follower asks for 100 rows a page and stops paging early on a short + // one, so a full page is what keeps it going. The rows carry only an id: + // this is about the page bound, not about rendering. + rows := make([]string, 0, 100) + for i := range 100 { + rows = append(rows, fmt.Sprintf(`{"trace_id":"trace-%03d"}`, i)) + } + // A full page every time, against a total the follower can never reach: + // every poll exhausts its page bound with rows still outstanding. + body := []byte(`{"data":[` + strings.Join(rows, ",") + + `],"summary":{"total_entries":1000000,"returned_entries":100}}`) + + // A fixed wall-clock budget for "one whole poll" races the page count + // against however long 32 sequential round trips take under load — land + // the deadline mid-poll and Poll returns a context error with stdout + // still empty, no product defect behind it. Cancel off the 32nd response + // instead of off a clock, so the interrupt cannot land before the poll + // that must finish first, regardless of how slow the run is. + var calls atomic.Int32 + done := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + if calls.Add(1) == 32 { + close(done) + } + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go func() { + <-done + // The 32nd response is written, not yet read: give the client time to + // receive and decode it before pulling the context out from under it. + // This only has to outlast one small JSON decode, not 32 round trips, + // so it stays well clear of the flake it replaces. + time.Sleep(50 * time.Millisecond) + cancel() + }() + + stdout, stderr, err := executeCtx(ctx, t, "logs", "tail", "--gateway-url", srv.URL) + if err != nil { + t.Fatalf("a truncated poll is not a failed command: %v", err) + } + if !strings.Contains(stdout, "trace-000") { + t.Fatalf("the rows a truncated poll did fetch must still reach stdout: %q (stderr %q)", stdout, stderr) + } + if !strings.Contains(stderr, "skipped") { + t.Fatalf("the skipped older rows must be stated on stderr: %q", stderr) + } + if strings.Contains(stderr, "retrying") { + t.Fatalf("a truncated poll made progress and must not back off: %q", stderr) + } +} + +func TestBackoffDoublesToCap(t *testing.T) { + d := tailInterval + for range 10 { + d = backoff(d) + } + if d != tailMaxInterval { + t.Fatalf("backoff must double to a %s cap, got %s", tailMaxInterval, d) + } +} + +// The flag set is shared by list and tail, so it is worth one direct test. +func TestLogsQueryFromFlagsPassesNoneSentinel(t *testing.T) { + cmd := &cobra.Command{} + addLogFilterFlags(cmd) + if err := cmd.ParseFlags([]string{"--api-key-id", "none"}); err != nil { + t.Fatal(err) + } + q, err := logsQueryFromFlags(cmd) + if err != nil { + t.Fatal(err) + } + if q.APIKeyID != "none" { + t.Fatalf("the gateway's none sentinel must pass through untouched, got %q", q.APIKeyID) + } +} + +// `logs tail` writes straight to stdout, and strings.Fields splits on +// whitespace only — an ESC survives it untouched. A colour or OSC sequence an +// upstream provider put in a name or an error_message is terminal control this +// tool has no business forwarding to whatever is reading the pipe. +func TestFormatLogLineStripsTerminalControl(t *testing.T) { + line := formatLogLine(api.LogEntry{ + TraceID: "tr_esc", + Provider: "op\x1b[31mai", + Model: "gpt-4o", + Stage: "on_error", + ErrorMessage: "upstream said \x1b]0;pwned\x07 and \x1b[2J", + }) + if strings.Contains(line, "\x1b") { + t.Fatalf("an escape sequence must never reach stdout: %q", line) + } + if strings.ContainsAny(line, "\n\r\t") { + t.Fatalf("tail rows must remain one line: %q", line) + } + if got := strings.Fields(line)[2]; got != "op_[31mai" { + t.Fatalf("a neutralized ESC must not widen the provider into a second column: %q (%q)", got, line) + } +} diff --git a/internal/command/models.go b/internal/command/models.go new file mode 100644 index 0000000..c27a9c7 --- /dev/null +++ b/internal/command/models.go @@ -0,0 +1,27 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/table" +) + +func newModelsCmd() *cobra.Command { + return &cobra.Command{ + Use: "models", + Short: "List the models this gateway routes", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + models, err := d.Client.Models(cmd.Context()) + if err != nil { + return err + } + if d.Printer.Format != FormatTable { + return d.printStructured(models) + } + d.Printer.Table(table.ModelHeaders, table.ModelRows(models)) + return nil + }, + } +} diff --git a/internal/command/output.go b/internal/command/output.go new file mode 100644 index 0000000..ec836cc --- /dev/null +++ b/internal/command/output.go @@ -0,0 +1,204 @@ +package command + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + + "go.yaml.in/yaml/v3" + "golang.org/x/term" + + "github.com/ferro-labs/gateway-cli/internal/table" +) + +// Format is the --format value. Report commands may refuse some of these +// rather than invent an unstable encoding of prose. +type Format string + +// The three output formats. Table is for reading, JSON and YAML for piping. +const ( + FormatTable Format = "table" + FormatJSON Format = "json" + FormatYAML Format = "yaml" +) + +// Printer enforces ferro's output discipline: stdout carries machine-readable +// payloads and nothing else, while human narrative, warnings, and errors go to +// stderr. That split is what makes `ferro status --format json | jq` safe. +type Printer struct { + Out, Err io.Writer + Format Format + Color bool + ASCII bool +} + +// isTTY reports whether f is attached to a terminal. It is the package's one +// answer to that question: the stdout/stderr split, the colour decision, and +// chat's piped-prompt detection all ask it here rather than each spelling out +// the same descriptor dance. A var, not a func, so a test can swap it in to +// simulate one stream being a terminal and another not being one -- real +// *os.File values do not let a test fake that any other way. +// +// The int conversion is imposed by the two standard APIs on either side of it — +// os.File.Fd returns a uintptr, golang.org/x/term.IsTerminal takes an int, and +// x/term offers no *os.File-shaped alternative. A descriptor never approaches +// the range where that conversion could lose information. +var isTTY = func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } + +// NoColor reports whether ANSI must be suppressed for writes to f. Any one of +// these is enough: the NO_COLOR convention, a dumb terminal, or f not being a +// TTY (a pipe or file must never receive escape codes). +// +// The decision is taken per stream rather than globally off stdout: a +// Printer's narrative (OK/Warn/Fail) always writes to p.Err, so with +// `ferro status 2> run.log` -- stdout still a terminal, stderr redirected to +// a file -- colour must be decided from stderr, or the escape codes land in +// run.log even though nothing asked for them there. Callers that draw on +// stdout (the full-screen console) still pass os.Stdout. +func NoColor(f *os.File) bool { + if _, set := os.LookupEnv("NO_COLOR"); set { + return true + } + if os.Getenv("TERM") == "dumb" { + return true + } + return !isTTY(f) +} + +const ( + ansiGreen = "\x1b[32m" + ansiYellow = "\x1b[33m" + ansiRed = "\x1b[31m" + ansiReset = "\x1b[0m" +) + +func (p *Printer) glyph(unicode, ascii string) string { + if p.ASCII { + return ascii + } + return unicode +} + +// note writes one narrative line to stderr. The glyph carries the state on its +// own so meaning survives NO_COLOR, a pipe, and colour-blind readers; colour +// is only ever an enhancement. +func (p *Printer) note(color, glyph, format string, a ...any) { + msg := fmt.Sprintf(format, a...) + if p.Color { + _, _ = fmt.Fprintf(p.Err, "%s%s%s %s\n", color, glyph, ansiReset, msg) + return + } + _, _ = fmt.Fprintf(p.Err, "%s %s\n", glyph, msg) +} + +// OK reports success on stderr, so it never contaminates a piped payload. +func (p *Printer) OK(format string, a ...any) { + p.note(ansiGreen, p.glyph("✓", "[OK]"), format, a...) +} + +// Warn reports a non-fatal condition on stderr. +func (p *Printer) Warn(format string, a ...any) { + p.note(ansiYellow, p.glyph("!", "[!]"), format, a...) +} + +// Fail reports a failure on stderr. The caller still decides the exit code. +func (p *Printer) Fail(format string, a ...any) { + p.note(ansiRed, p.glyph("✗", "[X]"), format, a...) +} + +// The shared column-header vocabulary. A fact keeps one word wherever it is +// printed — TIME is always when it happened, NAME the object's own name, STATE +// the condition ferro derived, STATUS the one the gateway reported — so a +// reader who has learnt one table can read the next, and a script locating a +// column by name finds it under that name everywhere. +// +// STATUS is not here: the provider listing was its only caller and that table's +// shape now lives in internal/table alongside the merge that builds it, so both +// this package and the console spell it from there. +const ( + colTime = "TIME" + colName = "NAME" + colState = "STATE" + colScopes = "SCOPES" + colExpires = "EXPIRES" + colProvider = "PROVIDER" + colModels = "MODELS" + colURL = "URL" +) + +// Table writes a plain, alignment-padded table to stdout. No ANSI, no box +// drawing: this output is as likely to be piped into awk as read by a human. +// The layout itself lives in internal/table, shared with the console's own +// rendering of the same verb — see internal/tui/home.go's tableRows. +func (p *Printer) Table(headers []string, rows [][]string) { + table.Write(p.Out, headers, rows) +} + +// JSON writes v to stdout as one indented document. +func (p *Printer) JSON(v any) error { + enc := json.NewEncoder(p.Out) + enc.SetIndent("", " ") + // This output is piped to jq or a file, never embedded in HTML, so the + // encoder's default <, >, & escaping only mangles audit details, provider + // error messages, and URLs that legitimately contain them. + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +// YAML renders v using the same key names the JSON output uses. +// +// Every wire type in internal/api carries `json:` tags and no `yaml:` tags, +// and yaml.v3 does not read json tags — it lowercases the Go field name +// instead, so total_entries would print as totalentries and cost_usd as +// costusd. Round-tripping through JSON makes one set of tags serve both +// formats, which also means a new field cannot be correct in JSON and wrong +// in YAML. +func (p *Printer) YAML(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + // UseNumber keeps integers exact; without it every number decodes as + // float64 and a trace timestamp renders as 1.786193769e+09. + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var doc any + if err := dec.Decode(&doc); err != nil { + return err + } + out, err := yaml.Marshal(nativeNumbers(doc)) + if err != nil { + return err + } + _, err = p.Out.Write(out) + return err +} + +// nativeNumbers hands the json.Number values UseNumber preserved to yaml as +// plain scalars. Left as json.Number they are a string type and YAML would +// quote them: total_entries: "1500". +func nativeNumbers(v any) any { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + t[k] = nativeNumbers(val) + } + return t + case []any: + for i, val := range t { + t[i] = nativeNumbers(val) + } + return t + case json.Number: + // The gateway's own digits, unquoted, with nothing re-parsed in + // between. Converting to int64 or float64 first would round anything + // past 2^53 that is not an int64 — and the by_provider / by_model + // breakdowns reach here as raw gateway JSON, so what fits is the + // gateway's to decide rather than this package's to assume. + return &yaml.Node{Kind: yaml.ScalarNode, Value: t.String()} + default: + return v + } +} diff --git a/internal/command/output_test.go b/internal/command/output_test.go new file mode 100644 index 0000000..062e1c2 --- /dev/null +++ b/internal/command/output_test.go @@ -0,0 +1,235 @@ +package command + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" +) + +func newTestPrinter(format Format) (*Printer, *bytes.Buffer, *bytes.Buffer) { + var out, errb bytes.Buffer + return &Printer{Out: &out, Err: &errb, Format: format}, &out, &errb +} + +func TestTableRendersHeaderUnderlineAndRows(t *testing.T) { + p, out, _ := newTestPrinter(FormatTable) + p.Table([]string{"STATE", "URL"}, [][]string{{"connected", "http://localhost:8080"}}) + + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("want header + underline + 1 row, got %d lines:\n%s", len(lines), out.String()) + } + if !strings.HasPrefix(lines[0], "STATE") { + t.Fatalf("header line: %q", lines[0]) + } + if !strings.HasPrefix(lines[1], "-----") { + t.Fatalf("underline must be dashes matching header width, got %q", lines[1]) + } + if !strings.Contains(lines[2], "connected") { + t.Fatalf("row line: %q", lines[2]) + } +} + +// stdout is the machine channel: `ferro status --format json | jq` must see a +// JSON document and nothing else, no matter how much narrative was produced. +func TestJSONPurityNarrativeGoesToStderr(t *testing.T) { + p, out, errb := newTestPrinter(FormatJSON) + p.OK("connected in 23ms") + p.Warn("anthropic circuit half-open") + p.Fail("something failed") + if err := p.JSON(map[string]any{"state": "degraded", "latency_ms": 23}); err != nil { + t.Fatal(err) + } + + var doc map[string]any + if err := json.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("stdout must be exactly one JSON document, got %q: %v", out.String(), err) + } + if doc["state"] != "degraded" { + t.Fatalf("payload lost: %v", doc) + } + for _, want := range []string{"connected in 23ms", "half-open", "something failed"} { + if !strings.Contains(errb.String(), want) { + t.Fatalf("narrative %q must reach stderr, got %q", want, errb.String()) + } + } +} + +// JSON is piped to jq or a file, never embedded in HTML, so <, >, and & in an +// audit detail, a provider error message, or a URL must survive literally +// rather than becoming <-style escapes. +func TestJSONDoesNotEscapeHTMLCharacters(t *testing.T) { + p, out, _ := newTestPrinter(FormatJSON) + if err := p.JSON(map[string]string{"url": "http://x?a=1&b=2", "detail": "a was denied"}); err != nil { + t.Fatal(err) + } + got := out.String() + // Verbatim substring match is the assertion: SetEscapeHTML(false) means + // these bytes appear exactly as given rather than as <-style escapes, + // and a literal match fails on its own if any got turned into an escape. + if !strings.Contains(got, "a was denied") || !strings.Contains(got, "http://x?a=1&b=2") { + t.Fatalf("HTML characters must stay literal, not escaped, got %q", got) + } +} + +func TestYAMLGoesToStdout(t *testing.T) { + p, out, _ := newTestPrinter(FormatYAML) + if err := p.YAML(map[string]string{"state": "connected"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "state: connected") { + t.Fatalf("yaml output: %q", out.String()) + } +} + +// Color must never be the only carrier of meaning, and it must be absent +// entirely when the printer is not in color mode. +func TestNoColorEmitsNoANSI(t *testing.T) { + p, _, errb := newTestPrinter(FormatTable) + p.Color = false + p.OK("all good") + p.Fail("all bad") + if strings.Contains(errb.String(), "\x1b[") { + t.Fatalf("ANSI leaked with Color=false: %q", errb.String()) + } + // The glyph still distinguishes the two, so meaning survives without color. + if !strings.Contains(errb.String(), "✓") || !strings.Contains(errb.String(), "✗") { + t.Fatalf("glyphs must carry state when color cannot: %q", errb.String()) + } +} + +func TestColorEmitsANSIWhenEnabled(t *testing.T) { + p, _, errb := newTestPrinter(FormatTable) + p.Color = true + p.OK("all good") + if !strings.Contains(errb.String(), "\x1b[") { + t.Fatalf("want ANSI with Color=true, got %q", errb.String()) + } +} + +func TestASCIIGlyphVocabulary(t *testing.T) { + p, _, errb := newTestPrinter(FormatTable) + p.ASCII = true + p.OK("ok") + p.Warn("warn") + p.Fail("fail") + got := errb.String() + for _, want := range []string{"[OK]", "[!]", "[X]"} { + if !strings.Contains(got, want) { + t.Fatalf("ascii mode must use %s, got %q", want, got) + } + } + if strings.ContainsAny(got, "✓✗") { + t.Fatalf("ascii mode leaked unicode glyphs: %q", got) + } +} + +// The api wire types carry json tags and no yaml tags. yaml.v3 does not read +// json tags, so a naive Marshal renders total_entries as totalentries — the +// same field, named differently depending on --format. +func TestYAMLUsesJSONFieldNames(t *testing.T) { + p, out, _ := newTestPrinter(FormatYAML) + payload := struct { + TotalEntries int `json:"total_entries"` + CostUSD float64 `json:"cost_usd"` + CreatedUnix int64 `json:"created_unix"` + TraceID string `json:"trace_id"` + }{TotalEntries: 1500, CostUSD: 0.0031, CreatedUnix: 1786193769, TraceID: "tr_abc"} + + if err := p.YAML(payload); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{"total_entries: 1500", "cost_usd: 0.0031", "trace_id: tr_abc"} { + if !strings.Contains(got, want) { + t.Fatalf("want %q in:\n%s", want, got) + } + } + // Large integers must stay integers: neither quoted (json.Number leaking + // through as a string) nor rendered in scientific notation (float64). + if !strings.Contains(got, "created_unix: 1786193769") { + t.Fatalf("integer precision lost:\n%s", got) + } + for _, bad := range []string{"totalentries", "costusd", `"1500"`, "e+09"} { + if strings.Contains(got, bad) { + t.Fatalf("unexpected %q in:\n%s", bad, got) + } + } +} + +// The by_provider / by_model breakdowns reach the printer as raw gateway JSON, +// so their numbers are not bounded by any Go type this package declares. A +// number is rendered with the digits it arrived with; re-parsing it as int64 or +// float64 first would round anything that does not fit and print the result as +// though the gateway had sent it. +func TestYAMLPreservesDigitsBeyondInt64(t *testing.T) { + p, out, _ := newTestPrinter(FormatYAML) + payload := map[string]json.RawMessage{ + "by_provider": json.RawMessage(`{"requests":18446744073709551615,"cost_usd":0.0031}`), + } + if err := p.YAML(payload); err != nil { + t.Fatal(err) + } + got := out.String() + if !strings.Contains(got, "requests: 18446744073709551615") { + t.Fatalf("a number past int64 must survive intact, not be rounded:\n%s", got) + } + if !strings.Contains(got, "cost_usd: 0.0031") || strings.Contains(got, `"0.0031"`) { + t.Fatalf("ordinary numbers must stay unquoted YAML numbers:\n%s", got) + } +} + +// NoColor takes the stream it is deciding for as a parameter: it must never +// answer for os.Stderr by inspecting os.Stdout, which is the bug that let +// `ferro status 2> run.log` leak ANSI into the redirected file while stdout +// stayed a terminal. +func TestNoColorHonoursNOCOLOREnvRegardlessOfStream(t *testing.T) { + t.Setenv("NO_COLOR", "1") + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close() }() + defer func() { _ = w.Close() }() + // The stream must read as a terminal for this to test anything: NoColor + // checks NO_COLOR before !isTTY, so against a bare pipe it returns true on + // the TTY check alone and the assertion holds even if NO_COLOR were + // ignored outright. + orig := isTTY + isTTY = func(*os.File) bool { return true } + t.Cleanup(func() { isTTY = orig }) + if !NoColor(w) { + t.Fatal("NO_COLOR must suppress colour on any stream, TTY or not") + } +} + +func TestNoColorRejectsNonTTYStream(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close() }() + defer func() { _ = w.Close() }() + // A pipe is never a terminal, independent of NO_COLOR/TERM. + if !NoColor(w) { + t.Fatal("a non-TTY stream must suppress colour") + } +} + +func TestYAMLKeepsNestedNumbersNative(t *testing.T) { + p, out, _ := newTestPrinter(FormatYAML) + payload := map[string]any{ + "summary": map[string]any{"count": int64(9007199254740991)}, + "series": []any{map[string]any{"value": 12.5}}, + } + if err := p.YAML(payload); err != nil { + t.Fatal(err) + } + got := out.String() + if !strings.Contains(got, "count: 9007199254740991") || !strings.Contains(got, "value: 12.5") || + strings.Contains(got, `"9007199254740991"`) { + t.Fatalf("nested numbers lost their native YAML types:\n%s", got) + } +} diff --git a/internal/command/providers.go b/internal/command/providers.go new file mode 100644 index 0000000..6a4875c --- /dev/null +++ b/internal/command/providers.go @@ -0,0 +1,48 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +// The merged row, its five columns and the dash-for-absent rendering live in +// internal/table (providers.go) because the console renders the same listing +// and cannot import this package. table.ProviderRow's json tags are this +// command's --format json|yaml contract, unchanged by the move. +func newProvidersCmd() *cobra.Command { + return &cobra.Command{ + Use: "providers", + Short: "Provider health, circuit state and model counts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + health, _, err := d.Client.Health(cmd.Context()) + if err != nil { + return err + } + // Best effort: an unusable credential costs the status and message + // columns, not the command. + admin, adminErr := d.Client.AdminHealth(cmd.Context()) + if adminErr != nil { + admin = nil + // Only a refused credential is reported as one; a timeout or a + // gateway error says so instead, so the reader is not sent to + // check a credential that was never the problem. + why := "admin health unavailable" + if api.IsUnauthorized(adminErr) { + why = "no admin credential accepted" + } + d.Printer.Warn("%s — status and message come from /health only (%v)", why, adminErr) + } + + rows := table.MergeProviders(health, admin) + if d.Printer.Format != FormatTable { + return d.printStructured(rows) + } + d.Printer.Table(table.ProviderHeaders, table.ProviderRows(rows)) + return nil + }, + } +} diff --git a/internal/command/root.go b/internal/command/root.go new file mode 100644 index 0000000..636b9a1 --- /dev/null +++ b/internal/command/root.go @@ -0,0 +1,207 @@ +// Package command holds ferro's scriptable Cobra surface. It owns the +// stdout/stderr split, output formats, and exit codes; it holds no terminal +// screen state. Bare `ferro` hands off to internal/tui. +package command + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/config" + "github.com/ferro-labs/gateway-cli/internal/tui" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" + "github.com/ferro-labs/gateway-cli/internal/version" +) + +type depsKey struct{} + +// verbList is the one spelling of the listing verb. Every noun that can be +// listed spells it this way, so an operator who has typed `keys list` can guess +// `logs list` rather than discover it is `logs ls`. +const verbList = "list" + +// runtimeDeps is what every verb needs and none of them should assemble for +// itself: the resolved connection, an output printer honouring the global +// flags, and a client pointed at that connection. +type runtimeDeps struct { + Resolved config.Resolved + Printer *Printer + Client *api.Client +} + +// deps returns the runtime wiring built by PersistentPreRunE. It is nil only +// for commands that opt out of setup (version), which never call it. +func deps(cmd *cobra.Command) *runtimeDeps { + d, _ := cmd.Context().Value(depsKey{}).(*runtimeDeps) + return d +} + +// printStructured renders a value for --format json|yaml. Table-formatted +// commands that have no sensible column layout fall back to JSON rather than +// inventing one. +func (d *runtimeDeps) printStructured(v any) error { + if d.Printer.Format == FormatYAML { + return d.Printer.YAML(v) + } + return d.Printer.JSON(v) +} + +// rootFlags are the persistent flags every subcommand inherits. They are +// resolved against environment variables and profiles in PersistentPreRunE. +type rootFlags struct { + configPath string + gatewayURL string + profile string + format string + ascii bool + insecureHTTP bool +} + +// NewRoot builds the command tree. Tests construct it directly so they can +// swap stdout/stderr; main() wraps it in fang for styled help and errors. +func NewRoot() *cobra.Command { + f := &rootFlags{} + root := &cobra.Command{ + Use: "ferro", + Short: "Operator console for the Ferro Labs AI Gateway", + Long: "ferro manages a running Ferro Labs AI Gateway over its HTTP admin API.\n" + + "With no arguments it opens a full-screen operations console; with a verb\n" + + "it runs a scriptable command whose output belongs to your pipeline.", + // Errors go to stderr on their own; the usage wall on every failure + // buries the one line that says what went wrong. + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + // The full-screen console needs a terminal. On a pipe or a file, + // drawing it would spray escape codes into somebody's stdout, so + // refuse and point at the scriptable surface instead. + if !stdinIsTTY() || !stdoutIsTTY() { + return errors.New("ferro's full-screen console needs terminal input and output; " + + "run `ferro --help` for the scriptable commands") + } + d := deps(cmd) + // tui takes the command tree rather than importing this package: + // root.go calls tui.Run, so the reverse import would be a cycle. + // Passing cmd.Root() keeps the console's verb vocabulary derived + // from the real cobra tree, so the two cannot drift. It always draws + // on stdout, so its colour decision is stdout's TTY state -- unlike + // the Printer's below, which follows stderr, the stream it writes to. + return tui.Run(d.Client, d.Resolved, + theme.Mode{Color: !NoColor(os.Stdout), ASCII: f.ascii}, cmd.Root()) + }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + // version must work offline, with no config and no credential. + if cmd.Name() == "version" || cmd.Name() == "help" || isCompletion(cmd) { + return nil + } + + // --config outranks FERRO_CONFIG, which outranks the OS default -- + // the same precedence --gateway-url takes over FERRO_URL below. + path := config.DefaultPath() + if v := os.Getenv(config.EnvConfigPath); v != "" { + path = v + } + if f.configPath != "" { + path = f.configPath + } + file, err := config.Load(path) + if err != nil { + return err + } + resolved, err := config.Resolve(file, f.gatewayURL, f.profile, os.Getenv) + if err != nil { + return err + } + + format := Format(f.format) + switch format { + case FormatTable, FormatJSON, FormatYAML: + default: + return fmt.Errorf("unknown --format %q (want table, json, or yaml)", f.format) + } + + clientOpts := []api.Option{api.WithUserAgent("ferro-cli/" + version.Version)} + if f.insecureHTTP { + clientOpts = append(clientOpts, api.WithInsecureHTTP()) + } + client, err := api.New(resolved.URL, resolved.APIKey, clientOpts...) + if err != nil { + return err + } + + d := &runtimeDeps{ + Resolved: resolved, + Printer: &Printer{ + Out: cmd.OutOrStdout(), + Err: cmd.ErrOrStderr(), + Format: format, + // note() (OK/Warn/Fail) always writes to Err, so the colour + // decision follows stderr's own TTY state, not stdout's -- + // `ferro status 2> run.log` must not leak ANSI into the file. + Color: !NoColor(os.Stderr), + ASCII: f.ascii, + }, + Client: client, + } + cmd.SetContext(context.WithValue(cmd.Context(), depsKey{}, d)) + return nil + }, + } + + pf := root.PersistentFlags() + pf.StringVar(&f.configPath, "config", "", "path to the ferro config file (env "+config.EnvConfigPath+")") + pf.StringVar(&f.gatewayURL, "gateway-url", "", "gateway base URL (env FERRO_URL)") + pf.StringVar(&f.profile, "profile", "", "connection profile from the ferro config file") + pf.StringVar(&f.format, "format", "table", "output format: table|json|yaml") + pf.BoolVar(&f.ascii, "ascii", false, "ASCII-only glyphs ([OK] [X] [!] [-])") + pf.BoolVar(&f.insecureHTTP, "insecure-http", false, "allow plaintext HTTP to a non-loopback gateway") + + root.AddCommand( + newVersionCmd(), + newStatusCmd(), + newKeysCmd(), + newLogsCmd(), + newModelsCmd(), + newProvidersCmd(), + newServicesCmd(), + newMCPCmd(), + newPluginsCmd(), + newSessionsCmd(), + newAuditCmd(), + newChatCmd(), + ) + return root +} + +var stdoutIsTTY = func() bool { return isTTY(os.Stdout) } + +// completionVerb is the name cobra gives the generated shell-completion +// command. The hidden helpers a shell actually runs on every Tab +// (__complete, __completeNoDesc) share cobra's own ShellCompRequestCmd as +// their prefix. None of them may load config or resolve a credential. +const completionVerb = "completion" + +func isCompletion(cmd *cobra.Command) bool { + for ; cmd != nil; cmd = cmd.Parent() { + if cmd.Name() == completionVerb || strings.HasPrefix(cmd.Name(), cobra.ShellCompRequestCmd) { + return true + } + } + return false +} + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print ferro version, commit, and build date", + RunE: func(cmd *cobra.Command, _ []string) error { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ferro %s\n", version.String()) + return nil + }, + } +} diff --git a/internal/command/root_test.go b/internal/command/root_test.go new file mode 100644 index 0000000..d9f6f8b --- /dev/null +++ b/internal/command/root_test.go @@ -0,0 +1,225 @@ +package command + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func execute(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + // A developer's real ~/.config/ferro/config.yaml must not leak into a + // test run: point the OS default config dir at an empty one so DefaultPath + // resolves to a file that never exists, leaving --config and FERRO_CONFIG + // free to override it as they always could. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + root := NewRoot() + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetArgs(args) + err = root.Execute() + return out.String(), errb.String(), err +} + +func TestVersionCommand(t *testing.T) { + stdout, _, err := execute(t, "version") + if err != nil { + t.Fatalf("version: %v", err) + } + if !strings.Contains(stdout, "ferro") || !strings.Contains(stdout, "dev") { + t.Fatalf("want binary name + version on stdout, got %q", stdout) + } +} + +func TestUnknownCommandFails(t *testing.T) { + _, _, err := execute(t, "definitely-not-a-verb") + if err == nil { + t.Fatal("unknown command must error (exit 1)") + } +} + +func TestBareCommandRequiresTerminalInput(t *testing.T) { + oldStdin, oldStdout := stdinIsTTY, stdoutIsTTY + stdinIsTTY = func() bool { return false } + stdoutIsTTY = func() bool { return true } + t.Cleanup(func() { stdinIsTTY, stdoutIsTTY = oldStdin, oldStdout }) + _, _, err := execute(t) + if err == nil || !strings.Contains(err.Error(), "input and output") { + t.Fatalf("bare ferro with redirected stdin must refuse the TUI: %v", err) + } +} + +func TestRootDoesNotAcceptAPIKeyInArguments(t *testing.T) { + if flag := NewRoot().PersistentFlags().Lookup("api-key"); flag != nil { + t.Fatal("bearer credentials must not be accepted through process arguments") + } +} + +func TestCompletionSkipsMalformedConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + if err := os.MkdirAll(filepath.Join(dir, "ferro"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ferro", "config.yaml"), []byte(": bad: [\n"), 0o600); err != nil { + t.Fatal(err) + } + root := NewRoot() + root.AddCommand(&cobra.Command{Use: "completion", Run: func(*cobra.Command, []string) {}}) + root.SetArgs([]string{"completion"}) + if err := root.Execute(); err != nil { + t.Fatalf("static completion must not parse connection config: %v", err) + } +} + +// The version command must work with no gateway and no credentials, so it +// cannot be gated behind the connection setup every other verb needs. +func TestVersionNeedsNoGateway(t *testing.T) { + t.Setenv("FERRO_URL", "http://127.0.0.1:1") + if _, _, err := execute(t, "version"); err != nil { + t.Fatalf("version must not touch the network: %v", err) + } +} + +// version opts out of connection setup entirely: it must work with no config +// file, no credential, and no reachable gateway. +func TestVersionSkipsConnectionSetup(t *testing.T) { + t.Setenv("FERRO_URL", "http://127.0.0.1:1") + if _, _, err := execute(t, "version", "--format", "xml"); err != nil { + t.Fatalf("version must bypass flag validation and client setup: %v", err) + } +} + +// Connection setup runs for every verb except version. Probed with a stub +// command so this stays a test of the wiring rather than of whichever verb +// happens to exist. +func withProbe(t *testing.T, args ...string) (*runtimeDeps, error) { + t.Helper() + // See execute's identical guard: without it, a real ferro config on the + // host machine could resolve into these tests' connection setup. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + root := NewRoot() + var got *runtimeDeps + root.AddCommand(&cobra.Command{ + Use: "probe", + RunE: func(cmd *cobra.Command, _ []string) error { + got = deps(cmd) + return nil + }, + }) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"probe"}, args...)) + // Execute first, then read got. Go orders function calls in a return + // statement left to right, but it does not order a variable read against + // a call beside it — and got is assigned inside this one, by RunE. As + // `return got, root.Execute()` the read may legally happen first, handing + // every caller a nil deps and passing anyway. + err := root.Execute() + return got, err +} + +func TestBadFormatRejectedWithGuidance(t *testing.T) { + _, err := withProbe(t, "--format", "xml") + if err == nil || !strings.Contains(err.Error(), "table") { + t.Fatalf("bad --format must name the valid values, got %v", err) + } +} + +func TestDepsBuiltFromFlagsAndEnv(t *testing.T) { + t.Setenv("FERRO_API_KEY", "fgw_from_env") + d, err := withProbe(t, "--gateway-url", "http://gw.example:9000", "--insecure-http") + if err != nil { + t.Fatal(err) + } + if d == nil { + t.Fatal("every non-version verb must receive runtime deps") + } + if d.Resolved.URL != "http://gw.example:9000" { + t.Fatalf("flag must win for URL, got %q", d.Resolved.URL) + } + if d.Resolved.KeySource != "env:FERRO_API_KEY" { + t.Fatalf("key source: %q", d.Resolved.KeySource) + } + if d.Client == nil || d.Printer == nil { + t.Fatal("deps must carry a client and a printer") + } +} + +func writeConfigFile(t *testing.T, dir, name, profile, url string) string { + t.Helper() + path := filepath.Join(dir, name) + body := "current_profile: " + profile + "\nprofiles:\n - name: " + profile + "\n url: " + url + "\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// --config names a config file DefaultPath would never find, so PersistentPreRunE +// must resolve the connection from it rather than from the OS default location. +func TestConfigFlagOverridesDefaultPath(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), "custom.yaml", "from-flag", "https://from-flag:1") + d, err := withProbe(t, "--config", path) + if err != nil { + t.Fatal(err) + } + if d.Resolved.URL != "https://from-flag:1" { + t.Fatalf("--config must load the named file, got %+v", d.Resolved) + } +} + +// FERRO_CONFIG is the env-var override the config package's own doc comment +// promises; without wiring it in, no flag or env var could ever change the +// path config.Load reads, no matter what the comment said. +func TestFerroConfigEnvOverridesDefaultPath(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), "env.yaml", "from-env", "https://from-env:2") + t.Setenv("FERRO_CONFIG", path) + d, err := withProbe(t) + if err != nil { + t.Fatal(err) + } + if d.Resolved.URL != "https://from-env:2" { + t.Fatalf("FERRO_CONFIG must override the default path, got %+v", d.Resolved) + } +} + +func TestConfigFlagBeatsFerroConfigEnv(t *testing.T) { + dir := t.TempDir() + envPath := writeConfigFile(t, dir, "env.yaml", "from-env", "https://from-env:1") + flagPath := writeConfigFile(t, dir, "flag.yaml", "from-flag", "https://from-flag:2") + t.Setenv("FERRO_CONFIG", envPath) + d, err := withProbe(t, "--config", flagPath) + if err != nil { + t.Fatal(err) + } + if d.Resolved.URL != "https://from-flag:2" { + t.Fatalf("--config must win over FERRO_CONFIG, got %+v", d.Resolved) + } +} + +// Regression for the Printer's colour decision being taken from stdout while +// its narrative (OK/Warn/Fail) is always written to stderr: with a terminal +// stdout and a redirected stderr -- `ferro status 2> run.log` -- the old +// stdout-only check would turn colour on and leak ANSI escapes into the file. +// The decision must follow the stream the Printer actually writes to. +func TestPrinterColorFollowsStderrNotStdout(t *testing.T) { + oldIsTTY := isTTY + // stdout is a terminal; stderr is not -- the shape `2> run.log` takes. + isTTY = func(f *os.File) bool { return f == os.Stdout } + t.Cleanup(func() { isTTY = oldIsTTY }) + + d, err := withProbe(t) + if err != nil { + t.Fatal(err) + } + if d.Printer.Color { + t.Fatal("colour must follow stderr's terminal state, not stdout's") + } +} diff --git a/internal/command/services.go b/internal/command/services.go new file mode 100644 index 0000000..5811e62 --- /dev/null +++ b/internal/command/services.go @@ -0,0 +1,328 @@ +package command + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +// auditOutcomes is the gateway's whole vocabulary for ?outcome=. Anything else +// is a 400 there, so it is refused here before a request is spent. +var auditOutcomes = []string{"ok", "denied", "error"} + +// The four services `ferro services` summarises, one row each. Each is named +// once so the row a reachable service produces and the row its absence produces +// cannot end up calling the same service two different things. +const ( + svcMCP = "MCP servers" + svcPlugins = "Plugins" + svcSessions = "Sessions" + svcAudit = "Audit" +) + +// stateUnsupported is the state a service row carries when this gateway does +// not serve that feature at all — a 501 rather than a count of zero, which +// would claim the feature is present and empty. +const stateUnsupported = "unsupported" + +type serviceSummary struct { + Name string `json:"name"` + State string `json:"state"` +} + +// serviceProbe is one row's name and the reading behind it. read is a closure +// rather than a value because the four services answer with four different +// types and only the sentence they reduce to is shared -- and because the +// reading must not be evaluated before its error is checked: audit's row reads +// through a pointer the gateway leaves nil when it refuses the call. +type serviceProbe struct { + name string + read func() (string, error) +} + +// summarize applies the rule every service row obeys: a gateway that does not +// serve the endpoint at all answers 501, which reports the feature absent +// rather than present-and-empty, and is not a failure. Anything else is. +// +// It exists so that rule is written once. Four copies of it meant a change to +// what counts as "absent" -- tolerating a 404 as well, say -- was four edits +// with nothing to catch the one that got missed. +func (p serviceProbe) summarize() (serviceSummary, error) { + state, err := p.read() + switch { + case api.IsNotSupported(err): + return serviceSummary{Name: p.name, State: stateUnsupported}, nil + case err != nil: + return serviceSummary{}, err + } + return serviceSummary{Name: p.name, State: state}, nil +} + +func newServicesCmd() *cobra.Command { + return &cobra.Command{ + Use: "services", + Short: "MCP, plugin, session, and audit service summary", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + probes := []serviceProbe{ + {svcMCP, func() (string, error) { + servers, err := mcpServers(cmd) + if err != nil { + return "", err + } + ready := 0 + for _, s := range servers { + if s.Ready { + ready++ + } + } + return fmt.Sprintf("%d/%d ready", ready, len(servers)), nil + }}, + {svcPlugins, func() (string, error) { + plugins, err := d.Client.Plugins(cmd.Context()) + if err != nil { + return "", err + } + active := 0 + for _, p := range plugins { + if p.Enabled { + active++ + } + } + return fmt.Sprintf("%d active", active), nil + }}, + {svcSessions, func() (string, error) { + sessions, err := d.Client.Sessions(cmd.Context()) + if err != nil { + return "", err + } + return fmt.Sprintf("%d operators", len(sessions)), nil + }}, + {svcAudit, func() (string, error) { + audit, err := d.Client.Audit(cmd.Context(), api.AuditQuery{Limit: 1}) + if err != nil { + return "", err + } + return fmt.Sprintf("%d events", audit.Summary.TotalEntries), nil + }}, + } + + rows := make([]serviceSummary, 0, len(probes)) + for _, p := range probes { + row, err := p.summarize() + if err != nil { + return err + } + rows = append(rows, row) + } + + if d.Printer.Format != FormatTable { + return d.printStructured(rows) + } + cells := make([][]string, 0, len(rows)) + for _, row := range rows { + cells = append(cells, []string{row.Name, row.State}) + } + d.Printer.Table([]string{"SERVICE", colState}, cells) + return nil + }, + } +} + +func newMCPCmd() *cobra.Command { + return &cobra.Command{ + Use: "mcp", + Short: "MCP tool servers and their readiness", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + // /admin/health is the richer source: it alone carries last_error, + // which /readyz withholds because it is unauthenticated and the + // reason can quote a server URL or a token. + servers, err := mcpServers(cmd) + if err != nil { + return err + } + if d.Printer.Format != FormatTable { + return d.printStructured(servers) + } + if len(servers) == 0 { + d.Printer.Warn("this gateway has no MCP servers configured") + } + rows := make([][]string, 0, len(servers)) + for _, s := range servers { + rows = append(rows, []string{s.Name, table.BoolYN(s.Ready), table.BoolYN(s.Required), table.OrDash(s.LastError)}) + } + d.Printer.Table([]string{colName, "READY", "REQUIRED", "LAST ERROR"}, rows) + return nil + }, + } +} + +func mcpServers(cmd *cobra.Command) ([]api.MCPServer, error) { + d := deps(cmd) + ah, err := d.Client.AdminHealth(cmd.Context()) + if err == nil { + return ah.MCPServers, nil + } + d.Printer.Warn("admin health unavailable (%v) — falling back to /readyz, which withholds last_error", err) + ready, _, err := d.Client.Ready(cmd.Context()) + if err != nil { + return nil, err + } + return ready.MCPServers, nil +} + +// pluginRow merges the two listings: /admin/plugins says what this gateway has +// configured, /admin/plugins/catalog says what this build ships — and only the +// catalog knows whether a plugin fails open. +type pluginRow struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + Fails string `json:"fails"` + Summary string `json:"summary,omitempty"` +} + +func mergePlugins(configured []api.PluginInfo, catalog []api.BuiltinPlugin) []pluginRow { + shipped := table.NewPluginCatalog(catalog) + rows := make([]pluginRow, 0, len(configured)) + for _, p := range configured { + policy := shipped.For(p.Name) + rows = append(rows, pluginRow{ + Name: p.Name, Type: p.Type, Enabled: p.Enabled, + Fails: policy.Fails, Summary: policy.Summary, + }) + } + return rows +} + +func newPluginsCmd() *cobra.Command { + return &cobra.Command{ + Use: "plugins", + Short: "Configured plugins, merged with this build's catalog", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + configured, err := d.Client.Plugins(cmd.Context()) + if err != nil { + return err + } + catalog, err := d.Client.PluginCatalog(cmd.Context()) + if err != nil { + // A build without the catalog route still lists what it runs; + // it just cannot say what fails open. + if !api.IsNotSupported(err) { + return err + } + d.Printer.Warn("this gateway serves no plugin catalog — fail-open policy and summaries are unavailable") + catalog = nil + } + rows := mergePlugins(configured, catalog) + if d.Printer.Format != FormatTable { + return d.printStructured(rows) + } + cells := make([][]string, 0, len(rows)) + for _, r := range rows { + cells = append(cells, []string{r.Name, r.Type, table.BoolYN(r.Enabled), r.Fails, table.OrDash(r.Summary)}) + } + d.Printer.Table([]string{colName, "TYPE", "ENABLED", "FAILS", "SUMMARY"}, cells) + return nil + }, + } +} + +func newSessionsCmd() *cobra.Command { + return &cobra.Command{ + Use: "sessions", + Short: "Active dashboard sessions", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + sessions, err := d.Client.Sessions(cmd.Context()) + if err != nil { + if api.IsNotSupported(err) { + return fmt.Errorf("dashboard sessions are not enabled on this gateway, so there are none to list: %w", err) + } + return err + } + if d.Printer.Format != FormatTable { + return d.printStructured(sessions) + } + rows := make([][]string, 0, len(sessions)) + for _, s := range sessions { + rows = append(rows, []string{ + s.Subject, strings.Join(s.Scopes, ","), + table.FmtTime(s.CreatedAt), table.FmtTimePtr(s.LastSeenAt), table.FmtTime(s.ExpiresAt), + }) + } + d.Printer.Table([]string{"SUBJECT", colScopes, "CREATED", "LAST SEEN", colExpires}, rows) + return nil + }, + } +} + +func newAuditCmd() *cobra.Command { + audit := &cobra.Command{ + Use: "audit", + Short: "Read the credential and configuration audit trail", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + q, err := auditQueryFromFlags(cmd) + if err != nil { + return err + } + page, err := d.Client.Audit(cmd.Context(), q) + if err != nil { + return err + } + if d.Printer.Format != FormatTable { + return d.printStructured(page) + } + rows := make([][]string, 0, len(page.Data)) + for _, e := range page.Data { + rows = append(rows, []string{ + table.FmtTime(e.OccurredAt), e.Action, table.OrDash(e.Actor), e.Outcome, table.OrDash(e.TargetID), + }) + } + d.Printer.Table([]string{colTime, "ACTION", "ACTOR", "OUTCOME", "TARGET"}, rows) + return nil + }, + } + f := audit.Flags() + f.String("action", "", "only this action (key.create, session.create, …)") + f.String("actor", "", "only this actor's credential id") + f.String("outcome", "", "ok|denied|error") + f.String("since", "", "duration (15m, 2h) or an RFC3339 timestamp") + f.Int("limit", 50, "rows to return (gateway caps at 200)") + return audit +} + +func auditQueryFromFlags(cmd *cobra.Command) (api.AuditQuery, error) { + f := cmd.Flags() + q := api.AuditQuery{} + q.Action, _ = f.GetString("action") + q.ActorID, _ = f.GetString("actor") + q.Outcome, _ = f.GetString("outcome") + q.Limit, _ = f.GetInt("limit") + + if q.Outcome != "" && !slices.Contains(auditOutcomes, q.Outcome) { + return q, fmt.Errorf("outcome %q is not valid: --outcome must be one of %s, and the gateway answers 400 to anything else", + q.Outcome, strings.Join(auditOutcomes, ", ")) + } + if since, _ := f.GetString("since"); since != "" { + t, err := api.ParseSince(since) + if err != nil { + return q, err + } + q.Since = t + } + return q, nil +} diff --git a/internal/command/services_test.go b/internal/command/services_test.go new file mode 100644 index 0000000..1d13c4d --- /dev/null +++ b/internal/command/services_test.go @@ -0,0 +1,366 @@ +package command + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/fixture" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +func TestModelsTable(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "models") + if err != nil { + t.Fatal(err) + } + for _, col := range []string{"ID", "OWNED BY", "MODE", "CONTEXT", "CAPABILITIES", "STATUS"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + if !strings.Contains(stdout, "claude-sonnet-4-6") || !strings.Contains(stdout, "anthropic") { + t.Fatalf("model rows missing: %q", stdout) + } +} + +func TestModelsJSONIsOneDocument(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "models", "--format", "json") + if err != nil { + t.Fatal(err) + } + var rows []map[string]any + if err := decodeOne(stdout, &rows); err != nil { + t.Fatalf("stdout must be one JSON document: %v (%q)", err, stdout) + } + if len(rows) == 0 { + t.Fatalf("expected models: %q", stdout) + } +} + +func TestProvidersMergesCircuitFromHealth(t *testing.T) { + s := fixture.Default() + s.Degraded = true + srv := gateway(t, s) + + stdout, _, err := run(t, srv, "providers") + if err != nil { + t.Fatal(err) + } + for _, col := range []string{"PROVIDER", "STATUS", "CIRCUIT", "MODELS", "MESSAGE"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + // Circuit comes from /health, status/models/message from /admin/health: + // neither endpoint alone can fill this table. + if !strings.Contains(stdout, "half_open") { + t.Fatalf("circuit must come from /health: %q", stdout) + } + if !strings.Contains(stdout, "circuit half_open after") { + t.Fatalf("message must come from /admin/health: %q", stdout) + } +} + +// Unauthenticated is a smaller answer, not an error: /health alone still names +// every provider and its circuit. +func TestMergeProvidersFallsBackToHealthAlone(t *testing.T) { + h := &api.HealthReport{Providers: []api.ProviderHealth{ + {Name: "anthropic", Status: "available", Circuit: "open", Models: 412}, + }} + rows := table.MergeProviders(h, nil) + if len(rows) != 1 || rows[0].Circuit != "open" || rows[0].Models != 412 { + t.Fatalf("health-only fallback lost data: %+v", rows) + } + if rows[0].Message != "" { + t.Fatalf("there is no message without /admin/health: %+v", rows) + } +} + +func TestMergeProvidersKeepsHealthOnlyRowsAndNumericCounts(t *testing.T) { + h := &api.HealthReport{Providers: []api.ProviderHealth{ + {Name: "openai", Status: "available", Circuit: "closed", Models: 10}, + {Name: "fallback", Status: "available", Circuit: "open", Models: 2}, + }} + ah := &api.AdminHealth{Providers: []api.AdminProviderHealth{{Name: "openai", Status: "healthy", Models: 10}}} + rows := table.MergeProviders(h, ah) + if len(rows) != 2 || rows[1].Name != "fallback" || rows[1].Models != 2 { + t.Fatalf("admin health omissions must not hide /health providers: %+v", rows) + } + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "providers", "--format", "json") + if err != nil { + t.Fatal(err) + } + var doc []map[string]any + if err := decodeOne(stdout, &doc); err != nil { + t.Fatal(err) + } + if len(doc) == 0 { + t.Fatal("expected provider rows") + } + if _, ok := doc[0]["models"].(float64); !ok { + t.Fatalf("models must be a JSON number, got %T (%v)", doc[0]["models"], doc[0]["models"]) + } +} + +func TestPluginsMergeShowsFailsOpenFromCatalog(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "plugins") + if err != nil { + t.Fatal(err) + } + for _, col := range []string{"NAME", "TYPE", "ENABLED", "FAILS", "SUMMARY"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + // fails_open lives only in the catalog, so its presence proves the merge. + if !strings.Contains(stdout, "open") || !strings.Contains(stdout, "closed") { + t.Fatalf("FAILS column must be merged from the catalog: %q", stdout) + } + if !strings.Contains(stdout, "Records one row per request stage") { + t.Fatalf("SUMMARY must be merged from the catalog: %q", stdout) + } +} + +// A plugin the build does not ship (registered out of tree) still lists, with +// the catalog-only columns blank rather than guessed. +func TestMergePluginsKeepsUncatalogedPlugins(t *testing.T) { + rows := mergePlugins( + []api.PluginInfo{{Name: "vendor-thing", Type: "guardrail", Enabled: true}}, + []api.BuiltinPlugin{{Name: "budget", Type: "guardrail", FailsOpen: false}}, + ) + if len(rows) != 1 || rows[0].Name != "vendor-thing" { + t.Fatalf("configured plugins drive the listing: %+v", rows) + } + if rows[0].Fails != "-" || rows[0].Summary != "" { + t.Fatalf("unknown fail policy must not be guessed: %+v", rows) + } +} + +func TestMCPTable(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "mcp") + if err != nil { + t.Fatal(err) + } + for _, col := range []string{"NAME", "READY", "REQUIRED", "LAST ERROR"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + if !strings.Contains(stdout, "filesystem") { + t.Fatalf("mcp rows missing: %q", stdout) + } +} + +func TestServicesAggregateIsReachable(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "services") + if err != nil { + t.Fatal(err) + } + for _, service := range []string{"MCP servers", "Plugins", "Sessions", "Audit"} { + if !strings.Contains(stdout, service) { + t.Fatalf("services output missing %q: %s", service, stdout) + } + } +} + +func TestServicesMarksUnsupportedComponentWithoutDroppingOthers(t *testing.T) { + s := fixture.Default() + s.NoSessions = true + srv := gateway(t, s) + stdout, _, err := run(t, srv, "services", "--format", "json") + if err != nil { + t.Fatal(err) + } + var rows []serviceSummary + if err := decodeOne(stdout, &rows); err != nil { + t.Fatalf("stdout must be one JSON document: %v (%q)", err, stdout) + } + if len(rows) != 4 { + t.Fatalf("one unsupported component must not hide the others: %+v", rows) + } + for _, row := range rows { + if row.Name == "Sessions" && row.State != "unsupported" { + t.Fatalf("unsupported sessions must not be reported as a healthy zero: %+v", rows) + } + } +} + +// /admin/health carries last_error but needs a credential; /readyz carries the +// same servers unauthenticated. A bad key degrades to the smaller answer. +func TestMCPFallsBackToReadyzWithoutCredential(t *testing.T) { + srv := gateway(t, fixture.Default()) + t.Setenv("FERRO_API_KEY", "fgw_wrong") + stdout, stderr, err := execute(t, "mcp", "--gateway-url", srv.URL) + if err != nil { + t.Fatalf("an unauthorized caller still gets the readyz view: %v", err) + } + if !strings.Contains(stdout, "filesystem") { + t.Fatalf("fallback lost the rows: %q", stdout) + } + if !strings.Contains(stderr, "readyz") { + t.Fatalf("the degraded source must be stated on stderr: %q", stderr) + } +} + +func TestSessionsTable(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "sessions") + if err != nil { + t.Fatal(err) + } + for _, col := range []string{"SUBJECT", "SCOPES", "CREATED", "LAST SEEN", "EXPIRES"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + if !strings.Contains(stdout, "ops-laptop") { + t.Fatalf("session rows missing: %q", stdout) + } + // ci-pipeline has never been seen. Only its LAST SEEN cell may be a dash: + // a "-" anywhere in the table would also be satisfied by a zero-time + // 0001-01-01 in that cell, or by every cell collapsing to a dash. + var row string + for _, l := range strings.Split(stdout, "\n") { + if strings.Contains(l, "ci-pipeline") { + row = l + } + } + if row == "" { + t.Fatalf("expected the seeded ci-pipeline session row: %q", stdout) + } + // SUBJECT SCOPES CREATED LAST-SEEN EXPIRES — every cell is whitespace-free, + // so the table still splits into fields for anything piping it. + fields := strings.Fields(row) + if len(fields) != 5 { + t.Fatalf("want 5 whitespace-free columns, got %d in %q", len(fields), row) + } + if fields[3] != "-" { + t.Fatalf("a null last_seen_at must render as -, got %q in %q", fields[3], row) + } + if fields[2] == "-" || fields[4] == "-" { + t.Fatalf("created_at and expires_at are set on this session and must render as dates: %q", row) + } +} + +func TestSessionsDisabledDegradesWithAHint(t *testing.T) { + s := fixture.Default() + s.NoSessions = true + srv := gateway(t, s) + + _, _, err := run(t, srv, "sessions") + if err == nil || !strings.Contains(err.Error(), "sessions") { + t.Fatalf("a 501 must be reported as a disabled feature: %v", err) + } + if strings.Contains(err.Error(), "501") && !strings.Contains(err.Error(), "not enabled") { + t.Fatalf("the hint must lead, not the status code: %v", err) + } +} + +func TestAuditTableAndFilters(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "audit", "--outcome", "ok", "--limit", "10") + if err != nil { + t.Fatal(err) + } + for _, col := range []string{"TIME", "ACTION", "ACTOR", "OUTCOME", "TARGET"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + if !strings.Contains(stdout, "key.create") { + t.Fatalf("audit rows missing: %q", stdout) + } +} + +// The gateway answers 400 for an outcome outside its vocabulary. Refusing it +// here spends no request and gives the same vocabulary back. +func TestAuditRejectsBadOutcomeClientSide(t *testing.T) { + _, _, err := execute(t, "audit", "--outcome", "bogus", "--gateway-url", "http://127.0.0.1:1") + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("--outcome must be validated client-side against ok|denied|error: %v", err) + } +} + +// A dash is a rendering of absence, not a value the gateway sent. Every field +// of table.ProviderRow must therefore stay literal in --format json, and be +// dashed only where the table is drawn. circuit briefly broke that rule on its own, +// so one document carried "-" for an absent circuit beside an empty string +// for an absent status — two conventions in one object. +func TestProvidersJSONKeepsAbsenceLiteral(t *testing.T) { + h := &api.HealthReport{Providers: []api.ProviderHealth{ + {Name: "anthropic", Status: "available", Models: 412}, // no circuit reported + }} + rows := table.MergeProviders(h, nil) + if len(rows) != 1 { + t.Fatalf("want one row, got %d", len(rows)) + } + if rows[0].Circuit != "" { + t.Fatalf("an absent circuit stays empty in the structured row, got %q", rows[0].Circuit) + } + + body, err := json.Marshal(rows[0]) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), `"-"`) { + t.Fatalf("no field may serialize a table dash: %s", body) + } + if !strings.Contains(string(body), `"circuit":""`) { + t.Fatalf("circuit must serialize as the empty string it is: %s", body) + } +} + +// "No admin credential accepted" is a specific claim. A gateway that times out +// or 500s on /admin/health costs the same two columns, but says so — telling +// the operator to check a credential that was never the problem is a wrong +// answer, and the one they would act on first. +func TestProvidersNamesWhyAdminHealthWasUnavailable(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want string + absent string + }{ + {"refused credential", http.StatusUnauthorized, `{"error":{"message":"nope"}}`, + "no admin credential accepted", "admin health unavailable"}, + {"broken gateway", http.StatusInternalServerError, `{"error":{"message":"boom"}}`, + "admin health unavailable", "no admin credential accepted"}, + } { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"closed","models":3}]}`)) + }) + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + _, stderr, err := run(t, srv, "providers") + if err != nil { + t.Fatalf("an unusable admin probe costs columns, not the command: %v", err) + } + if !strings.Contains(stderr, tc.want) { + t.Fatalf("want %q in the warning, got %q", tc.want, stderr) + } + if strings.Contains(stderr, tc.absent) { + t.Fatalf("must not claim %q for a %d, got %q", tc.absent, tc.status, stderr) + } + }) + } +} diff --git a/internal/command/status.go b/internal/command/status.go new file mode 100644 index 0000000..46d6755 --- /dev/null +++ b/internal/command/status.go @@ -0,0 +1,73 @@ +package command + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/table" +) + +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Health of a running gateway (exit 1 only when unreachable)", + Long: "status prints one row describing the gateway this profile points at.\n\n" + + "The exit code is the contract: 1 means the gateway could not be reached\n" + + "at all, so `ferro status || alert` pages on an outage and stays quiet for\n" + + "a degraded-but-serving gateway, which exits 0 with its state printed.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d := deps(cmd) + // Status returns a report even when it fails, naming the URL and the + // state it reached — printing it is what makes state:"unreachable" + // visible to a `--format json` consumer instead of an empty stdout. + report, statusErr := d.Client.Status(cmd.Context()) + + if d.Printer.Format != FormatTable { + if err := d.printStructured(report); err != nil { + return err + } + } else { + mcp := "-" + if report.MCP != nil { + mcp = fmt.Sprintf("%d/%d", report.MCP.Ready, report.MCP.Total) + } + // A not_ready gateway reports no targets array at all. Rendering + // "0/0" there would read as "none configured" when the truth is + // "yours are dead" — the warnings below carry the real reason. + targets := "-" + if report.Targets != nil { + targets = fmt.Sprintf("%d/%d", report.Targets.Routable, report.Targets.Total) + } + // statusErr is non-nil only when the gateway was never reached at + // all (Status returns early on a failed /health). The elapsed + // time up to that failure is not the gateway's RTT, so printing + // it as one would read as an instant reply on the exact row that + // reports an outage. + latency := "-" + if statusErr == nil { + latency = fmt.Sprintf("%dms", report.LatencyMs) + } + d.Printer.Table( + []string{colState, colURL, "LATENCY", "TARGETS", "PROVIDERS", colModels, "MCP", "AUTH"}, + [][]string{{ + report.State, + report.URL, + latency, + targets, + table.CountOrDash(report.Providers), + table.CountOrDash(report.Models), + mcp, + table.OrDash(report.Auth), + }}, + ) + } + for _, w := range report.Warnings { + d.Printer.Warn("%s", w) + } + // Degraded stays exit 0; only an unreachable gateway is exit 1. + return statusErr + }, + } +} diff --git a/internal/command/status_test.go b/internal/command/status_test.go new file mode 100644 index 0000000..0a88f36 --- /dev/null +++ b/internal/command/status_test.go @@ -0,0 +1,167 @@ +package command + +import ( + "encoding/json" + "errors" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/ferro-labs/gateway-cli/internal/fixture" +) + +// gateway starts the one in-repo definition of the wire contract. Every command +// test talks to it instead of a hand-rolled stub, so a fixture that drifts from +// the gateway fails these tests rather than quietly blessing the drift. +func gateway(t *testing.T, s fixture.State) *httptest.Server { + t.Helper() + srv := httptest.NewServer(fixture.Handler(s)) + t.Cleanup(srv.Close) + return srv +} + +// run executes a verb against srv with the fixture's credential. Config +// isolation is execute's (root_test.go) and reaches every test through here -- +// a test that builds its own root instead has to repeat it, as chat_test.go does. +func run(t *testing.T, srv *httptest.Server, args ...string) (stdout, stderr string, err error) { + t.Helper() + t.Setenv("FERRO_API_KEY", "fgw_test") + return execute(t, append(args, "--gateway-url", srv.URL)...) +} + +// decodeOne decodes stdout into v and fails if a second document follows: the +// stdout-is-the-machine-channel rule means exactly one payload, never a payload +// with narrative wrapped around it. +func decodeOne(stdout string, v any) error { + dec := json.NewDecoder(strings.NewReader(stdout)) + if err := dec.Decode(v); err != nil { + return err + } + var extra any + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + if err != nil { + return err + } + return errMoreThanOneDoc + } + return nil +} + +var errMoreThanOneDoc = errors.New("stdout carried more than one JSON document") + +// oneJSONDoc asserts stdout is exactly one JSON document — the whole point of +// keeping narrative on stderr. +func oneJSONDoc(t *testing.T, stdout string) map[string]any { + t.Helper() + var doc map[string]any + if err := decodeOne(stdout, &doc); err != nil { + t.Fatalf("stdout must be one JSON document, got %q: %v", stdout, err) + } + return doc +} + +func TestStatusTableExitAndColumns(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "status") + if err != nil { + t.Fatalf("healthy status must exit 0: %v", err) + } + for _, col := range []string{"STATE", "URL", "LATENCY", "TARGETS", "PROVIDERS", "MODELS", "MCP", "AUTH"} { + if !strings.Contains(stdout, col) { + t.Fatalf("missing column %s in %q", col, stdout) + } + } + if !strings.Contains(stdout, "connected") { + t.Fatalf("healthy gateway must report connected: %q", stdout) + } +} + +func TestStatusJSONIsPureAndStable(t *testing.T) { + srv := gateway(t, fixture.Default()) + stdout, _, err := run(t, srv, "status", "--format", "json") + if err != nil { + t.Fatal(err) + } + doc := oneJSONDoc(t, stdout) + for _, k := range []string{"state", "url", "latency_ms", "targets", "circuits"} { + if _, ok := doc[k]; !ok { + t.Fatalf("frozen status contract missing %q: %v", k, doc) + } + } +} + +// Degraded is not a failure: `ferro status || alert` must mean "unreachable", +// so a half-open circuit still exits 0 with the degraded state on stdout. +func TestStatusDegradedExits0AndWarnsOnStderr(t *testing.T) { + s := fixture.Default() + s.Degraded = true + srv := gateway(t, s) + + stdout, stderr, err := run(t, srv, "status") + if err != nil { + t.Fatalf("degraded must still exit 0: %v", err) + } + if !strings.Contains(stdout, "degraded") { + t.Fatalf("degraded state must be printed: %q", stdout) + } + if !strings.Contains(stderr, "half-open") { + t.Fatalf("warnings belong on stderr, got %q", stderr) + } + if strings.Contains(stdout, "half-open") { + t.Fatalf("narrative leaked into stdout: %q", stdout) + } +} + +func TestStatusUnreachableExits1ButStillReportsState(t *testing.T) { + stdout, _, err := execute(t, "status", "--gateway-url", "http://127.0.0.1:1", "--format", "json") + if err == nil { + t.Fatal("unreachable must exit 1") + } + doc := oneJSONDoc(t, stdout) + if doc["state"] != "unreachable" { + t.Fatalf("the report must still name the state that was reached: %v", doc) + } +} + +// Missing is not zero (README's "Contracts worth relying on"): an unreachable +// gateway never answered, so the elapsed time until the dial failed is not a +// latency measurement and must not print as one. +func TestStatusUnreachableRendersDashLatencyNot0ms(t *testing.T) { + stdout, _, err := execute(t, "status", "--gateway-url", "http://127.0.0.1:1") + if err == nil { + t.Fatal("unreachable must exit 1") + } + if strings.Contains(stdout, "0ms") { + t.Fatalf("an unreachable gateway must never print a latency, got %q", stdout) + } + lines := strings.Split(strings.TrimRight(stdout, "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("want header + underline + 1 row, got %d lines:\n%s", len(lines), stdout) + } + headers := strings.Fields(lines[0]) + row := strings.Fields(lines[2]) + for i, h := range headers { + if h == "LATENCY" { + if row[i] != "-" { + t.Fatalf("LATENCY must render \"-\" when the gateway was never reached, got %q in %q", row[i], lines[2]) + } + return + } + } + t.Fatalf("no LATENCY column in header: %q", lines[0]) +} + +// A gateway reachable without a usable credential is still reachable: the +// report degrades to what /health and /readyz say rather than failing. +func TestStatusWithoutCredentialDegradesNotFails(t *testing.T) { + srv := gateway(t, fixture.Default()) + t.Setenv("FERRO_API_KEY", "fgw_wrong") + stdout, _, err := execute(t, "status", "--gateway-url", srv.URL) + if err != nil { + t.Fatalf("a bad credential must not make status exit 1: %v", err) + } + if !strings.Contains(stdout, "unauthorized") { + t.Fatalf("auth column must report unauthorized: %q", stdout) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..bfa8d59 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,198 @@ +// Package config resolves where ferro connects and with which credential. +// Precedence — path: --config > FERRO_CONFIG > DefaultPath() +// +// URL: --gateway-url > FERRO_URL > profile.url > http://localhost:8080 +// key: FERRO_API_KEY > profile.api_key_env deref > MASTER_KEY (loopback URL only) > "" +// +// The MASTER_KEY step is restricted because, unlike the two above it, nobody +// named it for ferro: it is the gateway server's own variable and is simply +// present in the shell on a gateway host. Honouring it for any URL would let +// `ferro --gateway-url https://elsewhere status`, typed in that shell, hand the +// gateway's root credential to a stranger. +// +// ferro stores no gateway data; this file holds connection profiles only. +package config + +import ( + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "go.yaml.in/yaml/v3" +) + +// DefaultURL is where ferro looks when nothing else says otherwise. +const DefaultURL = "http://localhost:8080" + +// The environment variables Resolve reads, and the KeySource label it reports +// for each source. Building the labels from the variable names is what keeps +// the two spellings from drifting: a doctor screen naming a variable nothing +// reads is worse than no label at all. +const ( + envURL = "FERRO_URL" + envFerroKey = "FERRO_API_KEY" + envMasterKey = "MASTER_KEY" + + keySourceFerroEnv = "env:" + envFerroKey + keySourceMasterEnv = "env:" + envMasterKey + keySourceProfile = "profile:" + + // keySourceMasterSkipped is reported instead of a credential when + // MASTER_KEY is set but the gateway is remote. It rides on KeySource + // rather than a new field so any display that already shows where the + // credential came from also explains why there is none. + keySourceMasterSkipped = keySourceMasterEnv + " skipped (gateway is not loopback)" +) + +// Profile is one named connection. It holds no credential: api_key_env names +// an environment variable, so the config file never carries a secret. +type Profile struct { + Name string `yaml:"name"` + URL string `yaml:"url"` + APIKeyEnv string `yaml:"api_key_env,omitempty"` +} + +// File is the on-disk ferro config: connection profiles and nothing else. +type File struct { + CurrentProfile string `yaml:"current_profile,omitempty"` + Profiles []Profile `yaml:"profiles,omitempty"` +} + +// Resolved is what the rest of ferro consumes. KeySource is for doctor-style +// display ("env:FERRO_API_KEY", "profile:PROD_KEY", "env:MASTER_KEY"), and is +// the only place a refused MASTER_KEY is visible -- APIKey is then empty and +// the gateway's 401 is all ferro would otherwise have to go on. +type Resolved struct { + ProfileName string + URL string + APIKey string + KeySource string +} + +// EnvConfigPath is the environment variable that overrides DefaultPath. +// --config on the CLI outranks it in turn (see the precedence note above). A +// config path names a filesystem location, never a credential, so unlike +// FERRO_API_KEY it carries nothing that must not appear in a shell history. +const EnvConfigPath = "FERRO_CONFIG" + +// DefaultPath is the config location, empty when the OS reports no config dir. +func DefaultPath() string { + dir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(dir, "ferro", "config.yaml") +} + +// Load reads the config file. A missing file is not an error -- ferro works +// with flags and environment alone. +func Load(path string) (File, error) { + var f File + if path == "" { + return f, nil + } + // Normalise before use so the file that is read and the file a parse error + // names are spelled the same way, whatever lexical noise (`a/../b`, a + // trailing slash) the flag or FERRO_CONFIG carried. + clean := filepath.Clean(path) + b, err := os.ReadFile(clean) + if os.IsNotExist(err) { + return f, nil + } + if err != nil { + return f, err + } + if err := yaml.Unmarshal(b, &f); err != nil { + return f, fmt.Errorf("parse %s: %w", clean, err) + } + return f, nil +} + +// Resolve applies the precedence documented on this package to produce the +// connection ferro will use. +func Resolve(f File, flagURL, flagProfile string, getenv func(string) string) (Resolved, error) { + r := Resolved{URL: DefaultURL} + + name := flagProfile + if name == "" { + name = f.CurrentProfile + } + var prof *Profile + if name != "" { + for i := range f.Profiles { + if f.Profiles[i].Name == name { + prof = &f.Profiles[i] + break + } + } + if prof == nil { + names := make([]string, 0, len(f.Profiles)) + for _, p := range f.Profiles { + names = append(names, p.Name) + } + sort.Strings(names) + if len(names) == 0 { + return r, fmt.Errorf("unknown profile %q (no profiles are defined)", name) + } + return r, fmt.Errorf("unknown profile %q (valid: %s)", name, strings.Join(names, ", ")) + } + } + if prof != nil { + r.ProfileName = prof.Name + if prof.URL != "" { + r.URL = prof.URL + } + } + if v := getenv(envURL); v != "" { + r.URL = v + } + if flagURL != "" { + r.URL = flagURL + } + + switch { + case getenv(envFerroKey) != "": + r.APIKey, r.KeySource = getenv(envFerroKey), keySourceFerroEnv + case prof != nil && prof.APIKeyEnv != "" && getenv(prof.APIKeyEnv) != "": + r.APIKey, r.KeySource = getenv(prof.APIKeyEnv), keySourceProfile+prof.APIKeyEnv + case getenv(envMasterKey) != "": + // Loopback only -- see the package comment. Falling through to no + // credential rather than erroring keeps the failure the gateway's to + // report: it answers 401 and `ferro status` prints auth: unauthorized, + // which is the same story an expired key tells. + if isLoopbackURL(r.URL) { + r.APIKey, r.KeySource = getenv(envMasterKey), keySourceMasterEnv + } else { + r.KeySource = keySourceMasterSkipped + } + } + return r, nil +} + +// isLoopbackURL reports whether raw names a host on this machine. +// +// internal/api holds the other copy of this test (isLoopbackHost, which gates +// plaintext HTTP). They are duplicated rather than shared because config sits +// below the client and must not import it to answer a question about a string, +// and exporting one of them would put an internal predicate in a package API +// for a single caller. Keep the two in step: if they disagree about what +// loopback means, ferro would ship MASTER_KEY to a host it also refuses to +// speak plaintext to, or withhold it from one it will. +func isLoopbackURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + // A trailing dot is the same name in DNS ("localhost." resolves like + // "localhost"), so it must not be a way past this check. + host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..349ff3f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,138 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func env(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func TestResolvePrecedence(t *testing.T) { + file := File{ + CurrentProfile: "prod", + Profiles: []Profile{ + {Name: "prod", URL: "https://gw.example.com", APIKeyEnv: "PROD_KEY"}, + {Name: "local", URL: "http://localhost:9999"}, + }, + } + cases := []struct { + name string + flagURL string + flagProfile string + envv map[string]string + wantURL, wantKey string + wantSource string + }{ + {name: "URL flag and key environment beat profile", flagURL: "http://flag:1", + envv: map[string]string{"FERRO_URL": "http://env:1", "FERRO_API_KEY": "fgw_env"}, + wantURL: "http://flag:1", wantKey: "fgw_env", wantSource: "env:FERRO_API_KEY"}, + {name: "env beats profile", + envv: map[string]string{"FERRO_URL": "http://env:1", "FERRO_API_KEY": "fgw_env", "PROD_KEY": "fgw_prod"}, + wantURL: "http://env:1", wantKey: "fgw_env", wantSource: "env:FERRO_API_KEY"}, + {name: "profile url + key env deref", + envv: map[string]string{"PROD_KEY": "fgw_prod"}, + wantURL: "https://gw.example.com", wantKey: "fgw_prod", wantSource: "profile:PROD_KEY"}, + {name: "MASTER_KEY is the last key fallback", + flagProfile: "local", envv: map[string]string{"MASTER_KEY": "fgw_master"}, + wantURL: "http://localhost:9999", wantKey: "fgw_master", wantSource: "env:MASTER_KEY"}, + // The whole point of the restriction: MASTER_KEY is in the shell on a + // gateway host, and this invocation is aimed at somebody else's host. + {name: "MASTER_KEY is withheld from a remote gateway", flagURL: "https://someone-else.example.com", + flagProfile: "local", envv: map[string]string{"MASTER_KEY": "fgw_master"}, + wantURL: "https://someone-else.example.com", wantKey: "", + wantSource: "env:MASTER_KEY skipped (gateway is not loopback)"}, + // A named credential is named for this tool, so the restriction must + // not spread to it: the two upper steps still reach any host. + {name: "a named credential still reaches a remote gateway", flagURL: "https://someone-else.example.com", + envv: map[string]string{"FERRO_API_KEY": "fgw_env", "MASTER_KEY": "fgw_master"}, + wantURL: "https://someone-else.example.com", wantKey: "fgw_env", wantSource: "env:FERRO_API_KEY"}, + {name: "defaults", flagProfile: "none-selected", envv: map[string]string{}, + flagURL: "", wantURL: "http://localhost:8080", wantKey: "", wantSource: ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := file + if tc.flagProfile == "none-selected" { + f = File{} // no profiles at all + tc.flagProfile = "" + } + r, err := Resolve(f, tc.flagURL, tc.flagProfile, env(tc.envv)) + if err != nil { + t.Fatal(err) + } + if r.URL != tc.wantURL || r.APIKey != tc.wantKey || r.KeySource != tc.wantSource { + t.Fatalf("got url=%q key=%q src=%q", r.URL, r.APIKey, r.KeySource) + } + }) + } +} + +func TestResolveUnknownProfileErrors(t *testing.T) { + _, err := Resolve(File{Profiles: []Profile{{Name: "a"}}}, "", "nope", env(nil)) + if err == nil { + t.Fatal("unknown --profile must error and list valid names") + } +} + +func TestResolveStaleCurrentProfileErrors(t *testing.T) { + _, err := Resolve(File{CurrentProfile: "removed", Profiles: []Profile{{Name: "prod"}}}, "", "", env(nil)) + if err == nil || !strings.Contains(err.Error(), `unknown profile "removed"`) { + t.Fatalf("a stale current_profile must not silently connect to localhost: %v", err) + } + _, err = Resolve(File{CurrentProfile: "removed"}, "", "", env(nil)) + if err == nil || !strings.Contains(err.Error(), "no profiles are defined") { + t.Fatalf("an empty profile list needs a clear error: %v", err) + } +} + +func TestLoadMissingFileIsZero(t *testing.T) { + f, err := Load("/definitely/not/here.yaml") + if err != nil || len(f.Profiles) != 0 { + t.Fatalf("missing file must be zero value, got %+v, %v", f, err) + } +} + +func TestLoadMalformedYAMLNamesTheFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("profiles: [\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), path) { + t.Fatalf("malformed YAML must fail and name its file: %v", err) + } +} + +// TestIsLoopbackURL pins the semantics against internal/api's isLoopbackHost, +// the other copy of this test. A case that changes here without changing there +// means MASTER_KEY and the plaintext-HTTP gate have started disagreeing about +// what counts as this machine. +func TestIsLoopbackURL(t *testing.T) { + loopback := []string{ + "http://localhost:8080", "http://LOCALHOST", "http://localhost.:8080", + "http://app.localhost:3000", "http://127.0.0.1:8080", "http://127.9.9.9", + "http://[::1]:8080", "https://localhost", + } + remote := []string{ + "https://gw.example.com", "http://localhost.evil.com", "http://notlocalhost", + "http://0.0.0.0:8080", "http://10.0.0.1", "http://[fe80::1]", "", + // A scheme-less string parses as scheme "localhost" with an empty + // host, so it must not be read as the loopback name it resembles. + "localhost:8080", + "://not a url", + } + for _, u := range loopback { + if !isLoopbackURL(u) { + t.Errorf("%q is this machine; MASTER_KEY must be usable", u) + } + } + for _, u := range remote { + if isLoopbackURL(u) { + t.Errorf("%q is not this machine; MASTER_KEY must not be sent there", u) + } + } +} diff --git a/internal/fixture/handler.go b/internal/fixture/handler.go new file mode 100644 index 0000000..e6cbcfb --- /dev/null +++ b/internal/fixture/handler.go @@ -0,0 +1,890 @@ +package fixture + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "math" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Handler serves the whole v0.1.0 surface for the world State describes. The +// key store is stateful for the handler's lifetime: create/rotate/revoke/delete +// mutate it and later GETs reflect the change. +func Handler(s State) http.Handler { + if s.AcceptKey == "" { + s.AcceptKey = defaultKey + } + ks := newKeyStore() + tr := newTracer() + mux := http.NewServeMux() + registerHealth(mux, s) + registerKeys(mux, ks) + registerChat(mux, s, tr) + registerLogs(mux, s, tr) + registerAdmin(mux, s) + registerModels(mux) + + // Anything unrouted answers in the documented envelope rather than the + // mux's plain-text 404, so the CLI's decoder is exercised even here. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusNotFound, kindNotFound, "no route for "+r.Method+" "+r.URL.Path) + }) + + return withRequestID(withAuth(s, mux)) +} + +// registerHealth wires the liveness and readiness surface. /livez, /health and +// /readyz are unauthenticated on the real router and so are they here; +// /admin/health is the bearer-guarded probe that also reports component state. +func registerHealth(mux *http.ServeMux, s State) { + mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{fieldStatus: statusOK}) + }) + + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + // Verified against a live gateway: no_providers <=> providers is EMPTY, + // and 503 <=> no_providers. Nothing else makes /health non-200 — a + // half-open circuit with two live providers still answers 200 "ok". + // + // Degraded and provider-less are therefore independent axes, and an + // earlier version of this handler conflated them: it served 503 + // "no_providers" alongside two populated providers, so ferro reported + // "providers=2" next to the warning "health: no_providers" — a pairing + // live traffic can never show. Degraded now means what it means on the + // real server: a 200 whose circuit is half-open. + if s.NoProviders { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + fieldStatus: statusNoProviders, + fieldProviders: []map[string]any{}, + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + fieldStatus: statusOK, + fieldProviders: []map[string]any{ + {fieldName: providerAnthropic, fieldStatus: statusAvailable, fieldCircuit: circuitOf(s), fieldModels: 412}, + {fieldName: providerOpenAI, fieldStatus: statusAvailable, fieldCircuit: circuitClosed, fieldModels: 1104}, + }, + }) + }) + + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + // Every not-ready path on the real server goes through one writer that + // emits ONLY {status, reason} — no providers, no targets, no + // mcp_servers. Serving the arrays on a 503 taught callers to read + // counts that will not be there, which is how ferro status came to + // report "0/0 targets" for a gateway whose targets were merely dead. + if s.NoProviders { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + fieldStatus: statusNotReady, + "reason": "no routable targets", + }) + return + } + // A gateway with some routable targets is still ready — only "none + // routable" is 503 — so the degraded world reports 200 with one target + // marked unroutable. + out := map[string]any{ + fieldStatus: statusReady, + fieldProviders: []map[string]any{ + {fieldName: providerAnthropic, fieldCircuit: circuitOf(s)}, + {fieldName: providerOpenAI, fieldCircuit: circuitClosed}, + }, + "targets": []map[string]any{ + {fieldName: providerAnthropic, "routable": !s.Degraded}, + {fieldName: providerOpenAI, "routable": true}, + }, + } + if !s.NoMCP { + out["mcp_servers"] = []map[string]any{ + {fieldName: "filesystem", fieldReady: true, "required": false}, + } + } + writeJSON(w, http.StatusOK, out) + }) + + mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) { + anthropic := map[string]any{fieldName: providerAnthropic, fieldStatus: statusAvailable, fieldModels: 412} + status := statusHealthy + if s.NoProviders { + status = statusNoProviders // the default state of a credential-free gateway + } + if s.Degraded { + status = statusDegraded + anthropic[fieldMessage] = "circuit half_open after 3 consecutive failures" + } + // Component names and vocabulary verified against a live gateway: + // Title Case with spaces (not snake_case), status healthy/disabled/ + // unavailable (not "ok"), and five entries -- API and Audit log were + // both missing here. A missing log store reports "disabled", not an + // absent row. + logStore := statusHealthy + if s.NoLogStore { + logStore = statusDisabled + } + components := []map[string]any{ + {fieldName: "API", fieldStatus: statusHealthy}, + {fieldName: "Key store", fieldStatus: statusHealthy}, + {fieldName: "Config store", fieldStatus: statusHealthy}, + {fieldName: "Request logs", fieldStatus: logStore}, + {fieldName: "Audit log", fieldStatus: statusHealthy}, + } + out := map[string]any{ + // Always 200: this is the auth/scope probe. + fieldStatus: status, + fieldProviders: []map[string]any{ + anthropic, + {fieldName: providerOpenAI, fieldStatus: statusAvailable, fieldModels: 1104}, + }, + "components": components, + fieldScopes: []string{scopeAdmin}, + } + if !s.NoMCP { + out["mcp_servers"] = []map[string]any{ + {fieldName: "filesystem", fieldReady: true, "required": false}, + } + } + writeJSON(w, http.StatusOK, out) + }) + +} + +// registerLogs wires the request-log surface. Both routes answer 501 when no +// store is configured, which is how the CLI feature-detects it. +func registerLogs(mux *http.ServeMux, s State, tr *tracer) { + mux.HandleFunc("GET /admin/logs", func(w http.ResponseWriter, r *http.Request) { + if s.NoLogStore { + writeErr(w, http.StatusNotImplemented, kindServer, "no request log store configured") + return + } + q := r.URL.Query() + since, ok := parseSince(w, q) + if !ok { + return + } + rows := filterLogs(logRows(tr), q, since) + total := len(rows) + rows, ok = page(w, rows, q) + if !ok { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + fieldData: rows, + fieldSummary: map[string]any{ + fieldTotalEntries: total, + "returned_entries": len(rows), + }, + "filters": map[string]any{ + fieldLimit: q.Get(fieldLimit), + fieldOffset: q.Get(fieldOffset), + fieldSince: q.Get(fieldSince), + fieldModel: q.Get(fieldModel), + fieldProvider: q.Get(fieldProvider), + fieldStage: q.Get(fieldStage), + fieldAPIKeyID: q.Get(fieldAPIKeyID), + }, + }) + }) + + // GET /admin/logs/stats aggregates the same seeded rows /admin/logs lists, + // filtered the way the real handler's Stats query filters them + // (internal/admin/handlers/logs.go's logsStats + requestlog.SQLWriter.Stats): + // stage, model and provider are exact-match-or-absent and since keeps rows + // at or after the cursor. There is deliberately no default-to-terminal- + // stages narrowing here (unlike /admin/logs) and no api_key_id filter — the + // real endpoint applies neither, and internal/api.LogStats never sends one. + // An earlier version of this handler ignored every parameter and served one + // fixed aggregate regardless of the query, so a CLI bug that dropped a + // filter before it reached the wire was invisible against this fixture. + mux.HandleFunc("GET /admin/logs/stats", func(w http.ResponseWriter, r *http.Request) { + if s.NoLogStore { + writeErr(w, http.StatusNotImplemented, kindServer, "no request log store configured") + return + } + q := r.URL.Query() + since, ok := parseSince(w, q) + if !ok { + return + } + writeJSON(w, http.StatusOK, logStatsResponse(filterStats(logRows(tr), q, since))) + }) + +} + +// registerAdmin wires the remaining admin control-plane reads: sessions, the +// audit trail, the plugin surfaces and the provider inventory. +func registerAdmin(mux *http.ServeMux, s State) { + mux.HandleFunc("GET /admin/sessions", func(w http.ResponseWriter, _ *http.Request) { + if s.NoSessions { + writeErr(w, http.StatusNotImplemented, kindServer, "dashboard sessions are disabled") + return + } + now := time.Now().UTC() + writeJSON(w, http.StatusOK, map[string]any{ + fieldData: []map[string]any{ + { + "id": "sess_01", "credential_id": keyIDOps, "subject": actorOps, + fieldScopes: []string{scopeAdmin}, "created_at": ts(now.Add(-3 * time.Hour)), + "last_seen_at": ts(now.Add(-4 * time.Minute)), "expires_at": ts(now.Add(21 * time.Hour)), + }, + { + "id": "sess_02", "credential_id": keyIDCI, "subject": "ci-pipeline", + fieldScopes: []string{scopeReadOnly}, "created_at": ts(now.Add(-40 * time.Minute)), + "last_seen_at": nil, "expires_at": ts(now.Add(23 * time.Hour)), + }, + }, + }) + }) + + mux.HandleFunc("GET /admin/audit", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if out := q.Get(fieldOutcome); out != "" && + out != outcomeOK && out != outcomeDenied && out != outcomeError { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, + // Worded exactly as internal/admin/handlers/audit_read.go does. + "invalid outcome: must be one of ok, denied, error") + return + } + // Shared with /admin/logs and /admin/logs/stats: the real handler's + // since is the same parseSince on every route that accepts it + // (internal/admin/handlers/queryparams.go), so all three reject a + // malformed value with the identical 400 message. + since, ok := parseSince(w, q) + if !ok { + return + } + rows := filterAudit(auditRows(), q, since) + total := len(rows) + rows, ok = page(w, rows, q) + if !ok { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + fieldData: rows, + fieldSummary: map[string]any{ + fieldTotalEntries: total, + "returned_entries": len(rows), + }, + }) + }) + + mux.HandleFunc("GET /admin/plugins", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []map[string]any{ // bare array + {fieldName: "request-logger", fieldType: typeLogging, fieldEnabled: true}, + {fieldName: "budget", fieldType: typeGuardrail, fieldEnabled: true}, + {fieldName: "rate-limit", fieldType: typeRateLimit, fieldEnabled: true}, + {fieldName: "word-filter", fieldType: typeGuardrail, fieldEnabled: false}, + }) + }) + + mux.HandleFunc("GET /admin/plugins/catalog", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + fieldData: []map[string]any{ + { + fieldName: "request-logger", fieldType: typeLogging, + fieldSummary: "Records one row per request stage", + fieldSettings: []string{"persist", "log_bodies"}, fieldFailsOpen: true, + }, + { + fieldName: "budget", fieldType: typeGuardrail, + fieldSummary: "Refuses requests once a spend ceiling is reached", + fieldSettings: []string{"limit_usd", "window", "store_id"}, fieldFailsOpen: false, + }, + { + fieldName: "rate-limit", fieldType: typeRateLimit, + fieldSummary: "Per-credential request rate ceiling", + fieldSettings: []string{"requests_per_second", "burst"}, fieldFailsOpen: false, + }, + { + fieldName: "word-filter", fieldType: typeGuardrail, + fieldSummary: "Rejects prompts containing blocked words", + fieldSettings: []string{"blocked_words"}, fieldFailsOpen: false, + }, + }, + }) + }) + + mux.HandleFunc("GET /admin/providers", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []map[string]any{ // bare array + {fieldName: providerAnthropic, fieldModels: modelsOwnedBy(providerAnthropic)}, + {fieldName: providerOpenAI, fieldModels: modelsOwnedBy(providerOpenAI)}, + }) + }) +} + +// registerModels wires the /v1 discovery surface; the /v1 chat surface is +// registerChat's (stream.go). +func registerModels(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + fieldObject: "list", + fieldData: modelCatalog(), + }) + }) +} + +// withAuth enforces the bearer on /admin/ AND /v1/, matching the real router. +// +// /health, /livez and /readyz are unauthenticated there and so are they here. +// Everything else is not: the gateway puts /v1/* behind the same middleware as +// /admin/* (only ALLOW_UNAUTHENTICATED_PROXY, a dev-only switch, lifts it). A +// fake that let /v1/models and /v1/chat/completions through unauthenticated +// would let the CLI's model discovery and playground be built without sending +// a credential, and the omission would surface as a 401 on first contact with +// a real gateway — precisely the drift this package must not introduce. +func withAuth(s State, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + guarded := strings.HasPrefix(r.URL.Path, "/admin/") || + strings.HasPrefix(r.URL.Path, "/v1/") + if s.RequireAuth && guarded && + r.Header.Get("Authorization") != "Bearer "+s.AcceptKey { + writeErr(w, http.StatusUnauthorized, kindAuthentication, + "missing or invalid bearer token") + return + } + next.ServeHTTP(w, r) + }) +} + +// withRequestID stamps X-Request-ID on every response, as the gateway does. It +// is the id a log row carries as trace_id. +func withRequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Request-ID", newTraceID()) + next.ServeHTTP(w, r) + }) +} + +// tracer remembers the last chat stream so /admin/logs can answer with a row +// whose trace_id equals that stream's X-Request-ID. +type tracer struct { + mu sync.RWMutex + id string + model string +} + +func newTracer() *tracer { + return &tracer{id: newTraceID(), model: modelSonnet} +} + +func (t *tracer) set(id, model string) { + t.mu.Lock() + defer t.mu.Unlock() + t.id, t.model = id, model +} + +func (t *tracer) last() (id, model string) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.id, t.model +} + +// logRow is a /admin/logs entry, tags exactly as the gateway serves them. +// duration_ms, ttft_ms and cost_usd are nullable — the CLI renders "-" for a +// null, never 0 — and there is deliberately no cache/cached flag. +type logRow struct { + TraceID string `json:"trace_id"` + Stage string `json:"stage"` + Model string `json:"model"` + APIKeyID string `json:"api_key_id"` + Provider string `json:"provider"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + ErrorMessage string `json:"error_message"` + CreatedAt string `json:"created_at"` + DurationMS *float64 `json:"duration_ms"` + TTFTMS *float64 `json:"ttft_ms"` + CostUSD *float64 `json:"cost_usd"` +} + +// logRows is the seeded request log, newest first. The first row attributes the +// most recent chat stream — same trace_id as its X-Request-ID — which is what +// lets the playground resolve provider and cost against the fake. +// +// The rows are in descending created_at order because that is the only order a +// real log is read in, and the tail's since-cursor advances on it: a seed that +// put an older row last-but-one would validate paging and cursor behavior +// against an order the gateway cannot produce. +func logRows(tr *tracer) []logRow { + id, model := tr.last() + now := time.Now().UTC() + return []logRow{ + { + TraceID: id, Stage: stageAfterRequest, Model: model, APIKeyID: keyIDOps, + Provider: providerAnthropic, PromptTokens: 24, CompletionTokens: 7, TotalTokens: 31, + CreatedAt: ts(now.Add(-2 * time.Second)), + DurationMS: fp(1284.5), TTFTMS: fp(312), CostUSD: fp(0.0031), + }, + { + // Non-terminal stage: visible only with stage=all, so the default + // terminal-stage filter is observably doing something. It is the + // row above's own earlier stage — same request, one second before + // it completed — so it sorts here rather than at the end. + TraceID: id, Stage: stageBeforeRequest, Model: model, APIKeyID: keyIDOps, + Provider: providerAnthropic, CreatedAt: ts(now.Add(-3 * time.Second)), + }, + { + // Nullable measurements: the CLI must render "-", never 0. + TraceID: "8f2c1d4ab9e34f7c9a0d5e6b71c28d3f", Stage: stageAfterRequest, + Model: modelGPT4oMini, APIKeyID: keyIDCI, Provider: providerOpenAI, + PromptTokens: 812, CompletionTokens: 44, TotalTokens: 856, + CreatedAt: ts(now.Add(-51 * time.Second)), + }, + { + TraceID: "b31a77c0e5d24a1e8c93f0a6d2471be5", Stage: stageOnError, + Model: modelOpus, APIKeyID: keyIDOps, Provider: providerAnthropic, + ErrorMessage: "upstream_unavailable: circuit open", + CreatedAt: ts(now.Add(-4 * time.Minute)), + DurationMS: fp(30001.2), + }, + { + // Credential-less request: api_key_id is empty (?api_key_id=none). + TraceID: "c07e5f9138b64d2fa1c6e7092b4d8a11", Stage: stageAfterRequest, + Model: modelGPT4o, Provider: providerOpenAI, + PromptTokens: 128, CompletionTokens: 512, TotalTokens: 640, + CreatedAt: ts(now.Add(-9 * time.Minute)), + DurationMS: fp(4102), TTFTMS: fp(180), CostUSD: fp(0.0184), + }, + } +} + +// parseSince reads the optional since query parameter as RFC3339, writing the +// real handler's exact 400 (internal/admin/handlers/queryparams.go's +// parseSince) on anything that fails to parse and reporting ok=false so the +// caller returns. Reading it through url.Values.Get keeps a present-but-empty +// value read as absent — the same present-but-empty rule page applies to +// limit and offset — so `?since=` is not a second, silent way to ask for +// "unfiltered". Shared by /admin/logs, /admin/logs/stats and /admin/audit, +// the three routes that accept it. +func parseSince(w http.ResponseWriter, q url.Values) (time.Time, bool) { + raw := q.Get(fieldSince) + if raw == "" { + return time.Time{}, true + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, "invalid since: must be RFC3339 format") + return time.Time{}, false + } + return t, true +} + +// filterLogs applies the exact-match filters the endpoint documents. A fake +// that ignored them would answer rows the query excluded — a lie downstream +// tests would then enshrine. since is pre-parsed by parseSince rather than +// reparsed here, so a malformed value 400s before any row is ever filtered. +func filterLogs(rows []logRow, q url.Values, since time.Time) []logRow { + stage, model, provider, keyID := q.Get(fieldStage), q.Get(fieldModel), q.Get(fieldProvider), q.Get(fieldAPIKeyID) + + // `since` is what a live tail advances on: Follower re-sends its cursor + // every poll and expects to be given only what is newer. Echoing the + // parameter without applying it made every poll return the whole page, so + // the tail depended entirely on client-side dedupe to look correct. + out := make([]logRow, 0, len(rows)) + for _, row := range rows { + switch { + case stage == "" && row.Stage != stageAfterRequest && row.Stage != stageOnError: + continue // default filter: terminal stages only, one row per request + case stage != "" && stage != "all" && row.Stage != stage: + continue + case model != "" && row.Model != model: + continue + case provider != "" && row.Provider != provider: + continue + case keyID == "none" && row.APIKeyID != "": + continue + case keyID != "" && keyID != "none" && row.APIKeyID != keyID: + continue + case !since.IsZero() && olderThan(row.CreatedAt, since): + continue + } + out = append(out, row) + } + return out +} + +// filterStats applies the filters GET /admin/logs/stats actually documents: +// stage, model and provider match exactly or are absent; since keeps rows at +// or after the cursor. There is no default-to-terminal-stages narrowing (the +// real query's WHERE clause never adds one — internal/requestlog/store.go's +// SQLWriter.Stats) and no api_key_id filter (the real handler never reads +// one), so this is deliberately not filterLogs with a parameter dropped. +func filterStats(rows []logRow, q url.Values, since time.Time) []logRow { + stage, model, provider := q.Get(fieldStage), q.Get(fieldModel), q.Get(fieldProvider) + + out := make([]logRow, 0, len(rows)) + for _, row := range rows { + switch { + case stage != "" && row.Stage != stage: + continue + case model != "" && row.Model != model: + continue + case provider != "" && row.Provider != provider: + continue + case !since.IsZero() && olderThan(row.CreatedAt, since): + continue + } + out = append(out, row) + } + return out +} + +// dimensionStat is one group's contribution to by_stage/by_provider/by_model — +// the same five fields the real handler's encodeDimension serves per group +// (internal/admin/handlers/logs.go) — accumulated here from the filtered rows +// instead of the store's SQL aggregation. +type dimensionStat struct { + Count int + Errors int + Tokens int + CostUSD float64 + Unpriced int +} + +func (d *dimensionStat) add(row logRow) { + d.Count++ + d.Tokens += row.TotalTokens + if row.ErrorMessage != "" { + d.Errors++ + } + if row.CostUSD != nil { + d.CostUSD += *row.CostUSD + } else { + d.Unpriced++ + } +} + +func encodeDimensions(groups map[string]*dimensionStat) map[string]any { + out := make(map[string]any, len(groups)) + for name, stat := range groups { + out[name] = map[string]any{ + fieldCount: stat.Count, "errors": stat.Errors, "tokens": stat.Tokens, + "cost_usd": stat.CostUSD, "unpriced": stat.Unpriced, + } + } + return out +} + +// logStatsResponse aggregates exactly the rows filterStats left in, the way +// the real handler's Stats does: an empty result answers zeroed counts and +// null percentiles rather than 200-with-yesterday's-numbers, so a filter that +// matches nothing reads differently from one that was silently dropped. +func logStatsResponse(rows []logRow) map[string]any { + byStage := map[string]*dimensionStat{} + byProvider := map[string]*dimensionStat{} + byModel := map[string]*dimensionStat{} + errorCounts := map[string]int{} + errorEntries, totalTokens, promptTokens, completionTokens, unpriced := 0, 0, 0, 0, 0 + cost := 0.0 + var latencies, ttfts []float64 + + group := func(m map[string]*dimensionStat, key string, row logRow) { + if m[key] == nil { + m[key] = &dimensionStat{} + } + m[key].add(row) + } + + for _, row := range rows { + if row.ErrorMessage != "" { + errorEntries++ + errorCounts[row.ErrorMessage]++ + } + totalTokens += row.TotalTokens + promptTokens += row.PromptTokens + completionTokens += row.CompletionTokens + if row.CostUSD != nil { + cost += *row.CostUSD + } else { + unpriced++ + } + group(byStage, row.Stage, row) + group(byProvider, row.Provider, row) + group(byModel, row.Model, row) + if row.DurationMS != nil { + latencies = append(latencies, *row.DurationMS) + } + if row.TTFTMS != nil { + ttfts = append(ttfts, *row.TTFTMS) + } + } + + return map[string]any{ + fieldSummary: map[string]any{ + fieldTotalEntries: len(rows), + "error_entries": errorEntries, + "total_tokens": totalTokens, + "prompt_tokens": promptTokens, + "completion_tokens": completionTokens, + "cost_usd": cost, + "unpriced_requests": unpriced, + }, + "latency_ms": percentilesOf(latencies), + "ttft_ms": percentilesOf(ttfts), + "by_stage": encodeDimensions(byStage), + "by_provider": encodeDimensions(byProvider), + "by_model": encodeDimensions(byModel), + "top_errors": topErrors(errorCounts), + // The API defines series's inner shape (internal/admin/handlers/logs.go's + // encodeSeries), but no CLI surface reads it (internal/api.LogStats + // intentionally decodes no series field), so the fixture keeps the + // cheaper empty object rather than modeling bucket math nothing consumes. + "series": map[string]any{}, + } +} + +// topErrors mirrors the real handler's encodeErrorGroups shape +// ({message, count}), ranked highest count first with message as the +// tiebreaker so JSON array order — unlike a map's — is deterministic. +func topErrors(counts map[string]int) []map[string]any { + type group struct { + message string + count int + } + groups := make([]group, 0, len(counts)) + for msg, n := range counts { + groups = append(groups, group{msg, n}) + } + sort.Slice(groups, func(i, j int) bool { + if groups[i].count != groups[j].count { + return groups[i].count > groups[j].count + } + return groups[i].message < groups[j].message + }) + out := make([]map[string]any, 0, len(groups)) + for _, g := range groups { + out = append(out, map[string]any{fieldMessage: g.message, fieldCount: g.count}) + } + return out +} + +// percentilesOf mirrors the real handler's encodePercentiles: nil when +// nothing was measured — never a zero-filled block, which would read as a +// real, implausibly fast measurement — otherwise nearest-rank p50/p95/p99 +// plus max/mean/count. +func percentilesOf(vals []float64) any { + if len(vals) == 0 { + return nil + } + sorted := append([]float64(nil), vals...) + sort.Float64s(sorted) + rank := func(p float64) float64 { + idx := int(math.Ceil(p/100*float64(len(sorted)))) - 1 + if idx < 0 { + idx = 0 + } + return sorted[idx] + } + sum := 0.0 + for _, v := range sorted { + sum += v + } + return map[string]any{ + "p50": rank(50), "p95": rank(95), "p99": rank(99), + "max": sorted[len(sorted)-1], "mean": sum / float64(len(sorted)), fieldCount: len(sorted), + } +} + +// page applies offset then limit (limit is capped at 200, as documented). +// Shared by /admin/logs and /admin/audit, whose rows differ only in type. +// +// A limit that fails strconv.Atoi or is <= 0 writes the same 400 the real +// handler's parseLimit returns (internal/admin/handlers/queryparams.go) and +// reports ok=false, so the caller must return without serving its normal 200 +// body. +// +// Both bounds mirror internal/admin/handlers/queryparams.go exactly, including +// the case that is easy to get wrong: the parameter PRESENT BUT EMPTY. +// parseLimit and parseOffset both read through url.Values.Get and return their +// default on "", so `?limit=` is the same request as no limit at all — the +// convention the gateway applies to model, provider, stage and api_key_id too. +// Reading it through the same Get is what keeps that from drifting; matching on +// len(q[key]) instead would 400 a request the real gateway serves, and the +// resulting "bug" would look like it lived in the CLI. +func page[T any](w http.ResponseWriter, rows []T, q url.Values) ([]T, bool) { + limit := 100 + if raw := q.Get(fieldLimit); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, "invalid limit: must be a positive integer") + return nil, false + } + limit = n + } + limit = min(limit, 200) + + offset := 0 + if raw := q.Get(fieldOffset); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, "invalid offset: must be a non-negative integer") + return nil, false + } + offset = n + } + offset = min(offset, len(rows)) + + rows = rows[offset:] + if limit < len(rows) { + rows = rows[:limit] + } + return rows, true +} + +func auditRows() []map[string]any { + now := time.Now().UTC() + return []map[string]any{ + { + fieldOccurredAt: ts(now.Add(-3 * time.Minute)), fieldAction: "key.create", + fieldActor: actorOps, fieldActorID: keyIDOps, "target_id": "key_05", + fieldOutcome: outcomeOK, fieldDetail: "scopes=read_only", fieldSourceIP: sourceIPLocal, + "trace_id": "3a1f7c02d94b41e6b0f5a8c7d2e91b64", + }, + { + fieldOccurredAt: ts(now.Add(-2 * time.Hour)), fieldAction: "session.create", + fieldActor: "unknown", fieldOutcome: outcomeDenied, fieldDetail: "invalid credential", + fieldSourceIP: "10.0.4.19", + }, + { + fieldOccurredAt: ts(now.AddDate(0, 0, -12)), fieldAction: "key.revoke", + fieldActor: actorOps, fieldActorID: keyIDOps, "target_id": "key_03", + fieldOutcome: outcomeOK, fieldSourceIP: sourceIPLocal, + }, + { + fieldOccurredAt: ts(now.AddDate(0, 0, -20)), fieldAction: "logs.purge", + fieldActor: actorOps, fieldActorID: keyIDOps, fieldOutcome: outcomeError, + fieldDetail: "log store unavailable", fieldSourceIP: sourceIPLocal, + }, + } +} + +func filterAudit(rows []map[string]any, q url.Values, since time.Time) []map[string]any { + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + occurred, _ := time.Parse(time.RFC3339Nano, stringValue(row[fieldOccurredAt])) + if action := q.Get("action"); action != "" && stringValue(row["action"]) != action { + continue + } + if actorID := q.Get(fieldActorID); actorID != "" && stringValue(row[fieldActorID]) != actorID { + continue + } + if outcome := q.Get(fieldOutcome); outcome != "" && stringValue(row[fieldOutcome]) != outcome { + continue + } + if !since.IsZero() && occurred.Before(since) { + continue + } + out = append(out, row) + } + return out +} + +func stringValue(v any) string { + s, _ := v.(string) + return s +} + +// modelCatalog is the /v1/models payload; /admin/providers serves the same rows +// grouped by owner. +func modelCatalog() []map[string]any { + return []map[string]any{ + { + "id": modelSonnet, fieldObject: objectModel, fieldCreated: 1748736000, + fieldOwnedBy: providerAnthropic, fieldMode: modalityChat, fieldContextWindow: 200000, + fieldMaxOutputTokens: 64000, fieldStatus: statusStable, fieldDeprecated: false, + fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools, capabilityVision}, + }, + { + "id": modelOpus, fieldObject: objectModel, fieldCreated: 1751328000, + fieldOwnedBy: providerAnthropic, fieldMode: modalityChat, fieldContextWindow: 200000, + fieldMaxOutputTokens: 32000, fieldStatus: statusStable, fieldDeprecated: false, + fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools, capabilityVision}, + }, + { + "id": modelGPT4o, fieldObject: objectModel, fieldCreated: 1715558400, + fieldOwnedBy: providerOpenAI, fieldMode: modalityChat, fieldContextWindow: 128000, + fieldMaxOutputTokens: 16384, fieldStatus: statusStable, fieldDeprecated: false, + fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools, capabilityVision}, + }, + { + "id": modelGPT4oMini, fieldObject: objectModel, fieldCreated: 1721260800, + fieldOwnedBy: providerOpenAI, fieldMode: modalityChat, fieldContextWindow: 128000, + fieldMaxOutputTokens: 16384, fieldStatus: statusStable, fieldDeprecated: false, + fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools}, + }, + { + "id": modelEmbedding3Small, fieldObject: objectModel, fieldCreated: 1705363200, + fieldOwnedBy: providerOpenAI, fieldMode: modalityEmbedding, fieldContextWindow: 8191, + fieldStatus: statusStable, fieldDeprecated: false, + fieldCapabilities: []string{modalityEmbedding}, + }, + } +} + +func modelsOwnedBy(owner string) []map[string]any { + out := []map[string]any{} + for _, m := range modelCatalog() { + if m[fieldOwnedBy] == owner { + out = append(out, m) + } + } + return out +} + +func circuitOf(s State) string { + if s.Degraded { + return circuitHalfOpen + } + return circuitClosed +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +// writeErr emits the gateway's error envelope, so the CLI's decodeAPIError is +// exercised by the fixture rather than mocked around. +func writeErr(w http.ResponseWriter, code int, kind, msg string) { + writeJSON(w, code, map[string]any{ + fieldError: map[string]any{fieldMessage: msg, fieldType: kind, "code": kind}, + }) +} + +// ts renders a wire timestamp: RFC3339, UTC. +func ts(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +func sp(s string) *string { return &s } +func fp(f float64) *float64 { return &f } + +// newTraceID mints a 32-hex id shaped like the gateway's (its X-Request-ID is +// the OTel trace id). +func newTraceID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// randToken mints a random base32 token for a key secret. +func randToken() string { return rand.Text() } + +// olderThan reports whether an RFC3339 created_at precedes the cursor. A row +// whose timestamp will not parse is kept: dropping rows on a formatting +// problem would make a tail silently lossy, which is worse than showing one +// row twice. +func olderThan(createdAt string, since time.Time) bool { + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return false + } + return t.Before(since) +} diff --git a/internal/fixture/handler_test.go b/internal/fixture/handler_test.go new file mode 100644 index 0000000..21323e5 --- /dev/null +++ b/internal/fixture/handler_test.go @@ -0,0 +1,636 @@ +package fixture + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// reply is everything that outlives a finished request. do reads the body to +// completion and closes it, so there is no *http.Response worth handing back — +// only a spent connection a caller could forget to release. +type reply struct { + status int + header http.Header + body []byte +} + +// serve starts one server for state's handler. It is the unit a test shares: +// the fixture's key store lives in the handler, so a create followed by a read +// is one flow against one gateway, and opening a listener per request modelled +// it as four unrelated ones. +func serve(t *testing.T, state State) *httptest.Server { + t.Helper() + srv := httptest.NewServer(Handler(state)) + t.Cleanup(srv.Close) + return srv +} + +// do runs one request against srv. Every test goes through it, so the request +// context, the body read and the close are done in one place rather than +// re-hand-rolled per test. +func do(t *testing.T, srv *httptest.Server, method, path, bearer, body string) reply { + t.Helper() + var payload io.Reader + if body != "" { + payload = strings.NewReader(body) + } + req, err := http.NewRequestWithContext(t.Context(), method, srv.URL+path, payload) + if err != nil { + t.Fatal(err) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + t.Errorf("close response body: %v", err) + } + }() + out, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return reply{status: resp.StatusCode, header: resp.Header, body: out} +} + +// get is do for the read surface, which is most of it. +func get(t *testing.T, srv *httptest.Server, method, path, bearer string) reply { + t.Helper() + return do(t, srv, method, path, bearer, "") +} + +func TestHealthShapeMatchesContract(t *testing.T) { + res := get(t, serve(t, Default()), "GET", "/health", "") + if res.status != 200 { + t.Fatalf("healthy /health must be 200, got %d", res.status) + } + var out struct { + Status string `json:"status"` + Providers []struct { + Name string `json:"name"` + Circuit string `json:"circuit"` + Models int `json:"models"` + } `json:"providers"` + } + if err := json.Unmarshal(res.body, &out); err != nil { + t.Fatal(err) + } + if out.Status != "ok" || len(out.Providers) == 0 || out.Providers[0].Models == 0 { + t.Fatalf("fixture drifted from the documented /health shape: %s", res.body) + } +} + +// Degraded and provider-less are independent axes on the real gateway, and an +// earlier version of this fixture conflated them into one 503. Verified live: +// a half-open circuit with live providers still answers 200 "ok"; only an +// empty provider set makes /health non-200. +func TestDegradedIsA200WithAHalfOpenCircuit(t *testing.T) { + res := get(t, serve(t, State{Degraded: true}), "GET", "/health", "") + if res.status != 200 { + t.Fatalf("a circuit does not make /health non-200, got %d", res.status) + } + if !strings.Contains(string(res.body), "half_open") || + !strings.Contains(string(res.body), `"status":"ok"`) { + t.Fatalf("degraded must report ok with a half-open circuit: %s", res.body) + } +} + +func TestNoProvidersIs503WithAnEmptyProviderSet(t *testing.T) { + res := get(t, serve(t, State{NoProviders: true}), "GET", "/health", "") + if res.status != 503 { + t.Fatalf("an empty provider set is the one 503 /health has, got %d", res.status) + } + if !strings.Contains(string(res.body), `"no_providers"`) || + !strings.Contains(string(res.body), `"providers":[]`) { + t.Fatalf("no_providers must come with an EMPTY providers array: %s", res.body) + } +} + +// The not-ready body carries only status and reason. Serving the arrays here +// taught ferro status to read target counts that are not there, and report +// "0/0 targets" for a gateway whose targets were merely dead. +func TestNotReadyCarriesOnlyStatusAndReason(t *testing.T) { + res := get(t, serve(t, State{NoProviders: true}), "GET", "/readyz", "") + if res.status != 503 { + t.Fatalf("want 503, got %d", res.status) + } + // Assert on decoded KEYS, not substrings: the reason text is literally + // "no routable targets", which contains the very word being banned. + var doc map[string]any + if err := json.Unmarshal(res.body, &doc); err != nil { + t.Fatalf("decode: %v (%s)", err, res.body) + } + // doc["reason"] is nil when the key is absent, and nil != "", so comparing + // against the empty string alone cannot fail. Assert presence and a + // non-empty string instead. + reason, ok := doc["reason"].(string) + if doc["status"] != "not_ready" || !ok || reason == "" { + t.Fatalf("want status and a non-empty reason: %s", res.body) + } + for _, absent := range []string{"targets", "providers", "mcp_servers"} { + if _, present := doc[absent]; present { + t.Fatalf("a not_ready body must not carry %q: %s", absent, res.body) + } + } +} + +func TestAdminRoutesRequireBearer(t *testing.T) { + srv := serve(t, Default()) + res := get(t, srv, "GET", "/admin/keys", "") + if res.status != 401 || !strings.Contains(string(res.body), "authentication_error") { + t.Fatalf("want the documented 401 envelope, got %d %s", res.status, res.body) + } + if res = get(t, srv, "GET", "/admin/keys", "fgw_test"); res.status != 200 { + t.Fatalf("valid bearer must be accepted, got %d", res.status) + } +} + +func TestFeatureAbsenceIs501(t *testing.T) { + res := get(t, serve(t, State{NoLogStore: true}), "GET", "/admin/logs", "") + if res.status != 501 { + t.Fatalf("absent log store must 501 so the CLI can feature-detect, got %d", res.status) + } +} + +func TestKeysAreStatefulAcrossRequests(t *testing.T) { + srv := serve(t, Default()) // one server, two requests: the store must persist + res := do(t, srv, "POST", "/admin/keys", "fgw_test", + `{"name":"itest","scopes":["read_only"]}`) + if res.status != 201 { + t.Fatalf("create must be 201: %d %s", res.status, res.body) + } + var created struct { + ID string `json:"id"` + Key string `json:"key"` + } + if err := json.Unmarshal(res.body, &created); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(created.Key, "fgw_") || len(created.Key) < 20 { + t.Fatalf("create must return the full secret once, got %q", created.Key) + } + list := get(t, srv, "GET", "/admin/keys", "fgw_test") + if !strings.Contains(string(list.body), "itest") { + t.Fatal("created key must appear in the list — the fake is stateful by design") + } + if strings.Contains(string(list.body), created.Key) { + t.Fatal("list must mask the secret, like the real gateway does") + } +} + +// GET/rotate/revoke/delete had no unit coverage: only cli/itest exercised +// them, and only against a live gateway, skipping entirely when +// FERRO_ITEST_KEY is unset — so a regression in keyStore.rotate/revoke/remove +// could pass CI. This covers the mutating half of the key surface the way +// TestKeysAreStatefulAcrossRequests covers create/list. +func TestKeyLifecycleMutatesTheStore(t *testing.T) { + srv := serve(t, Default()) + + rotated := do(t, srv, http.MethodPost, "/admin/keys/key_01/rotate", "fgw_test", "") + if rotated.status != http.StatusOK { + t.Fatalf("rotate must be 200: %d %s", rotated.status, rotated.body) + } + var row struct { + Key string `json:"key"` + RotatedAt *string `json:"rotated_at"` + } + if err := json.Unmarshal(rotated.body, &row); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(row.Key, "fgw_") || row.RotatedAt == nil { + t.Fatalf("rotate must serve the new secret once and stamp rotated_at: %s", rotated.body) + } + if listed := get(t, srv, http.MethodGet, "/admin/keys/key_01", "fgw_test"); strings.Contains(string(listed.body), row.Key) { + t.Fatal("a later read must mask the rotated secret") + } + + if res := do(t, srv, http.MethodPost, "/admin/keys/key_02/revoke", "fgw_test", ""); res.status != http.StatusOK { + t.Fatalf("revoke must be 200: %d %s", res.status, res.body) + } + if res := do(t, srv, http.MethodDelete, "/admin/keys/key_02", "fgw_test", ""); res.status != http.StatusNoContent { + t.Fatalf("delete must be 204: %d %s", res.status, res.body) + } + res := get(t, srv, http.MethodGet, "/admin/keys/key_02", "fgw_test") + if res.status != http.StatusNotFound || !strings.Contains(string(res.body), "not_found_error") { + t.Fatalf("a deleted key must 404 in the documented envelope: %d %s", res.status, res.body) + } +} + +// The real gateway defaults an unspecified scope list to read_only, in the key +// store both its backends share. A fake that stored the empty list instead +// would hand every fixture-backed test a scopeless key the gateway never mints. +func TestCreateWithoutScopesDefaultsToReadOnly(t *testing.T) { + srv := serve(t, Default()) + for _, body := range []string{ + `{"name":"absent"}`, + `{"name":"null","scopes":null}`, + `{"name":"empty","scopes":[]}`, + } { + res := do(t, srv, "POST", "/admin/keys", "fgw_test", body) + if res.status != 201 { + t.Fatalf("create must be 201: %d %s", res.status, res.body) + } + var created struct { + Scopes []string `json:"scopes"` + } + if err := json.Unmarshal(res.body, &created); err != nil { + t.Fatal(err) + } + if len(created.Scopes) != 1 || created.Scopes[0] != "read_only" { + t.Fatalf("%s must mint a read-only key, got %v", body, created.Scopes) + } + } +} + +// A real log is read newest first, and the tail's since-cursor advances on that +// order. A seeded row out of order would validate paging against an order the +// gateway cannot produce. +func TestSeededLogIsNewestFirst(t *testing.T) { + res := get(t, serve(t, Default()), "GET", "/admin/logs?stage=all", "fgw_test") + var page struct { + Data []struct { + Stage string `json:"stage"` + CreatedAt string `json:"created_at"` + } `json:"data"` + } + if err := json.Unmarshal(res.body, &page); err != nil { + t.Fatal(err) + } + if len(page.Data) < 2 { + t.Fatalf("stage=all must expose every seeded row: %s", res.body) + } + previous := time.Now().UTC().Add(time.Hour) + for _, row := range page.Data { + at, err := time.Parse(time.RFC3339, row.CreatedAt) + if err != nil { + t.Fatalf("created_at must be RFC3339: %v", err) + } + if at.After(previous) { + t.Fatalf("row %q at %s breaks descending created_at order: %s", + row.Stage, row.CreatedAt, res.body) + } + previous = at + } +} + +func TestChatStreamFraming(t *testing.T) { + res := do(t, serve(t, Default()), "POST", "/v1/chat/completions", "fgw_test", + `{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}],"stream":true}`) + if ct := res.header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("want SSE content type, got %q", ct) + } + if res.header.Get("X-Request-ID") == "" { + t.Fatal("X-Request-ID is how the CLI attributes a stream — it must be set") + } + got := string(res.body) + if !strings.Contains(got, "data: ") || !strings.Contains(got, `"usage"`) || + !strings.Contains(got, "data: [DONE]") { + t.Fatalf("stream must be data-framed, carry a usage chunk, and end with [DONE]:\n%s", got) + } +} + +func TestChatErrorFrameHasNoDone(t *testing.T) { + // The real gateway ends an errored stream WITHOUT [DONE]; a fake that sends + // one anyway would hide the CLI bug this behavior exists to catch. + // + // The WHOLE stream is read, not its first frame: "there is no [DONE]" is a + // claim about the end of the stream, and a partial read can only fail to + // find one that was in fact sent. + res := do(t, serve(t, State{ChatFails: true}), "POST", "/v1/chat/completions", "", + `{"model":"m","messages":[],"stream":true}`) + got := string(res.body) + if !strings.Contains(got, "stream_error") || strings.Contains(got, "[DONE]") { + t.Fatalf("errored stream must carry stream_error and no [DONE]:\n%s", got) + } +} + +// The real gateway puts /v1/* behind the same bearer check as /admin/*. If the +// fake did not, the CLI's model discovery and playground could be built without +// sending a credential and would 401 on first contact with a real gateway. +func TestV1RoutesRequireBearerToo(t *testing.T) { + srv := serve(t, Default()) + // Each route is probed with the METHOD the CLI reaches it by. A handler + // that authenticated GET /v1/chat/completions but not the POST the + // playground sends would pass a GET-only loop and 401 in production. + for _, tc := range []struct{ method, path, body string }{ + {http.MethodGet, "/v1/models", ""}, + {http.MethodPost, "/v1/chat/completions", `{"model":"m","messages":[],"stream":true}`}, + } { + res := do(t, srv, tc.method, tc.path, "", tc.body) + if res.status != 401 { + t.Fatalf("%s %s without a bearer must be 401, got %d", tc.method, tc.path, res.status) + } + if !strings.Contains(string(res.body), "authentication_error") { + t.Fatalf("%s %s must use the documented error envelope: %s", tc.method, tc.path, res.body) + } + } + if res := get(t, srv, "GET", "/v1/models", "fgw_test"); res.status != 200 { + t.Fatalf("valid bearer must be accepted on /v1/models, got %d", res.status) + } +} + +// ...while the liveness surface stays open, as it is on the real router. +func TestHealthSurfaceStaysUnauthenticated(t *testing.T) { + srv := serve(t, Default()) + for _, path := range []string{"/health", "/readyz"} { + if res := get(t, srv, "GET", path, ""); res.status == 401 { + t.Fatalf("%s must not require a credential", path) + } + } + res := get(t, srv, "GET", "/livez", "") + if res.status != http.StatusOK || strings.TrimSpace(string(res.body)) != `{"status":"ok"}` { + t.Fatalf("/livez must be an open 200 status payload, got %d %s", res.status, res.body) + } +} + +func TestChatRequiresStreaming(t *testing.T) { + srv := serve(t, Default()) + for _, body := range []string{ + `{"model":"m"}`, + `{"model":"m","stream":false}`, + `{"model":"m","stream":true}{"stream":true}`, + `{"stream":false,"stream":true}`, + } { + res := do(t, srv, http.MethodPost, "/v1/chat/completions", "fgw_test", body) + if res.status != http.StatusBadRequest { + t.Fatalf("non-streaming request must be rejected, got %d", res.status) + } + } +} + +func TestAuditFiltersAndPagesRows(t *testing.T) { + srv := serve(t, Default()) + res := get(t, srv, "GET", "/admin/audit?outcome=ok&limit=1&offset=1", "fgw_test") + if res.status != http.StatusOK { + t.Fatalf("audit query failed: %d %s", res.status, res.body) + } + var page struct { + Data []map[string]any `json:"data"` + Summary struct { + TotalEntries int `json:"total_entries"` + } `json:"summary"` + } + if err := json.Unmarshal(res.body, &page); err != nil { + t.Fatal(err) + } + if page.Summary.TotalEntries != 2 || len(page.Data) != 1 || + page.Data[0]["outcome"] != "ok" || page.Data[0]["action"] != "key.revoke" { + t.Fatalf("filters must apply before paging: %s", res.body) + } +} + +func TestAuditRejectsInvalidSince(t *testing.T) { + res := get(t, serve(t, Default()), "GET", "/admin/audit?since=not-a-time", "fgw_test") + if res.status != http.StatusBadRequest || + !strings.Contains(string(res.body), `"type":"invalid_request_error"`) { + t.Fatalf("invalid since must use the invalid-request envelope: %d %s", res.status, res.body) + } +} + +// The real gateway's parseLimit 400s a limit that is <= 0 or not an integer +// (internal/admin/handlers/queryparams.go); an earlier version of this fixture +// silently fell back to its default and served an empty page instead. Both +// endpoints share the page[T] helper, so one table covers both. +func TestNonPositiveLimitIs400OnLogsAndAudit(t *testing.T) { + srv := serve(t, Default()) + for _, path := range []string{ + "/admin/logs?limit=0", + "/admin/logs?limit=-1", + "/admin/logs?limit=abc", + "/admin/audit?limit=0", + "/admin/audit?limit=-1", + "/admin/audit?limit=abc", + } { + res := get(t, srv, "GET", path, "fgw_test") + if res.status != http.StatusBadRequest || + !strings.Contains(string(res.body), `"type":"invalid_request_error"`) { + t.Fatalf("%s: want the documented 400 envelope for a non-positive limit, got %d %s", path, res.status, res.body) + } + } +} + +// parseOffset rejects the same two shapes parseLimit does, one bound over. +func TestNegativeOffsetIs400OnLogsAndAudit(t *testing.T) { + srv := serve(t, Default()) + for _, path := range []string{ + "/admin/logs?offset=-1", + "/admin/logs?offset=abc", + "/admin/audit?offset=-1", + "/admin/audit?offset=abc", + } { + res := get(t, srv, "GET", path, "fgw_test") + if res.status != http.StatusBadRequest || + !strings.Contains(string(res.body), `"type":"invalid_request_error"`) { + t.Fatalf("%s: want the documented 400 envelope for a bad offset, got %d %s", path, res.status, res.body) + } + } +} + +// A present-but-empty bound is the one shape that must NOT 400: parseLimit and +// parseOffset both read through url.Values.Get and return their default on "", +// so `?limit=` is the same request as no limit at all. A fixture that rejected +// it would send someone hunting a CLI bug that does not exist. +func TestEmptyBoundIsReadAsAbsentNotRejected(t *testing.T) { + srv := serve(t, Default()) + for _, path := range []string{ + "/admin/logs?limit=", + "/admin/logs?offset=", + "/admin/logs?limit=&offset=", + "/admin/audit?limit=", + "/admin/audit?offset=", + } { + res := get(t, srv, "GET", path, "fgw_test") + if res.status != http.StatusOK { + t.Fatalf("%s: an empty bound means absent, want 200, got %d %s", path, res.status, res.body) + } + } +} + +// The real stats endpoint 400s a malformed since exactly as /admin/logs and +// /admin/audit do (internal/admin/handlers/queryparams.go's parseSince is +// shared by all three) — this fixture used to accept anything on +// /admin/logs/stats because it never looked at the query at all. +func TestMalformedSinceIs400OnLogsAndStats(t *testing.T) { + srv := serve(t, Default()) + for _, path := range []string{ + "/admin/logs?since=not-a-time", + "/admin/logs/stats?since=not-a-time", + } { + res := get(t, srv, "GET", path, "fgw_test") + if res.status != http.StatusBadRequest || + !strings.Contains(string(res.body), `"type":"invalid_request_error"`) { + t.Fatalf("%s: want the documented 400 envelope for a malformed since, got %d %s", path, res.status, res.body) + } + } +} + +type statsSummary struct { + Summary struct { + TotalEntries int `json:"total_entries"` + } `json:"summary"` + ByProvider map[string]json.RawMessage `json:"by_provider"` +} + +func decodeStats(t *testing.T, srv *httptest.Server, path string) statsSummary { + t.Helper() + res := get(t, srv, "GET", path, "fgw_test") + if res.status != http.StatusOK { + t.Fatalf("%s: want 200, got %d %s", path, res.status, res.body) + } + var out statsSummary + if err := json.Unmarshal(res.body, &out); err != nil { + t.Fatalf("%s: %v", path, err) + } + return out +} + +// Before this fix /admin/logs/stats served the same fixed aggregate no matter +// what the query asked for, so a CLI regression that dropped a filter before +// it reached the wire was invisible against this fixture. Each of these +// narrows a five-row seed a different way, and the unfiltered count (5) proves +// the baseline itself is real data, not a coincidence of one filter. +func TestStatsFiltersNarrowTheAggregate(t *testing.T) { + srv := serve(t, Default()) + base := decodeStats(t, srv, "/admin/logs/stats") + if base.Summary.TotalEntries != 5 { + t.Fatalf("unfiltered stats: want all 5 seeded rows, got %d", base.Summary.TotalEntries) + } + if _, ok := base.ByProvider["anthropic"]; !ok { + t.Fatalf("unfiltered by_provider must include anthropic: %+v", base.ByProvider) + } + if _, ok := base.ByProvider["openai"]; !ok { + t.Fatalf("unfiltered by_provider must include openai: %+v", base.ByProvider) + } + + for _, tc := range []struct { + path string + want int + }{ + {"/admin/logs/stats?provider=openai", 2}, + {"/admin/logs/stats?stage=before_request", 1}, + {"/admin/logs/stats?model=does-not-exist", 0}, + } { + got := decodeStats(t, srv, tc.path) + if got.Summary.TotalEntries != tc.want { + t.Fatalf("%s: want %d entries, got %d (a fixture that dropped this filter would still report %d)", + tc.path, tc.want, got.Summary.TotalEntries, base.Summary.TotalEntries) + } + } + + // provider=openai must also drop anthropic out of the by_provider + // breakdown, not just shrink the total. + openaiOnly := decodeStats(t, srv, "/admin/logs/stats?provider=openai") + if _, ok := openaiOnly.ByProvider["anthropic"]; ok { + t.Fatalf("provider=openai must drop anthropic from by_provider: %+v", openaiOnly.ByProvider) + } +} + +// A present-but-empty filter value means absent, the same rule the other +// admin list routes apply (TestEmptyBoundIsReadAsAbsentNotRejected): it must +// not 400, and — because /admin/logs/stats used to ignore every parameter — +// it must not silently narrow the aggregate either. +func TestStatsEmptyFilterValueIsAbsentNotRejected(t *testing.T) { + srv := serve(t, Default()) + for _, path := range []string{ + "/admin/logs/stats?model=", + "/admin/logs/stats?provider=", + "/admin/logs/stats?stage=", + "/admin/logs/stats?since=", + } { + got := decodeStats(t, srv, path) + if got.Summary.TotalEntries != 5 { + t.Fatalf("%s: an empty value means absent, want all 5 seeded rows, got %d", path, got.Summary.TotalEntries) + } + } +} + +// since keeps only rows at or after the cursor (internal/requestlog/store.go's +// SQLWriter.Stats: "created_at >= ?"), so a cursor five minutes back must drop +// exactly the row seeded nine minutes back and keep the other four. +func TestStatsSinceKeepsOnlyRowsAtOrAfterTheCursor(t *testing.T) { + srv := serve(t, Default()) + since := time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339) + got := decodeStats(t, srv, "/admin/logs/stats?since="+since) + if got.Summary.TotalEntries != 4 { + t.Fatalf("since=-5m must drop the row seeded 9 minutes ago, want 4 entries, got %d", got.Summary.TotalEntries) + } +} + +// model.ValidateScopes refuses anything outside the closed set with a 400, so +// the fixture must too — a fake that accepts "readonly" lets a CLI that sends +// the wrong spelling pass every test here and fail against a real gateway. +func TestCreateRejectsAnUnknownScope(t *testing.T) { + srv := serve(t, Default()) + res := do(t, srv, http.MethodPost, "/admin/keys", "fgw_test", + `{"name":"typo","scopes":["readonly"]}`) + if res.status != http.StatusBadRequest { + t.Fatalf("an unknown scope must be 400, got %d %s", res.status, res.body) + } + for _, want := range []string{`unknown scope \"readonly\"`, "admin, read_only", "invalid_request_error"} { + if !strings.Contains(string(res.body), want) { + t.Fatalf("want %s in the gateway's own wording: %s", want, res.body) + } + } + // The refusal must not have created anything. + if list := get(t, srv, http.MethodGet, "/admin/keys", "fgw_test"); strings.Contains(string(list.body), "typo") { + t.Fatalf("a refused create must not reach the store: %s", list.body) + } +} + +// Both members of the closed set are accepted, so the guard above cannot pass +// by rejecting everything. +func TestCreateAcceptsBothValidScopes(t *testing.T) { + srv := serve(t, Default()) + for _, scope := range []string{scopeAdmin, scopeReadOnly} { + res := do(t, srv, http.MethodPost, "/admin/keys", "fgw_test", + `{"name":"ok-`+scope+`","scopes":["`+scope+`"]}`) + if res.status != http.StatusCreated { + t.Fatalf("%s must be accepted, got %d %s", scope, res.status, res.body) + } + } +} + +// An expired key is served the way the gateway serves one: active:true, +// revoked_at:null, expires_at in the past. Seeding active:false here was +// fixture-only fiction that hid a real CLI bug. +func TestExpiredKeyKeepsActiveTrueOnTheWire(t *testing.T) { + res := get(t, serve(t, Default()), http.MethodGet, "/admin/keys", "fgw_test") + var rows []struct { + Name string `json:"name"` + RevokedAt *string `json:"revoked_at"` + ExpiresAt *string `json:"expires_at"` + Active bool `json:"active"` + } + if err := json.Unmarshal(res.body, &rows); err != nil { + t.Fatal(err) + } + var found bool + for _, r := range rows { + if r.Name != "demo-temp" { + continue + } + found = true + if !r.Active || r.RevokedAt != nil || r.ExpiresAt == nil { + t.Fatalf("an expired key stays active:true with revoked_at:null and an expires_at: %+v", r) + } + expiry, err := time.Parse(time.RFC3339, *r.ExpiresAt) + if err != nil { + t.Fatal(err) + } + if !time.Now().After(expiry) { + t.Fatalf("the seeded expiry must already have passed, got %s", *r.ExpiresAt) + } + } + if !found { + t.Fatal("the expired fixture key must be seeded") + } +} diff --git a/internal/fixture/keys.go b/internal/fixture/keys.go new file mode 100644 index 0000000..1700ade --- /dev/null +++ b/internal/fixture/keys.go @@ -0,0 +1,284 @@ +package fixture + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +// keyRow is the /admin/keys row, tags exactly as the gateway serves them. +// There is deliberately no "state" and no "revoked" field: the CLI derives +// revoked/expired/active from revoked_at, active and expires_at. +type keyRow struct { + ID string `json:"id"` + Key string `json:"key"` // masked head8...tail4 on reads + Name string `json:"name"` + Scopes []string `json:"scopes"` + CreatedAt string `json:"created_at"` + RevokedAt *string `json:"revoked_at"` + ExpiresAt *string `json:"expires_at"` + RotatedAt *string `json:"rotated_at"` + LastUsedAt *string `json:"last_used_at"` + UsageCount int `json:"usage_count"` + Active bool `json:"active"` +} + +// keyStore is the stateful half of the fake: create/rotate/revoke/delete mutate +// it and later GETs reflect the change, so the TUI key wizard can be exercised +// end to end. One lock guards the whole store — a fake serves one operator, so +// a mutex and a slice are enough. +type keyStore struct { + mu sync.RWMutex + rows []keyRow + secrets map[string]string // key id -> full secret, never serialized on reads + n int +} + +func newKeyStore() *keyStore { + now := time.Now().UTC() + ks := &keyStore{secrets: map[string]string{}} + // Four seeded rows covering every state the CLI derives: active, active + // with an expiry, revoked, and expired. + ks.seed(actorOps, []string{scopeAdmin}, now.AddDate(0, -3, 0), func(r *keyRow) { + r.LastUsedAt = sp(ts(now.Add(-4 * time.Minute))) + r.UsageCount = 18422 + }) + ks.seed("ci-pipeline", []string{scopeReadOnly}, now.AddDate(0, -1, 0), func(r *keyRow) { + r.ExpiresAt = sp(ts(now.AddDate(0, 2, 0))) + r.LastUsedAt = sp(ts(now.Add(-51 * time.Minute))) + r.UsageCount = 3907 + }) + ks.seed("laptop-old", []string{scopeAdmin}, now.AddDate(-1, 0, 0), func(r *keyRow) { + r.RevokedAt = sp(ts(now.AddDate(0, 0, -12))) + r.LastUsedAt = sp(ts(now.AddDate(0, 0, -12))) + r.UsageCount = 240 + r.Active = false + }) + // An expired key keeps active:true and revoked_at:null. The gateway clears + // active only in Revoke (memory store keys.go, and the SQL store's own + // UPDATE), so nothing flips it when a deadline passes — model.KeyIsUsable + // checks expires_at separately for exactly that reason. Seeding this row + // as active:false was fixture-only fiction, and it hid a real bug: the CLI + // derived "expired" from !active, which a real expired key never sets, so + // `ferro keys list` called it active. + ks.seed("demo-temp", []string{scopeReadOnly}, now.AddDate(0, 0, -40), func(r *keyRow) { + r.ExpiresAt = sp(ts(now.AddDate(0, 0, -6))) + r.UsageCount = 12 + }) + return ks +} + +// seed appends one row; opt tweaks it into the state being seeded. +func (ks *keyStore) seed(name string, scopes []string, created time.Time, opt func(*keyRow)) { + id, secret := ks.nextID(), newSecret() + row := keyRow{ + ID: id, + Key: mask(secret), + Name: name, + Scopes: scopes, + CreatedAt: ts(created), + Active: true, + } + if opt != nil { + opt(&row) + } + ks.rows = append(ks.rows, row) + ks.secrets[id] = secret +} + +// nextID mints a readable, stable-looking id. Callers hold the lock (or are +// constructing the store). +func (ks *keyStore) nextID() string { + ks.n++ + return fmt.Sprintf("key_%02d", ks.n) +} + +// newSecret mints a master-key-shaped credential: `fgw_` + random token. +func newSecret() string { return "fgw_" + strings.ToLower(randToken()) } + +func (ks *keyStore) list() []keyRow { + ks.mu.RLock() + defer ks.mu.RUnlock() + out := make([]keyRow, len(ks.rows)) + copy(out, ks.rows) + return out +} + +// unknownScope reports the first scope outside the gateway's closed set, and +// ok=false with it. An empty list is valid — the store defaults it — so this +// rejects only a scope that was actually asked for and cannot be granted. +func unknownScope(scopes []string) (string, bool) { + for _, s := range scopes { + if s != scopeAdmin && s != scopeReadOnly { + return s, false + } + } + return "", true +} + +// defaultScopes gives an unspecified scope list the gateway's own default. The +// real server applies it in the key store rather than the handler — both +// backends call one defaultScopes on the way into Create — so an absent, null +// and empty `scopes` all mint a least-privilege read-only key. Storing the +// empty list instead would have handed every fixture-backed test a scopeless +// key the real gateway never issues. +func defaultScopes(scopes []string) []string { + if len(scopes) == 0 { + return []string{scopeReadOnly} + } + return scopes +} + +// create returns the stored (masked) row and the full secret, which the caller +// serves exactly once. +func (ks *keyStore) create(name string, scopes []string, expiresAt *string) (keyRow, string) { + ks.mu.Lock() + defer ks.mu.Unlock() + id, secret := ks.nextID(), newSecret() + row := keyRow{ + ID: id, + Key: mask(secret), + Name: name, + Scopes: defaultScopes(scopes), + CreatedAt: ts(time.Now().UTC()), + ExpiresAt: expiresAt, + Active: true, + } + ks.rows = append(ks.rows, row) + ks.secrets[id] = secret + return row, secret +} + +// rotate replaces the secret in place, returning the row and the new secret. +func (ks *keyStore) rotate(id string) (keyRow, string, bool) { + ks.mu.Lock() + defer ks.mu.Unlock() + i := ks.indexOf(id) + if i < 0 { + return keyRow{}, "", false + } + secret := newSecret() + ks.rows[i].Key = mask(secret) + ks.rows[i].RotatedAt = sp(ts(time.Now().UTC())) + ks.secrets[id] = secret + return ks.rows[i], secret, true +} + +func (ks *keyStore) revoke(id string) bool { + ks.mu.Lock() + defer ks.mu.Unlock() + i := ks.indexOf(id) + if i < 0 { + return false + } + ks.rows[i].RevokedAt = sp(ts(time.Now().UTC())) + ks.rows[i].Active = false + return true +} + +func (ks *keyStore) remove(id string) bool { + ks.mu.Lock() + defer ks.mu.Unlock() + i := ks.indexOf(id) + if i < 0 { + return false + } + ks.rows = append(ks.rows[:i], ks.rows[i+1:]...) + delete(ks.secrets, id) + return true +} + +// indexOf is called with the lock held. +func (ks *keyStore) indexOf(id string) int { + for i := range ks.rows { + if ks.rows[i].ID == id { + return i + } + } + return -1 +} + +// mask is the gateway's read masking: first 8 characters, "...", last 4. +func mask(secret string) string { + if len(secret) < 12 { + return "..." + } + return secret[:8] + "..." + secret[len(secret)-4:] +} + +// registerKeys wires the /admin/keys surface onto the mux. +func registerKeys(mux *http.ServeMux, ks *keyStore) { + mux.HandleFunc("GET /admin/keys", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, ks.list()) // bare array, no envelope + }) + + // The real gateway serves this (internal/admin/handlers/server.go); leaving + // it out made `ferro keys get` 404 against the fake while working against a + // live gateway — a fixture gap that reads as a CLI bug. + mux.HandleFunc("GET /admin/keys/{id}", func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + for _, row := range ks.list() { + if row.ID == id { + writeJSON(w, http.StatusOK, row) // masked, like every read + return + } + } + writeErr(w, http.StatusNotFound, kindNotFound, "api key not found") + }) + + mux.HandleFunc("POST /admin/keys", func(w http.ResponseWriter, r *http.Request) { + var in struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + ExpiresAt *string `json:"expires_at"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, "malformed request body") + return + } + if in.Name == "" { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, "name is required") + return + } + if bad, ok := unknownScope(in.Scopes); !ok { + // model.ValidateScopes, worded and ordered exactly as it words it. + writeErr(w, http.StatusBadRequest, kindInvalidRequest, + fmt.Sprintf("unknown scope %q: valid scopes are %s, %s", bad, scopeAdmin, scopeReadOnly)) + return + } + // No nil-to-empty normalisation here: the store defaults an unspecified + // list, so a created row always carries at least one scope. + row, secret := ks.create(in.Name, in.Scopes, in.ExpiresAt) + row.Key = secret // the full secret, served exactly once + writeJSON(w, http.StatusCreated, row) + }) + + mux.HandleFunc("POST /admin/keys/{id}/rotate", func(w http.ResponseWriter, r *http.Request) { + row, secret, ok := ks.rotate(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusNotFound, kindNotFound, msgKeyNotFound) + return + } + row.Key = secret // the new secret, served exactly once + writeJSON(w, http.StatusOK, row) + }) + + mux.HandleFunc("POST /admin/keys/{id}/revoke", func(w http.ResponseWriter, r *http.Request) { + if !ks.revoke(r.PathValue("id")) { + writeErr(w, http.StatusNotFound, kindNotFound, msgKeyNotFound) + return + } + writeJSON(w, http.StatusOK, map[string]any{fieldStatus: statusRevoked}) + }) + + mux.HandleFunc("DELETE /admin/keys/{id}", func(w http.ResponseWriter, r *http.Request) { + if !ks.remove(r.PathValue("id")) { + writeErr(w, http.StatusNotFound, kindNotFound, msgKeyNotFound) + return + } + w.WriteHeader(http.StatusNoContent) // 204, empty body + }) +} diff --git a/internal/fixture/state.go b/internal/fixture/state.go new file mode 100644 index 0000000..4d7e48b --- /dev/null +++ b/internal/fixture/state.go @@ -0,0 +1,37 @@ +// Package fixture serves stable gateway response shapes for the CLI and TUI. +// The shapes mirror supported HTTP responses and deliberately omit +// fields whose contract is unknown. +package fixture + +import "time" + +// defaultKey is the credential Default() accepts. Master keys are `fgw_`-prefixed. +const defaultKey = "fgw_test" + +// State picks which world the fake serves, so one handler covers the happy +// path, the degraded path, and the feature-absent path that drives the CLI's +// 404/501 degradation. Zero value = healthy and unauthenticated. +type State struct { + RequireAuth bool // admin routes 401 without a Bearer token + AcceptKey string // the credential RequireAuth accepts (default "fgw_test") + // Degraded is a HEALTHY gateway in trouble: /health stays 200 "ok" with a + // half-open circuit, and /readyz reports one target unroutable. Verified + // against a live gateway — a circuit does not make /health non-200. + Degraded bool + // NoProviders is the separate axis: no credential is configured, so + // /health is 503 "no_providers" with an EMPTY providers array. This is the + // default state of a credential-free gateway, and it is the one thing that + // makes /health non-200. + NoProviders bool + NoLogStore bool // /admin/logs and /admin/logs/stats return 501 + NoSessions bool // /admin/sessions returns 501 + NoMCP bool // omit mcp_servers from /readyz and /admin/health + ChatFails bool // emit a mid-stream error frame instead of [DONE] + ChatDelay time.Duration // per-chunk delay; 0 in tests, ~60ms for a visible demo +} + +// Default is the world most tests want: healthy, authenticated, every feature +// present. +func Default() State { + return State{RequireAuth: true, AcceptKey: defaultKey} +} diff --git a/internal/fixture/stream.go b/internal/fixture/stream.go new file mode 100644 index 0000000..76c94f7 --- /dev/null +++ b/internal/fixture/stream.go @@ -0,0 +1,163 @@ +package fixture + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// registerChat wires the streaming chat surface. +// +// Only streaming requests are supported because the CLI contract defines the +// SSE response and the playground always streams. +func registerChat(mux *http.ServeMux, s State, tr *tracer) { + mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + in, err := decodeChatRequest(r.Body) + if err != nil { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, "malformed request body") + return + } + if in.Stream == nil || !*in.Stream { + writeErr(w, http.StatusBadRequest, kindInvalidRequest, "stream must be true") + return + } + if in.Model == "" { + in.Model = modelSonnet + } + // The X-Request-ID the middleware minted is the log row's trace_id. + // Recording it here is what closes the attribution loop the playground + // depends on: /admin/logs answers with a row carrying this exact id. + trace := w.Header().Get("X-Request-ID") + tr.set(trace, in.Model) + streamChat(w, s, in.Model, trace) + }) +} + +type chatInput struct { + Model string + Stream *bool +} + +func decodeChatRequest(r io.Reader) (chatInput, error) { + dec := json.NewDecoder(r) + tok, err := dec.Token() + if err != nil || tok != json.Delim('{') { + return chatInput{}, fmt.Errorf("chat request must be an object") + } + var in chatInput + sawStream := false + for dec.More() { + tok, err = dec.Token() + if err != nil { + return chatInput{}, err + } + key, ok := tok.(string) + if !ok { + return chatInput{}, fmt.Errorf("chat request member must be a string") + } + switch key { + case fieldModel: + err = dec.Decode(&in.Model) + case "stream": + if sawStream { + return chatInput{}, fmt.Errorf("duplicate stream member") + } + sawStream = true + err = dec.Decode(&in.Stream) + default: + err = dec.Decode(&json.RawMessage{}) + } + if err != nil { + return chatInput{}, err + } + } + if _, err = dec.Token(); err != nil { + return chatInput{}, err + } + if err = dec.Decode(&struct{}{}); err != io.EOF { + return chatInput{}, fmt.Errorf("chat request must contain one JSON value") + } + return in, nil +} + +// streamedText is the reply the fake types out, one delta per element. +var streamedText = []string{"Ferro ", "routed ", "this ", "through ", "the ", "fake ", "gateway."} + +// streamChat writes the documented SSE framing: `data: \n\n` lines only, +// no `event:` names, terminated by `data: [DONE]` — except on an errored +// stream, which ends with an error frame and NO [DONE]. That asymmetry is real +// gateway behavior; a fake that smoothed it over would hide the CLI bug it +// exists to catch. +func streamChat(w http.ResponseWriter, s State, model, id string) { + h := w.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + flusher, canFlush := w.(http.Flusher) + // Flush per frame only when a delay is configured (the demo path, where a + // human watches text arrive). With ChatDelay == 0 the whole stream is + // written in one shot, so a test that does a single Read observes complete + // framing instead of racing the flusher. + live := s.ChatDelay > 0 + created := time.Now().Unix() + + frame := func(v any) { + b, _ := json.Marshal(v) + _, _ = fmt.Fprintf(w, "data: %s\n\n", b) + if live && canFlush { + flusher.Flush() + time.Sleep(s.ChatDelay) + } + } + chunk := func(choices []map[string]any) map[string]any { + return map[string]any{ + "id": id, + fieldObject: "chat.completion.chunk", + fieldCreated: created, + fieldModel: model, + "choices": choices, + } + } + delta := func(d map[string]any) []map[string]any { + return []map[string]any{{"index": 0, "delta": d}} + } + + frame(chunk(delta(map[string]any{"role": "assistant"}))) + + for i, word := range streamedText { + if s.ChatFails && i == 2 { + // Mid-stream failure: error frame, then the stream just ends. + frame(map[string]any{fieldError: map[string]any{ + fieldMessage: "upstream error from provider anthropic", + fieldType: "stream_error", + "code": "stream_error", + }}) + if canFlush { + flusher.Flush() + } + return + } + frame(chunk(delta(map[string]any{"content": word}))) + } + + frame(chunk([]map[string]any{{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}})) + + // The usage chunk always arrives (the gateway strips it only on an explicit + // include_usage:false); choices is [], never null. + usage := chunk([]map[string]any{}) + usage["usage"] = map[string]any{ + "prompt_tokens": 24, + "completion_tokens": len(streamedText), + "total_tokens": 24 + len(streamedText), + } + frame(usage) + + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + if canFlush { + flusher.Flush() + } +} diff --git a/internal/fixture/wire.go b/internal/fixture/wire.go new file mode 100644 index 0000000..00a7f11 --- /dev/null +++ b/internal/fixture/wire.go @@ -0,0 +1,148 @@ +package fixture + +// The gateway's wire vocabulary, in one place. +// +// This package is the in-repo definition of the gateway's HTTP contract, and +// the same field names recur across handlers that were written months apart — +// `status` alone appears in the health surface, the key surface, the plugin +// catalog and the model catalog. Spelled inline, one of them can drift from +// the rest and still compile, and the fixture then teaches every downstream +// test the wrong name for a field. +// +// The rule the file follows: a spelling written in three or more places gets a +// constant. A spelling below that gets one only when it completes a set already +// named here — the other provider, the other plugin type, the other stage — so +// no vocabulary ends up half constant and half literal. Everything a single +// handler writes once stays inline, where the wire shape reads as the wire. + +// JSON field names served on the wire. /admin/logs and /admin/audit name their +// query parameters after the fields they filter on, deliberately, so the +// filters share these constants with the rows they select. +const ( + fieldAction = "action" + fieldActor = "actor" + fieldActorID = "actor_id" + fieldAPIKeyID = "api_key_id" + fieldCapabilities = "capabilities" + fieldCircuit = "circuit" + fieldContextWindow = "context_window" + fieldCount = "count" + fieldCreated = "created" + fieldData = "data" + fieldDeprecated = "deprecated" + fieldDetail = "detail" + fieldEnabled = "enabled" + fieldError = "error" + fieldFailsOpen = "fails_open" + fieldLimit = "limit" + fieldMaxOutputTokens = "max_output_tokens" + fieldMessage = "message" + fieldMode = "mode" + fieldModel = "model" + fieldModels = "models" + fieldName = "name" + fieldObject = "object" + fieldOccurredAt = "occurred_at" + fieldOffset = "offset" + fieldOutcome = "outcome" + fieldOwnedBy = "owned_by" + fieldProvider = "provider" + fieldProviders = "providers" + fieldReady = "ready" + fieldScopes = "scopes" + fieldSettings = "settings" + fieldSince = "since" + fieldSourceIP = "source_ip" + fieldStage = "stage" + fieldStatus = "status" + fieldSummary = "summary" + fieldTotalEntries = "total_entries" + fieldType = "type" +) + +// The error envelope's `type`/`code` vocabulary, as writeErr serves it. +const ( + kindAuthentication = "authentication_error" + kindInvalidRequest = "invalid_request_error" + kindNotFound = "not_found_error" + kindServer = "server_error" +) + +// msgKeyNotFound is the one message three key routes share. +const msgKeyNotFound = "key not found" + +// Provider and model identity, repeated across /health, /readyz, /admin/health, +// /admin/providers, /admin/logs and /v1/models. +const ( + providerAnthropic = "anthropic" + providerOpenAI = "openai" + + modelSonnet = "claude-sonnet-4-6" + modelOpus = "claude-opus-4-6" + modelGPT4o = "gpt-4o" + modelGPT4oMini = "gpt-4o-mini" + modelEmbedding3Small = "text-embedding-3-small" + + // objectModel is the OpenAI object type a /v1/models row reports. It shares + // a spelling with fieldModel and means something else: one is the name of a + // field, the other is a value that field never carries. + objectModel = "model" +) + +// The closed vocabularies the gateway serves: key scopes, plugin stages, plugin +// types, the health words the CLI branches on, and the audit outcomes +// /admin/audit validates its query against. statusOK and outcomeOK share a +// spelling and nothing else — a health status and an audit outcome are separate +// sets that happen to have both settled on "ok". +const ( + scopeAdmin = "admin" + scopeReadOnly = "read_only" + + stageBeforeRequest = "before_request" + stageAfterRequest = "after_request" + stageOnError = "on_error" + + typeGuardrail = "guardrail" + typeLogging = "logging" + typeRateLimit = "ratelimit" + + // Every value the fixture ever serves under `status` is named here, so a + // reader can see the whole vocabulary the CLI branches on in one place and + // a new one cannot be added inline without joining it. + statusOK = "ok" + statusHealthy = "healthy" + statusDegraded = "degraded" + statusDisabled = "disabled" + statusAvailable = "available" + statusStable = "stable" + statusReady = "ready" + statusNotReady = "not_ready" + statusNoProviders = "no_providers" + statusRevoked = "revoked" + + circuitClosed = "closed" + circuitHalfOpen = "half_open" + + outcomeOK = "ok" + outcomeDenied = "denied" + outcomeError = "error" + + // modalityChat and modalityEmbedding are each both a model's mode and one + // of its capabilities — the same word for the same thing, so each is one + // constant rather than two. + modalityChat = "chat" + modalityEmbedding = "embedding" + capabilityStreaming = "streaming" + capabilityTools = "tools" + capabilityVision = "vision" +) + +// Seed identity: the ids and address the seeded rows cross-reference, so a key +// row, a session, an audit entry and a log row agree on which credential and +// which host they are talking about. +const ( + keyIDOps = "key_01" + keyIDCI = "key_02" + actorOps = "ops-laptop" + sourceIPLocal = "127.0.0.1" +) diff --git a/internal/table/format.go b/internal/table/format.go new file mode 100644 index 0000000..00deef2 --- /dev/null +++ b/internal/table/format.go @@ -0,0 +1,65 @@ +package table + +import ( + "fmt" + "time" +) + +// OrDash renders an empty string as "-". Every table cell that can be absent +// uses this rather than printing "": an empty cell collapses a column for +// anything splitting the table on whitespace, and reads as a blank status +// rather than as its absence. +func OrDash(s string) string { + if s == "" { + return "-" + } + return s +} + +// CountOrDash renders a count the gateway never supplied as "-", and one it +// supplied as the number it gave — a real zero included. Taking *int rather +// than int is what keeps the two apart: the gateway never asked and the +// gateway answered zero must not print the same cell. +func CountOrDash(n *int) string { + if n == nil { + return "-" + } + return fmt.Sprintf("%d", *n) +} + +// PositiveOrDash renders a measurement whose zero is not a real value — a +// model's context window, which no model publishes as 0 — so unlike +// CountOrDash a plain int is honest here: the two readings CountOrDash keeps +// apart genuinely coincide for this kind of value. +func PositiveOrDash(n int) string { + if n <= 0 { + return "-" + } + return fmt.Sprintf("%d", n) +} + +// BoolYN renders a bool as the table's yes/no vocabulary. +func BoolYN(b bool) string { + if b { + return "yes" + } + return "no" +} + +// FmtTime renders a wire timestamp in local time. RFC3339 keeps every table +// cell whitespace-free, so a piped table still splits into fields. +func FmtTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Local().Format(time.RFC3339) +} + +// FmtTimePtr renders an optional timestamp: "-" when the gateway did not +// supply one. +func FmtTimePtr(t *time.Time) string { + if t == nil { + return "-" + } + return FmtTime(*t) +} diff --git a/internal/table/models.go b/internal/table/models.go new file mode 100644 index 0000000..e0aa29c --- /dev/null +++ b/internal/table/models.go @@ -0,0 +1,36 @@ +package table + +import ( + "strconv" + + "github.com/ferro-labs/gateway-cli/internal/api" +) + +// ModelHeaders and ModelRows are the model listing's shape, held in one place +// because two callers render it: `ferro models` and the console's models verb. +// They were separate copies of the same six columns and the same six +// accessors, which is a drift waiting to happen — a column added to one reads +// as a column missing from the other, and the console's whole contract is that +// it shows what the scriptable verb shows. +// +// This is the shape NewPluginCatalog already established: the wire type is +// internal/api's, the presentation of it is here, and neither caller owns it. +var ModelHeaders = []string{"ID", "OWNED BY", "MODE", "CONTEXT", "CAPABILITIES", "STATUS"} + +// ModelRows renders models in ModelHeaders' order. Capabilities is a count, +// not a list: the names are unbounded and would push STATUS off a narrow +// terminal, and the count is what a listing is scanned for. +func ModelRows(models []api.Model) [][]string { + rows := make([][]string, 0, len(models)) + for _, m := range models { + rows = append(rows, []string{ + m.ID, + OrDash(m.OwnedBy), + OrDash(m.Mode), + PositiveOrDash(m.ContextWindow), + strconv.Itoa(len(m.Capabilities)), + OrDash(m.Status), + }) + } + return rows +} diff --git a/internal/table/plugins.go b/internal/table/plugins.go new file mode 100644 index 0000000..18a6adb --- /dev/null +++ b/internal/table/plugins.go @@ -0,0 +1,53 @@ +package table + +import "github.com/ferro-labs/gateway-cli/internal/api" + +// A plugin's fail policy is vocabulary the operator reads, so it is spelled +// once. "open" and "closed" answer what happens to a request when the plugin +// itself breaks: a logging or metrics plugin fails open and the request +// proceeds, while a guardrail, auth or rate limiter fails closed and the +// request does not. +const ( + FailsOpen = "open" + FailsClosed = "closed" +) + +// PluginPolicy is what this build's catalog says about one configured plugin. +// Fails is "-" when the catalog does not name the plugin at all — an unknown +// policy, which is a different claim from "closed". +type PluginPolicy struct { + Summary string + Fails string +} + +// PluginCatalog answers what a build ships, indexed by plugin name. +type PluginCatalog map[string]PluginPolicy + +// NewPluginCatalog indexes GET /admin/plugins/catalog. +// +// Only the catalog knows whether a plugin fails open, so both the scriptable +// table and the console must derive the FAILS cell from it. They each used to +// carry their own copy of this lookup and their own open/closed constants, +// which let one report a policy the other did not — for the same plugin, on +// the same gateway, in the same release. +func NewPluginCatalog(builtins []api.BuiltinPlugin) PluginCatalog { + c := make(PluginCatalog, len(builtins)) + for _, b := range builtins { + fails := FailsClosed + if b.FailsOpen { + fails = FailsOpen + } + c[b.Name] = PluginPolicy{Summary: b.Summary, Fails: fails} + } + return c +} + +// For resolves a configured plugin against the catalog. A plugin the build +// does not ship — registered out of tree — reports an unknown policy rather +// than being assumed to fail closed. +func (c PluginCatalog) For(name string) PluginPolicy { + if p, ok := c[name]; ok { + return p + } + return PluginPolicy{Fails: "-"} +} diff --git a/internal/table/providers.go b/internal/table/providers.go new file mode 100644 index 0000000..8998ed6 --- /dev/null +++ b/internal/table/providers.go @@ -0,0 +1,94 @@ +package table + +import ( + "strconv" + + "github.com/ferro-labs/gateway-cli/internal/api" +) + +// ProviderHeaders and ProviderRows are the provider listing's shape, and +// MergeProviders is the reading they render, held in one place because two +// callers print it: `ferro providers` and the console's providers verb. They +// each carried their own copy of the merge, under near-identical comments +// claiming they did the same thing, and they did not: the console iterated +// /admin/health's list alone, so a provider /health reported and /admin/health +// did not was a row in the pipe and no row on screen — two answers for one +// gateway at one instant. The union here loses nothing, and is now the only one. +// +// This is the shape models.go and plugins.go already established: the wire +// types are internal/api's, the presentation of them is here, and neither +// caller owns it. +var ProviderHeaders = []string{"PROVIDER", "STATUS", "CIRCUIT", "MODELS", "MESSAGE"} + +// ProviderRow is the merged view: no single endpoint serves all five columns. +// /health is unauthenticated and carries the circuit; /admin/health needs a +// credential and carries the status, the model count and the failure message. +// +// The json tags are `ferro providers --format json|yaml`'s output contract and +// are frozen — which is why the whole type moved here rather than the console +// growing a second one beside it. A dash is a rendering of absence, so it is +// applied in ProviderRows and never stored: these fields stay literally what +// the gateway said, empty string included. +type ProviderRow struct { + Name string `json:"name"` + Status string `json:"status"` + Circuit string `json:"circuit"` + Models int `json:"models"` + Message string `json:"message,omitempty"` +} + +// MergeProviders unions the two listings, ah being nil when no credential +// worked — unauthenticated is a smaller answer, not an error. +// +// /admin/health's reading wins where both endpoints name a provider: it is the +// only one carrying a message, and its status vocabulary is the richer one. +// /health's circuit is grafted on because /admin/health does not report it. +// Providers only /health knows are appended rather than dropped: /admin/health +// listing fewer of them is an omission by that endpoint, not a provider that +// stopped existing. +func MergeProviders(h *api.HealthReport, ah *api.AdminHealth) []ProviderRow { + circuits := make(map[string]string, len(h.Providers)) + for _, p := range h.Providers { + circuits[p.Name] = p.Circuit + } + + // No credential is just an empty authenticated listing, so both loops run + // either way and the union has one implementation rather than two. + var admin []api.AdminProviderHealth + if ah != nil { + admin = ah.Providers + } + rows := make([]ProviderRow, 0, max(len(admin), len(h.Providers))) + seen := make(map[string]struct{}, len(admin)) + for _, p := range admin { + seen[p.Name] = struct{}{} + rows = append(rows, ProviderRow{ + Name: p.Name, Status: p.Status, Circuit: circuits[p.Name], + Models: p.Models, Message: p.Message, + }) + } + for _, p := range h.Providers { + if _, ok := seen[p.Name]; ok { + continue + } + rows = append(rows, ProviderRow{ + Name: p.Name, Status: p.Status, Circuit: p.Circuit, Models: p.Models, + }) + } + return rows +} + +// ProviderRows renders merged rows in ProviderHeaders' order. Every cell that +// can be absent goes through OrDash: an empty one collapses the column for +// anything splitting this table on whitespace, and reads as a blank status +// rather than as its absence. MODELS is the exception — a provider routing +// zero models is a reading the gateway gave, so it prints 0. +func ProviderRows(rows []ProviderRow) [][]string { + cells := make([][]string, 0, len(rows)) + for _, r := range rows { + cells = append(cells, []string{ + r.Name, OrDash(r.Status), OrDash(r.Circuit), strconv.Itoa(r.Models), OrDash(r.Message), + }) + } + return cells +} diff --git a/internal/table/providers_test.go b/internal/table/providers_test.go new file mode 100644 index 0000000..072851a --- /dev/null +++ b/internal/table/providers_test.go @@ -0,0 +1,96 @@ +package table + +import ( + "slices" + "testing" + + "github.com/ferro-labs/gateway-cli/internal/api" +) + +// The listing the merge exists for: /health names two providers, /admin/health +// knows about fewer of them, and neither endpoint alone fills the five columns. +var providerHealth = &api.HealthReport{Providers: []api.ProviderHealth{ + {Name: "openai", Status: "available", Circuit: "closed", Models: 1104}, + {Name: "anthropic", Status: "available", Circuit: "half_open", Models: 412}, +}} + +// The console used to iterate /admin/health's list alone, so a provider that +// endpoint omitted disappeared from the screen while the pipe still printed +// it. Every case below asserts the whole row set, not a count: the bug was a +// missing row, and a length check would have missed which one. +func TestMergeProvidersUnionsBothListings(t *testing.T) { + for _, tc := range []struct { + name string + admin *api.AdminHealth + want []ProviderRow + }{ + { + // Unauthenticated is a smaller answer, not an error: /health alone + // still names every provider and its circuit, and no row carries a + // message because only /admin/health serves one. + name: "no credential leaves /health answering alone", + admin: nil, + want: []ProviderRow{ + {Name: "openai", Status: "available", Circuit: "closed", Models: 1104}, + {Name: "anthropic", Status: "available", Circuit: "half_open", Models: 412}, + }, + }, + { + // Where both endpoints know a provider the authenticated reading + // wins — it is the one with a message — and /health's circuit is + // grafted on because /admin/health does not report it. + name: "admin's status, message and count win, /health's circuit is grafted on", + admin: &api.AdminHealth{Providers: []api.AdminProviderHealth{ + {Name: "openai", Status: "healthy", Models: 1104}, + {Name: "anthropic", Status: "degraded", Models: 3, Message: "rate limited upstream"}, + }}, + want: []ProviderRow{ + {Name: "openai", Status: "healthy", Circuit: "closed", Models: 1104}, + {Name: "anthropic", Status: "degraded", Circuit: "half_open", Models: 3, + Message: "rate limited upstream"}, + }, + }, + { + // The divergence itself: one row from /admin/health, one that only + // /health knows. Dropping the second is what the console did. + name: "a provider only /health reports still gets a row", + admin: &api.AdminHealth{Providers: []api.AdminProviderHealth{ + {Name: "openai", Status: "healthy", Models: 1104}, + }}, + want: []ProviderRow{ + {Name: "openai", Status: "healthy", Circuit: "closed", Models: 1104}, + {Name: "anthropic", Status: "available", Circuit: "half_open", Models: 412}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := MergeProviders(providerHealth, tc.admin); !slices.Equal(got, tc.want) { + t.Fatalf("merge lost or reshaped a row:\n got %+v\nwant %+v", got, tc.want) + } + }) + } +} + +// A dash is how absence renders: an empty cell collapses the column for +// anything splitting this table on whitespace, and reads as a blank status +// rather than as no status at all. MODELS is deliberately not dashed — zero +// models is a reading the gateway gave, not a value it withheld. +func TestProviderRowsDashEveryAbsentCell(t *testing.T) { + got := ProviderRows([]ProviderRow{{Name: "openai"}, {Name: "anthropic", Status: "healthy", + Circuit: "closed", Models: 412, Message: "recovering"}}) + want := [][]string{ + {"openai", "-", "-", "0", "-"}, + {"anthropic", "healthy", "closed", "412", "recovering"}, + } + if len(got) != len(want) { + t.Fatalf("want %d rows, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if !slices.Equal(got[i], want[i]) { + t.Fatalf("row %d: got %q, want %q", i, got[i], want[i]) + } + } + if len(want[0]) != len(ProviderHeaders) { + t.Fatalf("a row must have one cell per header: %d cells, %d headers", len(want[0]), len(ProviderHeaders)) + } +} diff --git a/internal/table/sanitize.go b/internal/table/sanitize.go new file mode 100644 index 0000000..205be84 --- /dev/null +++ b/internal/table/sanitize.go @@ -0,0 +1,39 @@ +package table + +import "strings" + +// A gateway-provided string — a provider name, a plugin summary, an upstream +// error message, a model's answer — is data reaching a terminal, never layout +// and never terminal control. A tab or newline injects a column, a row, or an +// extra line into a pane that owes an exact line count; ESC starts a sequence +// this tool has no business forwarding from an upstream provider to whatever +// is reading stdout. Every surface that renders one of those strings collapses +// them here, so the vocabulary cannot drift between surfaces. +// +// The whole control range goes, not an enumerated handful. Listing the four +// characters that had actually caused a bug (tab, CR, LF, ESC) left BEL ringing +// the terminal and NUL reaching it, and the next escape hatch would have been +// found the same way — by someone hitting it. A terminal reads more than ESC as +// control: C0 and DEL, and C1 0x80–0x9f, where 0x9b is a single-byte CSI that +// opens a sequence exactly as ESC[ does. +func sanitize(s string, keepLF bool) string { + return strings.Map(func(r rune) rune { + if r == '\n' && keepLF { + return r + } + if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + return ' ' + } + return r + }, s) +} + +// SanitizeCell returns s with every control character collapsed to a single +// space, LF included. For a value that occupies one cell of a table or one +// line of a pane, where a line break would break the caller's line count. +func SanitizeCell(s string) string { return sanitize(s, false) } + +// SanitizeText returns s with every control character collapsed to a single +// space except LF, which is kept: for a multi-line body whose line breaks are +// its own (a streamed answer), where the caller splits on \n itself. +func SanitizeText(s string) string { return sanitize(s, true) } diff --git a/internal/table/table.go b/internal/table/table.go new file mode 100644 index 0000000..bb979b6 --- /dev/null +++ b/internal/table/table.go @@ -0,0 +1,61 @@ +// Package table lays out headers and rows as an aligned, tab-padded table and +// renders the handful of "-"-for-missing-value cells that both cli/internal/tui +// and cli/internal/command print. It exists to break an import cycle: +// internal/command hands bare `ferro` off to internal/tui (see +// command/root.go's REPL wiring), so internal/tui can never import +// internal/command back — yet a verb read in the console and the same verb +// read through a pipe must render identically. This package imports neither +// of those two, and both call it instead of keeping their own copy. +package table + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/tabwriter" +) + +func normalizeCells(cells []string) []string { + out := make([]string, len(cells)) + for i, c := range cells { + out[i] = SanitizeCell(c) + } + return out +} + +// Write renders headers and rows as a plain, alignment-padded table to w: a +// header line, a dashed rule the width of each heading, then one line per +// row. No ANSI, no box drawing — this is the layout `ferro ` pipes to +// stdout, as likely to be read by awk as by a person. +func Write(w io.Writer, headers []string, rows [][]string) { + headers = normalizeCells(headers) + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + _, _ = fmt.Fprintln(tw, strings.Join(headers, "\t")) + + dashes := make([]string, len(headers)) + for i, h := range headers { + dashes[i] = strings.Repeat("-", len(h)) + } + _, _ = fmt.Fprintln(tw, strings.Join(dashes, "\t")) + + for _, r := range rows { + _, _ = fmt.Fprintln(tw, strings.Join(normalizeCells(r), "\t")) + } + _ = tw.Flush() +} + +// Rows renders headers and rows through the identical layout Write uses, but +// returns one string per output line — header, dashed rule, then one per data +// row — with trailing padding trimmed. It exists for a caller with no +// io.Writer of its own to hand tabwriter: the console transcript, which wraps +// each line as its own row and dims the header and its rule. +func Rows(headers []string, rows [][]string) []string { + var buf bytes.Buffer + Write(&buf, headers, rows) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + for i, l := range lines { + lines[i] = strings.TrimRight(l, " ") + } + return lines +} diff --git a/internal/table/table_test.go b/internal/table/table_test.go new file mode 100644 index 0000000..7186f04 --- /dev/null +++ b/internal/table/table_test.go @@ -0,0 +1,189 @@ +package table + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestWriteRendersHeaderRuleAndRows(t *testing.T) { + var buf bytes.Buffer + Write(&buf, []string{"NAME", "STATE"}, [][]string{{"openai", "closed"}}) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("want header + rule + 1 row, got %d lines:\n%s", len(lines), buf.String()) + } + if !strings.HasPrefix(lines[0], "NAME") { + t.Fatalf("header line: %q", lines[0]) + } + if !strings.HasPrefix(lines[1], "----") { + t.Fatalf("rule must be dashes matching header width, got %q", lines[1]) + } + if !strings.Contains(lines[2], "openai") { + t.Fatalf("row line: %q", lines[2]) + } +} + +// Rows must be Write's output split into lines, not a second layout — that is +// the whole point of routing both callers through one implementation. +func TestRowsMatchesWrite(t *testing.T) { + headers := []string{"NAME", "READY"} + cells := [][]string{{"filesystem", "yes"}, {"a-much-longer-server-name", "no"}} + + var buf bytes.Buffer + Write(&buf, headers, cells) + want := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + for i := range want { + want[i] = strings.TrimRight(want[i], " ") + } + + got := Rows(headers, cells) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("Rows drifted from Write:\ngot: %q\nwant: %q", got, want) + } +} + +// A gateway-provided cell — a provider name, a plugin summary, an upstream +// error message — is attacker-influenced data reaching a terminal. A tab or +// newline must not inject a column or row, and an ESC must not reach the +// terminal as a live escape sequence. +func TestWriteNormalizesControlCharactersInCells(t *testing.T) { + var buf bytes.Buffer + Write(&buf, []string{"NAME"}, [][]string{{"evil\tname\r\ninjected\x1b[2Jrow"}}) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("control characters in a cell must not inject rows, got %d lines:\n%q", len(lines), buf.String()) + } + if strings.ContainsAny(lines[2], "\t\r\n\x1b") { + t.Fatalf("row line still carries a raw control character: %q", lines[2]) + } + + rows := Rows([]string{"NAME"}, [][]string{{"evil\tname\r\ninjected\x1b[2Jrow"}}) + if len(rows) != 3 { + t.Fatalf("Rows must not split an injected newline into a separate transcript record, got %d rows: %q", len(rows), rows) + } +} + +func TestOrDash(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"", "-"}, + {"anthropic", "anthropic"}, + } { + if got := OrDash(tc.in); got != tc.want { + t.Errorf("OrDash(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCountOrDashDistinguishesNilFromZero(t *testing.T) { + zero := 0 + five := 5 + for _, tc := range []struct { + name string + in *int + want string + }{ + {"nil means never asked", nil, "-"}, + {"zero is a real answer", &zero, "0"}, + {"a positive count", &five, "5"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := CountOrDash(tc.in); got != tc.want { + t.Errorf("CountOrDash(%v) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestPositiveOrDashTreatsZeroAsAbsent(t *testing.T) { + for _, tc := range []struct { + name string + in int + want string + }{ + {"zero is not a real context window", 0, "-"}, + {"negative is not a real context window", -1, "-"}, + {"a real context window", 200000, "200000"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := PositiveOrDash(tc.in); got != tc.want { + t.Errorf("PositiveOrDash(%d) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestBoolYN(t *testing.T) { + if BoolYN(true) != "yes" || BoolYN(false) != "no" { + t.Fatalf("BoolYN(true)=%q BoolYN(false)=%q", BoolYN(true), BoolYN(false)) + } +} + +func TestFmtTimeRendersLocalRFC3339(t *testing.T) { + if got := FmtTime(time.Time{}); got != "-" { + t.Fatalf("zero time must render as \"-\", got %q", got) + } + utc, err := time.Parse(time.RFC3339, "2026-08-08T09:00:00Z") + if err != nil { + t.Fatal(err) + } + // Pin the zone and assert a literal. Computing the expectation as + // utc.Local().Format(...) restates the implementation, and on a UTC + // machine — which CI is — it collapses to the same string however + // FmtTime handles zones, so the conversion would go untested precisely + // where it runs. FixedZone rather than LoadLocation because the test + // matrix includes Windows, where the tz database may not be present. + orig := time.Local + time.Local = time.FixedZone("IST", 5*60*60+30*60) + t.Cleanup(func() { time.Local = orig }) + + if got, want := FmtTime(utc), "2026-08-08T14:30:00+05:30"; got != want { + t.Fatalf("FmtTime must render in local time: got %q, want %q", got, want) + } +} + +func TestFmtTimePtr(t *testing.T) { + if got := FmtTimePtr(nil); got != "-" { + t.Fatalf("nil pointer must render as \"-\", got %q", got) + } + utc, err := time.Parse(time.RFC3339, "2026-08-08T09:00:00Z") + if err != nil { + t.Fatal(err) + } + if got, want := FmtTimePtr(&utc), FmtTime(utc); got != want { + t.Fatalf("FmtTimePtr(&t) = %q, want %q", got, want) + } +} + +// SanitizeCell and SanitizeText are the one vocabulary every surface that +// renders a gateway string shares. They differ on LF alone: a line break is +// injected layout in a table cell and content in a streamed body. +func TestSanitizeCellAndText(t *testing.T) { + for _, tc := range []struct { + name, in, cell, text string + }{ + {"a clean value is untouched", "anthropic", "anthropic", "anthropic"}, + {"tab", "a\tb", "a b", "a b"}, + {"carriage return", "a\rb", "a b", "a b"}, + {"newline is layout in a cell, content in a body", "a\nb", "a b", "a\nb"}, + {"the ESC that starts a colour sequence", "a\x1b[31mb", "a [31mb", "a [31mb"}, + {"the ESC that starts an OSC title write", "a\x1b]0;pwned", "a ]0;pwned", "a ]0;pwned"}, + {"BEL, which rings the terminal on its own", "a\x07b", "a b", "a b"}, + {"NUL", "a\x00b", "a b", "a b"}, + {"DEL", "a\x7fb", "a b", "a b"}, + {"the C1 CSI, which opens a sequence without an ESC", "a\u009b31mb", "a 31mb", "a 31mb"}, + {"a multi-byte rune is not a control character", "日本語", "日本語", "日本語"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := SanitizeCell(tc.in); got != tc.cell { + t.Errorf("SanitizeCell(%q) = %q, want %q", tc.in, got, tc.cell) + } + if got := SanitizeText(tc.in); got != tc.text { + t.Errorf("SanitizeText(%q) = %q, want %q", tc.in, got, tc.text) + } + }) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..675f681 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,922 @@ +// Package tui is ferro's full-screen operations console. +// +// One root model (App) owns navigation, polling and layout; each screen is a +// plain struct with pure update/view funcs, so only this file touches the +// Bubble Tea interface and a v2 API surprise is contained to one adapter. +// +// Two rules make the whole package testable and honest: +// +// - Update delegates to the unexported update, which mutates the App and +// returns a command. Tests call it directly — no terminal, no program loop. +// - Nothing here performs I/O from a view func. Every request lives in a +// tea.Cmd in msgs.go and comes back as a typed message. +// +// Measurements are in terminal cells (lipgloss.Width), never bytes: a frame +// padded with len() breaks the first time a CJK model name or a styled glyph +// arrives. +package tui + +import ( + "fmt" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/config" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" + "github.com/ferro-labs/gateway-cli/internal/version" +) + +// Screen is the pane the main frame is currently showing. +type Screen int + +// The screens the console can show. Each owns a pane; the shell owns the +// header, rail, composer, and polling. +const ( + ScreenHome Screen = iota + ScreenLogs + ScreenKeys + ScreenPlayground +) + +// Responsive breakpoints and the fixed rail geometry. +// Never render a border that cannot close within the measured width. +const ( + wideMin = 110 // mark + left rail + panels + mediumMin = 80 // compact brand + STATUS frame + railWidth = 34 + railGap = 2 + markGap = 3 // between the mark panel and the text stack beside it + + // minSplitWidth is the narrowest main frame that can carry the + // TARGETS│TRAFFIC split header with both titles and a closing rule. + minSplitWidth = 30 + + // providerRows caps the rail's provider list; the remainder collapses + // into a "+N more" line rather than pushing SERVICES off screen. + providerRows = 6 + + // unknownCount marks a rail counter this gateway does not serve (404/501) + // or would not answer without a credential. It renders as a dash, never 0. + unknownCount = -1 + + // emDash is the one honest answer for a value that does not exist on the + // wire. There is no gateway version over HTTP, so the header prints this. + emDash = "—" +) + +// The connection states the header reads. The first two are the gateway's own +// words, matched against what /health reported; the third is ours, for a +// gateway that reported nothing at all. +const ( + stateConnected = "connected" + stateDegraded = "degraded" + stateUnreachable = "unreachable" +) + +// The provider states that count as working. Anything else earns the warning +// glyph, so a status this console has never heard of is shown as a concern +// rather than silently as health. +const ( + statusHealthy = "healthy" + statusAvailable = "available" +) + +// RailData is the left rail's four sources, already reduced to what it draws. +// A count of unknownCount means "not served", which is not the same as zero. +type RailData struct { + // Providers is the last GOOD list, not this round's. fetchRail carries the + // previous snapshot forward, so a 401 or a failed admin-health round leaves + // what was already on screen there rather than blanking the rail; AuthError + // is what says this round could not confirm it. It is nil only before the + // first successful fetch, or on a gateway that reported none. + Providers []api.AdminProviderHealth + MCPReady, MCPTotal int + PluginsActive int + Sessions int + AuditToday int + AuthError bool +} + +// App is the root model. Every field the screens read lives here; the polling +// bookkeeping below the line stays unexported. +type App struct { + Client *api.Client + Profile config.Resolved + Theme theme.Theme + + Width, Height int + Screen Screen + + // Composer is the console's only navigation surface; Transcript is what + // the home screen shows. Both are values: the App owns them outright. + Composer Composer + Transcript Transcript + + // The three screens below Home. Each is a value the App owns outright and + // each carries its own generation counter, so a message from a screen the + // operator has left is dropped rather than applied — the same contract + // pollGen is reserved to give the shell's own polls. + Logs logsScreen + Keys keysScreen + Play playScreen + + // Modal is the console's one overlay. It renders in place of the pane and + // takes every key while it is open, which is what makes a typed + // confirmation a confirmation rather than a race with the command line. + Modal *Modal + + // Status is the last GOOD report. A failed poll sets StatusErr and leaves + // this alone, so one dropped refresh never blanks the header — it ages it. + Status *api.StatusReport + StatusErr error + StatusAt time.Time + + Traffic *api.LogStats // nil → the TRAFFIC panel renders "—" + TrafficErr error + + Rail RailData + RailErr error + + // pollGen is the generation every shell poll is issued under and matched + // against on arrival, so a slow in-flight response cannot overwrite state + // fetched against a newer connection. It is RESERVED: nothing bumps it and + // it is always zero, because v0.1 has neither a reconnect nor a profile + // switch to bump it for. The guards below are the seam either would land + // on; the screens' own gen counters are the live ones. + pollGen int + + statusEvery, trafficEvery time.Duration +} + +var _ tea.Model = (*App)(nil) + +func newApp(client *api.Client, profile config.Resolved, mode theme.Mode) *App { + return &App{ + Client: client, + Profile: profile, + Theme: theme.New(mode), + // Replaced by the first WindowSizeMsg; a sane default keeps the very + // first frame from drawing at 0 cells. + Width: 100, + Height: 30, + Screen: ScreenHome, + Rail: RailData{PluginsActive: unknownCount, Sessions: unknownCount, AuditToday: unknownCount}, + Composer: newComposer(Verbs(nil)), + statusEvery: statusPoll, + trafficEvery: trafficPoll, + } +} + +// Run starts the console. The caller has already established that stdout is a +// terminal — a bare `ferro` on a pipe must never draw this. +// +// root is the caller's own cobra tree, and the composer's vocabulary is walked +// out of it: the console completes exactly what the CLI can run. It is passed +// in rather than built here because internal/command is what hands bare `ferro` +// to this function, so importing it back would be a cycle. +func Run(client *api.Client, profile config.Resolved, mode theme.Mode, root *cobra.Command) error { + a := newApp(client, profile, mode) + a.Composer = newComposer(Verbs(root)) + _, err := tea.NewProgram(a).Run() + return err +} + +// Init starts the polling loops. Part of tea.Model. +func (a *App) Init() tea.Cmd { + return tea.Batch( + fetchStatus(a.Client, a.pollGen), + fetchRail(a.Client, a.pollGen, a.Rail), + fetchTraffic(a.Client, a.pollGen), + tickStatus(a.statusEvery), + tickTraffic(a.trafficEvery), + ) +} + +// Update delegates to update, which mutates a and returns a command. Tests +// drive that directly, with no terminal. Part of tea.Model. +func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.update(msg) } + +// View is the only adapter that knows about tea.View. Everything else — every +// screen, every test — deals in strings. +func (a *App) View() tea.View { + v := tea.NewView(a.render()) + v.AltScreen = true + return v +} + +func (a *App) update(msg tea.Msg) tea.Cmd { + switch m := msg.(type) { + case tea.WindowSizeMsg: + a.Width, a.Height = m.Width, m.Height + // The playground lays its markdown out ahead of the view, so it is the + // one screen that has to be told the pane changed size. + a.Play.resize(a.paneWidth()) + return nil + + case tea.KeyPressMsg: + key := m.String() + if key == keyCtrlC { + a.Play.leave() + a.Logs.leave() + a.Keys.leave() + return tea.Quit + } + if a.Modal != nil { + // An open modal owns the keyboard outright. A confirmation whose + // keys also reach the command line is not a confirmation. + return a.Modal.handle(key, a) + } + prev := a.Screen + if a.screenKey(key) { + return nil + } + // The composer owns every other key, esc included: in reverse search + // esc abandons the search, and only outside it does esc mean "back". + return a.left(prev, a.Composer.handle(key, a)) + + case runCmdMsg: + prev := a.Screen + return a.left(prev, a.route(m.raw)) + + case keysListMsg, modalResultMsg: + return a.Keys.update(a, msg) + + case logsBatchMsg, logsNamesMsg, tickLogsMsg: + return a.Logs.update(a, msg) + + case playModelsMsg, chatOpenMsg, chatDeltaMsg, chatUsageMsg, chatEndMsg, chatMetaMsg, flushTickMsg: + return a.Play.update(a, msg) + + case verbRowsMsg: + a.Transcript.Push(m.rows...) + if m.err != nil { + // The gateway's own words, not a paraphrase: an api.Error already + // says what failed and often what to do about it. + a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: m.err.Error()}) + } + a.Transcript.Push(TranscriptRow{ + Text: fmt.Sprintf("Completed in %dms", m.took.Milliseconds()), + Dim: true, + }) + return nil + + case statusMsg: + if m.gen != a.pollGen { + return nil + } + if m.err != nil { + a.StatusErr = m.err + a.statusEvery = backoff(a.statusEvery) + // api.Status returns a report alongside its error so the caller can + // still name the URL it tried. Adopt it only when nothing better is + // held — a good report is never replaced by a failure's. + if a.Status == nil && m.report != nil { + a.Status, a.StatusAt = m.report, time.Now() + } + return nil + } + a.Status, a.StatusErr, a.StatusAt = m.report, nil, time.Now() + a.statusEvery = statusPoll + return nil + + case trafficMsg: + if m.gen != a.pollGen { + return nil + } + if m.err != nil { + a.TrafficErr = m.err + a.trafficEvery = backoff(a.trafficEvery) + return nil + } + a.Traffic, a.TrafficErr = m.stats, nil + a.trafficEvery = trafficPoll + return nil + + case railMsg: + if m.gen != a.pollGen { + return nil + } + a.Rail, a.RailErr = m.data, m.err + return nil + + case tickStatusMsg: + return tea.Batch(fetchStatus(a.Client, a.pollGen), fetchRail(a.Client, a.pollGen, a.Rail), tickStatus(a.statusEvery)) + + case tickTrafficMsg: + return tea.Batch(fetchTraffic(a.Client, a.pollGen), tickTraffic(a.trafficEvery)) + } + return nil +} + +// backoff doubles a poll interval, capped, so an unreachable gateway is probed +// less often the longer it stays down. A success resets it at the call site. +func backoff(d time.Duration) time.Duration { + if d *= 2; d > maxPoll { + return maxPoll + } + return d +} + +// ---------------------------------------------------------------- rendering + +// render composes the whole screen. It is a pure function of App state: no +// network, no clock beyond the stale-age readout. +func (a *App) render() string { + wide := a.Width >= wideMin + medium := !wide && a.Width >= mediumMin + + var header string + if wide && a.Screen == ScreenHome { + header = a.viewBrandBlock() + } else { + header = a.compactBrand() + } + + mainW := a.Width + if wide { + mainW = a.Width - railWidth - railGap + } + + // Line budget: header + blank + body + composer + hints(1). The composer + // is measured rather than assumed: it grows by two lines when it has + // suggestions to show, and the pane must give those lines up so the header + // does not scroll off the top of the screen. + composer := a.composerView() + fixed := lipgloss.Height(header) + 2 + lipgloss.Height(composer) + overhead := 2 // a plain frame's top and bottom borders + if wide || medium { + overhead = 7 // top + 3 stat rows + divider + pane title + bottom + } + if medium { + fixed += 6 // the STATUS frame and its trailing newline + } + rows := max(a.Height-fixed-overhead, 1) + + main := a.mainFrame(mainW, wide || medium, rows) + + var b strings.Builder + b.WriteString(header) + b.WriteString("\n\n") + switch { + case wide: + rail := lipgloss.JoinVertical(lipgloss.Left, + a.Theme.Frame("CONNECTED PROVIDERS", a.viewProviders(), railWidth), + a.Theme.Frame("SERVICES", a.viewServices(), railWidth)) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, rail, strings.Repeat(" ", railGap), main)) + case medium: + b.WriteString(a.Theme.Frame("STATUS", a.viewCompactStatus(), a.Width)) + b.WriteString("\n") + b.WriteString(main) + default: + b.WriteString(main) + } + b.WriteString("\n") + b.WriteString(composer) + b.WriteString("\n") + b.WriteString(a.viewHints()) + return b.String() +} + +// viewBrandBlock is the wide home header: the split-F panel, the text stack, +// and the connection readout flush against the right edge. Identity reads +// left, live state reads right, and the two never compete for the same line. +// Every value in it is real — see stateBlock and originLine. +func (a *App) viewBrandBlock() string { + mark := theme.Mark(a.Theme) + state := a.stateBlock() + stateW := 0 + for _, l := range state { + stateW = max(stateW, lipgloss.Width(l)) + } + // The three columns are measured to total exactly a.Width, so the readout + // lands on the last cell of the screen however long the URL is; the stack + // is the column that gives, because it is the one that can truncate + // without losing a fact. + stackW := max(a.Width-lipgloss.Width(mark)-markGap-stateW, 10) + stack := clampLines([]string{ + a.brand() + " " + a.Theme.Dim.Render(version.Version), + a.Theme.Border.Render(a.rule(10)), + a.Theme.Text.Render("AI Gateway"), + a.Theme.Dim.Render(a.originLine()), + }, stackW) + for i, l := range stack { + stack[i] = padRight(l, stackW) + } + // One blank row first: the readout sits against the stack's body rather + // than level with the wordmark, which owns the top line alone. + col := make([]string, 0, 1+len(state)) + col = append(col, "") + for _, l := range state { + col = append(col, padLeft(l, stateW)) + } + // Centred: the panel stands one row taller than the text at each end, so + // the stack sits inside its span rather than hanging off its top border. + return lipgloss.JoinHorizontal(lipgloss.Center, + mark, strings.Repeat(" ", markGap), + strings.Join(stack, "\n"), strings.Join(col, "\n")) +} + +// compactBrand is the one-line header every other width and screen gets: the +// artwork collapses before any operational data does. +func (a *App) compactBrand() string { + line := a.brand() + " " + a.Theme.Dim.Render(version.Version) + " " + a.stateLine() + return ansi.Truncate(line, a.Width, "") +} + +// brand is the wordmark. Bold is still an ANSI escape, so it is gated on the +// same Color switch NO_COLOR and a non-TTY stdout turn off. +func (a *App) brand() string { + return a.Theme.Bright.Bold(a.Theme.Mode.Color).Render("Ferro Labs") +} + +// stateGlyph is the connection state as glyph, label and colour, taken from +// the report's own state. Both headers read it, so the compact line and the +// wide block can never disagree about what the gateway is doing. +func (a *App) stateGlyph() (string, string, lipgloss.Style) { + g := a.Theme.Mode.Glyphs() + if a.Status == nil { + if a.StatusErr != nil { + return g.Bad, stateUnreachable, a.Theme.Bad + } + return g.None, "connecting", a.Theme.Dim + } + switch a.Status.State { + case stateConnected: + return g.Dot, stateConnected, a.Theme.OK + case stateDegraded: + return g.Warn, stateDegraded, a.Theme.Warn + } + return g.Bad, a.Status.State, a.Theme.Bad +} + +// stateBlock is the wide header's right-hand readout: state, origin, round +// trip — one fact per line so the eye lands on the state before the URL. Same +// data stateLine carries, laid out for a column instead of a line. +func (a *App) stateBlock() []string { + glyph, label, style := a.stateGlyph() + origin := a.url() + if a.Profile.ProfileName != "" { + origin = a.Profile.ProfileName + " · " + origin + } + lines := []string{style.Render(glyph + " " + label), a.Theme.Dim.Render(origin)} + // A failed poll takes the third line: how old the data is matters more + // than how fast the request that fetched it was. + switch { + case a.StatusErr != nil && !a.StatusAt.IsZero(): + lines = append(lines, a.Theme.Dim.Render(fmt.Sprintf("stale %ds", int(time.Since(a.StatusAt).Seconds())))) + case a.StatusErr != nil: + lines = append(lines, a.Theme.Dim.Render("stale")) + case a.Status != nil && a.Status.LatencyMs > 0: + lines = append(lines, a.Theme.Dim.Render(fmt.Sprintf("%dms round trip", a.Status.LatencyMs))) + } + return lines +} + +// stateLine is the one-line connection readout: glyph and colour from the +// report's own state, then profile, URL and the client-measured round trip. +// When the last poll failed the line keeps its data and gains a staleness age. +func (a *App) stateLine() string { + glyph, label, style := a.stateGlyph() + parts := []string{} + if a.Profile.ProfileName != "" { + parts = append(parts, a.Profile.ProfileName) + } + parts = append(parts, a.url()) + if a.Status != nil && a.Status.LatencyMs > 0 { + parts = append(parts, fmt.Sprintf("%dms", a.Status.LatencyMs)) + } + if a.StatusErr != nil && !a.StatusAt.IsZero() { + parts = append(parts, fmt.Sprintf("stale %ds", int(time.Since(a.StatusAt).Seconds()))) + } else if a.StatusErr != nil { + parts = append(parts, "stale") + } + return style.Render(glyph+" "+label) + a.Theme.Dim.Render(" · "+strings.Join(parts, " · ")) +} + +// originLine is the counts row. The gateway serves no version over HTTP, so +// that segment is a dash — never a guess. +func (a *App) originLine() string { + parts := []string{"gateway " + a.dash()} + if a.Status != nil { + // A gateway that did not report targets gets a dash. /readyz answers a + // 503 carrying only {status, reason}, and "0 targets" there would read + // as "none configured" during exactly the outage this header is read in. + if t := a.Status.Targets; t != nil { + parts = append(parts, fmt.Sprintf("%d targets", t.Total)) + } else { + parts = append(parts, a.dash()+" targets") + } + if n := a.Status.Models; n != nil { + parts = append(parts, comma(*n)+" models") + } + } + return strings.Join(parts, " · ") +} + +func (a *App) url() string { + if a.Status != nil && a.Status.URL != "" { + return a.Status.URL + } + return a.Profile.URL +} + +// ------------------------------------------------------------------- panels + +// mainFrame draws the console's main frame. With panels it carries the split +// header layout that theme.Frame cannot express: +// +// ┌─ TARGETS ──────────┬─ TRAFFIC ──────────┐ +// │ Healthy 7 │ RPS 159.0 │ +// ├────────────────────┴────────────────────┤ +// │ COMMAND OUTPUT │ +// └─────────────────────────────────────────┘ +// +// This is the one frame drawn outside the theme; everything else goes through +// it. Below minSplitWidth the split cannot close, so the pane falls back to a +// plain titled frame. +func (a *App) mainFrame(w int, panels bool, rows int) string { + if !panels || w < minSplitWidth { + return a.Theme.Frame(a.paneTitle(), a.paneView(w-4, rows), w) + } + b := a.boxChars() + bd := a.Theme.Border + lw := (w - 3) / 2 + rw := w - 3 - lw + + out := make([]string, 0, rows+7) + out = append(out, bd.Render(b.tl+b.h)+" "+a.Theme.Dim.Render("TARGETS")+" "+ + bd.Render(strings.Repeat(b.h, lw-10)+b.tj+b.h)+" "+a.Theme.Dim.Render("TRAFFIC")+" "+ + bd.Render(strings.Repeat(b.h, rw-10)+b.tr)) + + targets, traffic := a.viewTargets(), a.viewTraffic() + edge := bd.Render(b.v) + for i := range 3 { + out = append(out, edge+" "+padRight(targets[i], lw-2)+" "+edge+" "+padRight(traffic[i], rw-2)+" "+edge) + } + out = append(out, bd.Render(b.lj+strings.Repeat(b.h, lw)+b.bj+strings.Repeat(b.h, rw)+b.rj)) + + body := append([]string{a.Theme.Bright.Render(a.paneTitle())}, strings.Split(a.paneView(w-4, rows), "\n")...) + for _, line := range body { + out = append(out, edge+" "+padRight(line, w-4)+" "+edge) + } + out = append(out, bd.Render(b.bl+strings.Repeat(b.h, w-2)+b.br)) + return strings.Join(out, "\n") +} + +// viewTargets is the TARGETS panel: routable targets, and the two circuit +// states that explain the difference. No report → dashes, never zeros. +func (a *App) viewTargets() [3]string { + if a.Status == nil { + d := a.dash() + return [3]string{ + padRight("Healthy", 14) + d, + padRight("Degraded", 14) + d, + padRight("Open circuit", 14) + d, + } + } + warn := func(n int, style lipgloss.Style) string { + if n > 0 { + return style.Render(strconv.Itoa(n)) + } + return strconv.Itoa(n) + } + // Circuits are reported by /health, which answers even when /readyz is a + // bare 503 — so they stay real while the target counts go absent. + healthy := a.Theme.Dim.Render(a.dash()) + if t := a.Status.Targets; t != nil { + healthy = a.Theme.OK.Render(strconv.Itoa(t.Routable)) + } + return [3]string{ + padRight("Healthy", 14) + healthy, + padRight("Degraded", 14) + warn(a.Status.Circuits.HalfOpen, a.Theme.Warn), + padRight("Open circuit", 14) + warn(a.Status.Circuits.Open, a.Theme.Bad), + } +} + +// viewTraffic derives the panel from one /admin/logs/stats window. Every +// unknown renders as a dash: a gateway with no log store answers 501, which is +// an absence of data, not a zero and not an error state. +func (a *App) viewTraffic() [3]string { + rps, p95, errRate := a.dash(), a.dash(), a.dash() + if t := a.Traffic; t != nil { + rps = fmt.Sprintf("%.1f", float64(t.Summary.TotalEntries)/trafficWindow.Seconds()) + if t.LatencyMs != nil { + p95 = fmt.Sprintf("%.0fms", t.LatencyMs.P95) + } + if t.Summary.TotalEntries > 0 { + errRate = fmt.Sprintf("%.2f%%", 100*float64(t.Summary.ErrorEntries)/float64(t.Summary.TotalEntries)) + } + } + // 7 cells, not 5: a p95 of 1284ms truncated to "1284m" does not read as a + // clipped number, it reads as minutes. + return [3]string{ + padRight("RPS", 14) + padLeft(rps, 7), + padRight("P95", 14) + padLeft(p95, 7), + padRight("Errors", 14) + padLeft(errRate, 7), + } +} + +// --------------------------------------------------------------------- rail + +// viewProviders is the CONNECTED PROVIDERS frame body. There is no per-provider +// latency on the wire, so the right column is the model count the gateway +// actually reports. +func (a *App) viewProviders() string { + inner := railWidth - 4 + g := a.Theme.Mode.Glyphs() + if a.Rail.AuthError { + return a.Theme.Dim.Render(padRight("auth required "+a.dash()+" set FERRO_API_KEY", inner)) + } + if len(a.Rail.Providers) == 0 { + return a.Theme.Dim.Render(padRight("no providers reported", inner)) + } + shown := min(len(a.Rail.Providers), providerRows) + lines := make([]string, 0, shown+1) + for _, p := range a.Rail.Providers[:shown] { + glyph := a.Theme.OK.Render(g.Dot) + if p.Status != statusHealthy && p.Status != statusAvailable { + glyph = a.Theme.Warn.Render(g.Warn) + } + lines = append(lines, gapPad(inner, glyph+" "+p.Name, a.Theme.Dim.Render(comma(p.Models)+" models"))) + } + if rest := len(a.Rail.Providers) - shown; rest > 0 { + lines = append(lines, a.Theme.Dim.Render(fmt.Sprintf("+%d more", rest))) + } + return strings.Join(lines, "\n") +} + +// viewServices is the SERVICES frame body: the four readiness counters the +// mock keeps permanently on screen. +func (a *App) viewServices() string { + mcp := a.dash() + style := a.Theme.Text + if a.Rail.MCPTotal > 0 { + mcp = fmt.Sprintf("%d/%d ready", a.Rail.MCPReady, a.Rail.MCPTotal) + if a.Rail.MCPReady < a.Rail.MCPTotal { + style = a.Theme.Warn + } else { + style = a.Theme.OK + } + } + // Label column 16 + the longest value ("N events today") is exactly the + // rail's 30 inner cells; widening it truncates the audit row. + return strings.Join([]string{ + padRight("MCP servers", 16) + style.Render(mcp), + padRight("Plugins", 16) + a.count(a.Rail.PluginsActive, "active", a.Theme.OK), + padRight("Sessions", 16) + a.count(a.Rail.Sessions, "operators", a.Theme.Text), + padRight("Audit", 16) + a.count(a.Rail.AuditToday, "events today", a.Theme.Dim), + }, "\n") +} + +// count renders a rail counter, or a dash when the gateway does not serve it. +func (a *App) count(n int, unit string, style lipgloss.Style) string { + if n == unknownCount { + return a.Theme.Dim.Render(a.dash()) + } + return style.Render(fmt.Sprintf("%d %s", n, unit)) +} + +// viewCompactStatus is the medium-width collapse of the rail plus the header +// details the one-line brand cannot carry. +func (a *App) viewCompactStatus() string { + // "gateway " is the header's phrasing; here the row label already + // says Gateway, so the dash stands alone rather than repeating the word. + gateway, targets, providers := a.dash(), a.dash(), a.dash() + if a.Status != nil { + if a.Status.LatencyMs > 0 { + gateway += fmt.Sprintf(" · %dms", a.Status.LatencyMs) + } + routable := a.dash() + "/" + a.dash() + if t := a.Status.Targets; t != nil { + routable = fmt.Sprintf("%d/%d", t.Routable, t.Total) + } + targets = fmt.Sprintf("%s routable · %d degraded · %d open", + routable, a.Status.Circuits.HalfOpen, a.Status.Circuits.Open) + // A count the gateway supplied is shown as given, zero included; one it + // never supplied leaves the dash these start as. + if n := a.Status.Providers; n != nil { + providers = strconv.Itoa(*n) + } + if n := a.Status.Models; n != nil { + providers += " · " + comma(*n) + " models" + } + } + services := fmt.Sprintf("MCP %s · plugins %s · sessions %s · audit %s", + a.mcpRatio(), plainCount(a.Rail.PluginsActive, a.dash()), + plainCount(a.Rail.Sessions, a.dash()), plainCount(a.Rail.AuditToday, a.dash())) + if a.Rail.AuthError { + services = "auth required " + a.dash() + " set FERRO_API_KEY" + } + inner := a.Width - 4 + return strings.Join(clampLines([]string{ + padRight("Gateway", 12) + a.Theme.Dim.Render(gateway), + padRight("Targets", 12) + a.Theme.Text.Render(targets), + padRight("Providers", 12) + a.Theme.Text.Render(providers), + padRight("Services", 12) + a.Theme.Dim.Render(services), + }, inner), "\n") +} + +func (a *App) mcpRatio() string { + if a.Rail.MCPTotal == 0 { + return a.dash() + } + return fmt.Sprintf("%d/%d", a.Rail.MCPReady, a.Rail.MCPTotal) +} + +func plainCount(n int, dash string) string { + if n == unknownCount { + return dash + } + return strconv.Itoa(n) +} + +// --------------------------------------------------------- pane + composer + +func (a *App) paneTitle() string { + switch a.Screen { + case ScreenLogs: + return "LOGS" + case ScreenKeys: + return "KEYS" + case ScreenPlayground: + return "PLAYGROUND" + default: + return "COMMAND OUTPUT" + } +} + +// paneView dispatches to the current screen's body. Every branch returns +// exactly rows lines, at most w cells wide, so the composer stays anchored to +// the bottom of the screen whatever is on show above it. +// +// A modal renders IN PLACE of the pane rather than over it. There is nothing +// to dim behind an overlay in a terminal, and the pane's exact-line contract is +// the one thing an overlay would have to break. +func (a *App) paneView(w, rows int) string { + if a.Modal != nil { + return a.Modal.view(a, w, rows) + } + switch a.Screen { + case ScreenLogs: + return a.Logs.view(a, w, rows) + case ScreenKeys: + return a.Keys.view(a, w, rows) + case ScreenPlayground: + return a.Play.view(a, w, rows) + default: + return a.homeView(w, rows) + } +} + +// paneWidth is the width paneView will next be called with, derived exactly as +// render() derives it. A screen that lays content out ahead of the view — the +// playground wraps markdown when a delta batch flushes, not per frame — needs +// the number outside a view func. +func (a *App) paneWidth() int { + w := a.Width + if w >= wideMin { + w -= railWidth + railGap + } + return max(w-4, 1) +} + +// screenKey offers a key to the pane before the composer sees it and reports +// whether the pane took it. +// +// The pane is only offered a key while the command line is EMPTY. The composer +// is the console's only navigation surface, so typing `keys` from the logs +// screen has to outrank moving a cursor — which is also why the selection keys +// are ↑/↓ and not j/k: `k` is the first letter of a verb, and a screen that +// swallowed it would make that verb untypeable from this pane. +func (a *App) screenKey(key string) bool { + if a.Composer.searchMode || a.Composer.Input != "" { + return false + } + switch a.Screen { + case ScreenLogs: + return a.Logs.handleKey(key) + case ScreenKeys: + return a.Keys.handleKey(key) + } + return false +} + +// left cancels the screen the shell has just moved off. Every path that can +// change a.Screen funnels through here — the router, and the composer's own +// esc — so a tail keeps ticking and a stream keeps streaming for exactly as +// long as its pane is on show, and not one message longer. +func (a *App) left(prev Screen, cmd tea.Cmd) tea.Cmd { + if a.Screen == prev { + return cmd + } + switch prev { + case ScreenLogs: + a.Logs.leave() + case ScreenKeys: + a.Keys.leave() + case ScreenPlayground: + a.Play.leave() + } + return cmd +} + +func (a *App) composerView() string { return a.Composer.view(a) } + +func (a *App) viewHints() string { + left, right := "↵ run tab complete ↑ history ctrl+r search", "? help ctrl+c quit" + if a.Theme.Mode.ASCII { + left = "enter run tab complete up history ctrl+r search" + } + return gapPad(a.Width, " "+a.Theme.Dim.Render(left), a.Theme.Dim.Render(right)) +} + +// ------------------------------------------------------------------ helpers + +// boxChars mirrors the theme's border vocabulary with the two junctions the +// split header needs; theme.Frame draws no junctions, so it exports none. +type boxChars struct{ tl, tr, bl, br, h, v, tj, bj, lj, rj string } + +func (a *App) boxChars() boxChars { + if a.Theme.Mode.ASCII { + return boxChars{"+", "+", "+", "+", "-", "|", "+", "+", "+", "+"} + } + return boxChars{"┌", "┐", "└", "┘", "─", "│", "┬", "┴", "├", "┤"} +} + +func (a *App) dash() string { + if a.Theme.Mode.ASCII { + return "-" + } + return emDash +} + +func (a *App) rule(n int) string { + if a.Theme.Mode.ASCII { + return strings.Repeat("-", n) + } + return strings.Repeat("─", n) +} + +// padRight pads or truncates to exactly w cells. ANSI-aware in both directions: +// measuring ignores escape sequences and truncation keeps them intact. +func padRight(s string, w int) string { + if w <= 0 { + return "" + } + s = ansi.Truncate(s, w, "") + if p := w - lipgloss.Width(s); p > 0 { + s += strings.Repeat(" ", p) + } + return s +} + +func padLeft(s string, w int) string { + if w <= 0 { + return "" + } + s = ansi.Truncate(s, w, "") + if p := w - lipgloss.Width(s); p > 0 { + s = strings.Repeat(" ", p) + s + } + return s +} + +// gapPad lays left and right out at the two ends of exactly w cells, giving up +// the left side first when they cannot both fit. +func gapPad(w int, left, right string) string { + if w <= 0 { + return "" + } + right = ansi.Truncate(right, w, "") + rw := lipgloss.Width(right) + left = ansi.Truncate(left, max(w-rw-1, 0), "") + return left + strings.Repeat(" ", max(w-lipgloss.Width(left)-rw, 0)) + right +} + +func clampLines(lines []string, w int) []string { + out := make([]string, len(lines)) + for i, l := range lines { + out[i] = ansi.Truncate(l, w, "") + } + return out +} + +// comma groups thousands. 2500 models reads as a quantity; 2500 reads as an id. +func comma(n int) string { + s := strconv.Itoa(n) + sign := "" + if strings.HasPrefix(s, "-") { + sign, s = "-", s[1:] + } + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + return sign + s +} diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go new file mode 100644 index 0000000..c1a56bf --- /dev/null +++ b/internal/tui/app_test.go @@ -0,0 +1,550 @@ +package tui + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/config" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// testApp builds a fully-populated shell with no client: every test here is +// pure MVU — no terminal, no program loop, no network. +func testApp(w, h int) *App { + a := newApp(nil, config.Resolved{ProfileName: "local", URL: "http://localhost:8080"}, theme.Mode{}) + a.Width, a.Height = w, h + a.Status = &api.StatusReport{ + State: "connected", + URL: "http://localhost:8080", + LatencyMs: 23, + Providers: api.Count(8), + Models: api.Count(2500), + Targets: &api.TargetSummary{Total: 8, Routable: 7}, + Circuits: api.CircuitSummary{Open: 0, HalfOpen: 1}, + MCP: &api.MCPSummary{Ready: 3, Total: 4}, + Auth: "admin", + } + a.StatusAt = time.Now() + a.Traffic = testStats(47700, 200, 916) + a.Rail = RailData{ + Providers: []api.AdminProviderHealth{ + {Name: "openai", Status: "healthy", Models: 1104}, + {Name: "anthropic", Status: "healthy", Models: 412}, + {Name: "gemini", Status: "healthy", Models: 96}, + {Name: "groq", Status: "healthy", Models: 40}, + {Name: "mistral", Status: "healthy", Models: 32}, + {Name: "cohere", Status: "healthy", Models: 18}, + {Name: "deepseek", Status: "healthy", Models: 9}, + {Name: "xai", Status: "healthy", Models: 6}, + }, + MCPReady: 3, MCPTotal: 4, + PluginsActive: 6, Sessions: 2, AuditToday: 4, + } + return a +} + +func testStats(total, errs int, p95 float64) *api.LogStats { + s := &api.LogStats{} + s.Summary.TotalEntries = total + s.Summary.ErrorEntries = errs + s.LatencyMs = &api.Percentiles{P50: 210, P95: p95, P99: 1800, Count: total} + return s +} + +func TestBreakpointComposition(t *testing.T) { + a := testApp(126, 40) + view := a.render() + for _, s := range []string{"CONNECTED PROVIDERS", "SERVICES", "TARGETS", "TRAFFIC", "COMMAND"} { + if !strings.Contains(view, s) { + t.Fatalf("wide view missing %q:\n%s", s, view) + } + } + + a.Width = 96 + if v := a.render(); strings.Contains(v, "CONNECTED PROVIDERS") || !strings.Contains(v, "STATUS") { + t.Fatalf("medium must collapse rail into STATUS frame:\n%s", v) + } + + a.Width = 72 + if v := a.render(); strings.Contains(v, "STATUS") || !strings.Contains(v, "COMMAND") { + t.Fatalf("narrow is pane + composer only:\n%s", v) + } +} + +// TestEveryViewLineFitsWidth is the box-drawing guard: a single line wider than +// the terminal wraps and destroys every frame below it. +func TestEveryViewLineFitsWidth(t *testing.T) { + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + for _, w := range []int{72, 96, 126} { + a := testApp(w, 40) + a.Theme = theme.New(mode) + for i, line := range strings.Split(a.render(), "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line) + } + } + } + } +} + +// The same guard, with each screen and a modal on show: the box drawing has to +// survive what the panes put inside it, not just an empty transcript. +func TestEveryViewLineFitsWidthOnEveryScreen(t *testing.T) { + now := time.Now() + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + for _, w := range []int{72, 96, 126} { + a := composerApp() + a.Theme, a.Width = theme.New(mode), w + + a.route("logs --since 15m --model claude-sonnet-4-6") + a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{ + logRow("tr_00000001", now), logRow("tr_00000002", now.Add(time.Second)), + }}) + a.Logs.sel, a.Logs.detail = 0, true + + a.update(keysListMsg{gen: a.Keys.gen, keys: []api.Key{ + {ID: "key_01", Key: "fgw_abcd…wxyz", Name: "ops-laptop", Scopes: []string{"admin"}, Active: true}, + }}) + a.Keys.sel, a.Keys.detail = 0, true + + a.route("playground") + a.Play.submit(a, "a prompt long enough to wrap at every width under test") + a.update(chatDeltaMsg{gen: a.Play.gen, text: strings.Repeat("streamed words ", 12)}) + a.update(flushTickMsg{gen: a.Play.gen}) + a.update(chatEndMsg{gen: a.Play.gen, done: true}) + a.update(chatMetaMsg{gen: a.Play.gen, row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}}) + + for _, screen := range []Screen{ScreenHome, ScreenLogs, ScreenKeys, ScreenPlayground} { + a.Screen = screen + for _, modal := range []*Modal{nil, { + Title: "Rotate ops-laptop?", + Body: "Blast radius: every client holding the current secret fails immediately\nuntil it is redeployed with the new one.", + Rows: []ModalKV{{K: "id", V: "key_01"}, {K: "last used", V: "3 minutes ago · 41,000 requests", Warn: true}}, + Input: true, + Placeholder: `type "ops-laptop" to confirm`, + Confirm: "ops-laptop", + OKLabel: "rotate", + Danger: true, + }} { + a.Modal = modal + for i, line := range strings.Split(a.render(), "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d screen=%v modal=%t line %d overflows: %d cells %q", + mode, w, screen, modal != nil, i, lw, line) + } + } + } + } + } + } +} + +func TestNarrowUnderMinimumStillRenders(t *testing.T) { + a := testApp(20, 12) + for i, line := range strings.Split(a.render(), "\n") { + if lw := lipgloss.Width(line); lw > 20 { + t.Fatalf("line %d overflows at w=20: %d cells %q", i, lw, line) + } + } +} + +func TestFailedPollKeepsLastGoodStatus(t *testing.T) { + a := testApp(126, 40) + a.Status, a.StatusErr = nil, nil + + good := &api.StatusReport{State: "connected", Providers: api.Count(3)} + a.update(statusMsg{gen: 0, report: good}) + a.update(statusMsg{gen: 0, err: errors.New("boom")}) + + if a.Status != good || a.StatusErr == nil { + t.Fatalf("stale data must persist with its age; errors annotate, never erase (status=%v err=%v)", a.Status, a.StatusErr) + } + if !strings.Contains(a.render(), "stale") { + t.Fatal("a stale header must say so") + } +} + +func TestStalePollGenerationDropped(t *testing.T) { + a := testApp(126, 40) + a.Status = nil + a.pollGen = 1 + a.update(statusMsg{gen: 0, report: &api.StatusReport{State: "connected"}}) + if a.Status != nil { + t.Fatal("results from a previous generation must be dropped") + } + a.update(trafficMsg{gen: 0, stats: testStats(10, 0, 5)}) + a.update(railMsg{gen: 0, data: RailData{PluginsActive: 99}}) + if a.Traffic == nil || a.Traffic.Summary.TotalEntries == 10 { + t.Fatal("stale trafficMsg must be dropped too") + } + if a.Rail.PluginsActive == 99 { + t.Fatal("stale railMsg must be dropped too") + } +} + +// There is no gateway version over HTTP. The header must say so, not guess. +func TestHeaderNeverInventsAGatewayVersion(t *testing.T) { + v := testApp(126, 40).render() + if !strings.Contains(v, "gateway "+emDash) { + t.Fatalf("header must render `gateway %s`:\n%s", emDash, v) + } + if m := regexp.MustCompile(`gateway v?\d`).FindString(v); m != "" { + t.Fatalf("header fabricated a gateway version: %q", m) + } +} + +func TestNilTrafficRendersDash(t *testing.T) { + a := testApp(126, 40) + a.Traffic = nil + v := a.render() + for _, label := range []string{"RPS", "P95", "Errors"} { + row := findRow(v, label) + if !strings.Contains(row, emDash) { + t.Fatalf("unknown %s must render %s, got %q", label, emDash, row) + } + for _, bad := range []string{"0.0", "NaN", "0ms", "0.00%"} { + if strings.Contains(row, bad) { + t.Fatalf("unknown %s must not render %q: %q", label, bad, row) + } + } + } + + // A measured window with no requests still has no percentiles. + a.Traffic = &api.LogStats{} + v = a.render() + if !strings.Contains(findRow(v, "P95"), emDash) { + t.Fatalf("nil LatencyMs must render %s: %q", emDash, findRow(v, "P95")) + } + if strings.Contains(findRow(v, "Errors"), "NaN") { + t.Fatalf("0/0 errors must not divide: %q", findRow(v, "Errors")) + } +} + +// A gateway that answers /readyz with a bare 503 reports no targets at all. +// Rendering that as 0 would say "none are configured" during exactly the +// outage the header is read in — and dereferencing it would take the console +// down instead. +func TestAbsentTargetsRenderDashNotZero(t *testing.T) { + for _, w := range []int{72, 96, 126} { + a := testApp(w, 40) + a.Status.State, a.Status.Targets = "unreachable", nil + v := a.render() + for _, bad := range []string{"0 targets", "0/0"} { + if strings.Contains(v, bad) { + t.Fatalf("w=%d: unreported targets must never render %q:\n%s", w, bad, v) + } + } + if w < mediumMin { + continue // narrow drops both the counts header and the panels + } + if row := findRow(v, "Healthy"); !strings.Contains(row, a.dash()) { + t.Fatalf("w=%d: unreported targets must render a dash: %q", w, row) + } + } + + // The wide header is the one place the total is spelled out in words. + a := testApp(126, 40) + a.Status.Targets = nil + if v := a.render(); !strings.Contains(v, a.dash()+" targets") { + t.Fatalf("the counts row must say %s targets, not invent one:\n%s", a.dash(), v) + } +} + +func TestCompactStatusDoesNotInventZeroMeasurements(t *testing.T) { + a := testApp(96, 30) + a.Status = &api.StatusReport{State: "connected", URL: "http://localhost:8080"} + view := a.viewCompactStatus() + gatewayRow := findRow(view, "Gateway") + providersRow := findRow(view, "Providers") + // A bare \b0\b, not strings.Contains: the row label or padding could + // legitimately carry a "0" elsewhere, and that must not fail this check — + // only an invented zero measurement should. + if !strings.Contains(gatewayRow, a.dash()) || strings.Contains(gatewayRow, "0ms") || + !strings.Contains(providersRow, a.dash()) || regexp.MustCompile(`\b0\b`).MatchString(providersRow) { + t.Fatalf("unreported latency and provider counts must render as dashes:\n%s", view) + } +} + +func TestTrafficDerivedFromLogStats(t *testing.T) { + v := testApp(126, 40).render() + for label, want := range map[string]string{ + "RPS": "159.0", // 47700 entries / 300s + "P95": "916ms", + "Errors": "0.42%", // 200 / 47700 + } { + if row := findRow(v, label); !strings.Contains(row, want) { + t.Fatalf("%s row must contain %q, got %q", label, want, row) + } + } +} + +func TestHeaderRendersRealStatusOnly(t *testing.T) { + v := testApp(126, 40).render() + for _, want := range []string{ + "Ferro Labs", + "AI Gateway", + "connected", + "local", + "http://localhost:8080", + "23ms", + "8 targets", + "2,500 models", + } { + if !strings.Contains(v, want) { + t.Fatalf("header missing %q:\n%s", want, v) + } + } +} + +// The header's three columns are measured, not spaced by eye: the readout has +// to land on the last cell of the screen at any width, or it drifts away from +// the right edge the moment a URL or a profile name changes length. +func TestHeaderReadoutIsFlushRight(t *testing.T) { + for _, w := range []int{110, 126, 200} { + a := testApp(w, 40) + for i, line := range strings.Split(a.viewBrandBlock(), "\n") { + if got := lipgloss.Width(line); got != w { + t.Fatalf("width %d: header row %d is %d cells, must fill exactly %d:\n%q", w, i, got, w, line) + } + } + want := "23ms round trip" + row := findRow(a.viewBrandBlock(), want) + if !strings.HasSuffix(row, want) { + t.Fatalf("width %d: readout must sit flush right, got %q", w, row) + } + } +} + +func TestUnreachableStateGetsBadGlyph(t *testing.T) { + a := testApp(126, 40) + a.Status = &api.StatusReport{State: "unreachable", URL: "http://localhost:8080"} + g := a.Theme.Mode.Glyphs() + if v := a.render(); !strings.Contains(v, g.Bad+" unreachable") { + t.Fatalf("unreachable must carry the bad glyph:\n%s", v) + } + a.Status.State = "degraded" + if v := a.render(); !strings.Contains(v, g.Warn+" degraded") { + t.Fatalf("degraded must carry the warn glyph:\n%s", v) + } +} + +func TestRailReportsMissingCredential(t *testing.T) { + a := testApp(126, 40) + a.Rail = RailData{AuthError: true, PluginsActive: -1, Sessions: -1, AuditToday: -1} + v := a.render() + if !strings.Contains(v, "auth required") { + t.Fatalf("unauthorized rail must say so:\n%s", v) + } + if !strings.Contains(findRow(v, "Plugins"), emDash) { + t.Fatalf("unknown service counts render %s: %q", emDash, findRow(v, "Plugins")) + } +} + +func TestWindowSizeAndQuitKeys(t *testing.T) { + a := testApp(80, 24) + a.update(tea.WindowSizeMsg{Width: 126, Height: 40}) + if a.Width != 126 || a.Height != 40 { + t.Fatalf("resize ignored: %dx%d", a.Width, a.Height) + } + if cmd := a.update(tea.KeyPressMsg{Mod: tea.ModCtrl, Code: 'c'}); cmd == nil { + t.Fatal("ctrl+c must return a quit command") + } + a.Screen = ScreenLogs + a.update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if a.Screen != ScreenHome { + t.Fatal("esc must return to home") + } +} + +func TestPollBackoffDoublesAndResets(t *testing.T) { + a := testApp(126, 40) + a.update(statusMsg{err: errors.New("boom")}) + a.update(statusMsg{err: errors.New("boom")}) + if a.statusEvery != 4*statusPoll { + t.Fatalf("two failures must back off to %v, got %v", 4*statusPoll, a.statusEvery) + } + for range 10 { + a.update(statusMsg{err: errors.New("boom")}) + } + if a.statusEvery != maxPoll { + t.Fatalf("backoff must cap at %v, got %v", maxPoll, a.statusEvery) + } + a.update(statusMsg{report: &api.StatusReport{State: "connected"}}) + if a.statusEvery != statusPoll { + t.Fatalf("a good poll must reset the cadence, got %v", a.statusEvery) + } +} + +func TestCommaGrouping(t *testing.T) { + for in, want := range map[int]string{0: "0", 12: "12", 999: "999", 1000: "1,000", 2500: "2,500", 1234567: "1,234,567"} { + if got := comma(in); got != want { + t.Fatalf("comma(%d) = %q, want %q", in, got, want) + } + } +} + +// findRow returns the first rendered line mentioning label, for asserting on +// one panel row without pinning the whole layout. +func findRow(view, label string) string { + for _, line := range strings.Split(view, "\n") { + if strings.Contains(line, label) { + return line + } + } + return "" +} + +// railStub answers the four sources fetchRail polls. Any handler left nil +// answers 500, so a test only wires the endpoints its scenario cares about. +func railStub(health, plugins, sessions, audit http.HandlerFunc) *httptest.Server { + mux := http.NewServeMux() + fail := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) } + for path, h := range map[string]http.HandlerFunc{ + "/admin/health": health, "/admin/plugins": plugins, + "/admin/sessions": sessions, "/admin/audit": audit, + } { + if h == nil { + h = fail + } + mux.HandleFunc(path, h) + } + return httptest.NewServer(mux) +} + +// A gateway poll fans out to four independent sources, and one of them can +// fail while the other three answer fine. fetchRail must not let that one +// failure blank the whole rail: a source that errors THIS round keeps prev's +// last good value for it, while a source that succeeds still refreshes. +func TestFetchRailKeepsLastGoodDataWhenOneSourceFails(t *testing.T) { + srv := railStub(nil, // /admin/health fails — left unwired, answers 500 + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `[{"name":"rate-limit","type":"ratelimit","enabled":true}]`) + }, + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"data":[{"id":"s1"}]}`) + }, + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"data":[],"summary":{"total_entries":3,"returned_entries":0}}`) + }) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "k") + if err != nil { + t.Fatalf("client: %v", err) + } + + prev := RailData{ + Providers: []api.AdminProviderHealth{{Name: "anthropic", Status: "healthy", Models: 412}}, + MCPReady: 2, MCPTotal: 2, + } + msg, ok := fetchRail(c, 0, prev)().(railMsg) + if !ok { + t.Fatal("fetchRail must answer a railMsg") + } + + if msg.err == nil { + t.Fatal("the failing source must still be reported") + } + if len(msg.data.Providers) != 1 || msg.data.Providers[0].Name != "anthropic" { + t.Fatalf("a failed health poll must keep the last known providers, got %+v", msg.data.Providers) + } + if msg.data.MCPTotal != 2 || msg.data.MCPReady != 2 { + t.Fatalf("a failed health poll must keep the last known MCP counts, got %d/%d ready", msg.data.MCPReady, msg.data.MCPTotal) + } + if msg.data.PluginsActive != 1 { + t.Fatalf("a source that succeeded this round must still refresh, got plugins=%d", msg.data.PluginsActive) + } + if msg.data.Sessions != 1 { + t.Fatalf("a source that succeeded this round must still refresh, got sessions=%d", msg.data.Sessions) + } + if msg.data.AuditToday != 3 { + t.Fatalf("a source that succeeded this round must still refresh, got audit=%d", msg.data.AuditToday) + } +} + +// AuthError is a verdict about the round just polled, not a sticky flag: a +// credential fixed after a 401 must clear it, or the rail would keep telling +// the operator to re-authenticate after they already have. +func TestFetchRailClearsAuthErrorOnceTheCredentialWorks(t *testing.T) { + answer := func(body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, body) } + } + srv := railStub( + answer(`{"status":"ok","providers":[]}`), + answer(`[]`), + answer(`{"data":[]}`), + answer(`{"data":[],"summary":{"total_entries":0,"returned_entries":0}}`), + ) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "k") + if err != nil { + t.Fatalf("client: %v", err) + } + + msg, ok := fetchRail(c, 0, RailData{AuthError: true})().(railMsg) + if !ok { + t.Fatal("fetchRail must answer a railMsg") + } + if msg.data.AuthError { + t.Fatal("a since-fixed credential must clear AuthError, not carry it forward forever") + } +} + +// Every counter fetchRail derives by walking a list must be recomputed from +// the round it is walking, not added to the round before it. d starts as prev +// so a failed source keeps its last good value — which turns any bare ++ into +// a running total. Feeding a poll's own answer back in as prev is the only +// shape that catches it: one poll in isolation always looks right. +func TestFetchRailCountersDoNotAccumulateAcrossPolls(t *testing.T) { + srv := railStub( + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"status":"healthy","providers":[{"name":"openai","status":"healthy","models":7}],`+ + `"mcp_servers":[{"name":"fs","ready":true},{"name":"search","ready":false}]}`) + }, + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `[{"name":"rate-limit","type":"ratelimit","enabled":true}]`) + }, + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"data":[{"id":"s1"}]}`) + }, + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"data":[],"summary":{"total_entries":3,"returned_entries":0}}`) + }) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "k") + if err != nil { + t.Fatalf("client: %v", err) + } + + first, ok := fetchRail(c, 0, RailData{})().(railMsg) + if !ok { + t.Fatal("fetchRail must answer a railMsg") + } + if first.data.MCPTotal != 2 || first.data.MCPReady != 1 { + t.Fatalf("first poll must count the served list: total=%d ready=%d", + first.data.MCPTotal, first.data.MCPReady) + } + + // The gateway has not changed, so neither may the counts. + second, ok := fetchRail(c, 0, first.data)().(railMsg) + if !ok { + t.Fatal("fetchRail must answer a railMsg") + } + if second.data.MCPTotal != 2 || second.data.MCPReady != 1 { + t.Fatalf("an unchanged gateway must report unchanged counts, got total=%d ready=%d", + second.data.MCPTotal, second.data.MCPReady) + } + if second.data.PluginsActive != first.data.PluginsActive { + t.Fatalf("plugin count drifted across polls: %d then %d", + first.data.PluginsActive, second.data.PluginsActive) + } +} diff --git a/internal/tui/composer.go b/internal/tui/composer.go new file mode 100644 index 0000000..e4001b3 --- /dev/null +++ b/internal/tui/composer.go @@ -0,0 +1,389 @@ +package tui + +import ( + "strings" + "unicode" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "github.com/spf13/cobra" +) + +// The composer is the console's only navigation surface: there is no menu and +// no key-per-screen. Everything an operator can do here is a verb they could +// also have typed at a shell, which is why the vocabulary is walked out of the +// real cobra tree rather than listed a second time in this file. + +const ( + // historyMax is the recall depth. A ring, not a log: the composer is a + // navigation aid, and a shell already keeps the durable history. + historyMax = 30 + + // suggestMax caps the suggestion row at what fits on one line without + // pushing the pane around. + suggestMax = 6 + + // cobraCompletion is the shell-completion command cobra generates. The + // console skips it for the same reason it skips help: it cannot install a + // script into the shell that launched it. + cobraCompletion = "completion" +) + +// The console's keyboard vocabulary, spelled exactly as tea.KeyPressMsg.String() +// spells it. The composer, the modal and every screen's handleKey are keyed on +// these strings rather than on tea key types — that is what makes them testable +// — so a mistyped literal is a binding that silently never fires and no test +// that drives the same typo can see it. Naming them once turns that into a +// compile error. The test suites deliberately keep typing the raw strings: they +// are the independent check that these values are what the terminal sends. +const ( + keyEnter = "enter" + keyEsc = "esc" + keyUp = "up" + keyDown = "down" + keyLeft = "left" + keyRight = "right" + keyTab = "tab" + keySpace = "space" + keyBackspace = "backspace" + keyCtrlC = "ctrl+c" + keyCtrlR = "ctrl+r" + keyCtrlU = "ctrl+u" +) + +// tuiVerbs exist only in the console — there is no `ferro clear` to script. +var tuiVerbs = []string{verbPlayground, verbModel, verbClear, verbHelp} + +// Composer is the command line: input, completion, bounded history, and +// reverse search. It holds no styles; every rendering decision reads the App's +// theme at draw time, so a mode switch needs no rebuild. +type Composer struct { + Input string + History []string // newest first + + verbs []string + + // histIdx is -1 for the live input, which live holds while a walk is in + // progress. Losing a half-typed line to an accidental ↑ is the one thing + // history recall must never do. + histIdx int + live string + + searchMode bool + searchTerm string +} + +func newComposer(verbs []string) Composer { + return Composer{verbs: verbs, histIdx: -1} +} + +// Verbs is the console's vocabulary: every path in the cobra tree, plus the +// three verbs that exist only here, plus a slash alias per top-level verb. +// +// It takes the root rather than building one so internal/tui imports no CLI +// package: internal/command is what hands bare `ferro` to Run, and an import +// back the other way would be a cycle. +func Verbs(root *cobra.Command) []string { + verbs := []string{} + var walk func(prefix string, cmd *cobra.Command) + walk = func(prefix string, cmd *cobra.Command) { + for _, sub := range cmd.Commands() { + name := sub.Name() + // help and completion are shell plumbing, not operations: the + // console prints its own verb tree and cannot install a completion + // script into the shell that launched it. + if name == verbHelp || name == cobraCompletion || sub.Hidden { + continue + } + path := strings.TrimSpace(prefix + " " + name) + verbs = append(verbs, path) + walk(path, sub) + } + } + if root != nil { + walk("", root) + } + verbs = append(verbs, tuiVerbs...) + + // Slash aliases are top-level only: /keys is an action, "/keys create" is + // not a spelling anyone types. + aliases := make([]string, 0, len(verbs)) + for _, v := range verbs { + if !strings.Contains(v, " ") { + aliases = append(aliases, "/"+v) + } + } + return append(verbs, aliases...) +} + +// handle is keyed on the key's string form, which is what makes the composer +// testable: constructing tea.KeyPressMsg values is awkward and the mapping from +// one to the other is untested glue either way. +func (c *Composer) handle(key string, a *App) tea.Cmd { + if c.searchMode { + return c.handleSearch(key) + } + switch key { + case keyEnter: + return c.submit(a) + case keyTab: + if m := c.match(); m != "" { + c.Input = m + } + return nil + case keyUp: + c.walk(1) + return nil + case keyDown: + c.walk(-1) + return nil + case keyCtrlR: + c.searchMode, c.searchTerm = true, "" + return nil + case keyEsc: + a.Screen = ScreenHome + return nil + case keyBackspace: + c.Input = trimLastRune(c.Input) + return nil + case keyCtrlU: + c.Input = "" + return nil + case keySpace: + c.Input += " " + return nil + case "?": + // Only on an empty line: mid-line it is a character, and `logs ?` must + // not silently become the help screen. + if strings.TrimSpace(c.Input) == "" { + return emit(runCmdMsg{raw: verbHelp}) + } + } + if r, ok := printable(key); ok { + c.Input += string(r) + } + return nil +} + +func (c *Composer) handleSearch(key string) tea.Cmd { + switch key { + case keyEnter: + if m := c.searchMatch(); m != "" { + c.Input = m + } + c.searchMode, c.searchTerm = false, "" + return nil + case keyEsc, keyCtrlC: + // Abandoning a search leaves the line exactly as it was found. + c.searchMode, c.searchTerm = false, "" + return nil + case keyBackspace: + c.searchTerm = trimLastRune(c.searchTerm) + return nil + case keySpace: + c.searchTerm += " " + return nil + } + if r, ok := printable(key); ok { + c.searchTerm += string(r) + } + return nil +} + +// submit runs the line. A blank line is a no-op rather than an error: pressing +// enter on an empty composer is how people check that the console is alive. +func (c *Composer) submit(a *App) tea.Cmd { + input := strings.TrimSpace(c.Input) + // EVERY leading slash goes, not one. The sanitisation below reads the head + // of raw to decide what may be retained, so a line that keeps a slash on its + // head names no verb, matches nothing, and is recorded whole — which for + // //chat is the prompt, in full, in history and reverse search. + raw := strings.TrimLeft(input, "/") + if raw == "" { + return nil + } + // Prompts can contain credentials or customer data. Keep command recall, but + // never retain prompt content in history or reverse search. + record := a.Screen != ScreenPlayground + if a.Screen == ScreenPlayground && strings.HasPrefix(input, "/") { + fields := strings.Fields(raw) + record = len(fields) > 0 && knownVerb(c.verbs, strings.ToLower(fields[0])) + } + if record { + history := raw + fields := strings.Fields(raw) + if len(fields) > 1 { + verb := strings.ToLower(fields[0]) + if verb == verbChat || verb == verbPlayground { + history = verb + } + } + c.record(history) + } + c.Input, c.histIdx, c.live = "", -1, "" + return emit(runCmdMsg{raw: raw}) +} + +// record pushes onto the ring, newest first. Consecutive duplicates collapse: +// running `status` four times while watching a rollout should not cost four +// entries of recall. +func (c *Composer) record(raw string) { + if len(c.History) > 0 && c.History[0] == raw { + return + } + next := make([]string, 0, min(len(c.History)+1, historyMax)) + next = append(next, raw) + next = append(next, c.History...) + if len(next) > historyMax { + next = next[:historyMax] + } + c.History = next +} + +// walk moves d steps toward older history (+1) or back toward the live input +// (-1), stopping at both ends rather than wrapping. +func (c *Composer) walk(d int) { + i := c.histIdx + d + if i < -1 || i >= len(c.History) { + return + } + if c.histIdx == -1 { + c.live = c.Input + } + c.histIdx = i + if i == -1 { + c.Input = c.live + return + } + c.Input = c.History[i] +} + +// query is the completion prefix: trimmed, and with the slash alias folded +// away so /log and log complete identically. +func (c *Composer) query() string { + return strings.TrimPrefix(strings.TrimSpace(c.Input), "/") +} + +// match is the verb tab completes to, and the one the ghost hint names. +func (c *Composer) match() string { + q := c.query() + if q == "" { + return "" + } + for _, v := range c.verbs { + if v != q && strings.HasPrefix(v, q) { + return v + } + } + return "" +} + +// ghost is the first matching verb, or "" when the line already names one. +func (c *Composer) ghost() string { return c.match() } + +func (c *Composer) suggestions() []string { + q := c.query() + if q == "" { + return nil + } + out := make([]string, 0, suggestMax) + for _, v := range c.verbs { + if v == q || !strings.HasPrefix(v, q) { + continue + } + out = append(out, v) + if len(out) == suggestMax { + break + } + } + return out +} + +func (c *Composer) searchMatch() string { + if c.searchTerm == "" { + return "" + } + for _, h := range c.History { + if strings.Contains(h, c.searchTerm) { + return h + } + } + return "" +} + +// view draws the framed command line. Its height is 3 lines, or 5 when there +// are suggestions to show; render() measures it rather than assuming, so the +// pane above gives up the two lines instead of the header scrolling away. +func (c *Composer) view(a *App) string { + inner := a.Width - 4 + g := a.Theme.Mode.Glyphs() + prompt := a.Theme.Accent.Bold(a.Theme.Mode.Color).Render(g.Prompt) + + var left, right string + switch { + case c.searchMode: + left = prompt + " " + a.Theme.Dim.Render("search:") + " " + + a.Theme.Bright.Render(c.searchTerm) + c.cursor(a) + if m := c.searchMatch(); m != "" { + right = a.Theme.Dim.Render(a.arrow() + " " + m) + } else if c.searchTerm != "" { + right = a.Theme.Dim.Render("no match") + } + case c.Input == "": + left = prompt + " " + c.cursor(a) + + a.Theme.Dim.Render(" Type a command, / for actions, or ? for help") + default: + left = prompt + " " + a.Theme.Bright.Render(c.Input) + c.cursor(a) + if m := c.ghost(); m != "" { + right = a.Theme.Faint.Render("tab " + a.arrow() + " " + m) + } + } + + lines := []string{gapPad(inner, left, right)} + if s := c.suggestions(); len(s) > 0 { + lines = append(lines, + a.Theme.Hairline.Render(a.rule(max(inner, 0))), + a.Theme.Dim.Render(padRight(strings.Join(s, " "), inner))) + } + return a.Theme.Frame("COMMAND", strings.Join(lines, "\n"), a.Width) +} + +// cursor is drawn rather than left to the terminal: the console renders one +// frame at a time and a hardware cursor would sit wherever the last write did. +func (c *Composer) cursor(a *App) string { + if a.Theme.Mode.ASCII { + return a.Theme.Accent.Render("_") + } + return a.Theme.Accent.Render("▌") +} + +// ------------------------------------------------------------------ helpers + +func emit(msg tea.Msg) tea.Cmd { return func() tea.Msg { return msg } } + +// arrow is the "leads to" glyph the ghost hint and search preview use. +func (a *App) arrow() string { + if a.Theme.Mode.ASCII { + return "->" + } + return "→" +} + +// printable reports whether a key string is a single printable rune — the +// only keys the composer types into the line. Named keys ("enter", "ctrl+r") +// are multi-rune and fall through. +func printable(key string) (rune, bool) { + r, size := utf8.DecodeRuneInString(key) + if size != len(key) || r == utf8.RuneError || !unicode.IsPrint(r) { + return 0, false + } + return r, true +} + +func trimLastRune(s string) string { + if s == "" { + return "" + } + _, size := utf8.DecodeLastRuneInString(s) + return s[:len(s)-size] +} diff --git a/internal/tui/composer_test.go b/internal/tui/composer_test.go new file mode 100644 index 0000000..289beef --- /dev/null +++ b/internal/tui/composer_test.go @@ -0,0 +1,352 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// fakeRoot mirrors the shape of the real cobra tree — a couple of leaf verbs, a +// parent with children, and the two commands the walk must skip. The real tree +// is asserted against in verbs_cobra_test.go, which lives in the external test +// package so internal/tui never imports internal/command. +func fakeRoot() *cobra.Command { + root := &cobra.Command{Use: "ferro"} + keys := &cobra.Command{Use: "keys"} + keys.AddCommand(&cobra.Command{Use: "create"}, &cobra.Command{Use: "rotate"}, &cobra.Command{Use: "revoke"}) + logs := &cobra.Command{Use: "logs"} + logs.AddCommand(&cobra.Command{Use: "tail"}, &cobra.Command{Use: "stats"}) + root.AddCommand( + &cobra.Command{Use: "status"}, + &cobra.Command{Use: "providers"}, + keys, logs, + &cobra.Command{Use: "completion"}, + &cobra.Command{Use: "help"}, + ) + return root +} + +// composerApp is an App whose composer speaks the fake tree's vocabulary. +func composerApp() *App { + a := testApp(126, 40) + a.Composer = newComposer(Verbs(fakeRoot())) + return a +} + +// run drives one key and returns the message its command produced, or nil. +func run(t *testing.T, a *App, key string) any { + t.Helper() + cmd := a.Composer.handle(key, a) + if cmd == nil { + return nil + } + return cmd() +} + +func typeIn(a *App, s string) { + for _, r := range s { + key := string(r) + if r == ' ' { + key = "space" + } + a.Composer.handle(key, a) + } +} + +func TestComposerEnterEmitsAndRecords(t *testing.T) { + a := composerApp() + typeIn(a, "status") + + msg, ok := run(t, a, "enter").(runCmdMsg) + if !ok || msg.raw != "status" { + t.Fatalf("enter must emit runCmdMsg{status}, got %#v", msg) + } + if a.Composer.Input != "" { + t.Fatalf("enter must clear the input, got %q", a.Composer.Input) + } + if len(a.Composer.History) != 1 || a.Composer.History[0] != "status" { + t.Fatalf("enter must record history, got %v", a.Composer.History) + } +} + +func TestComposerDoesNotRetainPlaygroundPrompts(t *testing.T) { + a := composerApp() + a.Screen = ScreenPlayground + typeIn(a, "SENTINEL_SECRET_DO_NOT_STORE") + + msg, ok := run(t, a, "enter").(runCmdMsg) + if !ok || msg.raw != "SENTINEL_SECRET_DO_NOT_STORE" { + t.Fatalf("the prompt must still be submitted, got %#v", msg) + } + if len(a.Composer.History) != 0 { + t.Fatalf("playground prompts must not enter command history: %v", a.Composer.History) + } +} + +func TestComposerDoesNotRetainUnknownSlashPrompts(t *testing.T) { + a := composerApp() + a.Screen = ScreenPlayground + a.Composer.Input = "/customer-secret please analyze" + msg, ok := run(t, a, "enter").(runCmdMsg) + if !ok || msg.raw != "customer-secret please analyze" { + t.Fatalf("unknown slash input remains a prompt: %#v", msg) + } + if len(a.Composer.History) != 0 { + t.Fatalf("unknown slash prompts can carry private data and must not be retained: %v", a.Composer.History) + } + + a.Composer.Input = "/model gpt-4o" + run(t, a, "enter") + if len(a.Composer.History) != 1 || a.Composer.History[0] != "model gpt-4o" { + t.Fatalf("known slash actions should remain recallable: %v", a.Composer.History) + } +} + +func TestComposerSanitizesInlineChatHistory(t *testing.T) { + for _, input := range []string{ + "chat SENTINEL_SECRET_DO_NOT_STORE", + "CHAT SENTINEL_SECRET_DO_NOT_STORE", + "PLAYGROUND SENTINEL_SECRET_DO_NOT_STORE", + "//chat SENTINEL_SECRET_DO_NOT_STORE", + } { + t.Run(input, func(t *testing.T) { + a := composerApp() + a.Composer.Input = input + run(t, a, "enter") + if got := a.Composer.History; len(got) != 1 || + (got[0] != "chat" && got[0] != "playground") { + t.Fatalf("history may retain the verb but not its prompt: %v", got) + } + if strings.Contains(strings.Join(a.Composer.History, " "), "SENTINEL") { + t.Fatalf("history retained prompt content: %v", a.Composer.History) + } + }) + } +} + +func TestComposerEmptyEnterIsNoop(t *testing.T) { + a := composerApp() + typeIn(a, " ") + if cmd := a.Composer.handle("enter", a); cmd != nil { + t.Fatal("a blank line must not run anything") + } + if len(a.Composer.History) != 0 { + t.Fatalf("a blank line must not enter history, got %v", a.Composer.History) + } +} + +func TestComposerTabCompletesPrefix(t *testing.T) { + a := composerApp() + typeIn(a, "sta") + run(t, a, "tab") + if a.Composer.Input != "status" { + t.Fatalf("tab must complete to the first matching verb, got %q", a.Composer.Input) + } + + // A prefix nothing matches leaves the line exactly as typed. + a.Composer.Input = "zzz" + run(t, a, "tab") + if a.Composer.Input != "zzz" { + t.Fatalf("tab with no match must not rewrite the line, got %q", a.Composer.Input) + } +} + +func TestComposerHistoryWalk(t *testing.T) { + a := composerApp() + typeIn(a, "status") + run(t, a, "enter") + typeIn(a, "providers") + run(t, a, "enter") + + // The live input must survive a walk into history and come back intact. + typeIn(a, "half typed") + + run(t, a, "up") + if a.Composer.Input != "providers" { + t.Fatalf("first up must recall the newest command, got %q", a.Composer.Input) + } + run(t, a, "up") + if a.Composer.Input != "status" { + t.Fatalf("second up must recall the older command, got %q", a.Composer.Input) + } + run(t, a, "up") + if a.Composer.Input != "status" { + t.Fatalf("up past the oldest entry must stay put, got %q", a.Composer.Input) + } + run(t, a, "down") + if a.Composer.Input != "providers" { + t.Fatalf("down must walk back toward the live input, got %q", a.Composer.Input) + } + run(t, a, "down") + if a.Composer.Input != "half typed" { + t.Fatalf("down off the end must restore the live input, got %q", a.Composer.Input) + } +} + +func TestComposerHistoryBounded(t *testing.T) { + a := composerApp() + for i := range 35 { + a.Composer.Input = string(rune('a'+i%26)) + strings.Repeat("x", i) + run(t, a, "enter") + } + if len(a.Composer.History) != historyMax { + t.Fatalf("history must be bounded at %d, got %d", historyMax, len(a.Composer.History)) + } + if a.Composer.History[0] != "i"+strings.Repeat("x", 34) { + t.Fatalf("history must be newest first, got %q", a.Composer.History[0]) + } +} + +func TestComposerHistoryDedupesConsecutive(t *testing.T) { + a := composerApp() + for range 3 { + a.Composer.Input = "status" + run(t, a, "enter") + } + a.Composer.Input = "providers" + run(t, a, "enter") + a.Composer.Input = "status" + run(t, a, "enter") + + if got := a.Composer.History; len(got) != 3 { + t.Fatalf("consecutive duplicates must collapse, got %v", got) + } +} + +func TestComposerReverseSearch(t *testing.T) { + a := composerApp() + typeIn(a, "status") + run(t, a, "enter") + typeIn(a, "providers") + run(t, a, "enter") + + run(t, a, "ctrl+r") + if !a.Composer.searchMode { + t.Fatal("ctrl+r must enter reverse search") + } + typeIn(a, "sta") + if got := a.Composer.searchMatch(); got != "status" { + t.Fatalf("search must match history, got %q", got) + } + if v := a.Composer.view(a); !strings.Contains(v, "status") { + t.Fatalf("search mode must show the match:\n%s", v) + } + run(t, a, "enter") + if a.Composer.searchMode || a.Composer.Input != "status" { + t.Fatalf("enter must accept the match and leave search (mode=%v input=%q)", a.Composer.searchMode, a.Composer.Input) + } + + // Esc abandons the search without touching the line. + a.Composer.Input = "kept" + run(t, a, "ctrl+r") + typeIn(a, "pro") + run(t, a, "esc") + if a.Composer.searchMode || a.Composer.Input != "kept" { + t.Fatalf("esc must abandon search intact (mode=%v input=%q)", a.Composer.searchMode, a.Composer.Input) + } +} + +func TestComposerSlashAlias(t *testing.T) { + a := composerApp() + typeIn(a, "/logs") + msg, ok := run(t, a, "enter").(runCmdMsg) + if !ok || msg.raw != "logs" { + t.Fatalf("a leading / must be stripped, got %#v", msg) + } +} + +func TestComposerQuestionMarkOnEmptyInputAsksForHelp(t *testing.T) { + a := composerApp() + msg, ok := run(t, a, "?").(runCmdMsg) + if !ok || msg.raw != "help" { + t.Fatalf("? on an empty line must run help, got %#v", msg) + } + if a.Composer.Input != "" { + t.Fatalf("? must not be typed into the line, got %q", a.Composer.Input) + } + + // With a line in progress it is just a character. + typeIn(a, "logs ?") + if a.Composer.Input != "logs ?" { + t.Fatalf("? mid-line is a character, got %q", a.Composer.Input) + } +} + +func TestComposerBackspaceAndEscape(t *testing.T) { + a := composerApp() + typeIn(a, "abc") + run(t, a, "backspace") + if a.Composer.Input != "ab" { + t.Fatalf("backspace must delete one rune, got %q", a.Composer.Input) + } + a.Screen = ScreenLogs + run(t, a, "esc") + if a.Screen != ScreenHome { + t.Fatal("esc outside search must return to home") + } +} + +func TestComposerGhostAndSuggestions(t *testing.T) { + a := composerApp() + typeIn(a, "k") + if got := a.Composer.ghost(); got != "keys" { + t.Fatalf("ghost must name the first matching verb, got %q", got) + } + if s := a.Composer.suggestions(); len(s) == 0 || !slices.Contains(s, "keys create") { + t.Fatalf("suggestions must include the subcommands, got %v", s) + } + if s := a.Composer.suggestions(); len(s) > suggestMax { + t.Fatalf("suggestions are capped at %d, got %d", suggestMax, len(s)) + } + v := a.Composer.view(a) + if !strings.Contains(v, "tab") || !strings.Contains(v, "keys") { + t.Fatalf("the composer must render the ghost hint:\n%s", v) + } + + a.Composer.Input = "" + if a.Composer.ghost() != "" || len(a.Composer.suggestions()) != 0 { + t.Fatal("an empty line offers no ghost and no suggestions") + } +} + +func TestVerbsWalksTheTree(t *testing.T) { + v := Verbs(fakeRoot()) + for _, want := range []string{"status", "keys", "keys create", "logs tail", "playground", "clear", "help", "/providers"} { + if !slices.Contains(v, want) { + t.Fatalf("Verbs must contain %q, got %v", want, v) + } + } + for _, unwanted := range []string{"completion", "/keys create"} { + if slices.Contains(v, unwanted) { + t.Fatalf("Verbs must not contain %q, got %v", unwanted, v) + } + } + // A nil tree still yields the TUI-only vocabulary rather than panicking. + if got := Verbs(nil); !slices.Contains(got, "clear") { + t.Fatalf("Verbs(nil) must still carry the TUI verbs, got %v", got) + } +} + +// The composer sits inside a frame; a line wider than the terminal wraps and +// destroys every frame above it. +func TestComposerViewFitsEveryWidth(t *testing.T) { + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + for _, w := range []int{40, 72, 96, 126} { + a := composerApp() + a.Theme, a.Width = theme.New(mode), w + typeIn(a, "k") + for _, view := range []string{a.Composer.view(a), a.render()} { + for i, line := range strings.Split(view, "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line) + } + } + } + } + } +} diff --git a/internal/tui/home.go b/internal/tui/home.go new file mode 100644 index 0000000..982ca90 --- /dev/null +++ b/internal/tui/home.go @@ -0,0 +1,370 @@ +package tui + +import ( + "context" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" + "github.com/ferro-labs/gateway-cli/internal/version" +) + +// This file is the home screen and the command router — the two halves of +// "typing a verb does something". The router is the console's whole navigation +// model, so it reads as one table on purpose. +// +// Nothing here performs I/O. A verb that needs the gateway returns the tea.Cmd +// from msgs.go and its rows arrive later as a verbRowsMsg. + +// The verbs more than one place has to agree on. A fetched verb is named twice +// — route() dispatches it and verbRows() answers it — and a spelling that +// matches in one but not the other is an "unknown command" or a "has no +// fetcher" that neither file can see on its own. The console-only verbs are +// named for the same reason: the composer offers them (tuiVerbs), the router +// runs them, and the playground has to recognise them to tell a command from a +// prompt. `services` is handled in exactly one place and stays a literal there; +// there is nothing for it to drift from. +const ( + verbHelp = "help" + verbClear = "clear" + verbVersion = "version" + verbModel = "model" + verbChat = "chat" + verbPlayground = "playground" + + verbStatus = "status" + verbModels = "models" + verbProviders = "providers" + verbMCP = "mcp" + verbPlugins = "plugins" + verbSessions = "sessions" + verbAudit = "audit" + + verbLogs = "logs" + verbKeys = "keys" +) + +// Column headings more than one table draws. The same fact is spelled the same +// way everywhere — NAME is the object's own name, STATE the condition, PROVIDER +// the target that served it — so an operator who has read one table can read the +// next. A heading only one table has stays a literal at its call site. +const ( + colName = "NAME" + colState = "STATE" + colProvider = "PROVIDER" +) + +// homeView is the transcript, padded to exactly rows lines. +func (a *App) homeView(w, rows int) string { + if a.Transcript.Len() == 0 { + return fill([]string{"", a.Theme.Dim.Render("No output yet. Type a command below, or ? for help.")}, w, rows) + } + return a.Transcript.view(a.Theme, w, rows) +} + +// route is the dispatch table. Every branch either renders into the transcript +// or switches screens; none of them touches the network directly. +func (a *App) route(raw string) tea.Cmd { + line := strings.TrimSpace(raw) + if line == "" { + return nil + } + // A slash action is the same verb: /logs and logs are one command. Only the + // verb is case-folded — an argument like --model Claude-* is a glob, and + // lowercasing it would quietly change which rows match. + line = strings.TrimPrefix(line, "/") + fields := strings.Fields(line) + if len(fields) == 0 { + return nil + } + head := strings.ToLower(fields[0]) + rest := strings.Join(fields[1:], " ") + + // The playground's seam. On that screen a line that does not name a verb is + // a prompt, not a mistyped command — and the test cannot key on the slash, + // which the composer strips before this function is ever reached. It sits + // here, ahead of the echo, because a prompt is not a command and must not be + // written into the transcript as one. `line` is still exactly what was + // typed, case and all, which is what a prompt needs. + if a.Screen == ScreenPlayground { + if cmd, claimed := a.Play.route(a, head, rest, line); claimed { + return cmd + } + } + + line = strings.TrimSpace(head + " " + rest) + + if head == verbClear { + a.Transcript.Reset() + a.Screen = ScreenHome + return nil + } + + echo := line + if rest != "" && (head == verbChat || head == verbPlayground) { + echo = head + } + a.Transcript.Push(TranscriptRow{Glyph: kindCmd, Text: "ferro " + echo}) + + switch head { + case verbHelp, "?": + a.Screen = ScreenHome + a.Transcript.Push(helpRows(a.Composer.verbs)...) + return nil + + case verbVersion: + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindOK, Text: "ferro " + version.String()}) + return nil + + // Screens own their own data. The router only moves to them; Tasks 14–16 + // wire the fetch, so starting one here would be output with nowhere to go. + case verbLogs: + return a.Logs.start(a, rest) + case verbKeys: + return a.Keys.enter(a, rest) + case verbPlayground, verbChat: + return a.Play.enter(a, rest) + case "services": + a.Screen = ScreenHome + for _, line := range strings.Split(a.viewServices(), "\n") { + a.Transcript.Push(TranscriptRow{Text: line}) + } + return nil + + case verbStatus, verbModels, verbProviders, verbMCP, verbPlugins, verbSessions, verbAudit: + a.Screen = ScreenHome + return verbFetch(a.Client, head) + } + + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{ + Glyph: kindBad, + Text: fmt.Sprintf("unknown command %q — press ? for the verb tree", head), + }) + return nil +} + +// helpRows prints the verb tree from the composer's own vocabulary, which came +// from the cobra tree. Hand-listing it here is how a help screen starts naming +// verbs that no longer exist. +func helpRows(verbs []string) []TranscriptRow { + var top, nested, slash []string + for _, v := range verbs { + switch { + case strings.HasPrefix(v, "/"): + slash = append(slash, v) + case strings.Contains(v, " "): + nested = append(nested, v) + default: + top = append(top, v) + } + } + rows := []TranscriptRow{{Glyph: kindNote, Text: strings.Join(top, " ")}} + if len(nested) > 0 { + rows = append(rows, TranscriptRow{Text: strings.Join(nested, " "), Dim: true}) + } + return append(rows, + TranscriptRow{Text: "slash actions: " + strings.Join(slash, " "), Dim: true}, + TranscriptRow{Text: "every verb has a scriptable twin — run it with --format json for a pipe", Dim: true}, + ) +} + +// ------------------------------------------------------------ verb rendering + +// verbRows fetches one verb and renders it as transcript rows. It is called +// from the tea.Cmd in msgs.go, never from a view func. +func verbRows(ctx context.Context, c *api.Client, verb string) ([]TranscriptRow, error) { + switch verb { + case verbStatus: + // Status returns a report even when it fails, naming the URL it tried; + // rendering it is what makes state:"unreachable" visible. + r, err := c.Status(ctx) + if r == nil { + return nil, err + } + mcp := "-" + if r.MCP != nil { + mcp = fmt.Sprintf("%d/%d", r.MCP.Ready, r.MCP.Total) + } + // Targets is nil when the gateway did not report them — a 503 /readyz + // carrying only {status, reason}. "0/0" there would claim none are + // configured when the truth is that they are dead. + targets := "-" + if t := r.Targets; t != nil { + targets = fmt.Sprintf("%d/%d", t.Routable, t.Total) + } + return tableRows( + []string{colState, "URL", "LATENCY", "TARGETS", "PROVIDERS", "MODELS", "MCP", "AUTH"}, + [][]string{{ + r.State, r.URL, fmt.Sprintf("%dms", r.LatencyMs), targets, + table.CountOrDash(r.Providers), table.CountOrDash(r.Models), mcp, table.OrDash(r.Auth), + }}), err + + case verbModels: + models, err := c.Models(ctx) + if err != nil { + return nil, err + } + cells := table.ModelRows(models) + return orEmpty(tableRows(table.ModelHeaders, cells), + len(cells), "this gateway routes no models"), nil + + case verbProviders: + health, _, err := c.Health(ctx) + if err != nil { + return nil, err + } + // Best effort: an unusable credential costs two columns, not the verb. + admin, adminErr := c.AdminHealth(ctx) + if adminErr != nil { + admin = nil + } + // The merge, the five columns and the dash-for-absent rendering are + // internal/table's: `ferro providers` renders this same listing and the + // two used to disagree about a provider /admin/health omits. + cells := table.ProviderRows(table.MergeProviders(health, admin)) + rows := tableRows(table.ProviderHeaders, cells) + if adminErr != nil { + // Name what actually happened. A refused credential and a timed-out + // or broken /admin/health cost the same two columns, but only one of + // them is fixed by looking at a credential. + why := "admin health unavailable" + if api.IsUnauthorized(adminErr) { + why = "no admin credential accepted" + } + rows = append(rows, TranscriptRow{Glyph: kindWarn, + Text: why + " — status and message come from /health only"}) + } + return orEmpty(rows, len(cells), "no providers reported"), nil + + case verbMCP: + servers, err := mcpServers(ctx, c) + if err != nil { + return nil, err + } + cells := make([][]string, 0, len(servers)) + for _, s := range servers { + cells = append(cells, []string{s.Name, table.BoolYN(s.Ready), table.BoolYN(s.Required), table.OrDash(s.LastError)}) + } + return orEmpty(tableRows([]string{colName, "READY", "REQUIRED", "LAST ERROR"}, cells), + len(cells), "this gateway has no MCP servers configured"), nil + + case verbPlugins: + configured, err := c.Plugins(ctx) + if err != nil { + return nil, err + } + catalog, err := c.PluginCatalog(ctx) + if err != nil { + // A build without the catalog route still lists what it runs; it + // just cannot say what fails open. + if !api.IsNotSupported(err) { + return nil, err + } + catalog = nil + } + cells := pluginCells(configured, catalog) + return orEmpty(tableRows([]string{colName, "TYPE", "ENABLED", "FAILS", "SUMMARY"}, cells), + len(cells), "this gateway has no plugins configured"), nil + + case verbSessions: + sessions, err := c.Sessions(ctx) + if err != nil { + return nil, err + } + cells := make([][]string, 0, len(sessions)) + for _, s := range sessions { + cells = append(cells, []string{ + s.Subject, strings.Join(s.Scopes, ","), + table.FmtTime(s.CreatedAt), table.FmtTimePtr(s.LastSeenAt), table.FmtTime(s.ExpiresAt), + }) + } + return orEmpty(tableRows([]string{"SUBJECT", "SCOPES", "CREATED", "LAST SEEN", "EXPIRES"}, cells), + len(cells), "no active dashboard sessions"), nil + + case verbAudit: + page, err := c.Audit(ctx, api.AuditQuery{Limit: auditRows}) + if err != nil { + return nil, err + } + cells := make([][]string, 0, len(page.Data)) + for _, e := range page.Data { + cells = append(cells, []string{ + table.FmtTime(e.OccurredAt), e.Action, table.OrDash(e.Actor), e.Outcome, table.OrDash(e.TargetID), + }) + } + return orEmpty(tableRows([]string{"TIME", "ACTION", "ACTOR", "OUTCOME", "TARGET"}, cells), + len(cells), "no audit entries in range"), nil + } + return nil, fmt.Errorf("verb %q has no fetcher", verb) +} + +// mcpServers prefers /admin/health: it alone carries last_error, which /readyz +// withholds because it is unauthenticated and the reason can quote a URL. +func mcpServers(ctx context.Context, c *api.Client) ([]api.MCPServer, error) { + if ah, err := c.AdminHealth(ctx); err == nil { + return ah.MCPServers, nil + } + ready, _, err := c.Ready(ctx) + if err != nil { + return nil, err + } + return ready.MCPServers, nil +} + +// The two failure policies a catalog entry reports. Which one a plugin has is +// the single most consequential thing on the plugins table — it is the +// difference between a broken guardrail stopping traffic and waving it through +// — so the column says it in the catalog's own words. +// pluginCells merges what this gateway has configured with what this build +// ships — only the catalog knows whether a plugin fails open, and the +// scriptable table derives that cell from the same place. +func pluginCells(configured []api.PluginInfo, catalog []api.BuiltinPlugin) [][]string { + shipped := table.NewPluginCatalog(catalog) + cells := make([][]string, 0, len(configured)) + for _, p := range configured { + policy := shipped.For(p.Name) + cells = append(cells, []string{ + p.Name, p.Type, table.BoolYN(p.Enabled), policy.Fails, table.OrDash(policy.Summary), + }) + } + return cells +} + +// ------------------------------------------------------------------ helpers + +// tableRows renders headers and cells through internal/table's shared layout +// — the same one Printer.Table (internal/command/output.go) calls — so a verb +// read in the console and the same verb read through a pipe align identically. +// This is the only thing left here: the TranscriptRow wrapping, dimming the +// header and its rule because they are furniture, leaving the data alone. +func tableRows(headers []string, cells [][]string) []TranscriptRow { + lines := table.Rows(headers, cells) + rows := make([]TranscriptRow, 0, len(lines)) + for i, line := range lines { + rows = append(rows, TranscriptRow{Text: line, Dim: i < 2}) + } + return rows +} + +// orEmpty replaces a table with nothing in it by a row that says so. An empty +// gateway and a broken query look identical otherwise. +func orEmpty(rows []TranscriptRow, n int, empty string) []TranscriptRow { + if n > 0 { + return rows + } + return []TranscriptRow{{Text: empty, Dim: true}} +} + +// fill pads a body to exactly rows lines, truncating each to w cells. +func fill(lines []string, w, rows int) string { + out := append(make([]string, 0, rows), clampLines(lines, w)...) + for len(out) < rows { + out = append(out, "") + } + return strings.Join(out[:max(rows, 0)], "\n") +} diff --git a/internal/tui/home_test.go b/internal/tui/home_test.go new file mode 100644 index 0000000..21d8b65 --- /dev/null +++ b/internal/tui/home_test.go @@ -0,0 +1,472 @@ +package tui + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// TableRows hands tableRows to the external test package. The layout is held +// equal to internal/command's Printer.Table there (verbs_cobra_test.go), +// because that assertion needs to import internal/command and this package +// never may. +var TableRows = tableRows + +// transcriptText flattens the transcript for assertions that do not care where +// a row's text sits on screen. +func transcriptText(a *App) string { + var b strings.Builder + for _, r := range a.Transcript.rows { + b.WriteString(r.Glyph + " " + r.Text + "\n") + } + return b.String() +} + +func lastRow(a *App) TranscriptRow { + if n := len(a.Transcript.rows); n > 0 { + return a.Transcript.rows[n-1] + } + return TranscriptRow{} +} + +func TestRouteEchoesEveryCommand(t *testing.T) { + a := composerApp() + a.route("status") + if got := a.Transcript.rows[0]; got.Glyph != "$" || got.Text != "ferro status" { + t.Fatalf("every command must be echoed as it would be run, got %#v", got) + } +} + +func TestRouteUnknownVerb(t *testing.T) { + a := composerApp() + if cmd := a.route("frobnicate --hard"); cmd != nil { + t.Fatal("an unknown verb must not reach the network") + } + row := lastRow(a) + if row.Glyph != "bad" || !strings.Contains(row.Text, `"frobnicate"`) { + t.Fatalf("an unknown verb must be named in a red row, got %#v", row) + } + if !strings.Contains(row.Text, "?") { + t.Fatalf("the refusal must point at the verb tree, got %q", row.Text) + } + if a.Screen != ScreenHome { + t.Fatal("an unknown verb leaves the operator on the transcript") + } +} + +func TestRouteHelpListsVerbs(t *testing.T) { + for _, raw := range []string{"help", "?"} { + a := composerApp() + a.route(raw) + text := transcriptText(a) + for _, want := range []string{"status", "logs", "playground", "keys", "keys create", "/providers"} { + if !strings.Contains(text, want) { + t.Fatalf("%q must list %q:\n%s", raw, want, text) + } + } + if !strings.Contains(text, "--format json") { + t.Fatalf("help must point at the scriptable twin:\n%s", text) + } + } +} + +func TestRouteStatusEchoesAndFetches(t *testing.T) { + a := composerApp() + cmd := a.route("status") + if cmd == nil { + t.Fatal("status must return a fetch command") + } + if !strings.Contains(transcriptText(a), "ferro status") { + t.Fatalf("status must echo its scriptable form:\n%s", transcriptText(a)) + } + if a.Screen != ScreenHome { + t.Fatal("status renders into the transcript, so it must land on home") + } + // The command must run without a client rather than panicking on nil. + msg, ok := cmd().(verbRowsMsg) + if !ok || msg.err == nil { + t.Fatalf("a verb fetched with no connection must report the failure, got %#v", msg) + } +} + +func TestRouteSwitchesScreens(t *testing.T) { + for raw, want := range map[string]Screen{ + "logs --model claude-*": ScreenLogs, + "logs": ScreenLogs, + "keys": ScreenKeys, + "keys create": ScreenKeys, + "playground": ScreenPlayground, + "chat": ScreenPlayground, + } { + a := composerApp() + a.route(raw) + if a.Screen != want { + t.Fatalf("%q must switch to screen %v, got %v", raw, want, a.Screen) + } + } +} + +func TestRouteClearResetsTranscriptAndScreen(t *testing.T) { + a := composerApp() + a.route("status") + a.Screen = ScreenLogs + a.route("clear") + if a.Transcript.Len() != 0 { + t.Fatalf("clear must empty the transcript, got %d rows", a.Transcript.Len()) + } + if a.Screen != ScreenHome { + t.Fatal("clear returns to the transcript") + } +} + +func TestRouteStripsSlashAndIgnoresVerbCase(t *testing.T) { + a := composerApp() + a.route("/STATUS") + if !strings.Contains(transcriptText(a), "ferro status") { + t.Fatalf("a slash action is the same verb, lowercased:\n%s", transcriptText(a)) + } + a = composerApp() + a.route(" ") + if a.Transcript.Len() != 0 { + t.Fatal("a blank line is not a command") + } + a.route("/") + if a.Transcript.Len() != 0 { + t.Fatal("a bare slash is not a command") + } +} + +func TestInlineChatPromptIsNotEchoedToTranscript(t *testing.T) { + a := composerApp() + a.route("chat SENTINEL_SECRET_DO_NOT_STORE") + if strings.Contains(transcriptText(a), "SENTINEL_SECRET_DO_NOT_STORE") { + t.Fatalf("chat prompt leaked into transcript:\n%s", transcriptText(a)) + } +} + +// Arguments keep their case: a --model filter is a glob, not a verb. +func TestRouteKeepsArgumentCase(t *testing.T) { + a := composerApp() + a.route("logs --model Claude-Sonnet") + if !strings.Contains(transcriptText(a), "Claude-Sonnet") { + t.Fatalf("arguments must survive verb normalisation:\n%s", transcriptText(a)) + } +} + +func TestVerbRowsLandInTranscript(t *testing.T) { + a := composerApp() + a.update(verbRowsMsg{ + verb: "status", + rows: []TranscriptRow{{Glyph: "ok", Text: "STATE URL"}, {Glyph: "ok", Text: "connected http://x"}}, + took: 41 * time.Millisecond, + }) + text := transcriptText(a) + if !strings.Contains(text, "connected http://x") { + t.Fatalf("fetched rows must land in the transcript:\n%s", text) + } + if !strings.Contains(text, "Completed in 41ms") { + t.Fatalf("every async verb must report its duration:\n%s", text) + } + if !lastRow(a).Dim { + t.Fatal("the duration row is dim furniture, not output") + } +} + +func TestVerbFailureAppendsARedRow(t *testing.T) { + a := composerApp() + err := &api.Error{Status: 401, Message: "missing credential"} + a.update(verbRowsMsg{verb: "keys", err: err, took: time.Millisecond}) + + var found bool + for _, r := range a.Transcript.rows { + if r.Glyph == "bad" && strings.Contains(r.Text, err.Error()) { + found = true + } + } + if !found { + t.Fatalf("a failed verb must carry the gateway's own error text:\n%s", transcriptText(a)) + } +} + +// The pane is the shell's anchor: exactly rows lines on every screen, so the +// composer never moves. +func TestPaneIsExactlyRowsLinesOnEveryScreen(t *testing.T) { + a := composerApp() + a.route("status") + for _, screen := range []Screen{ScreenHome, ScreenLogs, ScreenKeys, ScreenPlayground} { + a.Screen = screen + for _, rows := range []int{1, 5, 18} { + body := a.paneView(60, rows) + if got := len(strings.Split(body, "\n")); got != rows { + t.Fatalf("screen %v pane at rows=%d returned %d lines", screen, rows, got) + } + for i, line := range strings.Split(body, "\n") { + if lw := lipgloss.Width(line); lw > 60 { + t.Fatalf("screen %v pane line %d overflows: %d cells %q", screen, i, lw, line) + } + } + } + } +} + +// Every screen now says something. An empty pane would read as "this gateway +// has nothing to show", which is a claim none of them can support before their +// first fetch lands. +func TestEveryScreenSaysSomethingBeforeItsFirstFetch(t *testing.T) { + a := composerApp() // no client + for raw, want := range map[string]string{ + // With nothing to ask, the pane names the reason rather than looking + // like a gateway with no logs and no keys. + "logs": errNoConnection.Error(), + "keys": errNoConnection.Error(), + // The playground needs no fetch to accept a prompt; the failure + // surfaces on the turn, where it can be read. + "playground": "Type a prompt", + } { + a.route(raw) + if body := a.paneView(70, 8); !strings.Contains(body, want) { + t.Fatalf("%q must say %q rather than render blank:\n%s", raw, want, body) + } + } + + // And a real, genuinely empty listing says THAT, which is a different fact. + a = composerApp() + a.route("logs") + a.Logs.lastErr = nil + if body := a.paneView(70, 8); !strings.Contains(body, "no rows") { + t.Fatalf("an empty log window must say so:\n%s", body) + } + a.route("keys") + a.Keys.err = nil + if body := a.paneView(70, 8); !strings.Contains(body, "no API keys") { + t.Fatalf("a gateway with no keys must say so:\n%s", body) + } +} + +// The whole shell, with a transcript in it, still fits every width and mode. +func TestRenderWithTranscriptFitsEveryWidth(t *testing.T) { + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + for _, w := range []int{72, 96, 126} { + a := composerApp() + a.Theme, a.Width = theme.New(mode), w + a.route("help") + a.update(verbRowsMsg{verb: "status", rows: []TranscriptRow{ + {Glyph: "ok", Text: strings.Repeat("a very wide row ", 20)}, + }, took: 12 * time.Millisecond}) + for i, line := range strings.Split(a.render(), "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line) + } + } + } + } +} + +// stubGateway serves just enough of the admin API for the transcript verbs. +func stubGateway(t *testing.T) *api.Client { + t.Helper() + mux := http.NewServeMux() + body := func(path, json string) { + mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, json) + }) + } + body("/health", `{"status":"ok","providers":[ + {"name":"openai","status":"healthy","circuit":"closed","models":1104}, + {"name":"anthropic","status":"degraded","circuit":"half_open","models":412}]}`) + body("/readyz", `{"status":"ready","targets":[{"name":"openai","routable":true}], + "mcp_servers":[{"name":"filesystem","ready":true,"required":false}]}`) + body("/admin/health", `{"status":"ok","scopes":["admin"],"providers":[ + {"name":"openai","status":"healthy","models":1104}, + {"name":"anthropic","status":"degraded","models":412,"message":"rate limited upstream"}], + "mcp_servers":[{"name":"filesystem","ready":true,"required":true}, + {"name":"search","ready":false,"required":false,"last_error":"handshake timeout"}]}`) + body("/v1/models", `{"object":"list","data":[ + {"id":"claude-sonnet-4-6","owned_by":"anthropic","mode":"chat","context_window":200000, + "capabilities":["chat","tools"],"status":"available"}]}`) + body("/admin/plugins", `[{"name":"rate-limit","type":"ratelimit","enabled":true}, + {"name":"request-logger","type":"logging","enabled":false}]`) + body("/admin/plugins/catalog", `{"data":[{"name":"rate-limit","type":"ratelimit","summary":"caps request rate","fails_open":false}, + {"name":"request-logger","type":"logging","summary":"records requests","fails_open":true}]}`) + body("/admin/sessions", `{"data":[{"id":"s1","subject":"alice","scopes":["admin"], + "created_at":"2026-08-08T09:00:00Z","expires_at":"2026-08-09T09:00:00Z"}]}`) + body("/admin/audit", `{"data":[{"occurred_at":"2026-08-08T09:00:00Z","action":"key.create", + "actor":"alice","outcome":"ok","target_id":"ak_1"}],"summary":{"total_entries":1,"returned_entries":1}}`) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "test-key") + if err != nil { + t.Fatalf("client: %v", err) + } + return c +} + +// mustLocalRFC3339 renders the UTC wire timestamp the sessions and audit +// fixtures carry (stubGateway) the way verbRows actually renders it: through +// fmtTime, in the reader's local time. Asserting this rather than a literal +// RFC3339 string keeps the test honest about what the column shows without +// hardcoding a timezone-dependent value — the repo convention is to assert +// UTC for persisted/admin timestamps, but this cell is deliberately local. +func mustLocalRFC3339(t *testing.T, utc string) string { + t.Helper() + parsed, err := time.Parse(time.RFC3339, utc) + if err != nil { + t.Fatalf("bad fixture timestamp %q: %v", utc, err) + } + return parsed.Local().Format(time.RFC3339) +} + +// The transcript's verbs are the scriptable verbs: same sources, same columns, +// same alignment. This is the test that fails when one of them drifts. +func TestVerbRowsRenderEveryTranscriptVerb(t *testing.T) { + c := stubGateway(t) + // Both fixtures (stubGateway) carry this same UTC instant as their + // administrative timestamp; verbRows renders it through fmtTime, which + // converts to local time (home.go). Without this, a regression in that + // conversion — or in which field feeds the column — passes silently. + localCreatedAt := mustLocalRFC3339(t, "2026-08-08T09:00:00Z") + for verb, want := range map[string][]string{ + // One provider's circuit is half-open, so the report is degraded — the + // derivation lives in api.Status and the transcript must not soften it. + "status": {"STATE", "URL", "LATENCY", "degraded", "1/1", "admin"}, + "models": {"ID", "OWNED BY", "claude-sonnet-4-6", "anthropic", "200000"}, + "providers": {"PROVIDER", "CIRCUIT", "openai", "closed", "half_open", "rate limited upstream"}, + "mcp": {"NAME", "READY", "REQUIRED", "filesystem", "yes", "handshake timeout"}, + "plugins": {"NAME", "FAILS", "rate-limit", "closed", "request-logger", "open"}, + "sessions": {"SUBJECT", "SCOPES", "alice", "admin", localCreatedAt}, + "audit": {"ACTION", "OUTCOME", "key.create", "ok", "ak_1", localCreatedAt}, + } { + rows, err := verbRows(t.Context(), c, verb) + if err != nil { + t.Fatalf("%s: %v", verb, err) + } + var text strings.Builder + for _, r := range rows { + text.WriteString(r.Text + "\n") + } + for _, w := range want { + if !strings.Contains(text.String(), w) { + t.Fatalf("%s output missing %q:\n%s", verb, w, text.String()) + } + } + // The header and its rule are furniture, so they are dim; the data is + // the output and must not be. + if len(rows) < 3 || !rows[0].Dim || !rows[1].Dim || rows[2].Dim { + t.Fatalf("%s: header must be dim and data must not be: %#v", verb, rows[:min(3, len(rows))]) + } + } +} + +// The console and `ferro providers` read the same two endpoints, and they used +// to merge them differently: the console iterated /admin/health's list alone, +// so /health naming openai and anthropic while /admin/health named only openai +// printed two rows through a pipe and one on screen. That is the fixture below. +// +// The expectation is table.ProviderRows' own output rather than a table typed +// out here, because a hand-written one drifts the moment either side changes a +// column — and then the guard against divergence is itself divergent. +func TestProviderRowsAreTheScriptableVerbsRows(t *testing.T) { + h := &api.HealthReport{Status: "ok", Providers: []api.ProviderHealth{ + {Name: "openai", Status: "available", Circuit: "closed", Models: 3}, + {Name: "anthropic", Status: "available", Circuit: "open", Models: 412}, + }} + ah := &api.AdminHealth{Status: "degraded", Providers: []api.AdminProviderHealth{ + {Name: "openai", Status: "degraded", Models: 3, Message: "rate limited upstream"}, + }} + + mux := http.NewServeMux() + serve := func(path string, v any) { + mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) + }) + } + serve("/health", h) + serve("/admin/health", ah) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "test-key") + if err != nil { + t.Fatalf("client: %v", err) + } + + rows, err := verbRows(t.Context(), c, verbProviders) + if err != nil { + t.Fatalf("providers: %v", err) + } + want := tableRows(table.ProviderHeaders, table.ProviderRows(table.MergeProviders(h, ah))) + if !slices.Equal(rows, want) { + t.Fatalf("the console must render the rows `ferro providers` renders:\n got %#v\nwant %#v", rows, want) + } + // The union is the point: anthropic is in /health only, and three rows + // (header, rule, one provider) would be the console dropping it again. + if len(rows) != 4 { + t.Fatalf("want header, rule and both providers, got %d rows: %#v", len(rows), rows) + } +} + +// A gateway that does not serve an endpoint must surface its own words, not a +// paraphrase and not an empty table. +func TestVerbRowsSurfaceGatewayErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":{"message":"missing credential","code":"unauthorized"}}`) + })) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "") + if err != nil { + t.Fatalf("client: %v", err) + } + if _, err := verbRows(t.Context(), c, "sessions"); err == nil || + !strings.Contains(err.Error(), "missing credential") { + t.Fatalf("the gateway's own error must survive, got %v", err) + } + if _, err := verbRows(t.Context(), c, "frobnicate"); err == nil { + t.Fatal("a verb with no fetcher must not report success") + } +} + +// An empty listing and a broken query look identical unless the empty one says +// so in words. +func TestVerbRowsNameAnEmptyListing(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/admin/sessions", func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"data":[]}`) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "k") + if err != nil { + t.Fatalf("client: %v", err) + } + + rows, err := verbRows(t.Context(), c, "sessions") + if err != nil { + t.Fatalf("an empty listing is not an error: %v", err) + } + if len(rows) != 1 || !strings.Contains(rows[0].Text, "no active dashboard sessions") { + t.Fatalf("an empty listing must say so, got %#v", rows) + } +} + +// The runCmdMsg arm is the composer's only route into the router. +func TestRunCmdMsgReachesTheRouter(t *testing.T) { + a := composerApp() + a.update(runCmdMsg{raw: "frobnicate"}) + if !strings.Contains(transcriptText(a), "frobnicate") { + t.Fatalf("runCmdMsg must be dispatched:\n%s", transcriptText(a)) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..ae9b45d --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,721 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +// The keys screen is the console's only destructive surface, and every choice +// here follows from that: +// +// - Rotate and revoke are gated on typing the key's exact name. The wording +// of each gate is the approved blast-radius copy, because what an operator +// needs at that moment is not "are you sure" but "here is what breaks". +// - A secret exists in exactly one place: the modal that was handed it. It is +// never pushed to the transcript, never recorded in history, and is gone +// from the process the moment that modal closes. What the transcript gets +// instead names the key and its scope, so the action is on the record and +// the credential is not. +// - The table shows the wire's masked `key`, never a full secret — reads are +// masked head8…tail4 by the gateway and this screen does no unmasking. +// +// There is no state field on the wire; api.KeyState derives revoked/expired/ +// active from revoked_at, expires_at and active. Nothing here re-derives it. + +// defaultExpiry is the wizard's prefilled ceiling. An unexpiring admin key is +// a decision, so it costs a deliberate edit rather than a default. +const defaultExpiry = "720h" + +// The actions this screen can be asked to perform. One string travels a long +// way — from the subcommand the operator typed, through pendingVerb, into +// modalResultMsg.kind in msgs.go, and back out to the arm that decides whether +// a secret modal opens — and every hop is a plain string comparison. Naming +// them keeps the three switches on one vocabulary. The alternative spellings +// (`new`, `delete`) stay literals where they are accepted: they are input the +// operator may type, not values this package passes around. +const ( + actionGet = "get" + actionCreate = "create" + actionRotate = "rotate" + actionRevoke = "revoke" +) + +// The labels the modals share. A field named one thing in the create wizard +// and another in the detail strip reads as two different fields. +const ( + kvName = "name" + kvScope = "scope" + kvLastUsed = "last used" + + labelNext = "next" +) + +// The create wizard's three step titles. One prefix, three steps: the title is +// the only thing telling an operator where they are in the chain. +const ( + titleCreateName = "Create API key · name" + titleCreateScope = "Create API key · scope" + titleCreateExpiry = "Create API key · expiry" +) + +// keysScreen is the API keys pane: a table, an optional detail strip, and the +// modal flows that mutate it. +type keysScreen struct { + keys []api.Key + sel int + detail bool + err error + + // gen invalidates in-flight work from a visit the operator has left or a + // command that has been superseded, the same contract the shell's pollGen + // has. + gen int + + pendingVerb string + pendingArg string + + draft keyDraft +} + +// keyDraft is the create wizard's accumulated answers. It holds no secret: +// the secret does not exist until the gateway answers, and then it lives only +// in the modal built from that answer. +type keyDraft struct { + name, scope string + expires *time.Time +} + +// ------------------------------------------------------------------ lifecycle + +// enter opens the pane and dispatches the subcommand the operator typed. +// `keys` alone lists; `keys create|rotate|revoke` opens a flow. +func (s *keysScreen) enter(a *App, rest string) tea.Cmd { + if a.Screen != ScreenKeys { + s.detail = false + } + s.gen++ + s.pendingVerb, s.pendingArg = "", "" + a.Screen = ScreenKeys + cmd := s.fetch(a) + + f := strings.Fields(rest) + if len(f) == 0 { + return cmd + } + arg := strings.Join(f[1:], " ") + switch strings.ToLower(f[0]) { + case "list": + case actionGet: + s.pendingVerb, s.pendingArg = actionGet, arg + case actionCreate, "new": + s.openCreate(a) + case actionRotate: + s.pendingVerb, s.pendingArg = actionRotate, arg + case actionRevoke, "delete": + s.pendingVerb, s.pendingArg = actionRevoke, arg + default: + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindBad, + Text: fmt.Sprintf("keys: unknown action %q — list, get, create, rotate or revoke", f[0])}) + return nil + } + return cmd +} + +// leave drops whatever the visit had in flight. There is no stream and no tick +// here, so bumping the generation is the whole of it — a list fetch that lands +// after the operator moved on must not repopulate a pane they are not looking +// at, and a modal must not outlive its screen. +func (s *keysScreen) leave() { s.gen++ } + +// refresh re-reads the list. It is the only way rows change: this screen never +// patches a row locally from a mutation's response, because the gateway is the +// authority on a key's state and a local guess is how a revoked key keeps +// rendering as active. +func (s *keysScreen) refresh(a *App) tea.Cmd { + s.gen++ + return s.fetch(a) +} + +func (s *keysScreen) fetch(a *App) tea.Cmd { + if a.Client == nil { + s.err = errNoConnection + return nil + } + return fetchKeys(a.Client, s.gen) +} + +func (s *keysScreen) update(a *App, msg tea.Msg) tea.Cmd { + switch m := msg.(type) { + case keysListMsg: + if m.gen != s.gen { + return nil + } + s.err = m.err + if m.err == nil { + s.keys = m.keys + s.sel = clampIdx(s.sel, len(s.keys)) + return s.runPending(a) + } + s.pendingVerb, s.pendingArg = "", "" + return nil + + case modalResultMsg: + if m.gen != s.gen { + return nil + } + return s.result(a, m) + } + return nil +} + +func (s *keysScreen) runPending(a *App) tea.Cmd { + verb, arg := s.pendingVerb, s.pendingArg + s.pendingVerb, s.pendingArg = "", "" + switch verb { + case actionGet: + if s.selectNamed(arg) { + return nil + } + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: fmt.Sprintf("keys get: no key named %q", arg)}) + case actionRotate: + s.open(a, verb, arg, s.openRotate) + case actionRevoke: + s.open(a, verb, arg, s.openRevoke) + } + return nil +} + +// key returns the selected row. +func (s *keysScreen) key() (api.Key, bool) { + if s.sel < 0 || s.sel >= len(s.keys) { + return api.Key{}, false + } + return s.keys[s.sel], true +} + +func (s *keysScreen) selectNamed(arg string) bool { + for i, k := range s.keys { + if k.Name == arg || k.ID == arg { + s.sel, s.detail = i, true + return true + } + } + return false +} + +// handleKey is the pane's own keyboard. It claims a key only when the composer +// is empty, so typing a verb from this screen always beats moving a cursor. +func (s *keysScreen) handleKey(key string) bool { + switch key { + case keyUp: + s.sel = clampIdx(s.sel-1, len(s.keys)) + case keyDown: + s.sel = clampIdx(s.sel+1, len(s.keys)) + case keyEnter: + s.detail = s.sel >= 0 && s.sel < len(s.keys) + case keyEsc: + if !s.detail { + return false // esc means "back" once there is no strip to close + } + s.detail = false + default: + return false + } + return true +} + +// -------------------------------------------------------------------- flows + +// open resolves the key a destructive verb names — by name, by id, or by the +// current selection — and refuses rather than guessing when it names none. +func (s *keysScreen) open(a *App, verb, arg string, f func(*App, api.Key)) { + if arg != "" { + for _, k := range s.keys { + if k.Name == arg || k.ID == arg { + f(a, k) + return + } + } + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindBad, + Text: fmt.Sprintf("keys %s: no key named %q", verb, arg)}) + return + } + k, ok := s.key() + if !ok { + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindBad, + Text: fmt.Sprintf("keys %s: no key selected — run keys, pick a row with ↑/↓, then keys %s", verb, verb)}) + return + } + f(a, k) +} + +// openCreate is step 1 of the wizard. Each step's OnConfirm builds the next, +// so the flow is a chain of modals rather than a step counter to keep in sync. +func (s *keysScreen) openCreate(a *App) { + s.draft = keyDraft{scope: api.ScopeReadOnly} + a.Modal = &Modal{ + Title: titleCreateName, + Body: "Names are how log rows resolve after a key is revoked.", + Input: true, + Placeholder: "prod-ingest", + OKLabel: labelNext, + OnConfirm: func(a *App, v string) tea.Cmd { + if v == "" { + s.openCreate(a) // a nameless key is the one thing this step exists to prevent + return nil + } + s.draft.name = v + s.openScope(a) + return nil + }, + } +} + +func (s *keysScreen) openScope(a *App) { + a.Modal = &Modal{ + Title: titleCreateScope, + Body: "Scope is always sent explicitly, so the key carries the scope you\n" + + "chose rather than the gateway's default.", + Rows: []ModalKV{{K: kvName, V: s.draft.name, Bold: true}}, + Options: []string{api.ScopeReadOnly, api.ScopeAdmin}, + OptionLabel: kvScope, + OKLabel: labelNext, + OnConfirm: func(a *App, scope string) tea.Cmd { + s.draft.scope = scope + s.openExpiry(a, "") + return nil + }, + } +} + +func (s *keysScreen) openExpiry(a *App, errMsg string) { + a.Modal = &Modal{ + Title: titleCreateExpiry, + Body: "Default 720h. An unexpiring admin key needs a written exception.", + Rows: []ModalKV{ + {K: kvName, V: s.draft.name, Bold: true}, + {K: kvScope, V: s.draft.scope}, + }, + Input: true, + Value: defaultExpiry, + Placeholder: "720h · empty for no expiry", + Err: errMsg, + OKLabel: actionCreate, + OnConfirm: func(a *App, v string) tea.Cmd { + exp, err := parseExpiry(v) + if err != nil { + s.openExpiry(a, err.Error()) + return nil + } + s.draft.expires = exp + return createKey(a.Client, s.gen, api.KeyCreateRequest{ + Name: s.draft.name, + Scopes: []string{s.draft.scope}, + ExpiresAt: exp, + }) + }, + } +} + +func (s *keysScreen) openRotate(a *App, k api.Key) { + a.Modal = &Modal{ + Title: "Rotate " + k.Name + "?", + Body: "Blast radius: every client holding the current secret fails immediately\n" + + "until it is redeployed with the new one.", + Rows: []ModalKV{ + {K: "id", V: k.ID}, + {K: kvScope, V: strings.Join(k.Scopes, ",")}, + {K: kvLastUsed, V: lastUsed(k), Warn: true}, + }, + Input: true, + Placeholder: `type "` + k.Name + `" to confirm`, + Confirm: k.Name, + OKLabel: actionRotate, + Danger: true, + OnConfirm: func(a *App, _ string) tea.Cmd { + return rotateKey(a.Client, s.gen, k.ID) + }, + } +} + +func (s *keysScreen) openRevoke(a *App, k api.Key) { + a.Modal = &Modal{ + Title: "Revoke " + k.Name + "?", + Body: "Revocation is immediate and cannot be undone. Existing log rows keep\n" + + "their api_key_id and will render with a revoked badge.", + Rows: []ModalKV{ + {K: "id", V: k.ID}, + {K: kvScope, V: strings.Join(k.Scopes, ",")}, + }, + Input: true, + Placeholder: `type "` + k.Name + `" to confirm`, + Confirm: k.Name, + OKLabel: actionRevoke, + Danger: true, + OnConfirm: func(a *App, _ string) tea.Cmd { + return revokeKey(a.Client, s.gen, k.ID, k.Name, strings.Join(k.Scopes, ",")) + }, + } +} + +// result turns a finished mutation into the next thing on screen. A failure is +// pushed to the transcript in the gateway's own words and the operator is +// returned to it, because a red row on a pane nobody is looking at is a +// failure that happened silently. +func (s *keysScreen) result(a *App, m modalResultMsg) tea.Cmd { + if m.err != nil { + s.err = m.err + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: "keys " + m.kind + ": " + m.err.Error()}) + return nil + } + switch m.kind { + case actionRevoke: + a.Transcript.Push(TranscriptRow{Glyph: kindOK, Text: "revoked " + m.name + " (" + m.scope + ")"}) + return s.refresh(a) + case actionCreate, actionRotate: + s.openSecret(a, m.kind, m.key) + return nil + } + return nil +} + +// openSecret is the one place a secret is ever rendered. Confirm and cancel run +// the same closure: leaving by esc still records what happened and still drops +// the secret, because esc is a real way out and not an error path. +func (s *keysScreen) openSecret(a *App, kind string, k *api.Key) { + if k == nil { + s.err = fmt.Errorf("keys %s returned no key", kind) + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: s.err.Error()}) + return + } + scope := strings.Join(k.Scopes, ",") + // The list is re-read rather than patched from this response: it carries a + // secret we are about to forget on purpose, and the gateway is the + // authority on what the key now looks like. + done := func(a *App) tea.Cmd { + a.Transcript.Push(TranscriptRow{Glyph: kindOK, Text: summarize(kind, k, scope)}) + a.Screen = ScreenKeys + return s.refresh(a) + } + + if kind == actionRotate { + a.Modal = &Modal{ + Title: "Rotated " + k.Name, + Body: "The previous secret stopped authenticating immediately.", + Rows: []ModalKV{ + {K: "new secret", V: k.Key, Accent: true, Bold: true}, + {K: "id", V: k.ID}, + {K: "blast radius", V: "every client using the old secret", Warn: true}, + }, + OKLabel: "done", + OnConfirm: func(a *App, _ string) tea.Cmd { return done(a) }, + OnCancel: done, + } + return + } + + a.Modal = &Modal{ + Title: "Key created", + Body: "Shown exactly once. Copy it now — the gateway stores only a hash.", + Rows: []ModalKV{ + {K: kvName, V: k.Name, Bold: true}, + {K: "secret", V: k.Key, Accent: true, Bold: true}, + {K: "copy", V: "it will not be shown again"}, + }, + OKLabel: "done", + OnConfirm: func(a *App, _ string) tea.Cmd { return done(a) }, + OnCancel: done, + } +} + +// summarize is the transcript row a finished flow leaves behind. It names the +// key, its scope and its ceiling — everything except the credential. +func summarize(kind string, k *api.Key, scope string) string { + verb := map[string]string{actionCreate: "created", actionRotate: "rotated"}[kind] + out := verb + " " + k.Name + " (" + scope + if k.ExpiresAt != nil { + out += ", expires " + k.ExpiresAt.Local().Format(time.DateOnly) + } else if kind == actionCreate { + out += ", no expiry" + } + return out + ")" +} + +// --------------------------------------------------------------------- view + +// keyColumns is the table, widest-useful first. NAME flexes because it is the +// column an operator reads down; everything else is a fixed field. +func keyColumns() []column { + return []column{ + {head: colName, w: 18, flex: true}, + {head: "KEY", w: 17}, + {head: "SCOPE", w: 10}, + {head: "EXPIRES", w: 10}, + {head: colState, w: 7}, + } +} + +func (s *keysScreen) view(a *App, w, rows int) string { + th := a.Theme + lines := []string{s.subLine(a, w)} + + if s.err != nil { + return fill(append(lines, "", th.Bad.Render("keys: "+s.err.Error())), w, rows) + } + + var strip []string + if s.detail { + if k, ok := s.key(); ok { + strip = detailStrip(a, "Key "+k.Name, keyDetail(a, k), w) + } + } + + // The table gets whatever the sub-line and the strip leave. It never + // scrolls off its own header: the header is dropped last, not first. + body := max(rows-len(lines)-len(strip), 0) + cols := layout(keyColumns(), w) + if body > 0 { + lines = append(lines, th.Dim.Render(headerLine(cols))) + body-- + } + if len(s.keys) == 0 { + lines = append(lines, th.Dim.Render("this gateway has no API keys")) + } + for _, i := range window(len(s.keys), s.sel, body) { + lines = append(lines, s.row(a, cols, i, w)) + } + return fill(append(padTo(lines, rows-len(strip)), strip...), w, rows) +} + +func (s *keysScreen) subLine(a *App, w int) string { + th := a.Theme + left := th.Dim.Render("keys create · keys rotate · keys revoke") + right := th.Dim.Render(fmt.Sprintf("%d keys", len(s.keys))) + if len(s.keys) == 0 { + right = "" + } + return gapPad(w, " "+left, right) +} + +func (s *keysScreen) row(a *App, cols []column, i, w int) string { + th, k := a.Theme, s.keys[i] + state := api.KeyState(k) + + stateStyle := th.OK + switch state { + case api.KeyStateRevoked: + stateStyle = th.Bad + case api.KeyStateExpired: + stateStyle = th.Warn + } + expires := "never" + if k.ExpiresAt != nil { + expires = k.ExpiresAt.Local().Format(time.DateOnly) + } + + line := rowLine(cols, []cell{ + {v: k.Name, style: th.Bright}, + {v: k.Key, style: th.Dim}, + {v: strings.Join(k.Scopes, ","), style: th.Text}, + {v: expires, style: th.Dim}, + {v: state, style: stateStyle}, + }) + if i == s.sel { + return th.Selected.Render(padRight(line, w)) + } + return line +} + +// keyDetail is the strip's rows. Every value is from the wire; the actions row +// names the verbs rather than binding a key to them, because the composer is +// the console's only action surface. +func keyDetail(_ *App, k api.Key) []ModalKV { + state := api.KeyState(k) + row := ModalKV{K: "state", V: state} + switch state { + case api.KeyStateRevoked, api.KeyStateExpired: + row.Warn = true + } + return []ModalKV{ + {K: "id", V: k.ID, Bold: true}, + {K: "key", V: k.Key}, + {K: kvScope, V: strings.Join(k.Scopes, ",")}, + {K: "created", V: table.FmtTime(k.CreatedAt)}, + {K: kvLastUsed, V: lastUsed(k)}, + {K: "expires", V: table.FmtTimePtr(k.ExpiresAt)}, + row, + {K: "actions", V: "keys rotate · keys revoke", Accent: true}, + } +} + +func lastUsed(k api.Key) string { + if k.LastUsedAt == nil { + return "never" + } + return fmt.Sprintf("%s · %s requests", table.FmtTime(*k.LastUsedAt), comma(int(k.UsageCount))) +} + +// ------------------------------------------------------------------ helpers + +// parseExpiry reads the wizard's ceiling. Empty is a real answer — no expiry — +// so it is not an error; anything else must be a duration Go can parse. +func parseExpiry(v string) (*time.Time, error) { + v = strings.TrimSpace(v) + if v == "" { + return nil, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return nil, fmt.Errorf("%q is not a duration — try 720h, 30m, or empty for no expiry", v) + } + if d <= 0 { + return nil, fmt.Errorf("%q expires in the past", v) + } + t := time.Now().Add(d).UTC() + return &t, nil +} + +func clampIdx(i, n int) int { + if n == 0 { + return -1 + } + return min(max(i, 0), n-1) +} + +// ---------------------------------------------------- shared table furniture +// +// Used by this screen and by logs.go. Both draw a fixed-width table into a +// pane that owes an exact line count, so the one thing neither may do is wrap: +// a folded line breaks the count and every frame below it. Columns are +// therefore dropped, right to left, until the row fits. + +type column struct { + head string + w int + flex bool // absorbs the width the fixed columns leave + right bool // numerics align right +} + +type cell struct { + v string + style lipgloss.Style +} + +// layout returns the columns that fit w cells, with the flex column resized to +// whatever is left. It keeps at least the first column, which is the one that +// identifies the row. +func layout(cols []column, w int) []column { + const gap = 1 + fits := func(c []column) int { + total := 0 + for i, col := range c { + total += col.w + if i > 0 { + total += gap + } + } + return total + } + out := append([]column(nil), cols...) + for len(out) > 1 && fits(out) > w { + out = out[:len(out)-1] + } + if slack := w - fits(out); slack != 0 { + for i := range out { + if out[i].flex { + out[i].w = max(out[i].w+slack, 4) + break + } + } + } + return out +} + +func headerLine(cols []column) string { + parts := make([]string, 0, len(cols)) + for _, c := range cols { + if c.right { + parts = append(parts, padLeft(c.head, c.w)) + continue + } + parts = append(parts, padRight(c.head, c.w)) + } + return strings.Join(parts, " ") +} + +func rowLine(cols []column, cells []cell) string { + parts := make([]string, 0, len(cols)) + for i, c := range cols { + if i >= len(cells) { + break + } + v := table.SanitizeCell(cells[i].v) + if c.right { + v = padLeft(v, c.w) + } else { + v = padRight(v, c.w) + } + parts = append(parts, cells[i].style.Render(v)) + } + return strings.Join(parts, " ") +} + +// window returns the indices of the n rows to draw, scrolled so the selection +// stays on screen. A table that cannot show its selection is a table whose +// cursor keys do nothing visible. +func window(total, sel, n int) []int { + if n <= 0 || total == 0 { + return nil + } + start := 0 + if sel >= n { + start = sel - n + 1 + } + if end := min(start+n, total); end-start > 0 { + out := make([]int, 0, end-start) + for i := start; i < end; i++ { + out = append(out, i) + } + return out + } + return nil +} + +// detailStrip is the bottom-anchored inspector both tables share. +func detailStrip(a *App, title string, rows []ModalKV, w int) []string { + th := a.Theme + out := append(make([]string, 0, 2+len(rows)), + th.Border.Render(a.rule(max(w, 0))), + gapPad(w, th.Bright.Bold(th.Mode.Color).Render(table.SanitizeCell(title)), th.Dim.Render("esc close")), + ) + for _, r := range rows { + out = append(out, th.Dim.Render(padRight(table.SanitizeCell(r.K), modalKeyCol))+ + kvStyle(th, r).Render(table.SanitizeCell(r.V))) + } + return clampLines(out, w) +} + +// padTo pads or trims to exactly n lines, so a view can reserve the space a +// bottom-anchored strip will occupy before it appends one. +func padTo(lines []string, n int) []string { + if n <= 0 { + return nil + } + for len(lines) < n { + lines = append(lines, "") + } + return lines[:n] +} diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go new file mode 100644 index 0000000..2b5af4d --- /dev/null +++ b/internal/tui/keys_test.go @@ -0,0 +1,414 @@ +package tui + +import ( + "net/http/httptest" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/fixture" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// fixtureApp is a shell wired to the stateful fake: create/rotate/revoke +// actually mutate a key store, so the wizard is exercised end to end rather +// than against a canned response that can never disagree with it. +func fixtureApp(t *testing.T) *App { + t.Helper() + srv := httptest.NewServer(fixture.Handler(fixture.Default())) + t.Cleanup(srv.Close) + c, err := api.New(srv.URL, "fgw_test") + if err != nil { + t.Fatalf("client: %v", err) + } + a := composerApp() + a.Client = c + return a +} + +// runCmd runs one command and applies its message, failing rather than +// returning nil so a broken flow names the step it stopped at. +func runCmd(t *testing.T, a *App, cmd tea.Cmd, what string) tea.Msg { + t.Helper() + if cmd == nil { + t.Fatalf("%s returned no command", what) + } + msg := cmd() + a.update(msg) + return msg +} + +func loadKeys(t *testing.T, a *App) { + t.Helper() + runCmd(t, a, a.route("keys"), "keys") + if a.Keys.err != nil { + t.Fatalf("key list failed: %v", a.Keys.err) + } + if len(a.Keys.keys) == 0 { + t.Fatal("the fixture seeds keys; an empty list means the fetch did not land") + } + if a.Screen != ScreenKeys { + t.Fatalf("keys must switch to the keys pane, got %v", a.Screen) + } +} + +func keyNamed(a *App, name string) (api.Key, bool) { + for _, k := range a.Keys.keys { + if k.Name == name { + return k, true + } + } + return api.Key{}, false +} + +func TestKeysTableUsesMaskedKey(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + view := a.paneView(120, 18) + + for _, k := range a.Keys.keys { + if !strings.Contains(view, k.Name) { + t.Fatalf("key %q missing from the table:\n%s", k.Name, view) + } + } + // The wire's `key` field is masked head8…tail4 on every read; the table + // shows that, never a full secret and never a bare row id in its place. + masked := a.Keys.keys[0].Key + if masked == "" { + t.Fatal("the fixture serves a masked key on reads") + } + if !strings.Contains(view, masked) { + t.Fatalf("the table must show the masked key %q:\n%s", masked, view) + } +} + +func TestKeysTableRendersEveryDerivedState(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + view := a.paneView(120, 18) + // There is no state field on the wire — api.KeyState derives it. + seen := map[string]bool{} + for _, k := range a.Keys.keys { + seen[api.KeyState(k)] = true + } + for _, want := range []string{"active", "revoked", "expired"} { + if !seen[want] { + continue // the fixture may not seed every state + } + if !strings.Contains(view, want) { + t.Fatalf("state %q must appear in the table:\n%s", want, view) + } + } +} + +func TestNewKeysCommandIgnoresEarlierListResult(t *testing.T) { + a := composerApp() + a.route("keys list") + oldGen := a.Keys.gen + + a.route("keys rotate target") + newGen := a.Keys.gen + if newGen == oldGen { + t.Fatal("a newer keys command must invalidate the earlier list request") + } + + a.update(keysListMsg{gen: oldGen, keys: []api.Key{{ID: "old", Name: "target"}}}) + if a.Modal != nil { + t.Fatal("an earlier list result must not open the newer command's modal") + } + if a.Keys.pendingVerb != "rotate" || a.Keys.pendingArg != "target" { + t.Fatalf("stale result cleared the newer pending command: %q %q", + a.Keys.pendingVerb, a.Keys.pendingArg) + } + + a.update(keysListMsg{gen: newGen, keys: []api.Key{{ID: "current", Name: "target"}}}) + if a.Modal == nil || a.Modal.Title != "Rotate target?" { + t.Fatalf("the current list result must resume the pending command, got %+v", a.Modal) + } +} + +func TestWizardHappyPath(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + + a.route("keys create") + if a.Modal == nil { + t.Fatal("keys create must open the wizard") + } + if a.Modal.Title != "Create API key · name" { + t.Fatalf("step 1 title drifted from the expected wording: %q", a.Modal.Title) + } + if !strings.Contains(a.Modal.Body, "Names are how log rows resolve after a key is revoked.") { + t.Fatalf("step 1 body drifted from the expected wording: %q", a.Modal.Body) + } + typeModal(a, a.Modal, "ci-readonly") + a.Modal.handle("enter", a) + + if a.Modal == nil || !strings.Contains(a.Modal.Title, "scope") { + t.Fatalf("step 2 must be the scope step, got %+v", a.Modal) + } + if !strings.Contains(a.Modal.Body, "Scope is always sent explicitly") { + t.Fatalf("step 2 body drifted from the expected wording: %q", a.Modal.Body) + } + // The gateway defaults an omitted scope to read_only. This panel told the + // operator it was granted admin, which is the opposite. + if strings.Contains(a.Modal.Body, "granted admin") { + t.Fatalf("step 2 must not restate the old backwards claim: %q", a.Modal.Body) + } + if a.Modal.Option() != "read_only" { + t.Fatalf("the wizard defaults to read_only, got %q", a.Modal.Option()) + } + a.Modal.handle("space", a) // → admin + if a.Modal.Option() != "admin" { + t.Fatalf("space must toggle the scope, got %q", a.Modal.Option()) + } + a.Modal.handle("enter", a) + + if a.Modal == nil || !strings.Contains(a.Modal.Title, "expiry") { + t.Fatalf("step 3 must be the expiry step, got %+v", a.Modal) + } + if !strings.Contains(a.Modal.Body, "Default 720h. An unexpiring admin key needs a written exception.") { + t.Fatalf("step 3 body drifted from the expected wording: %q", a.Modal.Body) + } + if a.Modal.Value != "720h" { + t.Fatalf("the expiry step is prefilled with 720h, got %q", a.Modal.Value) + } + + before := time.Now() + msg := runCmd(t, a, a.Modal.handle("enter", a), "create") + res, ok := msg.(modalResultMsg) + if !ok || res.err != nil { + t.Fatalf("create must reach the gateway, got %#v", msg) + } + if res.key == nil || res.key.Key == "" { + t.Fatal("create returns the full secret exactly once") + } + secret := res.key.Key + + if a.Modal == nil || a.Modal.Title != "Key created" { + t.Fatalf("a created key must land in its own modal, got %+v", a.Modal) + } + if !strings.Contains(a.Modal.Body, "Shown exactly once. Copy it now — the gateway stores only a hash.") { + t.Fatalf("the secret step body drifted from the expected wording: %q", a.Modal.Body) + } + if !strings.Contains(a.paneView(110, 18), secret) { + t.Fatalf("the secret must be visible in its modal:\n%s", a.paneView(110, 18)) + } + + // Closing it refreshes the list, which is where the request's own values + // come back: the scope the toggle chose and the expiry it parsed. + runCmd(t, a, a.Modal.handle("enter", a), "close secret modal") + created, found := keyNamed(a, "ci-readonly") + if !found { + t.Fatalf("the created key must appear after the refresh: %+v", a.Keys.keys) + } + if len(created.Scopes) != 1 || created.Scopes[0] != "admin" { + t.Fatalf("the toggled scope must be what was sent, got %v", created.Scopes) + } + if created.ExpiresAt == nil { + t.Fatal("720h must have been sent as an expiry") + } + if d := created.ExpiresAt.Sub(before); d < 719*time.Hour || d > 721*time.Hour { + t.Fatalf("expiry must be ~720h out, got %v", d) + } +} + +func TestWizardSecretNeverInTranscriptOrHistory(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + + a.route("keys create") + typeModal(a, a.Modal, "ci-readonly") + a.Modal.handle("enter", a) + a.Modal.handle("enter", a) // keep read_only + msg := runCmd(t, a, a.Modal.handle("enter", a), "create") + secret := msg.(modalResultMsg).key.Key + if secret == "" { + t.Fatal("nothing to assert about if no secret was issued") + } + runCmd(t, a, a.Modal.handle("enter", a), "close secret modal") + + if strings.Contains(transcriptText(a), secret) { + t.Fatalf("the secret must never enter the transcript:\n%s", transcriptText(a)) + } + for _, h := range a.Composer.History { + if strings.Contains(h, secret) { + t.Fatalf("the secret must never enter the command history: %q", h) + } + } + if strings.Contains(a.render(), secret) { + t.Fatal("the secret must be gone from the screen once its modal closes") + } + // What it does leave behind is the key and its scope, so the operation is + // on the record without the credential being on it. + if !strings.Contains(transcriptText(a), "ci-readonly") || + !strings.Contains(transcriptText(a), "read_only") { + t.Fatalf("a completed create must name the key and its scope:\n%s", transcriptText(a)) + } +} + +func TestRotateFlowSecretOnce(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + a.Keys.sel = 0 + name := a.Keys.keys[0].Name + + runCmd(t, a, a.route("keys rotate"), "refresh before rotate") + if a.Modal == nil { + t.Fatal("keys rotate must open a confirmation") + } + if !strings.Contains(a.Modal.Body, + "Blast radius: every client holding the current secret fails immediately") { + t.Fatalf("rotate body drifted from the expected wording: %q", a.Modal.Body) + } + if a.Modal.Placeholder != `type "`+name+`" to confirm` { + t.Fatalf("rotate placeholder drifted: %q", a.Modal.Placeholder) + } + if cmd := a.Modal.handle("enter", a); cmd != nil { + t.Fatal("rotate must not fire before the key's name is typed") + } + if a.Modal == nil { + t.Fatal("an unconfirmed destructive modal stays open") + } + + typeModal(a, a.Modal, name) + msg := runCmd(t, a, a.Modal.handle("enter", a), "rotate") + res, ok := msg.(modalResultMsg) + if !ok || res.err != nil { + t.Fatalf("rotate must reach the gateway, got %#v", msg) + } + secret := res.key.Key + if secret == "" { + t.Fatal("rotate returns the new secret exactly once") + } + if !strings.Contains(a.paneView(110, 18), secret) { + t.Fatal("the rotated secret must be visible in its modal") + } + + runCmd(t, a, a.Modal.handle("enter", a), "close rotate secret modal") + if strings.Contains(transcriptText(a), secret) { + t.Fatalf("the rotated secret must never enter the transcript:\n%s", transcriptText(a)) + } + if strings.Contains(a.render(), secret) { + t.Fatal("the rotated secret must be gone once its modal closes") + } + if k, _ := keyNamed(a, name); k.RotatedAt == nil { + t.Fatal("the refreshed row must show the rotation") + } +} + +func TestRevokeUpdatesStateColumn(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + + // Pick a key that is currently active, so "revoked" is a change. + idx := -1 + for i, k := range a.Keys.keys { + if api.KeyState(k) == "active" { + idx = i + break + } + } + if idx < 0 { + t.Fatal("the fixture seeds at least one active key") + } + a.Keys.sel = idx + name := a.Keys.keys[idx].Name + + runCmd(t, a, a.route("keys revoke"), "refresh before revoke") + if a.Modal == nil { + t.Fatal("keys revoke must open a confirmation") + } + if !strings.Contains(a.Modal.Body, "Revocation is immediate and cannot be undone.") || + !strings.Contains(a.Modal.Body, "revoked badge.") { + t.Fatalf("revoke body drifted from the expected wording: %q", a.Modal.Body) + } + typeModal(a, a.Modal, "not-the-name") + if cmd := a.Modal.handle("enter", a); cmd != nil { + t.Fatal("a wrong phrase must not revoke") + } + a.Modal.handle("ctrl+u", a) + typeModal(a, a.Modal, name) + + runCmd(t, a, a.Modal.handle("enter", a), "revoke") + // The result arm refreshes the list; run that fetch too. + runCmd(t, a, a.Keys.refresh(a), "refresh") + + k, found := keyNamed(a, name) + if !found { + t.Fatalf("a revoked key keeps its row: %+v", a.Keys.keys) + } + if got := api.KeyState(k); got != "revoked" { + t.Fatalf("state column must read revoked after a revoke, got %q", got) + } + if !strings.Contains(a.paneView(120, 18), "revoked") { + t.Fatalf("the table must render the new state:\n%s", a.paneView(120, 18)) + } + if !strings.Contains(transcriptText(a), name) { + t.Fatalf("a completed revoke must name the key:\n%s", transcriptText(a)) + } +} + +func TestKeysDestructiveVerbsNeedASelection(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + a.Keys.keys, a.Keys.sel = nil, -1 + for _, verb := range []string{"keys rotate", "keys revoke"} { + a.route(verb) + a.update(keysListMsg{gen: a.Keys.gen, keys: nil}) + if a.Modal != nil { + t.Fatalf("%q with nothing selected must not open a destructive modal", verb) + } + if lastRow(a).Glyph != "bad" { + t.Fatalf("%q with nothing selected must say so: %#v", verb, lastRow(a)) + } + } +} + +func TestKeysScreenKeepsThePaneContract(t *testing.T) { + a := fixtureApp(t) + loadKeys(t, a) + a.Keys.sel, a.Keys.detail = 1, true + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + a.Theme = theme.New(mode) + for _, rows := range []int{1, 4, 9, 20} { + for _, w := range []int{30, 60, 92, 126} { + body := a.paneView(w, rows) + if got := len(strings.Split(body, "\n")); got != rows { + t.Fatalf("mode=%+v w=%d rows=%d returned %d lines", mode, w, rows, got) + } + for i, line := range strings.Split(body, "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line) + } + } + } + } + } +} + +// The keys pane owes the same exact line count, and a key name is gateway data +// like any other: it is echoed into the table and into the detail strip. +func TestKeysPaneKeepsItsLineCountWithControlCharactersInGatewayData(t *testing.T) { + a := composerApp() + a.Screen = ScreenKeys + a.Keys.keys = []api.Key{{ + ID: "key_01", Name: "prod\ningest", Key: "fgw_abcd…9f21", + Scopes: []string{"admin\x1b[31m"}, Active: true, CreatedAt: time.Now(), + }} + a.Keys.sel, a.Keys.detail = 0, true + + for _, rows := range []int{4, 9, 20} { + out := a.Keys.view(a, 100, rows) + if got := strings.Count(out, "\n") + 1; got != rows { + t.Fatalf("view(rows=%d) returned %d lines:\n%q", rows, got, out) + } + if strings.Contains(out, "\x1b") { + t.Fatalf("view(rows=%d) forwarded an escape sequence:\n%q", rows, out) + } + } +} diff --git a/internal/tui/logs.go b/internal/tui/logs.go new file mode 100644 index 0000000..df2d1d0 --- /dev/null +++ b/internal/tui/logs.go @@ -0,0 +1,499 @@ +package tui + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +// The logs screen is a live tail, and everything here is shaped by the one +// rule a tail must obey: a poll that fails does not erase what is already on +// screen. It keeps the rows, says how old they are, and probes less often the +// longer the gateway stays unreachable. Blanking the table because one request +// timed out would claim the traffic stopped, which is the opposite of what +// happened. +// +// The three measurements are pointers on the wire because the gateway sends +// null for "not measured" — a request that failed before reaching a provider, +// or one served by a provider with no price in the catalog. They render as a +// dash. Collapsing null into 0 would draw a real zero-cost, zero-latency +// request, which is a claim no row here can support. + +const ( + // logsRing is the display ring. A console that tails for a day must not + // grow without limit, and 200 rows is more than the tallest pane can show. + logsRing = 200 + + // logsPoll is the tail cadence. A failure doubles it up to maxPoll and the + // first success resets it. + logsPoll = 2 * time.Second + + // logsPage is the default page size per poll. + logsPage = 100 + + // traceCell is the trace-id column: "tr_" plus eight bytes of the id, which + // is what /admin/logs and the playground's attribution both key on. + traceCell = 11 +) + +// keyRef is what a log row's api_key_id resolves to. Revoked is carried +// separately because a key revoked SINCE it served the traffic still names the +// rows it served — that is the whole reason keys have names. +type keyRef struct { + Name string + Revoked bool +} + +// logsScreen is the request-log pane: a filtered tail, a selection, and a +// detail strip. +type logsScreen struct { + rows []api.LogEntry // newest first, bounded at logsRing + sel int // -1 when nothing is selected + detail bool + follower *api.Follower + filters api.LogsQuery + // raw is the filter arguments exactly as typed. The sub-line renders it + // rather than reconstructing one from the parsed query, so what is on + // screen is a line the operator could run again — "--since 15m", not the + // "14m59s" a round trip through time.Duration turns it into. + raw string + tailing bool + + // keyNames comes from one Keys() read at start. A caller without the + // credential for it gets an empty map and raw ids, which is strictly better + // than refusing to show the logs they can read. + keyNames map[string]keyRef + + lastErr error + staleAt time.Time // when the data went stale; zero while polls succeed + backoff time.Duration + // truncated records that the LAST poll hit the follower's page bound. The + // rows it returned are real, so this is a readout beside them and not an + // error: what is missing is the oldest end of that one window. + truncated bool + + gen int +} + +// ------------------------------------------------------------------ lifecycle + +// start parses the filter arguments and opens the tail. A bad argument never +// enters the screen: the refusal belongs in the transcript, which is on the +// home pane, and switching away from it would hide the reason. +func (s *logsScreen) start(a *App, args string) tea.Cmd { + q, err := parseLogFilters(args) + if err != nil { + a.Screen = ScreenHome + a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: err.Error()}) + return nil + } + + *s = logsScreen{gen: s.gen + 1, filters: q, raw: strings.TrimSpace(args), + sel: -1, backoff: logsPoll, keyNames: map[string]keyRef{}} + a.Screen = ScreenLogs + if a.Client == nil { + s.lastErr = errNoConnection + return nil + } + s.tailing = true + s.follower = api.NewFollower(a.Client, q) + // No tick is armed here: the first poll arms the second. See rearm. + return tea.Batch(fetchLogKeyNames(a.Client, s.gen), pollLogs(s.follower, s.gen)) +} + +// leave stops the tail. The generation bump is what actually stops it: a tick +// already in flight carries the old one and schedules nothing when it lands. +func (s *logsScreen) leave() { + s.gen++ + s.tailing = false + s.follower = nil +} + +func (s *logsScreen) update(a *App, msg tea.Msg) tea.Cmd { + switch m := msg.(type) { + case tickLogsMsg: + // Three independent reasons to stop: a newer visit, a pane that is no + // longer on show, and a tail that was never started. + if m.gen != s.gen || a.Screen != ScreenLogs || !s.tailing || s.follower == nil { + return nil + } + return pollLogs(s.follower, s.gen) + + case logsBatchMsg: + if m.gen != s.gen { + return nil + } + // api.ErrFollowTruncated is the one error that arrives WITH rows: the + // poll succeeded, the cursor moved past what it read, and what was lost + // is the oldest end of that single window. Treating it as a failed poll + // would discard rows the gateway did return and back the tail off + // against a gateway whose only problem is that it is busy. + if truncated := errors.Is(m.err, api.ErrFollowTruncated); m.err == nil || truncated { + s.lastErr, s.staleAt, s.backoff = nil, time.Time{}, logsPoll + s.truncated = truncated + s.prepend(m.rows) + return s.rearm() + } + s.lastErr = m.err + if api.IsNotSupported(m.err) { + // A gateway with no request-log store answers 501. That is an + // absent feature, not a failed poll: it will not become a 200, + // so the tail stops instead of backing off against it forever. + s.tailing = false + return nil + } + if s.staleAt.IsZero() { + s.staleAt = time.Now() + } + s.backoff = backoff(s.backoff) + return s.rearm() + + case logsNamesMsg: + if m.gen != s.gen { + return nil + } + s.keyNames = m.names + return nil + } + return nil +} + +// rearm schedules the next poll, and it is called from exactly one place: the +// arm that handles a finished one. That is what serialises the tail. +// +// api.Follower is one-per-tail-loop state — its cursor, its dedupe map and its +// row slice are unsynchronised — so two polls in flight against the same +// follower race, and a gateway whose /admin/logs takes longer than the poll +// interval is all it takes to have two. Arming the next tick only once the +// previous poll's rows have landed makes that unreachable. The generation +// counter cannot do this job: it discards a stale result after the fact, which +// is a different thing from never running two polls at once. +func (s *logsScreen) rearm() tea.Cmd { + if !s.tailing || s.follower == nil { + return nil + } + return tickLogs(s.backoff, s.gen) +} + +// prepend puts a poll's ascending rows at the head, newest first, and trims to +// the ring. The selection follows its row rather than its index, so a batch +// arriving under the cursor does not silently re-point the detail strip at a +// different request. +func (s *logsScreen) prepend(rows []api.LogEntry) { + if len(rows) == 0 { + return + } + selected, hadSelection := s.row() + + next := make([]api.LogEntry, 0, min(len(rows)+len(s.rows), logsRing)) + for i := len(rows) - 1; i >= 0; i-- { + next = append(next, rows[i]) + } + next = append(next, s.rows...) + if len(next) > logsRing { + next = next[:logsRing] + } + s.rows = next + + if !hadSelection { + return + } + for i, r := range s.rows { + if sameRow(r, selected) { + s.sel = i + return + } + } + s.sel, s.detail = -1, false +} + +// sameRow is api.dedupeKey's identity, spelled locally because the wire's +// notion of one row belongs to the API layer. A trace has one row per pipeline +// stage, so the trace id alone matches several rows: following it would land +// the cursor on a different stage of the same request, which is the exact +// silent re-pointing prepend exists to prevent. +func sameRow(a, b api.LogEntry) bool { + return a.TraceID == b.TraceID && a.Stage == b.Stage && a.CreatedAt.Equal(b.CreatedAt) +} + +func (s *logsScreen) row() (api.LogEntry, bool) { + if s.sel < 0 || s.sel >= len(s.rows) { + return api.LogEntry{}, false + } + return s.rows[s.sel], true +} + +// handleKey is the pane's own keyboard; see App.screenKey for why it is only +// ever offered a key while the command line is empty. +func (s *logsScreen) handleKey(key string) bool { + switch key { + case keyUp: + s.sel = clampIdx(s.sel-1, len(s.rows)) + case keyDown: + s.sel = clampIdx(s.sel+1, len(s.rows)) + case keyEnter: + s.detail = s.sel >= 0 && s.sel < len(s.rows) + case keyEsc: + if !s.detail { + return false + } + s.detail = false + default: + return false + } + return true +} + +// -------------------------------------------------------------------- filters + +// logFilterHelp is the vocabulary a refusal names. It is one string so the +// parser and its error message cannot drift apart. +const logFilterHelp = "filters are --since --model --provider --stage --key --limit" + +// parseLogFilters walks `--flag value` pairs. An unknown flag is refused rather +// than ignored: a filter silently dropped shows a wider log than was asked for, +// and the operator has no way to tell. +func parseLogFilters(args string) (api.LogsQuery, error) { + q := api.LogsQuery{Limit: logsPage} + f := strings.Fields(args) + for i := 0; i < len(f); i++ { + name := f[i] + if !strings.HasPrefix(name, "--") { + return q, fmt.Errorf("logs: unexpected argument %q — %s", name, logFilterHelp) + } + if i+1 >= len(f) { + return q, fmt.Errorf("logs: %s needs a value — %s", name, logFilterHelp) + } + v := f[i+1] + i++ + switch name { + case "--since": + t, err := api.ParseSince(v) + if err != nil { + return q, fmt.Errorf("logs: %w — %s", err, logFilterHelp) + } + q.Since = t + case "--model": + q.Model = v + case "--provider": + q.Provider = v + case "--stage": + q.Stage = v + case "--key": + // "none" is the gateway's own sentinel for rows with no credential + // and is passed through untouched. + q.APIKeyID = v + case "--limit": + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return q, fmt.Errorf("logs: --limit %q is not a positive count — %s", v, logFilterHelp) + } + q.Limit = n + default: + return q, fmt.Errorf("logs: unknown flag %q — %s", name, logFilterHelp) + } + } + return q, nil +} + +// filterLabel renders the active filters the way they were typed, so what is +// on screen is a line the operator could run again. +func (s *logsScreen) filterLabel() string { + if s.raw == "" { + return "no filter · terminal rows only" + } + return strings.Join(strings.Fields(s.raw), " ") +} + +// --------------------------------------------------------------------- view + +// logColumns is the table. MODEL flexes because it is the only column whose +// content has no bound; everything else is a field of known shape. +func logColumns() []column { + return []column{ + {head: "TIME", w: 8}, + {head: "TRACE", w: traceCell}, + {head: colProvider, w: 10}, + {head: "MODEL", w: 14, flex: true}, + {head: "STAGE", w: 13}, + {head: "MS", w: 6, right: true}, + {head: "COST", w: 8, right: true}, + } +} + +func (s *logsScreen) view(a *App, w, rows int) string { + th := a.Theme + lines := []string{s.subLine(a, w)} + + // A 501 falls through to the empty note, which names the missing store. The + // gateway's raw words are right for a failure and wrong for an absence. + if s.lastErr != nil && len(s.rows) == 0 && !api.IsNotSupported(s.lastErr) { + return fill(append(lines, "", th.Bad.Render("logs: "+s.lastErr.Error())), w, rows) + } + + var strip []string + if s.detail { + if r, ok := s.row(); ok { + strip = detailStrip(a, "Request "+r.TraceID, s.rowDetail(a, r), w) + } + } + + body := max(rows-len(lines)-len(strip), 0) + cols := layout(logColumns(), w) + if body > 0 { + lines = append(lines, th.Dim.Render(headerLine(cols))) + body-- + } + if len(s.rows) == 0 { + lines = append(lines, th.Dim.Render(s.emptyNote())) + } + for _, i := range window(len(s.rows), s.sel, body) { + lines = append(lines, s.line(a, cols, i, w)) + } + return fill(append(padTo(lines, rows-len(strip)), strip...), w, rows) +} + +// emptyNote distinguishes "nothing has arrived yet" from "this gateway keeps no +// log": a tail against a gateway with no store would otherwise look identical +// to a quiet one. +func (s *logsScreen) emptyNote() string { + switch { + case s.lastErr != nil && api.IsNotSupported(s.lastErr): + return "this gateway has no request-log store configured" + case s.tailing: + return "no rows yet — the tail is live and will fill as traffic arrives" + default: + return "no rows in range" + } +} + +// subLine carries the active filters on the left and the tail's own health on +// the right. Staleness is a readout, not an error: the rows below it are real, +// they are just older than they look. +func (s *logsScreen) subLine(a *App, w int) string { + th := a.Theme + right := "" + switch { + case !s.staleAt.IsZero(): + right = th.Warn.Render(fmt.Sprintf("stale %ds · retrying in %s", + int(time.Since(s.staleAt).Seconds()), s.backoff)) + case s.truncated: + // The rows below are real; the gateway was simply busy enough that one + // poll could not reach the far end of its window. Saying which end was + // cut is what stops the table reading as the whole range. + right = th.Warn.Render("tailing · older rows in that window skipped") + case s.tailing: + right = th.Accent.Render("tailing…") + if th.Mode.ASCII { + right = th.Accent.Render("tailing...") + } + } + return gapPad(w, " "+th.Dim.Render(s.filterLabel()), right) +} + +func (s *logsScreen) line(a *App, cols []column, i, w int) string { + th, r := a.Theme, s.rows[i] + + stage, stageStyle := r.Stage, th.OK + if r.ErrorMessage != "" { + stageStyle = th.Bad + } + line := rowLine(cols, []cell{ + {v: r.CreatedAt.Local().Format(time.TimeOnly), style: th.Dim}, + {v: shortTrace(r.TraceID), style: th.Accent}, + {v: table.OrDash(r.Provider), style: th.Text}, + {v: table.OrDash(r.Model), style: th.Bright}, + {v: table.OrDash(stage), style: stageStyle}, + {v: msCell(r.DurationMs), style: th.Text}, + {v: costCell(r.CostUSD), style: th.Dim}, + }) + if i == s.sel { + return th.Selected.Render(padRight(line, w)) + } + return line +} + +// rowDetail is the strip. It carries the full trace id (the table's is cut), +// both measurements, and the credential resolved to a name — which is the one +// thing the table cannot show and the reason a row is opened at all. +func (s *logsScreen) rowDetail(_ *App, r api.LogEntry) []ModalKV { + name, revoked := s.resolveKey(r.APIKeyID) + out := []ModalKV{ + {K: "trace", V: r.TraceID, Bold: true}, + {K: "provider", V: table.OrDash(r.Provider)}, + {K: "model", V: table.OrDash(r.Model)}, + {K: "stage", V: table.OrDash(r.Stage)}, + {K: "duration", V: msUnit(r.DurationMs)}, + {K: "ttft", V: msUnit(r.TTFTMs)}, + {K: "tokens", V: fmt.Sprintf("%d in / %d out", r.PromptTokens, r.CompletionTokens)}, + {K: "cost", V: costCell(r.CostUSD)}, + {K: "api key", V: name, Warn: revoked}, + } + if r.ErrorMessage != "" { + out = append(out, ModalKV{K: "error", V: r.ErrorMessage, Warn: true}) + } + return out +} + +// resolveKey turns a row's api_key_id into something an operator can act on. +// Three cases, all real: the master credential carries a synthetic id with no +// key row behind it, a named key may have been revoked since it served the +// traffic, and a deleted key keeps its rows but loses its name. +func (s *logsScreen) resolveKey(id string) (string, bool) { + switch { + case id == "": + return "none", false + case strings.HasPrefix(id, "master-key:"): + return "master key", false + } + if ref, ok := s.keyNames[id]; ok && ref.Name != "" { + if ref.Revoked { + return ref.Name + " [revoked]", true + } + return ref.Name, false + } + return id, false +} + +// ------------------------------------------------------------------ helpers + +// shortTrace cuts a trace id to the table's column without inventing a shape: +// it keeps the head, which is what /admin/logs?trace= matches on. +func shortTrace(id string) string { + if id == "" { + return "-" + } + if len(id) <= traceCell { + return id + } + return id[:traceCell] +} + +// msCell and costCell render an absent measurement as a dash, never as a zero. +func msCell(v *float64) string { + if v == nil { + return "-" + } + return strconv.FormatFloat(*v, 'f', 0, 64) +} + +func msUnit(v *float64) string { + if v == nil { + return "-" + } + return msCell(v) + "ms" +} + +func costCell(v *float64) string { + if v == nil { + return "-" + } + return "$" + strconv.FormatFloat(*v, 'f', 4, 64) +} diff --git a/internal/tui/logs_test.go b/internal/tui/logs_test.go new file mode 100644 index 0000000..ceb2aca --- /dev/null +++ b/internal/tui/logs_test.go @@ -0,0 +1,498 @@ +package tui + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +func f64(v float64) *float64 { return &v } + +// logRow builds one entry with everything measured, so a test that wants an +// absent measurement removes exactly the field it is about. +func logRow(trace string, at time.Time) api.LogEntry { + return api.LogEntry{ + TraceID: trace, Stage: "response", Model: "claude-sonnet-4-6", + Provider: "anthropic", APIKeyID: "ak_1", + PromptTokens: 412, CompletionTokens: 189, TotalTokens: 601, + CreatedAt: at, DurationMs: f64(934), TTFTMs: f64(210), CostUSD: f64(0.0031), + } +} + +func TestLogsStartParsesFilters(t *testing.T) { + a := composerApp() + before := time.Now() + a.route("logs --since 15m --model claude-* --provider anthropic --stage response --key none --limit 25") + + if a.Screen != ScreenLogs { + t.Fatalf("a valid filter set enters the screen, got %v", a.Screen) + } + q := a.Logs.filters + if q.Model != "claude-*" { + t.Fatalf("--model must keep its case and its glob, got %q", q.Model) + } + if q.Provider != "anthropic" || q.Stage != "response" { + t.Fatalf("provider/stage filters dropped: %+v", q) + } + if q.APIKeyID != "none" { + t.Fatalf(`--key none is the gateway's own sentinel and passes through, got %q`, q.APIKeyID) + } + if q.Limit != 25 { + t.Fatalf("--limit dropped, got %d", q.Limit) + } + if d := before.Sub(q.Since); d < 14*time.Minute || d > 16*time.Minute { + t.Fatalf("--since 15m must be 15m into the past, got %v before now", d) + } + if !strings.Contains(a.paneView(120, 10), "--model claude-*") { + t.Fatalf("the active filters must be on screen:\n%s", a.paneView(120, 10)) + } +} + +func TestLogsStartRejectsUnknownFlag(t *testing.T) { + for _, args := range []string{"--frobnicate x", "--since", "--limit banana", "claude-*"} { + a := composerApp() + if cmd := a.route("logs " + args); cmd != nil { + t.Fatalf("%q must not reach the network", args) + } + if a.Screen != ScreenHome { + t.Fatalf("%q must leave the operator on the transcript, got %v", args, a.Screen) + } + row := lastRow(a) + if row.Glyph != "bad" { + t.Fatalf("%q must be refused in a red row, got %#v", args, row) + } + if !strings.Contains(row.Text, "--since") { + t.Fatalf("a refusal must name the filters that do exist: %q", row.Text) + } + } +} + +func TestLogsBatchPrependsBounded(t *testing.T) { + a := composerApp() + a.route("logs") + now := time.Now() + + seed := make([]api.LogEntry, 0, 195) + for i := range 195 { + seed = append(seed, logRow(fmt.Sprintf("tr_seed%03d", i), now.Add(time.Duration(i)*time.Second))) + } + a.update(logsBatchMsg{gen: a.Logs.gen, rows: seed}) + + batch := make([]api.LogEntry, 0, 10) + for i := range 10 { + batch = append(batch, logRow(fmt.Sprintf("tr_new%03d", i), now.Add(time.Duration(200+i)*time.Second))) + } + a.update(logsBatchMsg{gen: a.Logs.gen, rows: batch}) + + if got := len(a.Logs.rows); got != logsRing { + t.Fatalf("the ring is bounded at %d, got %d", logsRing, got) + } + // Newest first: the last row of the newest ascending batch leads. + if a.Logs.rows[0].TraceID != "tr_new009" { + t.Fatalf("rows must be newest first, got %q", a.Logs.rows[0].TraceID) + } + if a.Logs.rows[9].TraceID != "tr_new000" { + t.Fatalf("a batch must keep its own order when prepended, got %q", a.Logs.rows[9].TraceID) + } +} + +func TestLogsSelectionAndDetail(t *testing.T) { + a := composerApp() + a.route("logs") + now := time.Now() + rows := []api.LogEntry{logRow("tr_aaaaaaaa", now), logRow("tr_bbbbbbbb", now.Add(time.Second))} + rows[0].APIKeyID = "ak_revoked" + rows[1].APIKeyID = "master-key:0f21" + a.update(logsBatchMsg{gen: a.Logs.gen, rows: rows}) + a.update(logsNamesMsg{gen: a.Logs.gen, names: map[string]keyRef{ + "ak_revoked": {Name: "laptop-old", Revoked: true}, + }}) + + if !a.screenKey("down") { + t.Fatal("the logs pane must take ↓ while the command line is empty") + } + if a.Logs.sel != 0 { + t.Fatalf("↓ with nothing selected takes the newest row, got %d", a.Logs.sel) + } + a.screenKey("down") + if a.Logs.sel != 1 { + t.Fatalf("↓ must move the selection, got %d", a.Logs.sel) + } + a.screenKey("up") + if a.Logs.sel != 0 { + t.Fatalf("↑ must move it back, got %d", a.Logs.sel) + } + + // Row 0 is the newest, which is tr_bbbbbbbb with the master credential. + a.screenKey("enter") + view := a.paneView(120, 20) + if !strings.Contains(view, "tr_bbbbbbbb") { + t.Fatalf("the strip must carry the full trace id:\n%s", view) + } + if !strings.Contains(view, "master key") { + t.Fatalf("a master-key:* id must resolve to `master key`:\n%s", view) + } + + a.screenKey("down") + a.screenKey("enter") + view = a.paneView(120, 20) + if !strings.Contains(view, "laptop-old") { + t.Fatalf("a resolvable api_key_id must render as its name:\n%s", view) + } + if !strings.Contains(view, "[revoked]") { + t.Fatalf("a key revoked since it served the traffic must carry the badge:\n%s", view) + } + + if !a.screenKey("esc") { + t.Fatal("esc must close the strip before it means back") + } + if a.Logs.detail { + t.Fatal("esc must close the detail strip") + } + if a.screenKey("esc") { + t.Fatal("with no strip open, esc belongs to the composer") + } +} + +// An unresolvable id is rendered raw. A deleted key keeps its log rows, and +// that is exactly the case this question is most often asked about. +func TestLogsUnresolvableKeyRendersRawID(t *testing.T) { + a := composerApp() + a.route("logs") + row := logRow("tr_cccccccc", time.Now()) + row.APIKeyID = "ak_deleted" + a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{row}}) + a.Logs.sel, a.Logs.detail = 0, true + if v := a.paneView(120, 20); !strings.Contains(v, "ak_deleted") { + t.Fatalf("an id no key store can name must render as itself:\n%s", v) + } +} + +func TestLogsPollFailureKeepsRowsShowsStale(t *testing.T) { + a := composerApp() + a.route("logs") + a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{logRow("tr_keepme0", time.Now())}}) + + base := a.Logs.backoff + a.update(logsBatchMsg{gen: a.Logs.gen, err: errors.New("connection refused")}) + if len(a.Logs.rows) != 1 { + t.Fatal("a failed poll must never blank the table") + } + if a.Logs.backoff != 2*base { + t.Fatalf("a failure must double the cadence, got %v", a.Logs.backoff) + } + if a.Logs.staleAt.IsZero() { + t.Fatal("a failed poll must record when the data went stale") + } + view := a.paneView(120, 12) + if !strings.Contains(view, "tr_keepme0") { + t.Fatalf("the rows already shown must survive:\n%s", view) + } + if !strings.Contains(view, "stale") || !strings.Contains(view, "retrying") { + t.Fatalf("stale data must say so and say it is still trying:\n%s", view) + } + + for range 10 { + a.update(logsBatchMsg{gen: a.Logs.gen, err: errors.New("still down")}) + } + if a.Logs.backoff != maxPoll { + t.Fatalf("backoff must cap at %v, got %v", maxPoll, a.Logs.backoff) + } + + a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{logRow("tr_backnow", time.Now())}}) + if a.Logs.backoff != logsPoll || !a.Logs.staleAt.IsZero() || a.Logs.lastErr != nil { + t.Fatalf("one good poll clears the staleness: backoff=%v stale=%v err=%v", + a.Logs.backoff, a.Logs.staleAt, a.Logs.lastErr) + } +} + +func TestLogsNullsRenderDash(t *testing.T) { + a := composerApp() + a.route("logs") + row := logRow("tr_nulls000", time.Now()) + row.DurationMs, row.TTFTMs, row.CostUSD = nil, nil, nil + a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{row}}) + a.Logs.sel, a.Logs.detail = 0, true + + view := a.paneView(126, 20) + line := findRow(view, "tr_nulls") + if line == "" { + t.Fatalf("the row must render:\n%s", view) + } + for _, bad := range []string{"0.0000", "$0.00", " 0 ", "0ms"} { + if strings.Contains(line, bad) { + t.Fatalf("an unmeasured value must never render as %q: %q", bad, line) + } + } + if !strings.Contains(line, "-") { + t.Fatalf("an unmeasured value renders as a dash: %q", line) + } + for _, label := range []string{"duration", "ttft", "cost"} { + if r := findRow(view, label); !strings.Contains(r, "-") { + t.Fatalf("strip row %q must render a dash for an absent measurement: %q", label, r) + } + } +} + +func TestLogsTickIgnoredAfterLeave(t *testing.T) { + a := fixtureApp(t) + a.route("logs") + gen := a.Logs.gen + if !a.Logs.tailing { + t.Fatal("entering the logs screen starts the tail") + } + + // esc is how an operator leaves, and it goes through the composer. + a.update(runCmdMsg{raw: "help"}) + if a.Screen != ScreenHome { + t.Fatalf("help must leave the logs screen, got %v", a.Screen) + } + if a.Logs.tailing { + t.Fatal("leaving the screen must stop the tail") + } + if cmd := a.update(tickLogsMsg{gen: gen}); cmd != nil { + t.Fatal("a tick from a screen the operator has left must schedule nothing") + } + if cmd := a.update(tickLogsMsg{gen: a.Logs.gen}); cmd != nil { + t.Fatal("a tick while the pane is off screen must schedule nothing either") + } + // A batch that lands late must not repopulate a pane nobody is watching. + a.update(logsBatchMsg{gen: gen, rows: []api.LogEntry{logRow("tr_late0000", time.Now())}}) + if len(a.Logs.rows) != 0 { + t.Fatalf("a late batch must be dropped, got %d rows", len(a.Logs.rows)) + } +} + +// A gateway with no request-log store answers 501 to every poll. That is an +// absent feature, not an outage: the pane names it, and the tail stops rather +// than backing off against something that will never succeed. +func TestLogsNoStoreStopsTailingAndSaysWhy(t *testing.T) { + a := fixtureApp(t) + a.route("logs") + a.update(logsBatchMsg{gen: a.Logs.gen, err: &api.Error{ + Status: 501, Code: "not_supported", Message: "no request log store configured", + }}) + + if a.Logs.tailing { + t.Fatal("a 501 will not become a 200 — the tail must stop") + } + if !a.Logs.staleAt.IsZero() || a.Logs.backoff != logsPoll { + t.Fatalf("an absent feature is not stale data: stale=%v backoff=%v", a.Logs.staleAt, a.Logs.backoff) + } + view := a.paneView(110, 10) + if !strings.Contains(view, "no request-log store configured") { + t.Fatalf("the pane must name the missing store:\n%s", view) + } + for _, bad := range []string{"stale", "retrying", "501"} { + if strings.Contains(view, bad) { + t.Fatalf("an absent store must not read as a failure (%q):\n%s", bad, view) + } + } +} + +func TestLogsNoConnectionSaysSoWithoutTailing(t *testing.T) { + a := composerApp() // no client + a.route("logs") + if a.Logs.tailing { + t.Fatal("there is nothing to tail without a connection") + } + if v := a.paneView(110, 10); !strings.Contains(v, errNoConnection.Error()) { + t.Fatalf("the pane must say why it is empty:\n%s", v) + } +} + +func TestLogsSelectionClearsWhenItsRowIsEvicted(t *testing.T) { + var s logsScreen + base := time.Now() + for i := range logsRing { + s.rows = append(s.rows, logRow(fmt.Sprintf("tr_%08d", i), base.Add(-time.Duration(i)*time.Second))) + } + s.sel, s.detail = len(s.rows)-1, true + s.prepend([]api.LogEntry{logRow("tr_new", base.Add(time.Second))}) + if s.sel != -1 || s.detail { + t.Fatalf("evicted selection must close its detail strip: sel=%d detail=%v", s.sel, s.detail) + } +} + +func TestLogsScreenKeepsThePaneContract(t *testing.T) { + a := composerApp() + a.route("logs --since 15m --model claude-sonnet-4-6") + now := time.Now() + rows := make([]api.LogEntry, 0, 12) + for i := range 12 { + r := logRow(fmt.Sprintf("tr_%08d", i), now.Add(time.Duration(i)*time.Second)) + if i%3 == 0 { + r.ErrorMessage = "upstream error from provider anthropic" + r.Stage = "on_error" + } + rows = append(rows, r) + } + a.update(logsBatchMsg{gen: a.Logs.gen, rows: rows}) + a.Logs.sel, a.Logs.detail = 4, true + + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + a.Theme = theme.New(mode) + for _, rowsN := range []int{1, 4, 9, 20} { + for _, w := range []int{30, 60, 92, 126} { + body := a.paneView(w, rowsN) + if got := len(strings.Split(body, "\n")); got != rowsN { + t.Fatalf("mode=%+v w=%d rows=%d returned %d lines", mode, w, rowsN, got) + } + for i, line := range strings.Split(body, "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line) + } + } + } + } + } +} + +// api.Follower is one-per-tail-loop state — its cursor, dedupe map and row +// slice are unsynchronised — so the tail must never have two polls in flight. +// The next tick is armed by the arm that handles a finished poll, and nowhere +// else: a gateway whose /admin/logs takes longer than the interval is all it +// would take to overlap two otherwise. +func TestLogsPollsAreSerialised(t *testing.T) { + a := fixtureApp(t) + a.route("logs") + + // A tick asks for ONE poll and schedules nothing. If it also armed the next + // tick, this command would answer with a batch instead of a poll result. + cmd := a.update(tickLogsMsg{gen: a.Logs.gen}) + if cmd == nil { + t.Fatal("a tick on a live tail must poll") + } + if msg := cmd(); !isLogsBatch(msg) { + t.Fatalf("a tick must produce exactly one poll, got %T", msg) + } + + // The finished poll is what arms the next tick — on both of its outcomes. + if cmd = a.update(logsBatchMsg{gen: a.Logs.gen, + rows: []api.LogEntry{logRow("tr_serial01", time.Now())}}); cmd == nil { + t.Fatal("a finished poll must arm the next tick") + } + // Read the armed command on the failure path, where the cadence is ours to + // shrink: a good poll resets it to logsPoll and running that would sleep. + a.Logs.backoff = time.Millisecond + cmd = a.update(logsBatchMsg{gen: a.Logs.gen, err: errors.New("connection refused")}) + if cmd == nil { + t.Fatal("a failed poll must arm the retry") + } + if msg := cmd(); !isTickLogs(msg) { + t.Fatalf("a finished poll arms a tick, not another poll, got %T", msg) + } +} + +func isLogsBatch(msg any) bool { _, ok := msg.(logsBatchMsg); return ok } +func isTickLogs(msg any) bool { _, ok := msg.(tickLogsMsg); return ok } + +// A tail that has stopped arms nothing, or the loop outlives the screen. +func TestLogsStoppedTailArmsNothing(t *testing.T) { + a := fixtureApp(t) + a.route("logs") + if cmd := a.update(logsBatchMsg{gen: a.Logs.gen, err: &api.Error{ + Status: 501, Code: "not_supported", Message: "no request log store configured", + }}); cmd != nil { + t.Fatal("a 501 stops the tail — it must not arm another poll") + } +} + +// ErrFollowTruncated arrives WITH rows: the poll worked and the cursor moved, +// and what was lost is the oldest end of that one window. It is a readout +// beside real rows, not a failure — backing off or dropping the rows would +// punish a gateway whose only problem is that it is busy. +func TestLogsTruncatedWindowKeepsRowsAndSaysSo(t *testing.T) { + a := fixtureApp(t) + a.route("logs") + a.update(logsBatchMsg{ + gen: a.Logs.gen, + rows: []api.LogEntry{logRow("tr_trunc001", time.Now())}, + err: api.ErrFollowTruncated, + }) + + if len(a.Logs.rows) != 1 { + t.Fatalf("the rows a truncated poll did return are real, got %d", len(a.Logs.rows)) + } + if a.Logs.lastErr != nil || !a.Logs.staleAt.IsZero() || a.Logs.backoff != logsPoll { + t.Fatalf("a truncated window is not a failed poll: err=%v stale=%v backoff=%v", + a.Logs.lastErr, a.Logs.staleAt, a.Logs.backoff) + } + if !a.Logs.tailing { + t.Fatal("a truncated window must not stop the tail") + } + if view := a.paneView(110, 10); !strings.Contains(view, "older rows") { + t.Fatalf("the pane must say which end of the window was cut:\n%s", view) + } + + // The next clean poll takes the notice back down. + a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{logRow("tr_trunc002", time.Now())}}) + if a.Logs.truncated { + t.Fatal("a clean poll clears the truncation notice") + } +} + +// Every pane owes exactly `rows` lines — that contract is what anchors the +// composer to the bottom of the screen. Gateway data reaches both the table +// (provider, model, stage) and the detail strip (error_message), so a newline +// in either would add a line to the frame and shift everything below it. +func TestLogsPaneKeepsItsLineCountWithControlCharactersInGatewayData(t *testing.T) { + a := composerApp() + a.route("logs") + r := logRow("tr_control0", time.Now()) + r.Provider = "anth\nropic" + r.ErrorMessage = "upstream\nrefused \x1b[31mhard\x1b[0m" + a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{r}}) + a.Logs.sel, a.Logs.detail = 0, true + + for _, rows := range []int{4, 9, 20} { + out := a.Logs.view(a, 100, rows) + if got := strings.Count(out, "\n") + 1; got != rows { + t.Fatalf("view(rows=%d) returned %d lines:\n%q", rows, got, out) + } + if strings.Contains(out, "\x1b") { + t.Fatalf("view(rows=%d) forwarded an escape sequence:\n%q", rows, out) + } + } +} + +// api.dedupeKey identifies a log row as trace + stage + created_at, because one +// trace has one row per pipeline stage. Following the trace id alone lands the +// cursor on whichever row of that trace arrives first, which is the silent +// re-pointing of the detail strip prepend exists to prevent. +func TestLogsSelectionFollowsItsRowNotItsTrace(t *testing.T) { + now := time.Now() + for _, tc := range []struct { + name string + mutate func(*api.LogEntry) + }{ + {"another stage of the same trace", func(e *api.LogEntry) { e.Stage = "on_error" }}, + {"the same trace and stage seen again later", func(e *api.LogEntry) { e.CreatedAt = now.Add(time.Second) }}, + } { + t.Run(tc.name, func(t *testing.T) { + var s logsScreen + selected := logRow("tr_onetrace", now) + s.rows = []api.LogEntry{selected} + s.sel, s.detail = 0, true + + arriving := logRow("tr_onetrace", now) + tc.mutate(&arriving) + s.prepend([]api.LogEntry{arriving}) + + if s.sel != 1 { + t.Fatalf("the selection must follow its own row to index 1, got %d", s.sel) + } + got, ok := s.row() + if !ok || got.Stage != selected.Stage || !got.CreatedAt.Equal(selected.CreatedAt) { + t.Fatalf("the strip is describing a different row now: %+v", got) + } + }) + } +} diff --git a/internal/tui/modal.go b/internal/tui/modal.go new file mode 100644 index 0000000..4d2f50c --- /dev/null +++ b/internal/tui/modal.go @@ -0,0 +1,291 @@ +package tui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// This file is the console's one overlay. It renders in place of the pane +// rather than over it: a terminal has no transparency to dim with, and a modal +// drawn on top of live content is a modal whose scrim is a lie about what is +// underneath it. +// +// Two things here are load-bearing rather than cosmetic: +// +// - Confirm gates the OK arm on typing an exact phrase. A destructive +// operation confirmed by one keypress is not confirmed, it is acknowledged. +// - Nothing in this file writes to the transcript or the composer history. +// A secret shown in a modal is shown here and nowhere else, which is what +// makes "shown exactly once" true rather than aspirational. + +// modalKeyCol is the label column of a key/value row, wide enough for the +// longest label the flows use ("blast radius"). +const modalKeyCol = 14 + +// ModalKV is one key/value row of a modal card. The three flags are the only +// emphasis a modal can apply — a row is never styled by matching on its text. +type ModalKV struct { + K, V string + Accent bool + Warn bool + Bold bool +} + +// Modal is a titled card that owns the keyboard while it is open. +// +// A modal has at most one editing surface: Input (a one-line editor) or +// Options (a left/right toggle). OnConfirm receives the typed value, which for +// a gated modal is exactly Confirm — the caller does not have to re-check it. +type Modal struct { + Title, Body string + Rows []ModalKV + + Input bool + Placeholder string + Value string + + Options []string + Opt int + // OptionLabel names the toggle row. Empty falls back to "option". + OptionLabel string + + // Err is a refusal to accept what was typed — a duration that does not + // parse. It renders under the input rather than replacing the body, so the + // instruction that was misread stays on screen next to the mistake. + Err string + + OKLabel string + Danger bool + + // Confirm is the phrase Value must equal before OK is enabled. Empty means + // enter confirms immediately. + Confirm string + + OnConfirm func(a *App, value string) tea.Cmd + // OnCancel runs on esc. A flow that must finish something on the way out — + // recording that a secret was shown and closed, refreshing the list behind + // it — hangs it here, because esc is a real way to leave and not an error + // path. + OnCancel func(a *App) tea.Cmd +} + +// Option is the currently selected toggle value, or "" when there is no toggle. +func (m *Modal) Option() string { + if len(m.Options) == 0 { + return "" + } + return m.Options[m.Opt%len(m.Options)] +} + +// collected is what this modal gathered: the selected option for a toggle, the +// typed value otherwise. OnConfirm reads one field rather than branching on +// which editing surface it was given. +func (m *Modal) collected() string { + if len(m.Options) > 0 { + return m.Option() + } + return strings.TrimSpace(m.Value) +} + +// ready reports whether enter may confirm. +func (m *Modal) ready() bool { + return m.Confirm == "" || strings.TrimSpace(m.Value) == m.Confirm +} + +// handle is keyed on the key's string form, exactly as the composer is: the +// mapping from a tea.KeyPressMsg to that string is untested glue either way, +// and keeping it out of here is what makes a confirmation flow testable. +func (m *Modal) handle(key string, a *App) tea.Cmd { + switch key { + case keyEsc: + a.Modal = nil + if m.OnCancel != nil { + return m.OnCancel(a) + } + return nil + + case keyEnter: + if !m.ready() { + return nil + } + a.Modal = nil + if m.OnConfirm != nil { + return m.OnConfirm(a, m.collected()) + } + return nil + + case keyLeft: + m.cycle(-1) + return nil + + case keyRight: + m.cycle(1) + return nil + + case keySpace: + if len(m.Options) > 0 { + m.cycle(1) + return nil + } + if m.Input { + m.Value += " " + } + return nil + + case keyBackspace: + if m.Input { + m.Value, m.Err = trimLastRune(m.Value), "" + } + return nil + + case keyCtrlU: + if m.Input { + m.Value, m.Err = "", "" + } + return nil + } + + if r, ok := printable(key); ok && m.Input { + m.Value, m.Err = m.Value+string(r), "" + } + return nil +} + +func (m *Modal) cycle(d int) { + if n := len(m.Options); n > 0 { + m.Opt = ((m.Opt+d)%n + n) % n + } +} + +// view draws the card into the pane's exact-line contract: rows lines, none +// wider than w. Below the width or height a border can close in, the card +// degrades to its bare content rather than emitting a frame with a hole in it. +func (m *Modal) view(a *App, w, rows int) string { + if rows < 4 || w < 14 { + return fill(m.fit(a, w, rows), w, rows) + } + return a.Theme.FrameRound(fill(m.fit(a, w-4, rows-2), w-4, rows-2), w) +} + +// fit drops the blank separators while the card is taller than the budget. +// fill truncates from the tail, and the tail is the OK arm and the "type the +// name to enable" gate hint — the one control a destructive modal cannot +// render without — so those blanks have to go before fill ever sees them. +func (m *Modal) fit(a *App, w, rows int) []string { + out := m.lines(a, w) + for i := 0; i < len(out) && len(out) > rows; { + if out[i] == "" { + out = append(out[:i], out[i+1:]...) + continue + } + i++ + } + return out +} + +// lines is the card's content, in order: title, body, rows, editor, actions. +// Blank separators are dropped as the pane gets shorter, so a 4-line modal +// still shows its title and its OK arm. +func (m *Modal) lines(a *App, w int) []string { + th := a.Theme + out := []string{th.Bright.Bold(th.Mode.Color).Render(m.Title)} + for _, line := range strings.Split(m.Body, "\n") { + if line == "" && m.Body == "" { + continue + } + out = append(out, th.Dim.Render(line)) + } + + if len(m.Rows) > 0 { + out = append(out, "") + for _, r := range m.Rows { + out = append(out, th.Dim.Render(padRight(r.K, modalKeyCol))+kvStyle(th, r).Render(r.V)) + } + } + + switch { + case m.Input: + g := th.Mode.Glyphs() + field := th.Accent.Bold(th.Mode.Color).Render(g.Prompt) + " " + if m.Value == "" { + field += a.Composer.cursor(a) + " " + th.Dim.Render(m.Placeholder) + } else { + field += th.Bright.Render(m.Value) + a.Composer.cursor(a) + } + out = append(out, "", field) + case len(m.Options) > 0: + label := m.OptionLabel + if label == "" { + label = "option" + } + out = append(out, "", th.Dim.Render(padRight(label, modalKeyCol))+m.options(th)) + } + + if m.Err != "" { + out = append(out, th.Bad.Render(m.Err)) + } + return append(out, "", m.actions(a, w)) +} + +// kvStyle resolves a key/value row's emphasis. Shared with the detail strips, +// which draw the same row shape outside a modal. +func kvStyle(th theme.Theme, r ModalKV) lipgloss.Style { + s := th.Text + switch { + case r.Accent: + s = th.Accent + case r.Warn: + s = th.Warn + case r.Bold: + s = th.Bright + } + if r.Bold { + s = s.Bold(th.Mode.Color) + } + return s +} + +// options draws the toggle. The selected value carries the accent AND the +// brackets: colour never carries state alone, here or anywhere else. +func (m *Modal) options(th theme.Theme) string { + parts := make([]string, 0, len(m.Options)) + for i, o := range m.Options { + if i == m.Opt { + parts = append(parts, th.Accent.Bold(th.Mode.Color).Render("["+o+"]")) + continue + } + parts = append(parts, th.Dim.Render(" "+o+" ")) + } + return strings.Join(parts, " ") + th.Faint.Render(" space toggles") +} + +// actions is the footer: cancel on the left, OK on the right. A gated OK that +// is not yet armed renders faint and says what is missing, so the arm reads as +// disabled rather than broken. +func (m *Modal) actions(a *App, w int) string { + th := a.Theme + label := m.OKLabel + if label == "" { + label = "ok" + } + if len(m.Options) > 0 { + label = m.Option() + " " + a.arrow() + " " + label + } + + style := th.Accent + if m.Danger { + style = th.Bad + } + right := style.Bold(th.Mode.Color).Render("[ "+label+" ]") + th.Dim.Render(" ↵") + if a.Theme.Mode.ASCII { + right = style.Bold(th.Mode.Color).Render("[ "+label+" ]") + th.Dim.Render(" enter") + } + if !m.ready() { + right = th.Faint.Render("[ " + label + " ] type the name to enable") + } + return gapPad(w, th.Dim.Render("esc cancel"), right) +} diff --git a/internal/tui/modal_test.go b/internal/tui/modal_test.go new file mode 100644 index 0000000..157d32c --- /dev/null +++ b/internal/tui/modal_test.go @@ -0,0 +1,200 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// typeModal types a literal string into the open modal, mapping the space bar +// to the key name the runtime sends. It is the modal's twin of typeIn. +func typeModal(a *App, m *Modal, s string) { + for _, r := range s { + key := string(r) + if r == ' ' { + key = "space" + } + m.handle(key, a) + } +} + +func TestModalEscCancels(t *testing.T) { + a := composerApp() + confirmed := false + a.Modal = &Modal{ + Title: "Revoke ci-pipeline?", + Input: true, + OnConfirm: func(*App, string) tea.Cmd { confirmed = true; return nil }, + } + a.update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if a.Modal != nil { + t.Fatal("esc must close the modal") + } + if confirmed { + t.Fatal("esc must never confirm") + } +} + +func TestModalEscRunsCancelCallback(t *testing.T) { + a := composerApp() + cancelled := false + a.Modal = &Modal{OnCancel: func(*App) tea.Cmd { + return func() tea.Msg { + cancelled = true + return nil + } + }} + cmd := a.Modal.handle("esc", a) + if cmd == nil { + t.Fatal("cancel callback command was dropped") + } + _ = cmd() + if !cancelled || a.Modal != nil { + t.Fatal("esc must close the modal and run OnCancel") + } +} + +func TestModalUsesConfiguredOptionLabel(t *testing.T) { + a := composerApp() + a.Modal = &Modal{Options: []string{"one", "two"}, OptionLabel: "mode"} + if view := a.paneView(80, 12); !strings.Contains(view, "mode") || strings.Contains(view, "scope") { + t.Fatalf("option label must be generic and caller-defined:\n%s", view) + } +} + +// An open modal owns the keyboard. A typed confirmation that leaks its keys +// into the composer is a confirmation racing a command line. +func TestModalTakesEveryKeyFromTheComposer(t *testing.T) { + a := composerApp() + a.Modal = &Modal{Input: true} + a.update(tea.KeyPressMsg{Code: 'x'}) + if a.Composer.Input != "" { + t.Fatalf("the composer must not see a key while a modal is open, got %q", a.Composer.Input) + } + if a.Modal.Value != "x" { + t.Fatalf("the modal input must receive the key, got %q", a.Modal.Value) + } +} + +func TestModalTypedConfirmGate(t *testing.T) { + a := composerApp() + calls, got := 0, "" + m := &Modal{ + Title: "Revoke ci-pipeline?", Input: true, Confirm: "ci-pipeline", + OnConfirm: func(_ *App, v string) tea.Cmd { calls, got = calls+1, v; return nil }, + } + a.Modal = m + + m.handle("enter", a) + typeModal(a, m, "ci-pipe") + m.handle("enter", a) + if calls != 0 { + t.Fatalf("a partial phrase must not confirm (%d calls)", calls) + } + if a.Modal == nil { + t.Fatal("a gated modal stays open until the phrase matches") + } + + typeModal(a, m, "line") + m.handle("enter", a) + if calls != 1 || got != "ci-pipeline" { + t.Fatalf("the exact phrase must confirm once with the typed value, got %d calls %q", calls, got) + } + if a.Modal != nil { + t.Fatal("a confirmed modal closes") + } +} + +func TestModalOptionsToggle(t *testing.T) { + a := composerApp() + m := &Modal{Options: []string{"read_only", "admin"}} + a.Modal = m + if m.Option() != "read_only" { + t.Fatalf("the first option is the default, got %q", m.Option()) + } + m.handle("space", a) + if m.Option() != "admin" { + t.Fatalf("space must advance the toggle, got %q", m.Option()) + } + m.handle("right", a) + if m.Option() != "read_only" { + t.Fatalf("the toggle wraps, got %q", m.Option()) + } + m.handle("left", a) + if m.Option() != "admin" { + t.Fatalf("left steps back, got %q", m.Option()) + } +} + +// The modal renders in place of the pane, so it owes the pane's contract: +// exactly rows lines, none wider than the frame it sits in. +func TestModalViewKeepsThePaneContract(t *testing.T) { + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + a := composerApp() + a.Theme = theme.New(mode) + a.Modal = &Modal{ + Title: "Create API key · name", + Body: "Names are how log rows resolve after a key is revoked.", + Rows: []ModalKV{{K: "name", V: "ci-readonly", Bold: true}, {K: "scope", V: "read_only", Accent: true}}, + Input: true, + Placeholder: "prod-ingest", + OKLabel: "next", + Danger: true, + } + for _, rows := range []int{1, 3, 6, 14, 22} { + for _, w := range []int{30, 60, 92, 126} { + body := a.paneView(w, rows) + if got := len(strings.Split(body, "\n")); got != rows { + t.Fatalf("mode=%+v w=%d rows=%d: modal returned %d lines", mode, w, rows, got) + } + for i, line := range strings.Split(body, "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line) + } + } + } + } + } +} + +// fill truncates from the tail, and the tail is the OK arm and the "type the +// name to enable" gate hint. Before dropping blank separators first, a short +// pane lost those before it lost anything cosmetic, leaving a gated +// destructive modal with no visible confirmation control. +func TestModalViewKeepsActionsWhenPaneIsShort(t *testing.T) { + a := composerApp() + a.Modal = &Modal{ + Title: "Revoke ci-pipeline?", + Body: "Every client holding this key fails immediately.", + Rows: []ModalKV{{K: "name", V: "ci-pipeline", Bold: true}}, + Input: true, + Confirm: "ci-pipeline", + OKLabel: "revoke", + Danger: true, + } + view := a.paneView(60, 7) + if !strings.Contains(view, "revoke") || !strings.Contains(view, "type the name to enable") { + t.Fatalf("a short pane must drop blank separators before the OK arm, got:\n%s", view) + } +} + +// A modal body can carry intentional line breaks; rendering must preserve them +// instead of collapsing the wording into one run-on line. +func TestModalRendersEveryBodyLine(t *testing.T) { + a := composerApp() + a.Modal = &Modal{ + Title: "Rotate ops-laptop?", + Body: "Blast radius: every client holding the current secret fails immediately\nuntil it is redeployed with the new one.", + OKLabel: "rotate", + } + view := a.paneView(110, 16) + for _, want := range []string{"Blast radius", "until it is redeployed with the new one."} { + if !strings.Contains(view, want) { + t.Fatalf("modal body lost %q:\n%s", want, view) + } + } +} diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go new file mode 100644 index 0000000..2a9b65b --- /dev/null +++ b/internal/tui/msgs.go @@ -0,0 +1,544 @@ +package tui + +import ( + "context" + "errors" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" +) + +// This file is the shell's message vocabulary and the only place network I/O +// is started. A screen never calls the client from its view func: it returns a +// tea.Cmd built here, and the result arrives as one of the typed messages +// below. Screen-specific message types live alongside the shared poll results. +// +// Every poll result carries the generation it was requested under. The App +// drops a message whose gen no longer matches, so a slow in-flight response +// cannot overwrite state fetched against a newer profile. + +// statusMsg is one /health + /readyz + /admin/health round trip. report and err +// are both meaningful: a failed poll annotates the header, it never blanks it. +type statusMsg struct { + gen int + report *api.StatusReport + err error +} + +// trafficMsg carries the 5-minute log-stats window the TRAFFIC panel derives +// RPS, P95 and the error rate from. A gateway with no log store answers 501, +// which is not an error state — stats is simply nil and the panel renders "—". +type trafficMsg struct { + gen int + stats *api.LogStats + err error +} + +// railMsg is the fan-in of the four rail sources (admin health, plugins, +// sessions, audit). Partial failure is normal and already encoded in RailData: +// AuthError for a 401, a -1 count for an endpoint this gateway does not serve. +type railMsg struct { + gen int + data RailData + err error +} + +// runCmdMsg is a composer submission. The shell forwards the raw line to the +// router in home.go, which is the only place a verb is interpreted. +type runCmdMsg struct{ raw string } + +// verbRowsMsg is one finished transcript verb. rows and err are both +// meaningful: `status` renders a report even when the call failed, naming the +// URL it tried. took is measured around the request so the transcript can end +// with a duration the operator can trust. +type verbRowsMsg struct { + verb string + rows []TranscriptRow + err error + took time.Duration +} + +// The tick messages drive the two poll cadences. They carry no payload: the +// handler re-reads the current generation when it issues the next fetch. +type ( + tickStatusMsg struct{} + tickTrafficMsg struct{} +) + +// errNoConnection is what a screen reports when it was opened with no client +// to ask. It is a state, not a failed request: nothing was attempted. +var errNoConnection = errors.New("no gateway connection configured") + +// keysListMsg is one GET /admin/keys. Every row's `key` is masked by the +// gateway; nothing in this package ever holds an unmasked one from a read. +type keysListMsg struct { + gen int + keys []api.Key + err error +} + +// modalResultMsg is one finished key mutation. +// +// key carries the FULL secret for create and rotate — the only two responses +// that ever will. It goes straight into the modal that shows it once and is +// dropped when that modal closes. It is never pushed to the transcript, never +// recorded in history, and never logged. revoke has no row to answer with, so +// it carries the name and scope it was run against instead. +type modalResultMsg struct { + gen int + kind string // create | rotate | revoke + key *api.Key + name, scope string + err error +} + +// playModelsMsg is the playground's completion source and its default model. +// A failure is not surfaced: the pane still works, the gateway is simply the +// only authority on which names it accepts. +type playModelsMsg struct { + gen int + models []string + err error +} + +// chatOpenMsg is a stream that has begun. A non-2xx arriving BEFORE the stream +// starts is an error here; once streaming has begun the status is already 200 +// and every failure arrives on the channel instead. +type chatOpenMsg struct { + gen int + stream *api.ChatStream + err error +} + +// The three in-stream messages. One event becomes one message and the handler +// re-issues the reader, which is the whole pump. +type ( + chatDeltaMsg struct { + gen int + text string + } + chatUsageMsg struct { + gen int + usage *api.Usage + } + // chatEndMsg is terminal, and its three shapes are distinct: an error + // frame, a [DONE], or a channel that closed with neither — which is a + // truncated answer and must not be mistaken for a clean finish. + chatEndMsg struct { + gen int + err *api.Error + done bool + } + // chatMetaMsg carries the request-log row that names the provider and the + // cost, PLUS the turn, usage and elapsed time the fetch was issued for. + // Those three are pinned by fetchAttribution's caller at issue time and + // travel on the message rather than being resolved against screen state at + // arrival, because a second turn's fetch can be issued before the first + // turn's has landed (see playScreen.metaCmd) — carrying them here is what + // lets the handler match each arriving message back to its own turn. row + // is nil for every "cannot know" case and the meta line drops the segments + // it cannot support. + chatMetaMsg struct { + gen int + turn int + usage *api.Usage + took time.Duration + row *api.LogEntry + } + flushTickMsg struct{ gen int } +) + +func tickFlush(gen int) tea.Cmd { + return tea.Tick(flushEvery, func(time.Time) tea.Msg { return flushTickMsg{gen: gen} }) +} + +// fetchChatModels lists what this gateway advertises. Its failure is swallowed +// into an empty list: a playground that refuses to open because /v1/models was +// unreachable is worse than one that cannot pre-validate a model name. +func fetchChatModels(c *api.Client, gen int) tea.Cmd { + return func() tea.Msg { + if c == nil { + return playModelsMsg{gen: gen, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + models, err := c.Models(ctx) + if err != nil { + return playModelsMsg{gen: gen, err: err} + } + ids := make([]string, 0, len(models)) + for _, m := range models { + ids = append(ids, m.ID) + } + return playModelsMsg{gen: gen, models: ids} + } +} + +// openChat starts one streamed completion. The context is deliberately NOT +// bounded by fetchTimeout: a long answer is not a stuck request, and the +// gateway already applies its own idle bound. ChatStream.Cancel is what ends it. +func openChat(ctx context.Context, c *api.Client, gen int, req api.ChatRequest) tea.Cmd { + return func() tea.Msg { + if c == nil { + return chatOpenMsg{gen: gen, err: errNoConnection} + } + st, err := c.StreamChat(ctx, req) + return chatOpenMsg{gen: gen, stream: st, err: err} + } +} + +// readStream turns the next event into a message. Err and Done are both +// terminal and mutually exclusive — a stream that fails mid-flight ends with an +// error frame and NO [DONE] — so this reads until the channel closes and treats +// a bare close as its own terminal case rather than waiting for a Done that is +// never coming. +func readStream(gen int, ev <-chan api.StreamEvent) tea.Cmd { + return func() tea.Msg { + e, ok := <-ev + switch { + case !ok: + return chatEndMsg{gen: gen} + case e.Err != nil: + return chatEndMsg{gen: gen, err: e.Err} + case e.Done: + return chatEndMsg{gen: gen, done: true} + case e.Chunk != nil && e.Chunk.Usage != nil: + return chatUsageMsg{gen: gen, usage: e.Chunk.Usage} + } + var b strings.Builder + if e.Chunk != nil { + for _, c := range e.Chunk.Choices { + b.WriteString(c.Delta.Content) + } + } + return chatDeltaMsg{gen: gen, text: b.String()} + } +} + +// fetchAttribution resolves which provider served a streamed answer and what it +// cost. The log row is written after the stream ends, hence the one retry. +// Every failure answers a nil row: attribution is a detail beside an answer the +// operator already has, and failing over its absence would turn a cosmetic gap +// into a broken chat. +// +// turn, usage and took are the caller's pinned facts about the turn this fetch +// was issued for (see playScreen.metaCmd) — they are echoed back on every +// returned chatMetaMsg, success or failure alike, so the handler can match the +// message to its turn without consulting screen state that a second, later +// fetch may have since moved on from. +func fetchAttribution(c *api.Client, gen, turn int, usage *api.Usage, took time.Duration, traceID string, since time.Time) tea.Cmd { + return func() tea.Msg { + miss := chatMetaMsg{gen: gen, turn: turn, usage: usage, took: took} + if c == nil || traceID == "" { + return miss + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + for attempt := range 2 { + if attempt > 0 { + select { + case <-ctx.Done(): + return miss + case <-time.After(attributionRetry): + } + } + row, err := c.TraceAttribution(ctx, traceID, since) + if err != nil { + return miss + } + if row != nil { + miss.row = row + return miss + } + } + return miss + } +} + +// logsBatchMsg is one tail poll. rows and err are both meaningful: a failure +// annotates the table, it never empties it. +type logsBatchMsg struct { + gen int + rows []api.LogEntry + err error +} + +// logsNamesMsg resolves api_key_id → name for the detail strip. Its failure is +// swallowed into an empty map on purpose: a caller who can read the logs but +// not the key store still gets the logs, with raw ids. +type logsNamesMsg struct { + gen int + names map[string]keyRef +} + +// tickLogsMsg drives the tail. It carries the generation it was scheduled +// under, so a tick outliving its screen schedules nothing. +type tickLogsMsg struct{ gen int } + +func tickLogs(d time.Duration, gen int) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { return tickLogsMsg{gen: gen} }) +} + +func pollLogs(f *api.Follower, gen int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + rows, err := f.Poll(ctx) + return logsBatchMsg{gen: gen, rows: rows, err: err} + } +} + +// fetchLogKeyNames reads the key store once per tail so the detail strip can +// name a credential. A key revoked since it served the traffic still names its +// rows — that is what key names are for — so the revoked flag travels with it. +func fetchLogKeyNames(c *api.Client, gen int) tea.Cmd { + return func() tea.Msg { + names := map[string]keyRef{} + if c == nil { + return logsNamesMsg{gen: gen, names: names} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + keys, err := c.Keys(ctx) + if err != nil { + return logsNamesMsg{gen: gen, names: names} // raw ids beat no logs + } + for _, k := range keys { + names[k.ID] = keyRef{Name: k.Name, Revoked: api.KeyState(k) == api.KeyStateRevoked} + } + return logsNamesMsg{gen: gen, names: names} + } +} + +func fetchKeys(c *api.Client, gen int) tea.Cmd { + return func() tea.Msg { + if c == nil { + return keysListMsg{gen: gen, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + keys, err := c.Keys(ctx) + return keysListMsg{gen: gen, keys: keys, err: err} + } +} + +func createKey(c *api.Client, gen int, req api.KeyCreateRequest) tea.Cmd { + return func() tea.Msg { + if c == nil { + return modalResultMsg{gen: gen, kind: actionCreate, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + k, err := c.CreateKey(ctx, req) + return modalResultMsg{gen: gen, kind: actionCreate, key: k, err: err} + } +} + +func rotateKey(c *api.Client, gen int, id string) tea.Cmd { + return func() tea.Msg { + if c == nil { + return modalResultMsg{gen: gen, kind: actionRotate, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + k, err := c.RotateKey(ctx, id) + return modalResultMsg{gen: gen, kind: actionRotate, key: k, err: err} + } +} + +func revokeKey(c *api.Client, gen int, id, name, scope string) tea.Cmd { + return func() tea.Msg { + if c == nil { + return modalResultMsg{gen: gen, kind: actionRevoke, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + // RevokeKey answers with no row: the gateway confirms {"status": + // "revoked"} and the refreshed listing is what shows the new state. + err := c.RevokeKey(ctx, id) + return modalResultMsg{gen: gen, kind: actionRevoke, name: name, scope: scope, err: err} + } +} + +// Poll cadences and the failure backoff ceiling. A failing poll doubles its +// interval up to maxPoll and resets on the first success, so an unreachable +// gateway is not hammered every 5s while its stale-data age keeps climbing. +const ( + statusPoll = 5 * time.Second + trafficPoll = 10 * time.Second + maxPoll = 30 * time.Second + + // fetchTimeout bounds one poll. It is longer than the client's own 15s + // request timeout would need to be for a single call because a rail fetch + // makes four. + fetchTimeout = 20 * time.Second + + // trafficWindow is the log-stats window; RPS divides by its seconds. + trafficWindow = 5 * time.Minute + + // auditRows is one transcript page of the audit trail. The gateway caps at + // 200; the transcript ring is 60, so asking for more only drops rows. + auditRows = 50 +) + +func fetchStatus(c *api.Client, gen int) tea.Cmd { + return func() tea.Msg { + if c == nil { + return statusMsg{gen: gen, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + r, err := c.Status(ctx) + return statusMsg{gen: gen, report: r, err: err} + } +} + +func fetchTraffic(c *api.Client, gen int) tea.Cmd { + return func() tea.Msg { + if c == nil { + return trafficMsg{gen: gen, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + s, err := c.LogStats(ctx, api.LogsQuery{Since: time.Now().Add(-trafficWindow)}) + if api.IsNotSupported(err) { + // No log store configured. Not an error state — just no numbers. + return trafficMsg{gen: gen} + } + return trafficMsg{gen: gen, stats: s, err: err} + } +} + +// fetchRail collects the left rail in one command. Each source degrades on its +// own: an unsupported endpoint leaves its count at -1 ("—") and a credential +// problem sets AuthError, because the rail is exactly where an operator should +// see that ferro is connected but not authorized. +// +// prev is the rail's last good snapshot. Each of the four sources overwrites +// only ITS OWN slice/counters when it succeeds; a source that fails this round +// leaves prev's values for it untouched rather than zeroing them, so one +// flaky source no longer blanks CONNECTED PROVIDERS or MCP just because the +// other three answered fine. The caller still learns the round had a problem +// via railMsg.err — this only stops a transient failure from being drawn as +// "nothing to report" instead of "the last good answer, aging". +func fetchRail(c *api.Client, gen int, prev RailData) tea.Cmd { + return func() tea.Msg { + d := prev + // AuthError is a verdict about THIS round, not a running tally: it is + // re-earned every poll, or a since-fixed 401 would stay stuck on + // screen after the credential is corrected. + d.AuthError = false + if c == nil { + return railMsg{gen: gen, data: d, err: errNoConnection} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + var firstErr error + // note reports whether this source failed. A 404/501 is not a failure — + // it is a gateway that does not serve that endpoint, which leaves the + // counter at unknownCount and renders as a dash. + note := func(err error) bool { + if err == nil { + return false + } + if api.IsUnauthorized(err) { + d.AuthError = true + } + if firstErr == nil && !api.IsNotSupported(err) { + firstErr = err + } + return true + } + + type result struct { + kind string + health *api.AdminHealth + plugins []api.PluginInfo + sessions []api.Session + audit *api.AuditPage + err error + } + results := make(chan result, 4) + go func() { + value, err := c.AdminHealth(ctx) + results <- result{kind: "health", health: value, err: err} + }() + go func() { + value, err := c.Plugins(ctx) + results <- result{kind: verbPlugins, plugins: value, err: err} + }() + go func() { + value, err := c.Sessions(ctx) + results <- result{kind: verbSessions, sessions: value, err: err} + }() + since := time.Now() + since = time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, since.Location()) + go func() { + value, err := c.Audit(ctx, api.AuditQuery{Since: since, Limit: 1}) + results <- result{kind: verbAudit, audit: value, err: err} + }() + + for range 4 { + r := <-results + if note(r.err) { + continue + } + switch r.kind { + case "health": + d.Providers = r.health.Providers + // Recount from this round's body instead of adding to the last + // one's. d starts as prev so a failed source keeps its previous + // value, which makes every ++ here a running total unless the + // counter is cleared first — two good polls would report twice + // the MCP servers that exist. Providers above overwrites, and + // PluginsActive below clears for this same reason. + d.MCPTotal, d.MCPReady = 0, 0 + for _, s := range r.health.MCPServers { + d.MCPTotal++ + if s.Ready { + d.MCPReady++ + } + } + case verbPlugins: + d.PluginsActive = 0 + for _, p := range r.plugins { + if p.Enabled { + d.PluginsActive++ + } + } + case verbSessions: + d.Sessions = len(r.sessions) + case verbAudit: + d.AuditToday = r.audit.Summary.TotalEntries + } + } + return railMsg{gen: gen, data: d, err: firstErr} + } +} + +// verbFetch runs one transcript verb off the render path. Timing lives here, +// where the clock is; formatting the duration and the failure into rows is the +// update arm's job, which keeps it pure and testable. +func verbFetch(c *api.Client, verb string) tea.Cmd { + return func() tea.Msg { + start := time.Now() + if c == nil { + return verbRowsMsg{verb: verb, err: errNoConnection, took: time.Since(start)} + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + rows, err := verbRows(ctx, c, verb) + return verbRowsMsg{verb: verb, rows: rows, err: err, took: time.Since(start)} + } +} + +func tickStatus(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { return tickStatusMsg{} }) +} + +func tickTraffic(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { return tickTrafficMsg{} }) +} diff --git a/internal/tui/panetitle_probe_test.go b/internal/tui/panetitle_probe_test.go new file mode 100644 index 0000000..d275741 --- /dev/null +++ b/internal/tui/panetitle_probe_test.go @@ -0,0 +1,41 @@ +package tui + +import ( + "strings" + "testing" +) + +// A demo recording showed "COMMAND OUTPUT" rendered twice in the pane after +// navigating to another screen and back. mainFrame prepends the title and +// homeView returns only the transcript, so the pane title must appear exactly +// once in a render — on Home, and after any round trip through another screen. +func TestPaneTitleRendersExactlyOnce(t *testing.T) { + a := testApp(126, 34) + a.Transcript.Push(TranscriptRow{Glyph: "$", Text: "ferro status"}) + + count := func(where string) int { + n := strings.Count(a.render(), "COMMAND OUTPUT") + t.Logf("%s: %d occurrence(s)", where, n) + return n + } + + if got := count("home, first render"); got != 1 { + t.Fatalf("pane title must render once on Home, got %d", got) + } + + // Round trip: Home -> playground -> Home, which is what the recording did. + a.Screen = ScreenPlayground + _ = a.render() + a.Screen = ScreenHome + if got := count("home, after a playground round trip"); got != 1 { + t.Fatalf("pane title duplicated after returning to Home: %d occurrences", got) + } + + // And through the logs screen, the other transition the demo makes. + a.Screen = ScreenLogs + _ = a.render() + a.Screen = ScreenHome + if got := count("home, after a logs round trip"); got != 1 { + t.Fatalf("pane title duplicated after returning from logs: %d occurrences", got) + } +} diff --git a/internal/tui/playground.go b/internal/tui/playground.go new file mode 100644 index 0000000..1233957 --- /dev/null +++ b/internal/tui/playground.go @@ -0,0 +1,679 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/table" +) + +// The playground is a streamed chat with the route metadata a gateway operator +// actually needs beside the answer. Three things here are not obvious: +// +// - Deltas are BATCHED. Each one appends to a buffer and a tick moves the +// buffer into the turn every flushEvery, which is where the single markdown +// re-render happens. Rendering per token turns a 400-token answer into 400 +// full re-layouts and the terminal into a slideshow. +// - A streamed body names no provider and carries no price. Both come from +// the request log, matched on the response's X-Request-ID — so the meta +// line is assembled from two sources and every segment it cannot support is +// dropped. An invented provider is worse than a shorter line. +// - Answers are rendered as predictable, word-wrapped plain text. The +// renderer remains replaceable without changing the stream state machine. + +const ( + // flushEvery is the re-render cadence. Fast enough to read as streaming, + // slow enough that one render covers many tokens. + flushEvery = 80 * time.Millisecond + + // attributionRetry is the single re-ask. The log row is written after the + // stream ends, so the first look can legitimately be too early. + attributionRetry = 500 * time.Millisecond + + // attributionSlack widens the log window backwards from the moment the + // request started. created_at is stamped with the GATEWAY's clock and the + // cursor with ours, so a gateway running even slightly behind writes a row + // that looks older than the request that produced it — and attribution then + // fails permanently and silently for that deployment. The window is only a + // prefilter: the row is still matched on its exact trace id, which is + // unique, so widening it cannot select the wrong request. + attributionSlack = 2 * time.Minute + + // modelSuggestions is how many near misses a rejected /model names. + modelSuggestions = 5 + + // playTurnMax bounds both rendered transcript work and the conversation + // retained in memory. It is intentionally a turn count, not a byte count. + playTurnMax = 60 +) + +// The two sides of the conversation. Who is compared against these all over +// this file — to find the turn being written into, to decide a label's style, +// to map a turn onto an API role — so the strings are named rather than +// repeated: a misspelt one reads as a third speaker nothing ever matches. +const ( + whoYou = "you" + whoGateway = "gateway" +) + +// chatTurn is one side of the conversation. lines is the rendered body, cached +// at flush time: the view is called once per message and must not re-run a +// markdown pass to answer. +type chatTurn struct { + Who string // whoYou | whoGateway + Text string + Meta string + Bad bool + Note bool + + // id names this turn for as long as it exists. Attribution arrives after + // the turn it describes has stopped being the last one, and appendTurns + // trims from the head, so a position is not a name. + id int + + lines []string +} + +// playScreen is the chat pane. +type playScreen struct { + turns []chatTurn + lastID int // last id handed out by appendTurns + pending strings.Builder + + // queued holds a `chat ` prompt while model discovery is in + // flight. submit snapshots s.model into the request, so submitting before + // the first playModelsMsg lands sends an empty model. + queued string + + stream *api.ChatStream + openCancel context.CancelFunc + requestID string + started time.Time + took time.Duration + usage *api.Usage + busy bool + + model string + models []string + + width int + // renderer lays an answer out. It is a field so another renderer can be + // introduced independently, and so tests can count layout passes. + renderer func(text string, w int) string + + gen int +} + +// ------------------------------------------------------------------ lifecycle + +func (s *playScreen) enter(a *App, rest string) tea.Cmd { + if a.Screen != ScreenPlayground { + s.gen++ + } + a.Screen = ScreenPlayground + s.resize(a.paneWidth()) + + var cmds []tea.Cmd + discovering := len(s.models) == 0 && a.Client != nil + if discovering { + cmds = append(cmds, fetchChatModels(a.Client, s.gen)) + } + // `chat ` is one line that opens the pane and asks: the scriptable + // verb takes a prompt, so the console's twin of it must too. + // + // It waits for discovery when discovery is running. submit copies s.model + // into the request there and then, and until playModelsMsg picks a default + // that field is empty — so the first console chat would be sent naming no + // model at all and refused by a gateway that would have served it. + if rest != "" { + if discovering { + s.queued = rest + } else { + cmds = append(cmds, s.submit(a, rest)) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +// leave cancels the stream and invalidates the visit. Cancel is what releases +// the reader goroutine and the connection, and it is safe to call more than +// once — so it is called here whether or not the turn finished cleanly. +func (s *playScreen) leave() { + s.gen++ + s.cancel() + s.busy = false + s.queued = "" + s.pending.Reset() +} + +func (s *playScreen) cancel() { + if s.openCancel != nil { + s.openCancel() + s.openCancel = nil + } + if s.stream != nil { + s.stream.Cancel() + s.stream = nil + } +} + +// resize re-wraps every turn. The markdown pass runs off the render path, so a +// width change is the only other thing that can invalidate it. +func (s *playScreen) resize(w int) { + if w <= 0 || w == s.width { + return + } + s.width = w + if s.renderer == nil { + return + } + for i := range s.turns { + s.turns[i].lines = s.wrap(s.turns[i].Text) + } +} + +// --------------------------------------------------------------------- input + +// route is the playground's half of the command/prompt decision. +// +// It reports whether the playground claimed the line. The check cannot key on a +// leading slash: the composer strips it before route() is reached, so `/status` +// and `status` are the same string by then. What is asked instead is whether the +// head names a verb — the console's vocabulary is the cobra tree's, so "does +// this name something ferro can run" is a question with an exact answer. +func (s *playScreen) route(a *App, head, rest, line string) (tea.Cmd, bool) { + switch head { + case verbModel: + s.setModel(a, rest) + return nil, true + case verbClear: + s.reset() + return nil, true + } + if knownVerb(a.Composer.verbs, head) { + return nil, false + } + return s.submit(a, line), true +} + +// knownVerb reports whether head names something the router can run. +func knownVerb(verbs []string, head string) bool { + switch head { + case verbHelp, "?", verbClear, verbVersion: + return true + } + for _, v := range verbs { + if v == head || strings.HasPrefix(v, head+" ") { + return true + } + } + return false +} + +// setModel switches the model the next turn is sent to, refusing anything the +// gateway does not advertise. A model chosen here and refused by the gateway +// would fail one turn later with an error naming neither. +func (s *playScreen) setModel(a *App, id string) { + id = strings.TrimSpace(id) + if id == "" { + s.note(a, "model is "+s.model, false) + return + } + for _, m := range s.models { + if m == id { + s.model = id + s.note(a, "model is "+id, false) + return + } + } + if len(s.models) == 0 { + // Nothing to check against is not the same as a bad name; adopt it and + // let the gateway be the authority, which it is either way. + s.model = id + s.note(a, "model is "+id+" (this gateway advertised no model list)", false) + return + } + s.note(a, fmt.Sprintf("no model named %q — try %s", id, strings.Join(s.nearest(id), ", ")), true) +} + +// nearest offers the models closest to what was typed: shared prefix first, +// then the head of the list, so a refusal always names something runnable. +func (s *playScreen) nearest(id string) []string { + // Three RUNES, not three bytes. id is whatever the operator typed, so a + // byte slice cuts a non-ASCII name at an arbitrary point: for a CJK name + // three bytes is ONE character, which matches far more than the near miss + // this reads as, and for other scripts it lands mid-rune and the comparison + // becomes a byte accident. Hoisted out of the loop while it is here. + head := []rune(id) + prefix := string(head[:min(len(head), 3)]) + var out []string + for _, m := range s.models { + if strings.HasPrefix(m, prefix) { + out = append(out, m) + } + } + if len(out) == 0 { + out = s.models + } + return out[:min(len(out), modelSuggestions)] +} + +// note is how the playground answers a command: as a turn in the conversation, +// because that is where the operator is looking. The transcript is on another +// pane and a red row pushed there would not be seen. +func (s *playScreen) note(_ *App, text string, bad bool) { + s.appendTurns(chatTurn{Who: whoGateway, Text: text, Bad: bad, Note: true, lines: []string{text}}) +} + +func (s *playScreen) appendTurns(turns ...chatTurn) { + for i := range turns { + s.lastID++ + turns[i].id = s.lastID + } + s.turns = append(s.turns, turns...) + if len(s.turns) <= playTurnMax { + return + } + s.turns = s.turns[len(s.turns)-playTurnMax:] + // A chat answer belongs with the user prompt immediately before it. If a + // one-turn note pushed only that prompt out, discard the orphaned answer. + for i, turn := range s.turns { + if turn.Note { + continue + } + if turn.Who == whoGateway { + s.turns = append(s.turns[:i], s.turns[i+1:]...) + } + break + } +} + +func (s *playScreen) reset() { + s.cancel() + s.gen++ + s.turns, s.usage, s.busy, s.requestID = nil, nil, false, "" + s.queued = "" + s.pending.Reset() +} + +// submit opens a turn. The operator's line lands immediately and an empty +// gateway turn is opened beside it, so the answer streams into a slot that is +// already on screen rather than appearing all at once at the end. +func (s *playScreen) submit(a *App, prompt string) tea.Cmd { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return nil + } + if s.busy { + s.note(a, "a turn is still streaming — wait for it, or run clear", true) + return nil + } + if s.renderer == nil { + s.renderer = wrapPlain + } + if s.width <= 0 { + s.width = a.paneWidth() + } + + s.appendTurns( + chatTurn{Who: whoYou, Text: prompt, lines: s.wrap(prompt)}, + chatTurn{Who: whoGateway}) + s.usage, s.busy, s.started, s.requestID = nil, true, time.Now(), "" + s.pending.Reset() + + ctx, cancel := context.WithCancel(context.Background()) + s.openCancel = cancel + return openChat(ctx, a.Client, s.gen, api.ChatRequest{ + Model: s.model, + Messages: s.history(), + }) +} + +// history is the conversation as the gateway sees it. Failed turns are left +// out: a turn that ended in a stream error has no answer, and sending its +// partial text back as though the model had said it is a fabrication. +func (s *playScreen) history() []api.ChatMessage { + out := make([]api.ChatMessage, 0, len(s.turns)) + for _, t := range s.turns { + if t.Text == "" || t.Bad || t.Note { + continue + } + role := "assistant" + if t.Who == whoYou { + role = "user" + } + out = append(out, api.ChatMessage{Role: role, Content: t.Text}) + } + return out +} + +// -------------------------------------------------------------------- update + +func (s *playScreen) update(a *App, msg tea.Msg) tea.Cmd { + switch m := msg.(type) { + case playModelsMsg: + if m.gen != s.gen { + return nil + } + if m.err == nil { + s.models = m.models + if s.model == "" && len(s.models) > 0 { + s.model = s.models[0] + } + } + // A queued prompt is released either way. A failed listing leaves the + // gateway as the only authority on which names it accepts, which it is + // regardless — refusing to ask would be worse than asking and being + // told why. + if q := s.queued; q != "" { + s.queued = "" + return s.submit(a, q) + } + return nil + + case chatOpenMsg: + if m.gen != s.gen { + // A stream opened for a visit the operator has left still owns a + // connection and a goroutine. Cancel it rather than leak it. + if m.stream != nil { + m.stream.Cancel() + } + return nil + } + if m.err != nil { + return s.fail(a, "gateway: "+m.err.Error()) + } + if m.stream == nil { + return s.fail(a, "gateway: stream opened without a response") + } + s.stream, s.requestID = m.stream, m.stream.RequestID + return tea.Batch(readStream(s.gen, m.stream.Events), tickFlush(s.gen)) + + case chatDeltaMsg: + if m.gen != s.gen || !s.busy { + return nil + } + s.pending.WriteString(m.text) + return s.next() + + case chatUsageMsg: + if m.gen != s.gen || !s.busy { + return nil + } + s.usage = m.usage + return s.next() + + case chatEndMsg: + if m.gen != s.gen || !s.busy { + return nil + } + s.flush(a) + s.took = time.Since(s.started) + s.cancel() + s.busy = false + switch { + case m.err != nil: + s.markFailed(streamFailure(m.err)) + return nil + case !m.done: + // The channel closed with neither a terminator nor an error. The + // answer is truncated and saying so is the whole job. + s.markFailed("gateway: the stream ended with no terminator — the answer above is truncated") + return nil + } + return s.metaCmd(a) + + case chatMetaMsg: + if m.gen != s.gen { + return nil + } + for i := range s.turns { + if s.turns[i].id == m.turn { + s.turns[i].Meta = metaLine(m.usage, m.row, m.took) + break + } + } + return nil + + case flushTickMsg: + if m.gen != s.gen { + return nil + } + s.flush(a) + if !s.busy { + return nil + } + return tickFlush(s.gen) + } + return nil +} + +// next keeps the reader pumping. One event per command, re-issued until the +// stream ends — the standard Bubble Tea channel pump. +func (s *playScreen) next() tea.Cmd { + if s.stream == nil { + return nil + } + return readStream(s.gen, s.stream.Events) +} + +// answer is the index of the turn currently being written into. +func (s *playScreen) answer() int { + for i := len(s.turns) - 1; i >= 0; i-- { + if s.turns[i].Who == whoGateway && !s.turns[i].Note { + return i + } + } + return -1 +} + +// flush moves the batched deltas into the turn and re-renders it ONCE. +func (s *playScreen) flush(_ *App) { + if s.pending.Len() == 0 { + return + } + text := s.pending.String() + s.pending.Reset() + i := s.answer() + if i < 0 { + return + } + if s.renderer == nil { + s.renderer = wrapPlain + } + s.turns[i].Text += text + s.turns[i].lines = s.wrap(s.turns[i].Text) +} + +// markFailed turns the open answer into a visible failure, keeping whatever +// text arrived before it broke. +func (s *playScreen) markFailed(text string) { + i := s.answer() + if i < 0 { + return + } + s.turns[i].Bad = true + if s.turns[i].Text != "" { + text = s.turns[i].Text + "\n" + text + } + s.turns[i].Text = text + s.turns[i].lines = s.wrap(text) +} + +func (s *playScreen) fail(_ *App, text string) tea.Cmd { + s.busy = false + s.cancel() + s.markFailed(text) + return nil +} + +// metaCmd resolves the route metadata a streamed body cannot carry. It reads +// the turn and the two measurements the answer will be labelled with HERE, +// because everything it reads is about to become the PREVIOUS turn's, and +// hands them to fetchAttribution to carry on the returned command's eventual +// message rather than pinning them in screen state. +// +// That distinction matters because chatEndMsg clears busy before this +// command's result lands, so the operator can submit and finish a SECOND turn +// while the first turn's fetch is still in flight — two fetches really can be +// outstanding at once. A single screen-level slot would let the second turn's +// metaCmd overwrite the turn id, usage and duration the first turn's fetch was +// issued for, so its answer — arriving later — would be applied to the wrong +// turn. Carrying the values on the message and matching on turn id when it +// arrives keeps the two independent regardless of arrival order. +func (s *playScreen) metaCmd(a *App) tea.Cmd { + var turn int + if i := s.answer(); i >= 0 { + turn = s.turns[i].id + } + return fetchAttribution(a.Client, s.gen, turn, s.usage, s.took, s.requestID, s.started.Add(-attributionSlack)) +} + +// streamFailure is the operator-facing wording for each stream error code. It +// matches `ferro chat`'s, so the same failure reads the same in both. +func streamFailure(e *api.Error) string { + switch e.Code { + case api.CodeStreamTimeout: + // Neither "gateway:" nor a hand-written 2m: the idle timer is ferro's + // own (api.DefaultStreamIdleTimeout), so the gateway is not who gave + // up, and a duration typed out here drifts the moment the constant + // moves. `ferro chat` says the same thing from the same constant. + return fmt.Sprintf("stream idled out — the gateway sent no events for %s, so ferro closed the connection", + api.DefaultStreamIdleTimeout) + case api.CodeStreamIncomplete: + return "gateway: stream ended mid-answer with no terminator — the answer above is truncated" + } + if e.Message == "" { + return "gateway: stream failed: " + e.Error() + } + return "gateway: " + e.Message +} + +// metaLine assembles the route readout from the two sources that have it: the +// usage chunk the stream sent, and the request-log row the response's +// X-Request-ID matched. Every segment whose fact is missing is DROPPED — a +// gateway with no log store gets a shorter line, never a guessed provider and +// never a $0.00 that was really "unpriced". +func metaLine(u *api.Usage, row *api.LogEntry, took time.Duration) string { + var parts []string + if row != nil && row.Provider != "" { + parts = append(parts, row.Provider+" → served") + } + parts = append(parts, fmt.Sprintf("%.2fs", took.Seconds())) + if u != nil { + parts = append(parts, fmt.Sprintf("%d in / %d out", u.PromptTokens, u.CompletionTokens)) + } + if row != nil && row.CostUSD != nil { + parts = append(parts, costCell(row.CostUSD)) + } + return strings.Join(parts, " · ") +} + +// --------------------------------------------------------------------- view + +func (s *playScreen) view(a *App, w, rows int) string { + th := a.Theme + // One sub-line plus at most rows-1 body lines: the tail below is cut to + // exactly that before it is appended. + out := append(make([]string, 0, rows), s.subLine(a, w)) + + body := make([]string, 0, rows) + for i, t := range s.turns { + if i > 0 { + body = append(body, "") + } + label := th.Accent.Bold(th.Mode.Color).Render(t.Who) + if t.Who != whoYou { + label = th.Bright.Bold(th.Mode.Color).Render(t.Who) + } + body = append(body, label) + + lines := t.lines + if len(lines) == 0 && t.Text != "" { + lines = strings.Split(t.Text, "\n") + } + for _, line := range lines { + if t.Bad { + line = th.Bad.Render(line) + } + body = append(body, line) + } + if t.Meta != "" { + body = append(body, + th.Hairline.Render(a.rule(min(lipgloss.Width(t.Meta), w))), + th.Dim.Render(t.Meta)) + } + } + if len(s.turns) == 0 { + body = append(body, "", th.Dim.Render("Type a prompt to start. /model switches models, clear resets.")) + } + + // The transcript of a conversation is read from the bottom. + return fill(append(out, tail(body, max(rows-len(out), 0))...), w, rows) +} + +func (s *playScreen) subLine(a *App, w int) string { + th := a.Theme + model := s.model + if model == "" { + model = "gateway default" + } + right := "" + if s.busy { + right = th.Accent.Render("streaming…") + if th.Mode.ASCII { + right = th.Accent.Render("streaming...") + } + } + return gapPad(w, " "+th.Dim.Render("/model "+model+" · clear resets · esc home"), right) +} + +// tail keeps the last n lines. A chat scrolls up, so what falls off the top is +// what the operator has already read. +func tail(lines []string, n int) []string { + if n <= 0 { + return nil + } + if len(lines) <= n { + return lines + } + return lines[len(lines)-n:] +} + +// ---------------------------------------------------------------- rendering + +func (s *playScreen) wrap(text string) []string { + if text == "" { + return nil + } + // The one chokepoint where model-supplied text becomes rendered lines: + // submit, flush and markFailed all arrive here, so this is where an answer + // stops being able to drive the terminal. wrapPlain is deliberately + // ANSI-AWARE and would carry a \x1b[2J or an OSC title change straight into + // the frame, breaking both the pane's exact-line contract and the rule that + // this tool forwards no terminal control from an upstream provider. LF + // survives — an answer's own line breaks are meaningful and the split below + // is what honours them. Text itself is left untouched: it is what history() + // sends back to the gateway as the conversation. + text = table.SanitizeText(text) + w := max(s.width, 8) + out := text + if s.renderer != nil { + out = s.renderer(text, w) + } + return strings.Split(strings.TrimRight(out, "\n"), "\n") +} + +// wrapPlain folds an answer to the pane width. It is ANSI- and grapheme-aware, +// so a wrapped line still measures what it draws. +func wrapPlain(text string, w int) string { return ansi.Wrap(text, max(w, 8), "") } diff --git a/internal/tui/playground_test.go b/internal/tui/playground_test.go new file mode 100644 index 0000000..85ded65 --- /dev/null +++ b/internal/tui/playground_test.go @@ -0,0 +1,680 @@ +package tui + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/api" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// stubStream is a ChatStream whose events the test writes itself. Cancel is +// counted rather than asserted once, because leaving a screen and finishing a +// turn can both reach it and both must be safe. +func stubStream(events ...api.StreamEvent) (*api.ChatStream, *atomic.Int32) { + ch := make(chan api.StreamEvent, len(events)+1) + for _, e := range events { + ch <- e + } + close(ch) + var cancels atomic.Int32 + return &api.ChatStream{ + RequestID: "tr_stub0001", + Events: ch, + Cancel: func() { cancels.Add(1) }, + }, &cancels +} + +func delta(text string) api.StreamEvent { + return api.StreamEvent{Chunk: &api.ChatChunk{ + Choices: []api.StreamChoice{{Delta: api.MessageDelta{Content: text}}}, + }} +} + +func usageEvent(in, out int) api.StreamEvent { + return api.StreamEvent{Chunk: &api.ChatChunk{ + Usage: &api.Usage{PromptTokens: in, CompletionTokens: out, TotalTokens: in + out}, + }} +} + +// pump drains a stream into the app exactly as the reader command does. +func pump(a *App, st *api.ChatStream) { + for range 64 { + msg := readStream(a.Play.gen, st.Events)() + a.update(msg) + if _, done := msg.(chatEndMsg); done { + return + } + } +} + +func lastTurn(a *App) chatTurn { + if n := len(a.Play.turns); n > 0 { + return a.Play.turns[n-1] + } + return chatTurn{} +} + +func TestSubmitAppendsTurnsAndStreams(t *testing.T) { + a := composerApp() + a.route("playground") + a.Play.submit(a, "which target serves sonnet?") + + if len(a.Play.turns) != 2 { + t.Fatalf("a submission opens the operator's turn and the answer's, got %d", len(a.Play.turns)) + } + if a.Play.turns[0].Who != "you" || a.Play.turns[0].Text != "which target serves sonnet?" { + t.Fatalf("the prompt must be echoed verbatim, got %#v", a.Play.turns[0]) + } + if !a.Play.busy { + t.Fatal("a submitted turn is busy until the stream ends") + } + + st, cancels := stubStream(delta("Ferro "), delta("routed "), delta("this."), + usageEvent(318, 142), api.StreamEvent{Done: true}) + a.update(chatOpenMsg{gen: a.Play.gen, stream: st}) + pump(a, st) + + if a.Play.busy { + t.Fatal("[DONE] ends the turn") + } + if got := lastTurn(a).Text; got != "Ferro routed this." { + t.Fatalf("every delta must land in the turn, got %q", got) + } + if a.Play.usage == nil || a.Play.usage.PromptTokens != 318 { + t.Fatalf("the usage chunk must be kept, got %+v", a.Play.usage) + } + if cancels.Load() == 0 { + t.Fatal("Cancel releases the reader and the connection even after a clean Done") + } + if v := a.paneView(120, 20); !strings.Contains(v, "Ferro routed this.") { + t.Fatalf("the answer must be on screen:\n%s", v) + } +} + +func TestLeavingPlaygroundCancelsStreamBeforeHeaders(t *testing.T) { + requestStarted := make(chan struct{}) + releaseServer := make(chan struct{}) + // sync.Once, not a bare close: a second request reaching this handler — + // a client retry, or a later test change probing the same server — must + // not panic the whole test binary on a double close. + var once sync.Once + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + once.Do(func() { close(requestStarted) }) + <-releaseServer + })) + defer func() { + close(releaseServer) + srv.Close() + }() + c, err := api.New(srv.URL, "") + if err != nil { + t.Fatal(err) + } + a := composerApp() + a.Client = c + a.route("playground") + cmd := a.Play.submit(a, "hello") + done := make(chan struct{}) + go func() { + _ = cmd() + close(done) + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + select { + case <-requestStarted: + case <-ctx.Done(): + t.Fatal("the stream-opening request never reached the server") + } + a.Play.leave() + select { + case <-done: + case <-ctx.Done(): + t.Fatal("the stream-opening command did not return after cancellation") + } +} + +// The whole point of the flush tick: one markdown render per batch, never one +// per token. +func TestFlushCadenceBatchesDeltas(t *testing.T) { + a := composerApp() + a.route("playground") + + var renders atomic.Int32 + a.Play.renderer = func(text string, _ int) string { + renders.Add(1) + return text + } + a.Play.submit(a, "hello") + renders.Store(0) + + for range 10 { + a.update(chatDeltaMsg{gen: a.Play.gen, text: "word "}) + } + if n := renders.Load(); n != 0 { + t.Fatalf("a delta must not re-render; %d renders before the flush", n) + } + if lastTurn(a).Text != "" { + t.Fatal("deltas are batched, not written straight into the turn") + } + + a.update(flushTickMsg{gen: a.Play.gen}) + if n := renders.Load(); n != 1 { + t.Fatalf("one flush is one render, got %d", n) + } + if got := lastTurn(a).Text; got != strings.Repeat("word ", 10) { + t.Fatalf("the flush must move every batched delta, got %q", got) + } +} + +func TestMetaLineFromUsageAndAttribution(t *testing.T) { + u := &api.Usage{PromptTokens: 318, CompletionTokens: 142, TotalTokens: 460} + row := &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)} + + got := metaLine(u, row, 930*time.Millisecond) + for _, want := range []string{"anthropic", "served", "0.93s", "318 in / 142 out", "$0.0031"} { + if !strings.Contains(got, want) { + t.Fatalf("meta line missing %q: %q", want, got) + } + } +} + +// A gateway with no log store answers 501, which TraceAttribution reports as +// (nil, nil). The line must lose the segments it cannot support and keep the +// ones it measured itself — never invent a provider or a cost. +func TestMetaWithoutLogStore(t *testing.T) { + u := &api.Usage{PromptTokens: 318, CompletionTokens: 142} + got := metaLine(u, nil, 930*time.Millisecond) + if !strings.Contains(got, "318 in / 142 out") || !strings.Contains(got, "0.93s") { + t.Fatalf("what was measured must survive: %q", got) + } + for _, bad := range []string{"served", "$", "→"} { + if strings.Contains(got, bad) { + t.Fatalf("an unattributed turn must not carry %q: %q", bad, got) + } + } + + // And with no usage chunk either, the elapsed time is all there is. + if got := metaLine(nil, nil, time.Second); got != "1.00s" { + t.Fatalf("a bare turn reports only what it timed, got %q", got) + } +} + +func TestSlashModelValidates(t *testing.T) { + a := composerApp() + a.route("playground") + a.update(playModelsMsg{gen: a.Play.gen, models: []string{ + "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5", "gpt-4o-mini", "gpt-4o", + }}) + if a.Play.model != "claude-sonnet-4-6" { + t.Fatalf("the first advertised model is the default, got %q", a.Play.model) + } + + a.update(runCmdMsg{raw: "model gpt-4o"}) + if a.Play.model != "gpt-4o" { + t.Fatalf("/model must switch to an advertised model, got %q", a.Play.model) + } + if a.Screen != ScreenPlayground { + t.Fatal("/model is a playground action, not a command that leaves it") + } + + a.update(runCmdMsg{raw: "model claude-nope"}) + if a.Play.model != "gpt-4o" { + t.Fatalf("an unknown model must not be adopted, got %q", a.Play.model) + } + turn := lastTurn(a) + if !turn.Bad { + t.Fatalf("an unknown model must be refused visibly, got %#v", turn) + } + if !strings.Contains(turn.Text, "claude-sonnet-4-6") { + t.Fatalf("a refusal must suggest the models that do exist: %q", turn.Text) + } + if strings.Contains(transcriptText(a), "ferro model") { + t.Fatalf("a playground action is not a shell verb and must not be echoed:\n%s", transcriptText(a)) + } +} + +func TestPlaygroundNotesNeverEnterModelHistoryOrReceiveDeltas(t *testing.T) { + a := composerApp() + a.route("playground") + a.Play.renderer = wrapPlain + a.Play.width = 80 + a.Play.appendTurns( + chatTurn{Who: "you", Text: "hello"}, + chatTurn{Who: "gateway", Text: "answer"}, + ) + a.Play.note(a, "model is gpt-4o", false) + + history := a.Play.history() + if len(history) != 2 || history[0].Content != "hello" || history[1].Content != "answer" { + t.Fatalf("console notes must not be sent as assistant messages: %+v", history) + } + a.Play.pending.WriteString(" delta") + a.Play.flush(a) + if lastTurn(a).Text != "model is gpt-4o" || a.Play.turns[1].Text != "answer delta" { + t.Fatalf("stream deltas must target the chat answer, not the note: %+v", a.Play.turns) + } +} + +func TestPlaygroundTranscriptIsBounded(t *testing.T) { + s := playScreen{} + s.appendTurns( + chatTurn{Who: "you", Text: "old prompt"}, chatTurn{Who: "gateway", Text: "old answer"}, + chatTurn{Who: "you", Text: "kept prompt"}, chatTurn{Who: "gateway", Text: "kept answer"}, + ) + for i := 0; i < playTurnMax-3; i++ { + s.appendTurns(chatTurn{Who: "gateway", Text: "note", Note: true}) + } + if len(s.turns) > playTurnMax || len(s.turns) < 2 || s.turns[0].Who != "you" || s.turns[1].Who != "gateway" { + t.Fatalf("retention must remove orphaned answers and preserve pairing: %+v", s.turns[:min(3, len(s.turns))]) + } +} + +func TestNilOpenedStreamFailsVisibly(t *testing.T) { + a := composerApp() + a.route("playground") + a.Play.submit(a, "hello") + a.update(chatOpenMsg{gen: a.Play.gen}) + if a.Play.busy || !lastTurn(a).Bad || !strings.Contains(lastTurn(a).Text, "without a response") { + t.Fatalf("nil stream must become a visible failed turn: %#v", lastTurn(a)) + } +} + +func TestLeaveCancelsStream(t *testing.T) { + a := composerApp() + a.route("playground") + a.Play.submit(a, "hello") + st, cancels := stubStream(delta("one "), delta("two ")) + a.update(chatOpenMsg{gen: a.Play.gen, stream: st}) + gen := a.Play.gen + + a.update(chatDeltaMsg{gen: gen, text: "one "}) + a.update(runCmdMsg{raw: "help"}) // leaves the screen + + if cancels.Load() == 0 { + t.Fatal("leaving the playground must cancel the stream in flight") + } + if a.Play.busy { + t.Fatal("a cancelled turn is not busy") + } + before := lastTurn(a).Text + a.update(chatDeltaMsg{gen: gen, text: "two "}) + a.update(chatUsageMsg{gen: gen, usage: &api.Usage{PromptTokens: 9}}) + a.update(chatEndMsg{gen: gen, done: true}) + if lastTurn(a).Text != before { + t.Fatalf("a delta from a screen the operator has left must be dropped, got %q", lastTurn(a).Text) + } + if a.Play.usage != nil { + t.Fatal("a late usage chunk must be dropped too") + } +} + +// Every stream failure ends as a turn an operator can see. stream_incomplete +// is the one most easily lost: it is ferro's own synthesis for a connection +// that ended with neither a terminator nor an explanation. +func TestStreamErrorRendersRedTurn(t *testing.T) { + for _, tc := range []struct { + code, message, want string + }{ + {api.CodeStreamError, "upstream error from provider anthropic", "upstream error from provider anthropic"}, + {api.CodeStreamTimeout, "idle bound elapsed", "stream idled out"}, + {api.CodeStreamIncomplete, "", "terminator"}, + } { + a := composerApp() + a.route("playground") + a.Play.submit(a, "hello") + st, cancels := stubStream(delta("partial "), + api.StreamEvent{Err: &api.Error{Status: 200, Code: tc.code, Message: tc.message}}) + a.update(chatOpenMsg{gen: a.Play.gen, stream: st}) + pump(a, st) + + turn := lastTurn(a) + if !turn.Bad { + t.Fatalf("%s must render as a failed turn, got %#v", tc.code, turn) + } + if !strings.Contains(turn.Text, tc.want) { + t.Fatalf("%s must say %q, got %q", tc.code, tc.want, turn.Text) + } + if a.Play.busy { + t.Fatalf("%s ends the turn", tc.code) + } + if cancels.Load() == 0 { + t.Fatalf("%s must still release the stream", tc.code) + } + if v := a.paneView(110, 20); !strings.Contains(v, tc.want) { + t.Fatalf("%s must be visible on screen:\n%s", tc.code, v) + } + } +} + +// A channel that closes with neither Done nor an error frame is the same +// truncation, arriving a different way. +func TestSilentStreamCloseEndsTheTurn(t *testing.T) { + a := composerApp() + a.route("playground") + a.Play.submit(a, "hello") + st, _ := stubStream(delta("partial ")) + a.update(chatOpenMsg{gen: a.Play.gen, stream: st}) + pump(a, st) + + if a.Play.busy { + t.Fatal("a closed channel ends the turn") + } + if turn := lastTurn(a); !turn.Bad || !strings.Contains(turn.Text, "partial") { + t.Fatalf("a truncated answer keeps what arrived and says it was cut: %#v", turn) + } +} + +// The seam the plan got wrong: the composer strips the leading slash before +// route() sees the line, so "is this a command" cannot key on it. +func TestPlaygroundRoutesVerbsAndPromptsApart(t *testing.T) { + a := composerApp() + a.route("playground") + + a.update(runCmdMsg{raw: "status"}) + if a.Screen != ScreenHome { + t.Fatal("a line naming a verb is a command, even on the playground") + } + if !strings.Contains(transcriptText(a), "ferro status") { + t.Fatalf("a verb must still be echoed:\n%s", transcriptText(a)) + } + + a.route("playground") + a.update(runCmdMsg{raw: "which target is serving Sonnet right now?"}) + if a.Screen != ScreenPlayground { + t.Fatal("a prompt keeps the operator in the playground") + } + if got := a.Play.turns[0].Text; got != "which target is serving Sonnet right now?" { + t.Fatalf("a prompt must reach the model with its case intact, got %q", got) + } + if strings.Contains(transcriptText(a), "which target") { + t.Fatalf("a prompt is not a command and must not be echoed as one:\n%s", transcriptText(a)) + } +} + +func TestPlaygroundClearResetsTurnsNotTranscript(t *testing.T) { + a := composerApp() + a.route("status") + a.route("playground") + a.Play.submit(a, "hello") + st, cancels := stubStream(delta("hi")) + a.update(chatOpenMsg{gen: a.Play.gen, stream: st}) + + a.update(runCmdMsg{raw: "clear"}) + if len(a.Play.turns) != 0 { + t.Fatalf("/clear empties the conversation, got %d turns", len(a.Play.turns)) + } + if cancels.Load() == 0 { + t.Fatal("/clear must cancel a stream it is discarding the answer of") + } + if a.Screen != ScreenPlayground { + t.Fatal("/clear stays in the playground") + } + if a.Transcript.Len() == 0 { + t.Fatal("/clear on the playground is not the transcript's clear") + } +} + +// End to end against the fake: real SSE framing, real decode, real attribution. +func TestPlaygroundStreamsAgainstTheFixture(t *testing.T) { + a := fixtureApp(t) + a.route("playground") + a.Play.model = "claude-sonnet-4-6" + + msg := a.Play.submit(a, "hello")() + open, ok := msg.(chatOpenMsg) + if !ok || open.err != nil { + t.Fatalf("opening a stream against the fake must succeed, got %#v", msg) + } + a.update(open) + pump(a, open.stream) + a.update(flushTickMsg{gen: a.Play.gen}) + + if got := lastTurn(a).Text; !strings.Contains(got, "Ferro routed this through the fake gateway.") { + t.Fatalf("the fake's whole answer must arrive, got %q", got) + } + if a.Play.usage == nil || a.Play.usage.PromptTokens != 24 { + t.Fatalf("the usage chunk must be decoded, got %+v", a.Play.usage) + } + + // The end arm returns the attribution fetch; run it and apply the meta. This + // is the whole loop the plan calls out: a streamed body names no provider, + // so the answer's X-Request-ID is matched against the request log. + a.update(a.Play.metaCmd(a)()) + meta := lastTurn(a).Meta + for _, want := range []string{"anthropic → served", "24 in / 7 out", "$0.0031"} { + if !strings.Contains(meta, want) { + t.Fatalf("the meta line must carry %q, got %q", want, meta) + } + } + t.Logf("meta against the fixture: %q", meta) +} + +func TestPlaygroundKeepsThePaneContract(t *testing.T) { + a := composerApp() + a.route("playground") + a.Play.submit(a, "a fairly long prompt that will need wrapping at every width we test") + a.update(chatDeltaMsg{gen: a.Play.gen, text: "# Heading\n\nSome **markdown** with a list:\n\n- one\n- two\n\n```go\nfmt.Println(\"hi\")\n```\n"}) + a.update(flushTickMsg{gen: a.Play.gen}) + a.update(chatEndMsg{gen: a.Play.gen, done: true}) + a.update(chatMetaMsg{gen: a.Play.gen, turn: lastTurn(a).id, row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}}) + + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + a.Theme = theme.New(mode) + // The renderer must be REAL: resize returns early without one, so a nil + // renderer would leave every turn wrapped for whatever width came + // before and this loop would measure a cache instead of the wrapping + // it exists to check. + a.Play.renderer = wrapPlain + for _, rows := range []int{1, 4, 9, 20} { + for _, w := range []int{30, 60, 92, 126} { + a.Play.width = 0 // force the re-wrap regardless of the previous width + a.Play.resize(w) + body := a.paneView(w, rows) + if got := len(strings.Split(body, "\n")); got != rows { + t.Fatalf("mode=%+v w=%d rows=%d returned %d lines", mode, w, rows, got) + } + for i, line := range strings.Split(body, "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line) + } + } + } + } + } +} + +// Attribution is resolved from the request log AFTER the stream ends, with one +// retry 500ms later, so the operator can easily submit the next prompt inside +// that window. Everything the meta line is built from — which turn is last, the +// usage chunk, the elapsed time — is about to describe a different turn by the +// time the answer lands, so all of it is pinned when the fetch is ISSUED. +func TestAttributionStaysOnTheTurnThatEarnedIt(t *testing.T) { + a := composerApp() + a.route("playground") + + a.Play.submit(a, "which target serves sonnet?") + a.update(chatUsageMsg{gen: a.Play.gen, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7}}) + a.update(chatEndMsg{gen: a.Play.gen, done: true}) // issues the attribution fetch + turn := lastTurn(a).id + + // The operator does not wait for it, and the second prompt resets both the + // usage and the answer position the old code resolved against. + a.Play.submit(a, "and haiku?") + a.update(chatMetaMsg{gen: a.Play.gen, turn: turn, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7}, + row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}}) + + if n := len(a.Play.turns); n != 4 { + t.Fatalf("two prompts and two answers, got %d turns", n) + } + first := a.Play.turns[1].Meta + for _, want := range []string{"anthropic", "24 in / 7 out", "$0.0031"} { + if !strings.Contains(first, want) { + t.Fatalf("the answered turn must keep its own attribution %q, got %q", want, first) + } + } + if got := lastTurn(a).Meta; got != "" { + t.Fatalf("the turn still streaming has earned no attribution, got %q", got) + } +} + +// Two turns' attribution fetches CAN be outstanding at the same time: chatEndMsg +// clears busy before its fetch lands, so nothing stops the operator from +// submitting and finishing a second turn before the first turn's fetch has +// answered. Both fetches are issued here before either's chatMetaMsg is +// delivered, delivered out of submission order, and each must still land on +// the turn that earned it. +func TestOverlappingAttributionsResolveToTheirOwnTurn(t *testing.T) { + a := composerApp() + a.route("playground") + + a.Play.submit(a, "which target serves sonnet?") + a.update(chatUsageMsg{gen: a.Play.gen, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7}}) + fetch1 := a.update(chatEndMsg{gen: a.Play.gen, done: true}) // turn 1's fetch is now in flight + turn1 := lastTurn(a).id + + a.Play.submit(a, "and haiku?") + a.update(chatUsageMsg{gen: a.Play.gen, usage: &api.Usage{PromptTokens: 11, CompletionTokens: 3}}) + fetch2 := a.update(chatEndMsg{gen: a.Play.gen, done: true}) // turn 2 ends before turn 1's fetch has landed + turn2 := lastTurn(a).id + + if fetch1 == nil || fetch2 == nil || turn1 == turn2 { + t.Fatalf("both turns must issue their own attribution fetch, got fetch1=%v fetch2=%v turn1=%d turn2=%d", + fetch1 != nil, fetch2 != nil, turn1, turn2) + } + + // Turn 2's row lands first — arrival order is the opposite of submission + // order, and must not matter. + a.update(chatMetaMsg{gen: a.Play.gen, turn: turn2, usage: &api.Usage{PromptTokens: 11, CompletionTokens: 3}, + took: 2 * time.Second, row: &api.LogEntry{Provider: "openai", CostUSD: f64(0.0009)}}) + a.update(chatMetaMsg{gen: a.Play.gen, turn: turn1, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7}, + took: time.Second, row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}}) + + if n := len(a.Play.turns); n != 4 { + t.Fatalf("two prompts and two answers, got %d turns", n) + } + first, second := a.Play.turns[1].Meta, a.Play.turns[3].Meta + for _, want := range []string{"anthropic", "24 in / 7 out", "$0.0031"} { + if !strings.Contains(first, want) { + t.Fatalf("turn 1 must keep its own provider, usage and duration, got %q", first) + } + } + for _, want := range []string{"openai", "11 in / 3 out", "$0.0009"} { + if !strings.Contains(second, want) { + t.Fatalf("turn 2 must keep its own provider, usage and duration, got %q", second) + } + } +} + +// `chat ` opens the pane and asks in one line, which means the ask +// races the model listing that same command starts. submit copies s.model into +// the request there and then, so a prompt sent before discovery lands names no +// model at all — and a gateway that would have served it refuses instead. +func TestInlineChatWaitsForModelDiscovery(t *testing.T) { + a := fixtureApp(t) + a.route("chat which target serves sonnet?") + + if len(a.Play.turns) != 0 { + t.Fatalf("nothing may be sent before a model is known, got %d turns", len(a.Play.turns)) + } + if a.Play.queued == "" { + t.Fatal("the prompt must be held, not dropped") + } + + a.Play.update(a, playModelsMsg{gen: a.Play.gen, models: []string{"claude-sonnet-4-6", "gpt-4o"}}) + + if a.Play.model == "" { + t.Fatal("discovery must name the default before the held prompt is sent") + } + if len(a.Play.turns) != 2 || a.Play.turns[0].Text != "which target serves sonnet?" { + t.Fatalf("the held prompt must be sent once discovery lands, got %+v", a.Play.turns) + } + if a.Play.queued != "" { + t.Fatal("a released prompt must not be sent twice") + } +} + +// A gateway that will not list its models is still the authority on which +// names it accepts. Holding the prompt for a listing that never arrives would +// swallow it silently, which is worse than asking and being told why. +func TestInlineChatReleasesThePromptWhenDiscoveryFails(t *testing.T) { + a := fixtureApp(t) + a.route("chat which target serves sonnet?") + a.Play.update(a, playModelsMsg{gen: a.Play.gen, err: errors.New("connection refused")}) + + if len(a.Play.turns) != 2 { + t.Fatalf("a failed listing must not swallow the prompt, got %+v", a.Play.turns) + } +} + +// A streamed answer is model-supplied text, and wrapPlain is deliberately +// ANSI-AWARE: it preserved a \x1b[2J (clear screen) and an OSC window-title +// change straight into the frame. That is terminal control forwarded from an +// upstream provider into the operator's session, and a pane that no longer owes +// exactly rows lines. +func TestAnswerEscapesNeverReachTheFrame(t *testing.T) { + a := composerApp() + a.route("playground") + a.Play.renderer = wrapPlain + a.Play.submit(a, "hello") + a.update(chatDeltaMsg{gen: a.Play.gen, text: "\x1b[2Jcleared\x1b]0;pwned\x07titled\nsecond line"}) + a.update(flushTickMsg{gen: a.Play.gen}) + + turn := lastTurn(a) + // Text is the conversation history sent back to the gateway and stays as + // the model wrote it. What must be inert is the RENDERED lines. + if !strings.Contains(turn.Text, "\x1b[2J") { + t.Fatalf("the turn keeps the model's own text verbatim, got %q", turn.Text) + } + if len(turn.lines) < 2 { + t.Fatalf("the answer's own newline must still split the body: %q", turn.lines) + } + for i, line := range turn.lines { + if strings.ContainsRune(line, 0x1b) { + t.Fatalf("rendered line %d still carries an escape: %q", i, line) + } + } + + const rows = 12 + body := a.paneView(80, rows) + if strings.ContainsRune(body, 0x1b) { + t.Fatalf("an escape from the model reached the frame: %q", body) + } + if got := len(strings.Split(body, "\n")); got != rows { + t.Fatalf("the pane owes exactly %d lines, got %d", rows, got) + } + for i, line := range strings.Split(body, "\n") { + if lw := lipgloss.Width(line); lw > 80 { + t.Fatalf("line %d overflows: %d cells %q", i, lw, line) + } + } + if !strings.Contains(body, "cleared") || !strings.Contains(body, "second line") { + t.Fatalf("the answer's own words must survive sanitising:\n%s", body) + } +} + +// nearest slices what the OPERATOR typed. Three bytes is one character of a +// CJK name — and can land mid-rune — so the near miss it offered was a byte +// accident rather than the three-character prefix it reads as. +func TestNearestMatchesOnRunesNotBytes(t *testing.T) { + s := playScreen{models: []string{"日本語-alpha", "日本語-beta", "日本-gamma", "gpt-4o"}} + got := s.nearest("日本語-zeta") + if len(got) != 2 || got[0] != "日本語-alpha" || got[1] != "日本語-beta" { + t.Fatalf("want the three-character near misses, got %v", got) + } + // Shorter than three runes is still its whole self, not a panic. + if got := s.nearest("日"); len(got) != 3 { + t.Fatalf("a one-character name prefixes all three, got %v", got) + } +} diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go new file mode 100644 index 0000000..18ab8da --- /dev/null +++ b/internal/tui/theme/theme.go @@ -0,0 +1,274 @@ +// Package theme is the ferro console's visual vocabulary: palette, glyph set, +// cell-accurate frames, and the split-F mark. +// +// Every measurement here is in terminal cells (lipgloss.Width), never bytes and +// never runes: CJK is double-width, combining marks are zero-width, and ANSI +// sequences measure zero. A frame padded with len() breaks the first time real +// output arrives. +package theme + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// Console palette. Truecolor hex; lipgloss downgrades to the +// terminal's actual profile. Never paint a full-screen background — the +// terminal's own background is the ground. +const ( + colorAccent = "#d97757" + colorOK = "#77ba8d" + colorWarn = "#e4b354" + colorBad = "#dc6b67" + colorDim = "#7f817e" + colorText = "#e8e5df" + colorBright = "#fff8ef" + colorFaint = "#4a4b49" + colorBorder = "#3a3b38" + colorHairline = "#262726" + colorSelection = "#1a1717" +) + +// minFrameWidth is the narrowest frame that can still close around content: +// two border cells, two padding cells, and something in between. +const minFrameWidth = 8 + +// Mode is what the terminal will accept. Color is false under NO_COLOR, a +// non-TTY stdout, or TERM=dumb; ASCII is the --ascii glyph downgrade. +type Mode struct { + Color bool + ASCII bool +} + +// Glyphs is the whole status vocabulary. Color never carries state alone, so +// every state has a glyph here. +type Glyphs struct{ Dot, OK, Warn, Bad, None, Prompt string } + +// Glyphs returns the unicode set, or the ASCII set under --ascii. +func (m Mode) Glyphs() Glyphs { + if m.ASCII { + return Glyphs{Dot: "[+]", OK: "[OK]", Warn: "[!]", Bad: "[X]", None: "[-]", Prompt: ">"} + } + return Glyphs{Dot: "●", OK: "✓", Warn: "!", Bad: "✗", None: "·", Prompt: "❯"} +} + +// box is the border vocabulary for one frame style. +type box struct{ tl, tr, bl, br, h, v string } + +func (m Mode) box(round bool) box { + switch { + case m.ASCII: + return box{tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|"} + case round: + return box{tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│"} + default: + return box{tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│"} + } +} + +// Theme is the rendering surface every TUI file draws through. When +// Mode.Color is false every style is a plain no-op, so callers never branch on +// color themselves. +type Theme struct { + Mode Mode + Accent, OK, Warn, Bad, Dim, Text, Bright, Faint lipgloss.Style + Border, Hairline lipgloss.Style + Selected lipgloss.Style +} + +// New builds the theme for a mode. Under !m.Color every field is +// lipgloss.NewStyle(), which renders its input unchanged. +func New(m Mode) Theme { + fg := func(hex string) lipgloss.Style { + s := lipgloss.NewStyle() + if !m.Color { + return s + } + return s.Foreground(lipgloss.Color(hex)) + } + t := Theme{ + Mode: m, + Accent: fg(colorAccent), + OK: fg(colorOK), + Warn: fg(colorWarn), + Bad: fg(colorBad), + Dim: fg(colorDim), + Text: fg(colorText), + Bright: fg(colorBright), + Faint: fg(colorFaint), + Border: fg(colorBorder), + Hairline: fg(colorHairline), + Selected: lipgloss.NewStyle(), + } + if m.Color { + t.Selected = t.Selected.Background(lipgloss.Color(colorSelection)) + } + return t +} + +// Frame draws a square-corner frame exactly w cells wide, with the title +// embedded in the top border: +// +// ┌─ TITLE ────────┐ +// │ body │ +// └────────────────┘ +// +// Body lines are padded or truncated to fit. An empty title yields an +// unbroken top border. w < minFrameWidth returns the bare body: never emit a +// border that cannot close. +func (t Theme) Frame(title, body string, w int) string { + return t.frame(title, body, w, t.Mode.box(false)) +} + +// FrameRound is Frame with rounded corners and no title — the composer and +// modals. +func (t Theme) FrameRound(body string, w int) string { + return t.frame("", body, w, t.Mode.box(true)) +} + +func (t Theme) frame(title, body string, w int, b box) string { + if w < minFrameWidth { + return body + } + lines := strings.Split(body, "\n") + rows := make([]string, 0, len(lines)+2) + rows = append(rows, t.top(b, title, w)) + edge := t.Border.Render(b.v) + for _, line := range lines { + rows = append(rows, edge+" "+padCell(line, w-4)+" "+edge) + } + rows = append(rows, t.Border.Render(b.bl+strings.Repeat(b.h, w-2)+b.br)) + return strings.Join(rows, "\n") +} + +func (t Theme) top(b box, title string, w int) string { + if title == "" { + return t.Border.Render(b.tl + strings.Repeat(b.h, w-2) + b.tr) + } + // Cap the title so at least one trailing rule remains; w >= minFrameWidth + // leaves at least two cells for it. + title = ansi.Truncate(title, w-6, "") + fill := max(w-5-lipgloss.Width(title), 1) + return t.Border.Render(b.tl+b.h) + " " + t.Dim.Render(title) + " " + + t.Border.Render(strings.Repeat(b.h, fill)+b.tr) +} + +// padCell pads or truncates s to exactly w terminal cells. Truncation is +// ANSI- and grapheme-aware, so styled content keeps its escape sequences +// intact and a clipped double-width rune still leaves the line whole. +func padCell(s string, w int) string { + if w <= 0 { + return "" + } + s = ansi.Truncate(s, w, "") + if pad := w - lipgloss.Width(s); pad > 0 { + s += strings.Repeat(" ", pad) + } + return s +} + +// markStem is the F's stem: the three rows that carry no arm are the same row. +const markStem = " ███" + +// markRows is the split-F, sampled from f_logo.svg — canonical, do not redraw. +// +// An array, and unexported: markArt indexes row zero to measure the shared +// margin, so a caller that emptied a slice here would panic the whole console +// on a header render. The length is a compile-time fact instead. +var markRows = [...]string{ + " ████████", + " ██████████", + markStem, + " ██████", + " ████████", + markStem, + markStem, +} + +// Mark is the split-F in a rounded panel, drawn two art rows to a text row. +// +// A terminal cell is about twice as tall as it is wide, so the art drawn one +// row per row stands seven lines high — nine inside a panel — beside four +// lines of header text, and reads as a billboard. Half blocks fold it: ▀ inks +// the top half of a cell and ▄ the bottom, so each text row carries two art +// rows and the panel lands six rows over fourteen columns, close enough to +// square on screen. The fold is exact, not a downsample; no art row is dropped +// or merged away. +// +// The panel is what keeps the mark off the screen's corner: without it the art +// starts on the first cell of the first row with nothing around it. +// +// Under --ascii there are no half blocks and no panel — 2h falls below +// minFrameWidth, so FrameRound hands back the letter the mark degrades to. +func Mark(t Theme) string { + if t.Mode.ASCII { + return t.Accent.Render("F") + } + art := foldedArt() + widest := 0 + for _, r := range art { + widest = max(widest, lipgloss.Width(r)) + } + rows := make([]string, len(art)) + for i, r := range art { + rows[i] = t.Accent.Render(r) + } + // widest + 4: the panel's two border cells and the padding cell FrameRound + // sets inside each of them. + return t.FrameRound(strings.Join(rows, "\n"), widest+4) +} + +// foldedArt is the canonical rows paired off into half-block text rows. +func foldedArt() []string { + art := markArt() + rows := make([]string, 0, (len(art)+1)/2) + for i := 0; i < len(art); i += 2 { + bottom := "" // an odd row count leaves the last text row a top half only + if i+1 < len(art) { + bottom = art[i+1] + } + rows = append(rows, fold(art[i], bottom)) + } + return rows +} + +// fold merges two art rows into one text row of half blocks: a cell is full +// when both rows ink it, a top or bottom half when one does, blank when +// neither. +func fold(topRow, bottomRow string) string { + top, bottom := []rune(topRow), []rune(bottomRow) + out := make([]rune, 0, max(len(top), len(bottom))) + for i := range max(len(top), len(bottom)) { + inkedTop := i < len(top) && top[i] != ' ' + inkedBottom := i < len(bottom) && bottom[i] != ' ' + switch { + case inkedTop && inkedBottom: + out = append(out, '█') + case inkedTop: + out = append(out, '▀') + case inkedBottom: + out = append(out, '▄') + default: + out = append(out, ' ') + } + } + return strings.TrimRight(string(out), " ") +} + +// markArt is markRows with the shared left margin the SVG sample carried +// measured off, so the mark starts on its own first inked column. The shape is +// untouched: every row loses the same count, so the split arms keep their +// overhang. Byte slicing is safe because the margin is all spaces. +func markArt() []string { + indent := len(markRows[0]) + for _, r := range markRows { + indent = min(indent, len(r)-len(strings.TrimLeft(r, " "))) + } + out := make([]string, len(markRows)) + for i, r := range markRows { + out[i] = r[indent:] + } + return out +} diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go new file mode 100644 index 0000000..ae1c127 --- /dev/null +++ b/internal/tui/theme/theme_test.go @@ -0,0 +1,117 @@ +package theme + +import ( + "strings" + "testing" + "unicode" + + "charm.land/lipgloss/v2" +) + +func TestFrameClosesAtExactWidth(t *testing.T) { + th := New(Mode{}) // no color → deterministic output + got := th.Frame("LOGS", "hello", 20) + want := "" + + "┌─ LOGS ───────────┐\n" + + "│ hello │\n" + + "└──────────────────┘" + if got != want { + t.Fatalf("frame drift:\n%s\nwant:\n%s", got, want) + } + for i, line := range strings.Split(got, "\n") { + if w := lipgloss.Width(line); w != 20 { + t.Fatalf("line %d width %d != 20 (cell measurement)", i, w) + } + } +} + +// Frame is pinned above in the default unicode box only, and that is one of +// three border sets: Mode.box answers differently under --ascii, and FrameRound +// is what the composer and every modal draw. All three do the same +// cell-accurate padding, so all three are measured. +func TestFrameVariantsCloseAtExactWidth(t *testing.T) { + const w = 20 + for _, m := range []Mode{{}, {ASCII: true}} { + th := New(m) + for _, got := range []string{th.Frame("LOGS", "hello", w), th.FrameRound("hello", w)} { + for i, line := range strings.Split(got, "\n") { + if got := lipgloss.Width(line); got != w { + t.Fatalf("mode=%+v line %d width %d != %d: %q", m, i, got, w, line) + } + } + } + } +} + +func TestFrameUnicodeContentDoesNotBreakBorders(t *testing.T) { + th := New(Mode{}) + got := th.Frame("T", "métriques ✓ 日本語", 26) + for _, line := range strings.Split(got, "\n") { + if lipgloss.Width(line) != 26 { + t.Fatalf("unicode content broke cell math: %q", line) + } + } +} + +func TestFrameTooNarrowReturnsBody(t *testing.T) { + th := New(Mode{}) + if got := th.Frame("T", "x", 6); got != "x" { + t.Fatalf("narrow frame must degrade to bare body, got %q", got) + } +} + +func TestGlyphsASCII(t *testing.T) { + g := Mode{ASCII: true}.Glyphs() + if g.OK != "[OK]" || g.Bad != "[X]" || g.Warn != "[!]" || g.None != "[-]" || g.Prompt != ">" { + t.Fatalf("ascii glyph vocabulary drifted: %+v", g) + } +} + +func TestMarkShape(t *testing.T) { + if len(markRows) != 7 { + t.Fatal("mark is exactly 7 rows — never redraw it") + } +} + +// The mark is folded two art rows to a text row so it stands level with the +// four header lines beside it. The fold must lose nothing: half the height, +// the full width, and every inked column still inked. +func TestMarkFoldsToHalfHeight(t *testing.T) { + rows := foldedArt() + if want := (len(markRows) + 1) / 2; len(rows) != want { + t.Fatalf("mark folded to %d rows, want %d", len(rows), want) + } + art := markArt() + widest, folded := 0, 0 + for _, r := range art { + widest = max(widest, lipgloss.Width(r)) + } + for _, r := range rows { + folded = max(folded, lipgloss.Width(r)) + } + if folded != widest { + t.Fatalf("fold is %d cells wide, art is %d — the shape lost columns", folded, widest) + } +} + +func TestFoldPairsRowsIntoHalfBlocks(t *testing.T) { + for _, c := range []struct{ top, bottom, want string }{ + {"██", "██", "██"}, // both inked → full cell + {"██", "", "▀▀"}, // top only → upper half + {"", "██", "▄▄"}, // bottom only → lower half + {"█ █", "██", "█▄▀"}, // mixed, decided per column + {"", "", ""}, + } { + if got := fold(c.top, c.bottom); got != c.want { + t.Fatalf("fold(%q, %q) = %q, want %q", c.top, c.bottom, got, c.want) + } + } +} + +func TestMarkIsASCIIInASCIIMode(t *testing.T) { + for _, r := range Mark(New(Mode{ASCII: true})) { + if r > unicode.MaxASCII { + t.Fatalf("ASCII mark contains %q", r) + } + } +} diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go new file mode 100644 index 0000000..2ad4fb2 --- /dev/null +++ b/internal/tui/transcript.go @@ -0,0 +1,169 @@ +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/ferro-labs/gateway-cli/internal/table" + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +// transcriptMax is the ring depth. Bounded because a console that runs for a +// week must not grow without limit, and 60 rows is what the tallest pane can +// scroll through without paging. +const transcriptMax = 60 + +// The row kinds. A kind is resolved by glyphCell into a styled glyph, and an +// unrecognised one is rendered as written — which means a mistyped literal +// draws itself into the transcript instead of failing, so the vocabulary is +// named here and every caller spells it from this list. The empty kind ("") is +// a row with no glyph and needs no name. +const ( + kindCmd = "$" // an echoed command line + kindOK = "ok" + kindWarn = "warn" + kindBad = "bad" + kindNote = "·" +) + +// TranscriptRow is one line of command output. Glyph is a kind, not a +// character: the theme resolves it, so the same row reads correctly under +// --ascii and under NO_COLOR. +type TranscriptRow struct { + Glyph string + Text string + Dim bool + Bold bool +} + +// Transcript is the home screen's bounded output history. +// +// dropped is the honest half of the ring: when rows fall off the top the view +// says so. Silent truncation reads as "this is everything", which is exactly +// the wrong thing to believe while reading a gateway's output. +type Transcript struct { + rows []TranscriptRow + dropped bool +} + +// Push appends rows, dropping the oldest past the ring bound and recording +// that it did so, so the view can say output was lost. +func (tr *Transcript) Push(rows ...TranscriptRow) { + tr.rows = append(tr.rows, rows...) + if n := len(tr.rows) - transcriptMax; n > 0 { + tr.rows = append(tr.rows[:0], tr.rows[n:]...) + tr.dropped = true + } +} + +// Reset empties the ring, drop marker included: a cleared transcript has +// dropped nothing. +func (tr *Transcript) Reset() { *tr = Transcript{} } + +// Len reports how many rows are currently retained. +func (tr *Transcript) Len() int { return len(tr.rows) } + +// view renders exactly h lines at most w cells wide — the pane's contract, and +// what keeps the composer anchored to the bottom of the screen. +func (tr *Transcript) view(th theme.Theme, w, h int) string { + if h <= 0 { + return "" + } + rows, clipped := tr.rows, tr.dropped + if len(rows) > h { + clipped = true + } + if clipped { + // The marker costs a line, so the window it announces is one shorter. + if len(rows) > h-1 { + rows = rows[len(rows)-max(h-1, 0):] + } + } + + lines := make([]string, 0, h) + if clipped { + lines = append(lines, dropMarker(th, w)) + } + gw := glyphWidth(th) + for _, r := range rows { + lines = append(lines, r.render(th, w, gw)) + } + for len(lines) < h { + lines = append(lines, "") + } + return strings.Join(lines[:h], "\n") +} + +func (r TranscriptRow) render(th theme.Theme, w, gw int) string { + text := th.Text + switch r.Glyph { + case kindBad: + text = th.Bad + case kindWarn: + text = th.Warn + } + if r.Dim { + text = th.Dim + } + if r.Bold { + text = text.Bold(th.Mode.Color) + } + line := padRight(glyphCell(th, r.Glyph), gw) + " " + text.Render(table.SanitizeCell(r.Text)) + // A row wider than the pane is cut, never wrapped — wrapping would break + // the pane's line count and every frame below it. The marker is what keeps + // the cut from reading as the end of the row. + return ansi.Truncate(line, w, cutMark(th)) +} + +func cutMark(th theme.Theme) string { + if th.Mode.ASCII { + return ">" + } + return "…" +} + +// glyphCell resolves a row's kind into a styled glyph. An unrecognised kind is +// rendered as written, which is how kindCmd and any future literal marker work. +func glyphCell(th theme.Theme, kind string) string { + g := th.Mode.Glyphs() + switch kind { + case "": + return "" + case kindOK: + return th.OK.Render(g.OK) + case kindWarn: + return th.Warn.Render(g.Warn) + case kindBad: + return th.Bad.Render(g.Bad) + case kindNote: + return th.Dim.Render(g.None) + default: + return th.Dim.Render(kind) + } +} + +// glyphWidth is the glyph column: one cell for the unicode set, four for the +// ASCII set's widest member ("[OK]"). Measured, never assumed — and measured +// over EVERYTHING glyphCell can put in the column, which is the four mapped +// glyphs plus the literal kinds it renders as written. A set that widens a +// glyph nothing measured shifts every row's text by the difference. +func glyphWidth(th theme.Theme) int { + g := th.Mode.Glyphs() + return max( + lipgloss.Width(g.OK), lipgloss.Width(g.Bad), + lipgloss.Width(g.Warn), lipgloss.Width(g.None), + lipgloss.Width(kindCmd), 1) +} + +func dropMarker(th theme.Theme, w int) string { + label := " older output dropped " + rule := "─" + if th.Mode.ASCII { + rule = "-" + } + fill := max((w-lipgloss.Width(label))/2, 1) + side := strings.Repeat(rule, fill) + return ansi.Truncate(th.Faint.Render(side)+th.Dim.Render(label)+th.Faint.Render(side), w, "") +} diff --git a/internal/tui/transcript_test.go b/internal/tui/transcript_test.go new file mode 100644 index 0000000..2ae6d3a --- /dev/null +++ b/internal/tui/transcript_test.go @@ -0,0 +1,123 @@ +package tui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + + "github.com/ferro-labs/gateway-cli/internal/tui/theme" +) + +func TestTranscriptRingAndDropMarker(t *testing.T) { + var tr Transcript + for i := range 70 { + tr.Push(TranscriptRow{Text: "row " + string(rune('a'+i%26))}) + } + if tr.Len() != transcriptMax { + t.Fatalf("the transcript is a %d-row ring, got %d", transcriptMax, tr.Len()) + } + v := tr.view(theme.New(theme.Mode{}), 60, 80) + if !strings.Contains(v, "older output dropped") { + t.Fatalf("dropped rows must be announced, not silently swallowed:\n%s", v) + } +} + +// A window narrower than the transcript hides rows too, and that is the same +// lie: what is on screen is not everything. +func TestTranscriptMarksAWindowThatClips(t *testing.T) { + var tr Transcript + for i := range 10 { + tr.Push(TranscriptRow{Text: "row " + string(rune('a'+i))}) + } + th := theme.New(theme.Mode{}) + if v := tr.view(th, 60, 10); strings.Contains(v, "older output dropped") { + t.Fatalf("a window that shows everything must not claim otherwise:\n%s", v) + } + v := tr.view(th, 60, 4) + if !strings.Contains(v, "older output dropped") { + t.Fatalf("a clipped window must say so:\n%s", v) + } + if !strings.Contains(v, "row j") { + t.Fatalf("a clipped window keeps the newest rows:\n%s", v) + } +} + +// The pane's height is the shell's anchor for the composer: exactly h lines, +// whatever the transcript holds. +func TestTranscriptViewIsExactlyHLines(t *testing.T) { + th := theme.New(theme.Mode{}) + for _, rows := range []int{0, 3, 40} { + var tr Transcript + for i := range rows { + tr.Push(TranscriptRow{Text: strings.Repeat("wide ", i)}) + } + for _, h := range []int{1, 2, 8, 20} { + if got := len(strings.Split(tr.view(th, 50, h), "\n")); got != h { + t.Fatalf("view(%d rows, h=%d) returned %d lines", rows, h, got) + } + } + } + if tr := (Transcript{}); tr.view(th, 50, 0) != "" { + t.Fatal("a zero-height pane renders nothing") + } +} + +func TestTranscriptGlyphColumnAndWidth(t *testing.T) { + for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} { + th := theme.New(mode) + g := mode.Glyphs() + var tr Transcript + tr.Push( + TranscriptRow{Glyph: "$", Text: "ferro status"}, + TranscriptRow{Glyph: "ok", Text: "gateway ready"}, + TranscriptRow{Glyph: "bad", Text: "unknown command"}, + TranscriptRow{Glyph: "warn", Text: "degraded"}, + TranscriptRow{Text: strings.Repeat("long ", 40), Dim: true}, + ) + v := tr.view(th, 40, 6) + for _, want := range []string{"$", g.OK, g.Bad, g.Warn, cutMark(th)} { + if !strings.Contains(v, want) { + t.Fatalf("mode=%+v glyph %q missing:\n%s", mode, want, v) + } + } + for i, line := range strings.Split(v, "\n") { + if lw := lipgloss.Width(line); lw > 40 { + t.Fatalf("mode=%+v line %d overflows: %d cells %q", mode, i, lw, line) + } + } + } +} + +func TestTranscriptResetClearsTheDropMarker(t *testing.T) { + var tr Transcript + for range 70 { + tr.Push(TranscriptRow{Text: "x"}) + } + tr.Reset() + if tr.Len() != 0 { + t.Fatalf("reset must empty the ring, got %d", tr.Len()) + } + if v := tr.view(theme.New(theme.Mode{}), 40, 5); strings.Contains(v, "older output dropped") { + t.Fatalf("a cleared transcript has dropped nothing:\n%s", v) + } +} + +// The transcript renders the gateway's own words — an api.Error message reaches +// it whole. A newline in one must not add a line to a pane that owes exactly h, +// and an ESC in one must not reach the terminal as a live sequence. +func TestTranscriptRowWithGatewayControlCharactersKeepsTheLineCount(t *testing.T) { + th := theme.New(theme.Mode{}) + var tr Transcript + tr.Push(TranscriptRow{Glyph: kindBad, Text: "gateway: upstream\nrefused \x1b[2J"}) + + for _, h := range []int{1, 5, 12} { + out := tr.view(th, 60, h) + if got := strings.Count(out, "\n") + 1; got != h { + t.Fatalf("view(h=%d) returned %d lines: %q", h, got, out) + } + if strings.Contains(out, "\x1b") { + t.Fatalf("view(h=%d) forwarded an escape sequence: %q", h, out) + } + } +} diff --git a/internal/tui/verbs_cobra_test.go b/internal/tui/verbs_cobra_test.go new file mode 100644 index 0000000..dae4a70 --- /dev/null +++ b/internal/tui/verbs_cobra_test.go @@ -0,0 +1,80 @@ +// This file is deliberately in the external test package: internal/command is +// the package that hands bare `ferro` off to internal/tui, so internal/tui must +// never import it back. An external test may, which is how the TUI's vocabulary +// gets asserted against the real scriptable tree with no import cycle. +package tui_test + +import ( + "bytes" + "slices" + "strings" + "testing" + + "github.com/ferro-labs/gateway-cli/internal/command" + "github.com/ferro-labs/gateway-cli/internal/tui" +) + +// The composer completes what the CLI can actually run. If a verb is added, +// renamed or removed in internal/command, this is where the TUI finds out. +func TestVerbsMatchTheScriptableTree(t *testing.T) { + v := tui.Verbs(command.NewRoot()) + for _, want := range []string{ + "status", "keys", "keys create", "keys rotate", "keys revoke", + "logs", "logs tail", "logs stats", "models", "providers", + "mcp", "plugins", "sessions", "audit", + "services", "chat", "version", + } { + if !slices.Contains(v, want) { + t.Fatalf("the TUI vocabulary has drifted from the cobra tree: missing %q\ngot %v", want, v) + } + } + if slices.Contains(v, "completion") || slices.Contains(v, "help completion") { + t.Fatalf("shell-plumbing commands are not TUI verbs, got %v", v) + } +} + +// tui.TableRows and command.Printer.Table both call internal/table's shared +// layout now (table.Rows and table.Write, the latter also underneath the +// former), so the two sides of this comparison can no longer drift on their +// own — the whole point of moving the layout into that leaf package. This +// test earns its keep anyway, cheaply, as the trip-wire against the failure +// mode that motivated the move: it fails the moment either call site stops +// routing through internal/table and grows a layout — or a special case — of +// its own again, which is exactly how the two copies drifted before. The +// Dim-row assertions below are independent of the parity question and still +// exercise the console's own furniture-vs-output contract. +func TestConsoleTableMatchesThePipedTable(t *testing.T) { + headers := []string{"NAME", "READY", "REQUIRED", "LAST ERROR"} + cells := [][]string{ + {"filesystem", "yes", "no", "-"}, + {"search", "no", "yes", "dial tcp: connection refused"}, + {"a-considerably-longer-server-name", "yes", "no", "-"}, + } + + var buf bytes.Buffer + (&command.Printer{Out: &buf}).Table(headers, cells) + piped := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + for i, line := range piped { + // The console trims the trailing padding the last column carries; + // nothing else about the two layouts may differ. + piped[i] = strings.TrimRight(line, " ") + } + + rows := tui.TableRows(headers, cells) + console := make([]string, 0, len(rows)) + for _, r := range rows { + console = append(console, r.Text) + } + + if !slices.Equal(console, piped) { + t.Fatalf("the console table has drifted from the piped one:\nconsole: %q\npiped: %q", console, piped) + } + if len(rows) < 2 || !rows[0].Dim || !rows[1].Dim { + t.Fatalf("the heading and its rule are furniture and must be dimmed, got %+v", rows[:min(2, len(rows))]) + } + for _, r := range rows[2:] { + if r.Dim { + t.Fatalf("a data row is the output, not furniture: %+v", r) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..7a037b5 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,83 @@ +// Package version holds build metadata injected via -ldflags, mirroring the +// gateway's internal/version so release tooling stays uniform across the two +// projects. What the linker did not stamp is recovered from the build info the +// toolchain embeds anyway — see fillFromBuildInfo. +package version + +import ( + "fmt" + "runtime/debug" +) + +// Version, Commit, and Date are overwritten at link time by the release build. +var ( + Version = devVersion + Commit = noCommit + Date = noDate +) + +// The values a build with no -ldflags starts from. They double as the marker +// that the linker stamped nothing, which is what lets fillFromBuildInfo tell +// "not set" from "set to something that happens to look unhelpful". +const ( + devVersion = "dev" + noCommit = "none" + noDate = "unknown" +) + +func init() { fillFromBuildInfo(debug.ReadBuildInfo) } + +// fillFromBuildInfo recovers what the linker did not set. +// +// `go install github.com/ferro-labs/gateway-cli/cmd/ferro@v0.1.0` is the +// install path the README documents first, and go install applies no ldflags — +// so without this, everyone who followed the README reports +// "dev (commit none, built unknown)" and cannot tell a bug report apart from +// any other. The module version is in the build info for that build, and a +// build from a checkout carries the revision and time as vcs settings. +// +// A stamped field is never overwritten: what the release build says is what +// ferro reports, so a goreleaser binary and this fallback can never disagree. +// read is a parameter so a test can supply build info a test binary cannot have. +func fillFromBuildInfo(read func() (*debug.BuildInfo, bool)) { + bi, ok := read() + if !ok { + return + } + // "(devel)" is what a local build reports for its own module; it says less + // than "dev" already does and would only obscure that nothing stamped this. + if Version == devVersion && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + Version = bi.Main.Version + } + // Read all three before deciding: the settings arrive in no promised order, + // and vcs.modified changes what vcs.revision is allowed to claim. + var revision, when string + var dirty bool + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + revision = s.Value + case "vcs.time": + when = s.Value + case "vcs.modified": + dirty = s.Value == "true" + } + } + if Commit == noCommit && revision != "" { + if dirty { + // The worktree had uncommitted changes, so this commit does not + // describe the binary. Say so rather than name a tree nobody can + // check out to reproduce it. + revision += "-dirty" + } + Commit = revision + } + if Date == noDate && when != "" { + Date = when + } +} + +// String renders the full build stamp for `ferro version`. +func String() string { + return fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, Date) +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..62e634f --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,93 @@ +package version + +import ( + "runtime/debug" + "strings" + "testing" +) + +// reset returns the package vars to their unstamped defaults for the duration +// of one test. They are package state written by init(), and this test binary's +// own build info has already run through it. +func reset(t *testing.T) { + t.Helper() + old := [3]string{Version, Commit, Date} + t.Cleanup(func() { Version, Commit, Date = old[0], old[1], old[2] }) + Version, Commit, Date = devVersion, noCommit, noDate +} + +func buildInfo(mainVersion string, settings ...debug.BuildSetting) func() (*debug.BuildInfo, bool) { + return func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Version: mainVersion}, Settings: settings}, true + } +} + +// The release build is the one that must never be second-guessed: goreleaser +// stamps a tag and a commit, and a binary built from a checkout also carries +// vcs settings that disagree with them. +func TestLdflagsValuesWin(t *testing.T) { + reset(t) + Version, Commit, Date = "1.2.3", "abc1234", "2026-08-16T00:00:00Z" + fillFromBuildInfo(buildInfo("v9.9.9", + debug.BuildSetting{Key: "vcs.revision", Value: "deadbeef"}, + debug.BuildSetting{Key: "vcs.time", Value: "1999-01-01T00:00:00Z"})) + if got := String(); got != "1.2.3 (commit abc1234, built 2026-08-16T00:00:00Z)" { + t.Fatalf("the linker's values must survive the fallback, got %q", got) + } +} + +// The README's primary install path: go install applies no ldflags, but the +// module version it resolved is in the build info. +func TestGoInstallVersionFillsTheDevDefault(t *testing.T) { + reset(t) + fillFromBuildInfo(buildInfo("v0.1.0")) + if Version != "v0.1.0" { + t.Fatalf("go install must not report %q", Version) + } + // go install builds from the module cache, which has no VCS to read. + if Commit != noCommit || Date != noDate { + t.Fatalf("nothing supplied a commit or date: %q %q", Commit, Date) + } +} + +func TestVCSSettingsFillCommitAndDate(t *testing.T) { + reset(t) + fillFromBuildInfo(buildInfo("(devel)", + debug.BuildSetting{Key: "vcs.revision", Value: "deadbeef"}, + debug.BuildSetting{Key: "vcs.time", Value: "2026-08-16T12:00:00Z"})) + // "(devel)" carries no more information than the default it would replace. + if Version != devVersion { + t.Fatalf("(devel) must not become the reported version: %q", Version) + } + if Commit != "deadbeef" || Date != "2026-08-16T12:00:00Z" { + t.Fatalf("vcs settings ignored: %q %q", Commit, Date) + } +} + +// A commit from a dirty worktree does not describe the binary, and a bug report +// quoting it would send somebody to the wrong tree. +func TestDirtyWorktreeIsMarked(t *testing.T) { + reset(t) + fillFromBuildInfo(buildInfo("", + debug.BuildSetting{Key: "vcs.modified", Value: "true"}, + debug.BuildSetting{Key: "vcs.revision", Value: "deadbeef"})) + if Commit != "deadbeef-dirty" { + t.Fatalf("an uncommitted build must say so: %q", Commit) + } +} + +// `go run .` in a sandbox with no VCS and no ldflags. The stamp is useless but +// it still has to be a line, not three gaps in a sentence. +func TestNoLdflagsAndNoBuildInfoStillRenders(t *testing.T) { + reset(t) + fillFromBuildInfo(func() (*debug.BuildInfo, bool) { return nil, false }) + got := String() + if got != "dev (commit none, built unknown)" { + t.Fatalf("unstamped builds have a fixed rendering, got %q", got) + } + for _, part := range strings.Fields(got) { + if part == "" || part == "()" { + t.Fatalf("no field may render empty: %q", got) + } + } +} diff --git a/itest/itest_test.go b/itest/itest_test.go new file mode 100644 index 0000000..3a18769 --- /dev/null +++ b/itest/itest_test.go @@ -0,0 +1,489 @@ +//go:build integration + +// Package itest checks ferro's typed client against a running gateway. +// A decode failure indicates that the client is incompatible with the +// gateway response. +// +// Every test skips cleanly when FERRO_ITEST_URL is unset, so +// `go test -tags integration ./itest/` is always safe to run. Boot a gateway +// and run the suite with ./scripts/with-gateway.sh. +package itest + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/ferro-labs/gateway-cli/internal/api" +) + +func client(t *testing.T) *api.Client { + t.Helper() + u := os.Getenv("FERRO_ITEST_URL") + if u == "" { + t.Skip("FERRO_ITEST_URL not set") + } + c, err := api.New(u, os.Getenv("FERRO_ITEST_KEY")) + if err != nil { + t.Fatal(err) + } + return c +} + +// raw fetches a path with no decoding, so a failure can quote what the gateway +// actually sent rather than only how the typed decode objected to it. +func raw(t *testing.T, p string) (int, []byte) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + strings.TrimRight(os.Getenv("FERRO_ITEST_URL"), "/")+p, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "application/json") + if k := os.Getenv("FERRO_ITEST_KEY"); k != "" { + req.Header.Set("Authorization", "Bearer "+k) + } + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + t.Fatalf("GET %s: %v", p, err) + } + defer func() { _ = resp.Body.Close() }() + b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + t.Fatalf("GET %s: read body: %v", p, err) + } + return resp.StatusCode, b +} + +func TestStatusAgainstRealGateway(t *testing.T) { + r, err := client(t).Status(context.Background()) + if err != nil { + t.Fatalf("status: %v (report %+v)", err, r) + } + // Providers is deliberately NOT asserted non-zero: a gateway booted with no + // provider credentials reports no_providers and zero targets, which is the + // state this suite runs in by default. + if r.State == "unreachable" { + t.Fatalf("expected a live gateway, got %+v", r) + } + if r.URL == "" || r.State == "" { + t.Fatalf("status report is not usable: %+v", r) + } + // Targets is nil when /readyz answered 503 — its not_ready body carries no + // targets array at all. That is the default state of this harness (no + // provider credentials), so this is the common path here, not the edge one. + targets := "—" + if r.Targets != nil { + targets = fmt.Sprintf("%d/%d", r.Targets.Routable, r.Targets.Total) + } + t.Logf("status: state=%s providers=%d models=%d targets=%s auth=%q warnings=%v", + r.State, r.Providers, r.Models, targets, r.Auth, r.Warnings) +} + +// A gateway with no routable targets must never report a target count. The +// client cannot know one: /readyz 503s with only {status, reason}. Reporting +// 0/0 would read as "none configured" when the truth is "none routable", and +// this suite runs in exactly that state, so the assertion is always live. +func TestNotReadyGatewayReportsNoTargetCount(t *testing.T) { + r, err := client(t).Status(context.Background()) + if err != nil { + t.Fatal(err) + } + ready, status, err := client(t).Ready(context.Background()) + if err != nil { + t.Fatal(err) + } + if status == http.StatusServiceUnavailable { + if len(ready.Targets) != 0 { + t.Fatalf("a not_ready body must carry no targets, got %d", len(ready.Targets)) + } + if r.Targets != nil { + t.Fatalf("Status must leave Targets nil when the gateway reported none, got %+v", r.Targets) + } + if len(r.Warnings) == 0 { + t.Fatal("a not-ready gateway must explain itself in warnings") + } + } +} + +func TestKeyLifecycle(t *testing.T) { + c := client(t) + if os.Getenv("FERRO_ITEST_KEY") == "" { + t.Skip("needs admin key") + } + ctx := context.Background() + exp := time.Now().Add(1 * time.Hour).UTC() + created, err := c.CreateKey(ctx, api.KeyCreateRequest{ + Name: "ferro-itest", Scopes: []string{"read_only"}, ExpiresAt: &exp, + }) + if err != nil { + t.Fatal(err) + } + // Deferred so a failure below never leaves a live credential behind. + defer func() { _ = c.RevokeKey(ctx, created.ID) }() + + if !strings.HasPrefix(created.Key, "fgw_") || strings.Contains(created.Key, "...") { + t.Fatalf("create response did not contain a valid one-time secret (length=%d, masked=%v)", + len(created.Key), strings.Contains(created.Key, "...")) + } + if created.ID == "" || api.KeyState(*created) != "active" { + t.Fatalf("created key is not usable: id_present=%v state=%s", created.ID != "", api.KeyState(*created)) + } + + got, err := c.Key(ctx, created.ID) + if err != nil { + t.Fatalf("read back %s: %v", created.ID, err) + } + if got.Key == created.Key || strings.Contains(got.Key, strings.TrimPrefix(created.Key, "fgw_")) { + t.Fatalf("read-back leaked the full one-time secret for key id %s", created.ID) + } + if !strings.Contains(got.Key, "...") { + t.Fatalf("read-back must be masked for key id %s", created.ID) + } + if s := api.KeyState(*got); s != "active" { + t.Fatalf("state after create = %q, want active for key id %s", s, created.ID) + } + + rotated, err := c.RotateKey(ctx, created.ID) + if err != nil { + t.Fatalf("rotate: %v", err) + } + if !strings.HasPrefix(rotated.Key, "fgw_") || strings.Contains(rotated.Key, "...") { + t.Fatalf("rotate response did not contain a valid one-time secret (length=%d, masked=%v)", + len(rotated.Key), strings.Contains(rotated.Key, "...")) + } + if rotated.Key == created.Key { + t.Fatal("rotate returned the previous secret") + } + + if err := c.RevokeKey(ctx, created.ID); err != nil { + t.Fatalf("revoke: %v", err) + } + after, err := c.Key(ctx, created.ID) + if err != nil { + if !api.IsNotSupported(err) { // a store that deletes on revoke would 404 + t.Fatalf("read back after revoke: %v", err) + } + return + } + if s := api.KeyState(*after); s != "revoked" { + t.Fatalf("state after revoke = %q, want revoked for key id %s", s, created.ID) + } +} + +// TestAdminKeysWireShape asserts the two shapes internal/fixture hard-codes. +// A disagreement here means the local fixture must be reconciled with the +// running gateway before it can support other CLI tests. +func TestAdminKeysWireShape(t *testing.T) { + c := client(t) + if os.Getenv("FERRO_ITEST_KEY") == "" { + t.Skip("needs admin key") + } + + status, body := raw(t, "/admin/keys") + if status != http.StatusOK { + t.Fatalf("GET /admin/keys = %d: %s", status, body) + } + var arr []json.RawMessage + if err := json.Unmarshal(body, &arr); err != nil { + t.Fatalf("/admin/keys is not a bare array (%v).\nredacted JSON: %s", err, safeBody(body)) + } + + ctx := context.Background() + created, err := c.CreateKey(ctx, api.KeyCreateRequest{Name: "ferro-itest-shape", Scopes: []string{"read_only"}}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.RevokeKey(ctx, created.ID) }() + + listed, err := c.Keys(ctx) + if err != nil { + t.Fatalf("list keys: %v", err) + } + var found *api.Key + for i := range listed { + if listed[i].ID == created.ID { + found = &listed[i] + break + } + } + if found == nil { + t.Fatalf("created key %s is missing from the listing (%d rows)", created.ID, len(listed)) + } + if found.Key == created.Key { + t.Fatalf("SECRET LEAK: /admin/keys serves the full secret for %s", created.ID) + } + if !strings.Contains(found.Key, "...") { + t.Fatalf("listed key %s is not masked head...tail", created.ID) + } +} + +// contract is one endpoint of the parity table. +type contract struct { + path string + optional bool // may answer 404/501 on a build without the feature + call func(context.Context, *api.Client) (any, error) +} + +// TestContractParity calls every endpoint ferro reads through the typed client +// and asserts it decodes. A decode failure means internal/api's types have +// drifted from the gateway — the whole reason this suite exists. +func TestContractParity(t *testing.T) { + c := client(t) + + cases := []contract{ + {path: "/health", call: func(ctx context.Context, c *api.Client) (any, error) { + r, _, err := c.Health(ctx) + return r, err + }}, + {path: "/readyz", call: func(ctx context.Context, c *api.Client) (any, error) { + r, _, err := c.Ready(ctx) + return r, err + }}, + {path: "/admin/health", call: func(ctx context.Context, c *api.Client) (any, error) { + return c.AdminHealth(ctx) + }}, + {path: "/v1/models", call: func(ctx context.Context, c *api.Client) (any, error) { + return c.Models(ctx) + }}, + {path: "/admin/keys", call: func(ctx context.Context, c *api.Client) (any, error) { + return c.Keys(ctx) + }}, + {path: "/admin/logs", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) { + return c.Logs(ctx, api.LogsQuery{Limit: 5}) + }}, + {path: "/admin/logs/stats", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) { + return c.LogStats(ctx, api.LogsQuery{}) + }}, + {path: "/admin/plugins", call: func(ctx context.Context, c *api.Client) (any, error) { + return c.Plugins(ctx) + }}, + {path: "/admin/plugins/catalog", call: func(ctx context.Context, c *api.Client) (any, error) { + return c.PluginCatalog(ctx) + }}, + {path: "/admin/sessions", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) { + return c.Sessions(ctx) + }}, + {path: "/admin/audit", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) { + return c.Audit(ctx, api.AuditQuery{Limit: 5}) + }}, + } + + authed := os.Getenv("FERRO_ITEST_KEY") != "" + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // /admin/* and /v1/* sit behind the same bearer middleware, so + // without a credential they answer 401 — which proves nothing about + // the payload contract this test exists to check. + if !authed && tc.path != "/health" && tc.path != "/readyz" { + t.Skip("needs FERRO_ITEST_KEY") + } + // The wire bytes, not the round-tripped struct: a decoded struct + // cannot show a field internal/api silently drops, and the point of + // this suite is what the gateway actually sends. + status, body := raw(t, tc.path) + t.Logf("HTTP %d %s", status, safeBody(body)) + + _, err := tc.call(ctx, c) + switch { + case err == nil: + case tc.optional && api.IsNotSupported(err): + t.Logf("absent on this build (expected for an optional endpoint): %v", err) + default: + t.Fatalf("typed client could not read %s: %v\nHTTP %d, redacted JSON: %s", + tc.path, err, status, safeBody(body)) + } + }) + } +} + +// TestPluginCatalogIsPopulated pins the one parity case an empty decode hides: +// /admin/plugins/catalog is fixed for the life of the binary and never empty, +// so a successful decode of zero rows means the envelope moved. +func TestPluginCatalogIsPopulated(t *testing.T) { + c := client(t) + if os.Getenv("FERRO_ITEST_KEY") == "" { + t.Skip("needs admin key") + } + got, err := c.PluginCatalog(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + _, body := raw(t, "/admin/plugins/catalog") + t.Fatalf("catalog decoded to zero plugins — the {\"data\":…} envelope has moved.\nredacted JSON: %s", safeBody(body)) + } + for _, p := range got { + if p.Name == "" || p.Type == "" { + t.Fatalf("catalog row is missing name/type: %+v", p) + } + } + t.Logf("catalog: %d built-in plugins", len(got)) +} + +func TestChatStreamAndAttribution(t *testing.T) { + model := os.Getenv("FERRO_ITEST_CHAT_MODEL") + if model == "" { + t.Skip("FERRO_ITEST_CHAT_MODEL not set (needs live provider credentials)") + } + c := client(t) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + start := time.Now().Add(-2 * time.Minute) + st, err := c.StreamChat(ctx, api.ChatRequest{ + Model: model, + Messages: []api.ChatMessage{{Role: "user", Content: "Reply with the word ok."}}, + }) + if err != nil { + t.Fatal(err) + } + defer st.Cancel() + + var text strings.Builder + var sawUsage, sawDone bool + for ev := range st.Events { + switch { + case ev.Err != nil: + t.Fatalf("stream error: %v", ev.Err) + case ev.Done: + sawDone = true + case ev.Chunk != nil: + if ev.Chunk.Usage != nil { + sawUsage = true + } + for _, ch := range ev.Chunk.Choices { + text.WriteString(ch.Delta.Content) + } + } + } + if !sawDone || !sawUsage || text.Len() == 0 { + t.Fatalf("done=%v usage=%v answer_bytes=%d", sawDone, sawUsage, text.Len()) + } + + probeCtx, cancelProbe := context.WithTimeout(ctx, 5*time.Second) + _, err = c.Logs(probeCtx, api.LogsQuery{Limit: 1}) + cancelProbe() + if api.IsNotSupported(err) { + return + } else if err != nil { + t.Fatalf("probe request-log store: %v", err) + } + deadline := time.Now().Add(5 * time.Second) + for { + attributionCtx, cancelAttribution := context.WithTimeout(ctx, 5*time.Second) + entry, err := c.TraceAttribution(attributionCtx, st.RequestID, start) + cancelAttribution() + if err != nil { + t.Fatal(err) + } + if entry != nil { + if entry.Provider == "" { + t.Fatalf("matched log row should attribute a provider: trace=%s", entry.TraceID) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("request-log store is enabled but trace %s was not attributed within 5s", st.RequestID) + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(250 * time.Millisecond): + } + } +} + +func safeBody(b []byte) string { + var value any + if json.Unmarshal(b, &value) != nil { + return fmt.Sprintf("<%d non-JSON bytes>", len(b)) + } + redact(value) + b, _ = json.Marshal(value) + const max = 600 + s := strings.TrimSpace(string(b)) + if len(s) > max { + return s[:max] + "… (truncated)" + } + return s +} + +func TestSafeBodyRedactsCompoundCredentials(t *testing.T) { + got := safeBody([]byte(`{ + "access_token":"one", + "nested":{"refresh_token":"two","client_secret":"three"}, + "items":[{"private_key":"four"}], + "apiKey":"five","X-API-Key":"six","sessionToken":"seven", + "credential":"eight","credentials":"nine", + "note":"visible" + }`)) + for _, secret := range []string{"one", "two", "three", "four", "five", "six", "seven", "eight", "nine"} { + if strings.Contains(got, secret) { + t.Fatalf("safeBody leaked %q: %s", secret, got) + } + } + if strings.Count(got, "[REDACTED]") != 9 || !strings.Contains(got, "visible") { + t.Fatalf("every compound credential field must be redacted: %s", got) + } +} + +// TestSafeBodyRedactsFgwSecretUnderAnyFieldName pins the value-based catch: a +// live gateway can drift a master key onto a field name this vocabulary has +// never seen, but every key this gateway issues is fgw_-prefixed regardless +// of where it lands (newSecret, internal/fixture/keys.go). +func TestSafeBodyRedactsFgwSecretUnderAnyFieldName(t *testing.T) { + got := safeBody([]byte(`{"unexpected_field":"fgw_wildcardvalue","note":"visible"}`)) + if strings.Contains(got, "fgw_wildcardvalue") { + t.Fatalf("safeBody leaked a fgw_ secret under an unlisted field name: %s", got) + } + if strings.Count(got, "[REDACTED]") != 1 || !strings.Contains(got, "visible") { + t.Fatalf("fgw_-prefixed value must be redacted regardless of field name: %s", got) + } +} + +func redact(value any) { + switch value := value.(type) { + case map[string]any: + for key, child := range value { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + switch normalized { + // credential/credentials joins this list because it is this + // codebase's own word for the secret (AcceptKey, defaultKey in + // internal/fixture/state.go) even though the wire field is + // spelled "key". + case "key", "apikey", "xapikey", "privatekey", + "secret", "clientsecret", "token", "accesstoken", + "refreshtoken", "sessiontoken", "authorization", + "credential", "credentials": + value[key] = "[REDACTED]" + default: + if s, ok := child.(string); ok && strings.HasPrefix(s, "fgw_") { + // Every master key this gateway issues is fgw_-prefixed + // (newSecret, internal/fixture/keys.go). raw() dumps a + // live gateway's actual bytes, not internal/api's + // modeled shape, so matching the value catches a secret + // riding under a field name this vocabulary has never + // seen. + value[key] = "[REDACTED]" + } else { + redact(child) + } + } + } + case []any: + for _, child := range value { + redact(child) + } + } +} diff --git a/scripts/check-module-boundary.sh b/scripts/check-module-boundary.sh new file mode 100755 index 0000000..6308745 --- /dev/null +++ b/scripts/check-module-boundary.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! grep -Fxq 'module github.com/ferro-labs/gateway-cli' go.mod; then + echo 'go.mod must declare module github.com/ferro-labs/gateway-cli' >&2 + exit 1 +fi + +# go list walks the real import graph (covers aliased and blank imports, and +# can't be tripped by a doc comment or string literal naming the module the +# way a text grep can) instead of grepping source text. -tags integration +# pulls itest/ into the graph too, and -test the imports that only _test.go +# files have: a test reaching into the gateway module puts it in go.mod the +# same as any other import, so leaving those 30-odd packages unscanned left +# the one file type most likely to "just import it for a fixture" unchecked. +# Two statements, not one pipeline: `|| true` is needed for grep's "no match" +# exit 1, and on a pipeline it would swallow a go list failure too — passing +# the check because the scan never ran, which is the hole this rewrite closes. +deps="$(go list -tags integration -deps -test ./...)" +offenders="$(printf '%s\n' "$deps" | grep '^github\.com/ferro-labs/ai-gateway' || true)" +if [ -n "$offenders" ]; then + echo "$offenders" >&2 + echo 'gateway-cli must not import AI Gateway packages' >&2 + exit 1 +fi + +if grep -qE '(^|[[:space:]>])github\.com/ferro-labs/ai-gateway([[:space:]/]|$)' go.mod; then + echo 'gateway-cli must not reference the AI Gateway module in go.mod' >&2 + exit 1 +fi diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..89678f4 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Clean-machine sanity for a built ferro binary. Also the manual release gate. +# +# ./scripts/smoke.sh [path-to-binary] +# FERRO_SMOKE_URL=http://localhost:8080 ./scripts/smoke.sh # adds live checks +set -euo pipefail + +bin="${1:-./ferro}" +[ -x "$bin" ] || { echo "FAIL: $bin is not executable (run: make build)" >&2; exit 1; } + +"$bin" version +"$bin" --help >/dev/null + +# Exit codes are the API: an unknown verb must be a non-zero exit, or every +# `ferro ... || alert` in a pipeline silently passes. +if "$bin" definitely-not-a-verb >/dev/null 2>&1; then + echo "FAIL: unknown verb must exit non-zero" >&2 + exit 1 +fi + +# The full-screen console must never appear on a non-TTY stdout. This runs with +# stdout piped, so a bare invocation has to refuse rather than emit escape codes. +command -v timeout >/dev/null || { echo "FAIL: timeout is required for the non-TTY smoke check" >&2; exit 1; } +set +e +bare_output="$(timeout --signal=TERM --kill-after=1s 5s "$bin" /dev/null)" +bare_status=$? +set -e +if [ "$bare_status" -eq 124 ] || [ "$bare_status" -eq 137 ]; then + echo "FAIL: bare ferro blocked on a non-TTY invocation" >&2 + exit 1 +fi +if [ "$bare_status" -eq 0 ]; then + echo "FAIL: bare ferro must refuse a non-TTY invocation" >&2 + exit 1 +fi +if [[ "$bare_output" == *$'\e['* ]]; then + echo "FAIL: bare ferro emitted ANSI on a non-TTY stdout" >&2 + exit 1 +fi + +if [ -n "${FERRO_SMOKE_URL:-}" ]; then + FERRO_URL="$FERRO_SMOKE_URL" "$bin" status + command -v python3 >/dev/null || { echo "FAIL: python3 is required for the live JSON smoke check" >&2; exit 1; } + # stdout must be exactly one JSON document — nothing else may leak into it. + FERRO_URL="$FERRO_SMOKE_URL" "$bin" status --format json | python3 -m json.tool >/dev/null + echo "[OK] live smoke against $FERRO_SMOKE_URL" +fi + +echo "[OK] smoke passed" diff --git a/scripts/with-gateway.sh b/scripts/with-gateway.sh new file mode 100755 index 0000000..f040e51 --- /dev/null +++ b/scripts/with-gateway.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Boot an AI Gateway checkout, run the ferro integration suite against it, and +# always tear it down. +# +# FERRO_GATEWAY_SOURCE=../ai-gateway ./scripts/with-gateway.sh +# +# This is the one check the fixture (cmd/fakegw) cannot perform. The fixture +# proves ferro is correct given a gateway that behaves as documented; only this +# proves the real server agrees. Any divergence found here is a FIXTURE bug: +# fix internal/fixture to match the gateway, then re-run. +# +# No provider credentials are required. The gateway starts with zero targets +# and reports no_providers, which is enough to exercise status, keys, logs, +# sessions, and audit — everything except live chat. +set -euo pipefail + +# Checked up front: the health probe below reads curl's exit status to decide +# the gateway is not up yet, so a missing curl looks exactly like a gateway +# that never starts — a 15-second wait ending in the wrong diagnosis. +command -v curl >/dev/null || { echo "curl is required to probe the gateway" >&2; exit 2; } + +cli="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +gateway_source="${FERRO_GATEWAY_SOURCE:-}" +if [ -z "$gateway_source" ]; then + candidate="$(cd "$cli/.." && pwd)/ai-gateway" + if [ -f "$candidate/go.mod" ] && grep -q '^module github.com/ferro-labs/ai-gateway$' "$candidate/go.mod"; then + gateway_source="$candidate" + else + echo "FERRO_GATEWAY_SOURCE must point to an AI Gateway checkout" >&2 + exit 2 + fi +fi +gateway_source="$(cd "$gateway_source" && pwd)" +work="$(mktemp -d)" +port="${FERRO_ITEST_PORT:-18080}" +gw_pid="" + +# A hex master key of the shape the gateway expects (fgw_ + 32 hex chars). +key="fgw_$(od -An -tx1 -N16 /dev/urandom | tr -d ' \n')" + +cleanup() { + if [ -n "$gw_pid" ]; then + kill "$gw_pid" 2>/dev/null || true + wait "$gw_pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT + +echo "==> building ferrogw from $gateway_source" +(cd "$gateway_source" && go build -o "$work/ferrogw" ./cmd/ferrogw) + +echo "==> scaffolding a throwaway config" +# init writes a config and prints a master key once; we supply our own key via +# the environment instead, so a failure here is not fatal. +(cd "$work" && "$work/ferrogw" init -o "$work/gateway.yaml" --non-interactive >/dev/null 2>&1) \ + || echo " (init declined; starting with an empty config)" + +echo "==> starting gateway on :$port" +# A throwaway SQLite request log. Without it /admin/logs and /admin/logs/stats +# answer 501 and the suite can only prove they are absent -- and those two carry +# the most intricate types in internal/api (nullable duration_ms/ttft_ms/cost_usd, +# a nullable percentile block). One file in the temp dir turns two skipped rows +# into real decode checks. +MASTER_KEY="$key" GATEWAY_CONFIG="$work/gateway.yaml" PORT="$port" \ + REQUEST_LOG_STORE_BACKEND=sqlite REQUEST_LOG_STORE_DSN="$work/requestlog.db" \ + "$work/ferrogw" serve >"$work/gateway.log" 2>&1 & +gw_pid=$! + +# /health answers 503 with {"status":"no_providers"} when no provider credential +# is present -- which is exactly how this harness runs -- so `curl -f` is the +# wrong probe: it reports a serving gateway as dead. Ask for the status code +# instead and accept either answer the endpoint can give. +healthy() { + local code + code="$(curl -sS --connect-timeout 1 --max-time 1 -o /dev/null -w '%{http_code}' "http://localhost:$port/health" 2>/dev/null)" || return 1 + [ "$code" = "200" ] || [ "$code" = "503" ] +} + +startup_deadline=$((SECONDS + 15)) +while (( SECONDS < startup_deadline )); do + # A gateway that died is never coming up; fail fast instead of waiting out + # the whole loop or accepting another process already bound to the port. + if ! kill -0 "$gw_pid" 2>/dev/null; then + echo "gateway exited during startup; log follows:" >&2 + cat "$work/gateway.log" >&2 + exit 1 + fi + if healthy; then + break + fi + sleep 0.25 +done + +if ! healthy; then + echo "gateway did not become healthy within 15s; log follows:" >&2 + cat "$work/gateway.log" >&2 + exit 1 +fi + +echo "==> running the integration suite" +cd "$cli" +FERRO_ITEST_URL="http://localhost:$port" FERRO_ITEST_KEY="$key" \ + go test -race -tags integration ./itest/ "$@"