THE OUTAGE. Every paid completion in the fleet answered 503 balance_unavailable
on v1.801.340 while every subsystem was healthy.
The cloud pod keeps its run directory on a volume, so /var/lib/cloud/run outlives
the pod that wrote it. commerce is a LAZY app — nothing starts it until someone
asks the router to. A previous pod left commerce.sock behind (mtime 23:10, pod
start 23:41, no listener for it in /proc/net/unix) and reach() decided the peer
was up by STAT'ing that file. So commerce was never woken, the dial hit a kernel
with nothing to hand it, and apps/billing read the failure as "this fleet runs no
commerce" and fell through to an HTTP proxy that is unset in this deployment.
ai's balance gate is fail-CLOSED, so an unreadable balance denied everything. The
whole finance surface went with it: /v1/billing/{balance,usage} and
/v1/finance/{balance,usage,ledger,credits} answered 501 to signed-in customers.
It was invisible for three days because the error was dropped on the floor: one
line turned EVERY cloud.Ask failure into "split deploy", so nothing logged the
reason — after that line nothing had it.
1. plane.go — reach() proves a LISTENER; listening() connects, exists() is gone.
A file that outlives its process is not evidence of a peer, and for a lazy app
that is the whole answer: the file suppresses the wake that would have put a
listener behind it, so "here and broken" (learnable from the call) is really
"here and never coming up" (not). ENOENT and ECONNREFUSED are one fact — no
listener — and both are the wake path's business; any other dial error is an
outage and is returned as one. It removes nothing: the listener already unlinks
a stale path when it binds, so the run dir keeps ONE writer.
2. apps/billing — only cloud.ErrNoPeer may be read as "fall back". Ask's own
contract says so; availableCents, coResidentUsage and peerTxns each collapsed
it with every other error. Now a failed read is (ok=true, err) → the existing
s.Log.Warn + 502 "billing upstream unreachable", which NAMES it. peerTxns
grew the error return its caller needed to say that.
3. middleware_identity.go — the isTrustedServiceToken restore of X-Org-Id was
dead code and is deleted, with the predicate that served only it. The
no-verified-principal tail already restores cliOrg unconditionally, and a
service token is not a JWT, so the early copy could never change an outcome.
It also wrote the header as a string literal, which is what
TestEveryHeaderWrittenIsAName had been failing on; that test is green again.
Its reasoning moved to the line that does the work.
TESTS, each red before its fix and green after:
TestWakeStartsALazyAppBehindALeftoverSocket — real router, real lazy child,
real leftover socket: before, "connection refused"; after, the app comes up.
TestStaleSocketIsNotAPeer — a leftover file reaches the SAME answer as no file
(ErrNoPeer), plus TestALiveSocketIsAPeer so the fix is not "always absent".
TestBalance_APlaneOutageIsNotASplitDeploy — a present, broken commerce peer is
502, never a plausible 200 from a proxy nobody asked; and
TestBalance_NoCommerceInTheFleetStillFallsBack keeps the real split deploy.
middleware_identity_s2sorg_test.go now asserts the BOUNDARY. It re-implemented
the token compare in the test file and asserted the copy, so it passed
whatever the middleware did — it could not have caught the org being dropped,
which is the one thing it was written for.
Test scaffolding: the plane's run dir in tests is now a SHORT mkdtemp. A unix
socket path is capped near 104 bytes and t.TempDir() spends most of it on the
test's own name, so 4 tests failed on darwin with "invalid argument" about
something they were not about. Pre-existing and unrelated; fixed because the new
router test cannot run otherwise.
Baseline compared before and after across every plane consumer (apps/admin, ai,
deploy, marketing, marketplace, o11y, platform, projects, tools, x402, credz):
no new failures, none of the pre-existing ones claimed.
150 lines
6.1 KiB
Go
150 lines
6.1 KiB
Go
package cloud_test
|
|
|
|
// plane_stale_test.go — a socket FILE is not a peer.
|
|
//
|
|
// THE OUTAGE. The cloud pod keeps its run directory on a volume, so
|
|
// /var/lib/cloud/run survives the pod that wrote it. commerce is a LAZY app: nothing
|
|
// starts it until someone asks the router to. A previous pod left commerce.sock behind
|
|
// (mtime 23:10, pod start 23:41, and /proc/net/unix listing no listener for it), and
|
|
// reach() decided the peer was up by stat'ing that file. So commerce was never woken,
|
|
// every plane call to it was refused by a kernel with nothing to hand it, and — because
|
|
// apps/billing then read the failure as "this fleet has no commerce" — the prepaid
|
|
// balance read fell through to an unconfigured HTTP proxy. ai's balance gate is
|
|
// fail-CLOSED, so every paid completion in the fleet answered 503 balance_unavailable.
|
|
//
|
|
// One file, three days, and nothing in any log said why.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/cloud"
|
|
"github.com/hanzoai/cloud/plane"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// runDir points the plane at a SHORT directory. A unix socket path is capped at ~104
|
|
// bytes and t.TempDir() spends most of that on the test's own name, so the run dir is
|
|
// where a long test name turns into "bind: invalid argument" — a failure about
|
|
// something the test is not about.
|
|
func runDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "pl")
|
|
if err != nil {
|
|
t.Fatalf("run dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
return dir
|
|
}
|
|
|
|
// leftover writes the socket file a killed process leaves: bound once, never unlinked,
|
|
// with nobody behind it. SetUnlinkOnClose(false) is what makes it a leftover rather
|
|
// than a tidy shutdown — it is the pod that was killed, not the one that stopped.
|
|
func leftover(t *testing.T, app string) string {
|
|
t.Helper()
|
|
path := zip.SocketPath(app)
|
|
ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
|
if err != nil {
|
|
t.Fatalf("bind %s: %v", path, err)
|
|
}
|
|
ln.SetUnlinkOnClose(false)
|
|
if err := ln.Close(); err != nil {
|
|
t.Fatalf("close %s: %v", path, err)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("the leftover socket file is not there: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// TestStaleSocketIsNotAPeer is the regression guard for the outage above.
|
|
//
|
|
// With no router in the process tree, an app that is not deployed here answers
|
|
// ErrNoPeer — that is the developer's case and the split-deploy case alike. The rule
|
|
// under test is that a LEFTOVER SOCKET FILE changes nothing about that answer: the file
|
|
// is not evidence of a listener, so the call must reach exactly the same conclusion it
|
|
// reaches when the file is absent.
|
|
//
|
|
// Before the fix reach() stat'd the path, took the leftover for a live peer, returned
|
|
// nil, and the dial failed with a bare "connection refused" that no caller could tell
|
|
// apart from a peer that answered badly. That is the misread the whole ErrNoPeer
|
|
// distinction exists to prevent, and it arrived through the one door that skipped it.
|
|
func TestStaleSocketIsNotAPeer(t *testing.T) {
|
|
t.Setenv("ZIP_RUNTIME_DIR", runDir(t))
|
|
cloud.ResetPlane()
|
|
|
|
// The control: no file at all is unambiguously ErrNoPeer.
|
|
_, absent := cloud.Ask[plane.BalanceIn, plane.Balance](context.Background(), "commerce",
|
|
plane.FinanceBalance, &plane.BalanceIn{Subject: "acme", Currency: "usd"})
|
|
if !errors.Is(absent, cloud.ErrNoPeer) {
|
|
t.Fatalf("no socket and no router must be ErrNoPeer, got: %v", absent)
|
|
}
|
|
|
|
path := leftover(t, "commerce")
|
|
|
|
_, err := cloud.Ask[plane.BalanceIn, plane.Balance](context.Background(), "commerce",
|
|
plane.FinanceBalance, &plane.BalanceIn{Subject: "acme", Currency: "usd"})
|
|
if err == nil {
|
|
t.Fatal("a call answered by a socket file with no listener SUCCEEDED")
|
|
}
|
|
if !errors.Is(err, cloud.ErrNoPeer) {
|
|
t.Fatalf("a leftover socket file was taken for a live peer: %v\n"+
|
|
"a file is not a listener, and reading it as one is what left commerce cold "+
|
|
"and every paid completion in the fleet answering 503", err)
|
|
}
|
|
// The prod shape exactly: the run dir is a volume, so the file is STILL there on
|
|
// the next call and the next pod. The answer may not drift.
|
|
if _, serr := os.Stat(path); serr != nil {
|
|
t.Fatalf("the leftover file vanished; this no longer reproduces prod: %v", serr)
|
|
}
|
|
}
|
|
|
|
// TestALiveSocketIsAPeer is the other half, and it is what stops the fix above from
|
|
// being "always report absent": a socket with a real listener behind it must be reached
|
|
// with no router involved at all. That is the steady state of every plane call in the
|
|
// fleet.
|
|
func TestALiveSocketIsAPeer(t *testing.T) {
|
|
t.Setenv("ZIP_RUNTIME_DIR", runDir(t))
|
|
cloud.ResetPlane()
|
|
|
|
// A leftover file at the SAME path first: the listener binds over it, exactly as
|
|
// zaphttp.Server.ListenAndServe does in prod, which is why reach() removes nothing.
|
|
_ = leftover(t, "ledger")
|
|
|
|
app := zip.New(zip.Config{AppName: "ledger"})
|
|
zip.Post[plane.BalanceIn, plane.Balance](app, "/ledger/balance",
|
|
func(context.Context, *plane.BalanceIn) (*plane.Balance, error) {
|
|
return &plane.Balance{Amount: plane.Money{Decimal: "149533.00", Currency: "USD"}}, nil
|
|
}, zip.WithOperationID("stale_balance"))
|
|
go func() { _ = app.Listen(zip.SocketPath("ledger")) }()
|
|
t.Cleanup(func() { _ = app.Shutdown() })
|
|
waitFor(t, "ledger")
|
|
|
|
out, err := cloud.Ask[plane.BalanceIn, plane.Balance](cloud.For(context.Background(), "acme"),
|
|
"ledger", "stale_balance", &plane.BalanceIn{Subject: "acme", Currency: "usd"})
|
|
if err != nil {
|
|
t.Fatalf("a live peer was not reached: %v", err)
|
|
}
|
|
if out.Amount.Decimal != "149533.00" {
|
|
t.Fatalf("reply lost its value: %+v", out)
|
|
}
|
|
}
|
|
|
|
// TestSocketPathIsUnderTheRunDir keeps the two halves above honest about WHERE they
|
|
// looked: both write and probe zip.SocketPath(name), which is the one scheme a server
|
|
// and its callers share. If that ever stops resolving under the run dir, the tests
|
|
// would be exercising a path nothing in prod uses.
|
|
func TestSocketPathIsUnderTheRunDir(t *testing.T) {
|
|
dir := runDir(t)
|
|
t.Setenv("ZIP_RUNTIME_DIR", dir)
|
|
cloud.ResetPlane()
|
|
|
|
if got, want := zip.SocketPath("commerce"), filepath.Join(dir, "commerce.sock"); got != want {
|
|
t.Fatalf("SocketPath(commerce) = %q, want %q", got, want)
|
|
}
|
|
}
|