compat: SemVer version windows between app and server
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:
protocol_version — CAN these builds talk. The correctness axis. Below 1.0.0
the minor is the breaking axis, per SemVer §4.
release window — MAY they, per policy. [min, max), advertised in the
profile, overridable by the operator.
The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.
Three rules that shape the rest:
- GET /v1/profile is never gated. It is where a refused client learns which
version it needs; gating it leaves the user with a network error instead of
an answer, which is precisely the confusion this exists to remove.
- An unparseable or absent version is "unknown", and is allowed. Development
builds report "dev", and a client too old to send the header cannot be
identified anyway.
- Bounds sit at breaking boundaries, not at releases, so shipping a patch
never requires editing a range. The app's server minimum is 0.4.2 for a
stated reason: earlier multi-homed servers mis-addressed granted sends and
the client measured 100% downstream loss that never happened.
The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
277e33da75
commit
0c5b021b63
@@ -24,6 +24,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/compat"
|
||||
"echo-lot.app/server/internal/dataplane"
|
||||
"echo-lot.app/server/internal/runs"
|
||||
"echo-lot.app/server/internal/session"
|
||||
@@ -70,22 +71,87 @@ type Server struct {
|
||||
// server's own egress isn't full-MTU, client MTU results measure the
|
||||
// server, not the client.
|
||||
ProvenGood func() (mtuOK, sysctlOK bool)
|
||||
|
||||
// AppRange is the app-version window this server will serve. Zero value means the built-in
|
||||
// default (see DefaultAppRange).
|
||||
AppRange compat.Range
|
||||
}
|
||||
|
||||
// AppVersionHeader is how a client states its version. A client too old to send it is treated as
|
||||
// unknown rather than refused: the check exists to turn confusing failures into clear ones, and
|
||||
// refusing something we cannot identify achieves the opposite.
|
||||
const AppVersionHeader = "X-Echolot-App-Version"
|
||||
|
||||
// ProtocolVersion is the wire contract (probe-protocol.md) this build implements. It is what the
|
||||
// version window is really about; the release version is only a proxy for it.
|
||||
const ProtocolVersion = "1.0.0"
|
||||
|
||||
// SchemaVersion is the measurement-document format this server can store.
|
||||
const SchemaVersion = "1.0.0"
|
||||
|
||||
// DefaultAppRange: everything from the first app that speaks this protocol up to — but not
|
||||
// including — the next breaking series. Bounds sit at breaking boundaries so shipping a patch
|
||||
// never requires touching this.
|
||||
func DefaultAppRange() compat.Range {
|
||||
r, err := compat.ParseRange("0.2.0", "1.0.0")
|
||||
if err != nil {
|
||||
panic("built-in app range is malformed: " + err.Error())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) appRange() compat.Range {
|
||||
if s.AppRange.Min == (compat.Version{}) && !s.AppRange.HasMax {
|
||||
return DefaultAppRange()
|
||||
}
|
||||
return s.AppRange
|
||||
}
|
||||
|
||||
// requireCompatibleApp wraps a handler with the version window.
|
||||
//
|
||||
// Deliberately NOT applied to GET /v1/profile: that is where a client learns which version it
|
||||
// should be. Gating it would leave a refused client with nothing to show its user but a timeout,
|
||||
// which is precisely the confusion this check exists to remove.
|
||||
func (s *Server) requireCompatibleApp(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
verdict, msg := compat.Check(r.Header.Get(AppVersionHeader), s.appRange(), "app")
|
||||
switch verdict {
|
||||
case compat.TooOld, compat.TooNew:
|
||||
slog.Info("refused incompatible app", "app_version", r.Header.Get(AppVersionHeader),
|
||||
"accepts", s.appRange().String(), "path", r.URL.Path)
|
||||
// 426 says exactly this and nothing else; the body carries the window so the app can
|
||||
// show the user the number to reach, not just that it failed.
|
||||
writeJSON(w, http.StatusUpgradeRequired, map[string]any{
|
||||
"error": msg,
|
||||
"app_version": r.Header.Get(AppVersionHeader),
|
||||
"accepts_app": s.appRange().String(),
|
||||
"server_version": Version,
|
||||
"protocol_version": ProtocolVersion,
|
||||
})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/enroll", s.enroll)
|
||||
// Always reachable, whatever the version window says: this is how a client discovers the
|
||||
// window it has to satisfy.
|
||||
mux.HandleFunc("GET /v1/profile", s.profile)
|
||||
mux.HandleFunc("POST /v1/sessions", s.newSession)
|
||||
mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession)
|
||||
mux.HandleFunc("GET /v1/sessions/{id}/observations", s.observations)
|
||||
mux.HandleFunc("POST /v1/sessions/{id}/actions", s.actions)
|
||||
mux.HandleFunc("POST /v1/echo", s.httpEcho)
|
||||
mux.HandleFunc("GET /v1/tls-reference", s.tlsReference)
|
||||
mux.HandleFunc("POST /v1/runs", s.uploadRun)
|
||||
mux.HandleFunc("GET /v1/runs", s.listRuns)
|
||||
mux.HandleFunc("GET /v1/runs/{id}", s.getRun)
|
||||
mux.HandleFunc("DELETE /v1/runs/{id}", s.deleteRun)
|
||||
|
||||
gate := s.requireCompatibleApp
|
||||
mux.HandleFunc("POST /v1/enroll", gate(s.enroll))
|
||||
mux.HandleFunc("POST /v1/sessions", gate(s.newSession))
|
||||
mux.HandleFunc("DELETE /v1/sessions/{id}", gate(s.deleteSession))
|
||||
mux.HandleFunc("GET /v1/sessions/{id}/observations", gate(s.observations))
|
||||
mux.HandleFunc("POST /v1/sessions/{id}/actions", gate(s.actions))
|
||||
mux.HandleFunc("POST /v1/echo", gate(s.httpEcho))
|
||||
mux.HandleFunc("GET /v1/tls-reference", gate(s.tlsReference))
|
||||
mux.HandleFunc("POST /v1/runs", gate(s.uploadRun))
|
||||
mux.HandleFunc("GET /v1/runs", gate(s.listRuns))
|
||||
mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun))
|
||||
mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun))
|
||||
// TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
|
||||
return mux
|
||||
}
|
||||
@@ -424,6 +490,14 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
|
||||
// The app needs the upload rules before it offers the switch: whether uploads are
|
||||
// accepted at all, and how much identifying detail it must strip first.
|
||||
"uploads": s.uploadPolicy(),
|
||||
// What this build speaks, and which app versions it will serve. A client checks the
|
||||
// server side of the same question against its own bounds.
|
||||
"compat": map[string]any{
|
||||
"protocol_version": ProtocolVersion,
|
||||
"schema_version": SchemaVersion,
|
||||
"app_min": s.appRange().Min.String(),
|
||||
"app_max": maxOrEmpty(s.appRange()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -574,3 +648,12 @@ func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// maxOrEmpty renders an unbounded ceiling as "" rather than as a sentinel version, so a client
|
||||
// reading the profile cannot mistake a placeholder for a real bound.
|
||||
func maxOrEmpty(r compat.Range) string {
|
||||
if !r.HasMax {
|
||||
return ""
|
||||
}
|
||||
return r.Max.String()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user