mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
An op learns its address from zip.OpOf and zip.AddressOf, so the claim table, the five declaring verbs and the address bookkeeping are gone. A host that declares one of these addresses itself now gets a Build conflict from zip. Co-authored-by: Hanzo Dev <[email protected]>
290 lines
9.8 KiB
Go
290 lines
9.8 KiB
Go
package o11y_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/o11y"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// THE CENSUS. This file is the arithmetic that the wildcard used to hide.
|
|
//
|
|
// While Mount ended in app.All("/v1/o11y/*", …) there was no way to tell a
|
|
// converted route from an unconverted one from a MISSING one: the catch-all
|
|
// answered all three the same. That is exactly how 83 typed ops shipped in
|
|
// files nothing called — the package built, the suite passed, and every one of
|
|
// those routes still fell through the wildcard. These tests are the replacement
|
|
// for that catch-all's silence.
|
|
|
|
// registered is every non-HEAD, non-OPTIONS route the mount actually installed.
|
|
func registered(t *testing.T, app *zip.App) map[string]bool {
|
|
t.Helper()
|
|
got := map[string]bool{}
|
|
for _, r := range app.Fiber().GetRoutes(true) {
|
|
if r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
|
continue
|
|
}
|
|
got[r.Method+" "+r.Path] = true
|
|
}
|
|
return got
|
|
}
|
|
|
|
// assertRoutes proves a slice registered EXACTLY its own routes under prefix —
|
|
// no more, no fewer, at the same methods. Each slice counts only its own routes,
|
|
// so a slice that stops being mounted fails here rather than being quietly
|
|
// answered by something else.
|
|
func assertRoutes(t *testing.T, want map[string]bool, prefix string) {
|
|
t.Helper()
|
|
got := map[string]bool{}
|
|
for route := range registered(t, mounted(t)) {
|
|
_, path, _ := strings.Cut(route, " ")
|
|
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
|
got[route] = true
|
|
}
|
|
}
|
|
for route := range want {
|
|
if !got[route] {
|
|
t.Errorf("%s is not registered", route)
|
|
}
|
|
}
|
|
for route := range got {
|
|
if !want[route] {
|
|
t.Errorf("%s is registered but not in the census", route)
|
|
}
|
|
}
|
|
}
|
|
|
|
// THE WHOLE SURFACE, counted. 329 is the number of method+path pairs the o11y
|
|
// runtime registers on its gorilla/mux tree (pkg/query-service/app/routes_*.go
|
|
// and pkg/apiserver/o11yapiserver/*.go). Mount registers the same 329 — 319 as
|
|
// typed ops that carry a named In, a named Out and their prose into the
|
|
// document, 7 as named escape hatches, 3 as native service probes.
|
|
//
|
|
// If this number moves, one of two things happened and both need a human: a
|
|
// route was added to the runtime and not named here (it would 404 in the
|
|
// composed binary), or a route was named here that the runtime does not serve
|
|
// (it would 404 at the runtime). Neither is visible without the count.
|
|
func TestEveryRouteIsNamedAndCounted(t *testing.T) {
|
|
app := mounted(t)
|
|
all := registered(t, app)
|
|
|
|
const (
|
|
wantTyped = 319 // typed ops: in the OpenAPI document and the MCP tool list
|
|
wantHatches = 7 // mount.go mountHatches, each with its reason
|
|
wantProbes = 3 // health.go livez/healthz/readyz, native with fall-through
|
|
)
|
|
if len(all) != wantTyped+wantHatches+wantProbes {
|
|
t.Fatalf("mount registered %d routes, want %d", len(all), wantTyped+wantHatches+wantProbes)
|
|
}
|
|
|
|
// The typed ops are exactly the operations zip published. Counting them off
|
|
// the document rather than off the router is what makes this a payoff
|
|
// measurement and not a tautology: an op that reaches the router but not the
|
|
// document is the defect this whole conversion exists to remove.
|
|
spec := app.OpenAPISpec()
|
|
if got := len(app.MCPTools()); got != wantTyped {
|
|
t.Fatalf("MCP tools = %d, want %d", got, wantTyped)
|
|
}
|
|
paths, ok := spec["paths"].(map[string]map[string]any)
|
|
if !ok {
|
|
t.Fatalf("OpenAPI paths has type %T", spec["paths"])
|
|
}
|
|
ops := 0
|
|
for path, item := range paths {
|
|
if !strings.HasPrefix(path, "/v1/o11y") && !strings.HasPrefix(path, "/v1/o11y/sentinel") {
|
|
t.Errorf("OpenAPI published %s, which is not o11y's surface", path)
|
|
}
|
|
for method := range item {
|
|
switch method {
|
|
case "get", "post", "put", "patch", "delete":
|
|
ops++
|
|
}
|
|
}
|
|
}
|
|
if ops != wantTyped {
|
|
t.Fatalf("OpenAPI operations = %d, want %d", ops, wantTyped)
|
|
}
|
|
}
|
|
|
|
// The table, address by address. The count above says how many routes there
|
|
// are; testdata/routes.txt says which, so a route that moves, disappears or
|
|
// changes its spelling reads as a diff instead of as a count that still adds up.
|
|
func TestRouteTableIsTheCensus(t *testing.T) {
|
|
census, err := os.ReadFile("testdata/routes.txt")
|
|
if err != nil {
|
|
t.Fatalf("read the census: %v", err)
|
|
}
|
|
var got []string
|
|
for route := range registered(t, mounted(t)) {
|
|
got = append(got, route)
|
|
}
|
|
sort.Strings(got)
|
|
if moved := drift(strings.Split(strings.TrimSpace(string(census)), "\n"), got); moved != "" {
|
|
t.Errorf("the route table is not the one in testdata/routes.txt:\n%s", moved)
|
|
}
|
|
}
|
|
|
|
// drift names what the census holds and the mount does not, and the reverse.
|
|
func drift(want, got []string) string {
|
|
held, have := map[string]bool{}, map[string]bool{}
|
|
for _, w := range want {
|
|
held[w] = true
|
|
}
|
|
for _, g := range got {
|
|
have[g] = true
|
|
}
|
|
var out []string
|
|
for _, w := range want {
|
|
if !have[w] {
|
|
out = append(out, " gone: "+w)
|
|
}
|
|
}
|
|
for _, g := range got {
|
|
if !held[g] {
|
|
out = append(out, " new: "+g)
|
|
}
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
// No route may end in a wildcard. This is the invariant the whole change buys:
|
|
// a NEW un-typed route is a 404 someone has to come and justify, not a silent
|
|
// fall-through into the runtime.
|
|
func TestNoCatchAllRemains(t *testing.T) {
|
|
for route := range registered(t, mounted(t)) {
|
|
if strings.HasSuffix(route, "*") || strings.HasSuffix(route, "/+") {
|
|
t.Errorf("%s is a catch-all", route)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A path under /v1/o11y that nothing named is a 404 — proof the catch-all is
|
|
// gone. Under the wildcard this same request reached the runtime, which is why
|
|
// an unmounted slice could go unnoticed.
|
|
func TestUnnamedPathIsNotServed(t *testing.T) {
|
|
app := mounted(t)
|
|
served := false
|
|
setRuntime(t, func(w http.ResponseWriter, r *http.Request) {
|
|
served = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
status, _ := call(t, app, member(http.MethodGet, "/v1/o11y/no-such-thing", nil))
|
|
if status != http.StatusNotFound {
|
|
t.Fatalf("status=%d want 404", status)
|
|
}
|
|
if served {
|
|
t.Fatal("an unnamed path still reached the runtime")
|
|
}
|
|
}
|
|
|
|
// setRuntime installs a stand-in runtime for the duration of the test.
|
|
func setRuntime(t *testing.T, fn http.HandlerFunc) {
|
|
t.Helper()
|
|
o11y.SetRuntime(o11y.Whole(fn))
|
|
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
|
}
|
|
|
|
// THE SEVEN. Each hatch is registered at its exact method and path, and each
|
|
// delegates with the path UNTOUCHED — a rewrite at this seam can only ever move
|
|
// a request off its own route, and a mangler is invisible to the compiler, so
|
|
// this is the only thing that catches one coming back.
|
|
func TestHatchesDelegateVerbatim(t *testing.T) {
|
|
hatches := []struct{ method, path string }{
|
|
{http.MethodGet, "/v1/o11y/logs/livetail"},
|
|
{http.MethodGet, "/v1/o11y/query_progress"},
|
|
{http.MethodPost, "/v1/o11y/export_raw_data"},
|
|
|
|
{http.MethodPost, "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
|
|
{http.MethodPost, "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
|
|
{http.MethodPost, "/v1/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
|
|
{http.MethodPost, "/v1/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
|
|
}
|
|
if len(hatches) != 7 {
|
|
t.Fatalf("the census itself is wrong: %d", len(hatches))
|
|
}
|
|
|
|
app := mounted(t)
|
|
var saw string
|
|
setRuntime(t, func(w http.ResponseWriter, r *http.Request) {
|
|
saw = r.URL.Path
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
for _, h := range hatches {
|
|
t.Run(h.method+" "+h.path, func(t *testing.T) {
|
|
saw = ""
|
|
status, body := call(t, app, member(h.method, h.path, strings.NewReader("{}")))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", status, body)
|
|
}
|
|
if saw != h.path {
|
|
t.Fatalf("runtime received %q, want %q verbatim", saw, h.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A hatch answers 503 rather than 404 while no runtime is registered — the same
|
|
// honest "not initialized yet" the wildcard gave, kept now that each hatch is
|
|
// its own route.
|
|
func TestHatchWithoutRuntimeIs503(t *testing.T) {
|
|
app := mounted(t)
|
|
o11y.SetRuntime(nil)
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/o11y/logs/livetail", nil))
|
|
if err != nil {
|
|
t.Fatalf("Test: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d want 503", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestEveryOperationDeclaresItsOwnId holds what made this package's op target
|
|
// unnecessary.
|
|
//
|
|
// An op with no declared id takes zip's shape-derived one, and that one is
|
|
// qualified by the prefix of the occurrence it answers under — so registering
|
|
// through a group used to publish every undeclared op as "v1.o11y.<derived>",
|
|
// a rename that once cost 353 ids. A struct answering with a fixed prefix was
|
|
// the way around it. Once every op states its id, a declared id survives
|
|
// composition verbatim and the group is just a group; the test that measured
|
|
// that gap reached zero and said so, which is why the struct is gone.
|
|
//
|
|
// This is the same property from the other side: the number that has to stay at
|
|
// zero is the UNDECLARED one.
|
|
func TestEveryOperationDeclaresItsOwnId(t *testing.T) {
|
|
app := mounted(t)
|
|
if err := app.Build(); err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
|
|
var undeclared []string
|
|
for _, op := range app.Registry() {
|
|
if op.OperationID == "" {
|
|
undeclared = append(undeclared, op.Method+" "+op.Path)
|
|
}
|
|
}
|
|
if len(undeclared) != 0 {
|
|
t.Errorf("%d of %d ops declare no id, so composition would rename them: %v",
|
|
len(undeclared), len(app.Registry()), undeclared)
|
|
}
|
|
if len(app.Registry()) != 319 {
|
|
t.Errorf("registry holds %d ops, want the 319 of TestEveryRouteIsNamedAndCounted", len(app.Registry()))
|
|
}
|
|
|
|
// The property itself, on the real mount: nothing this service publishes
|
|
// carries the occurrence qualification.
|
|
for _, tool := range app.MCPTools() {
|
|
name, _ := tool["name"].(string)
|
|
if strings.HasPrefix(name, "v1.") {
|
|
t.Errorf("published name %q is prefix-qualified", name)
|
|
}
|
|
}
|
|
}
|