Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions internal/api/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import (
"net/http"
"net/url"
"os"
"runtime"
"strings"
"time"
)
Expand All@@ -29,6 +30,56 @@ const (

const defaultTimeout = 30 * time.Second

// ── User-Agent: minimum-CLI-version handshake (RFC-0001 §13 / §14 R11 / C.1) ──
//
// Every backend request announces the CLI build as
//
// User-Agent: tracebloc-cli/<version> (<os>/<arch>)
//
// so the backend's MIN_SUPPORTED_CLI_VERSION gate (backend#888) can answer a
// too-old client with 426 Upgrade Required. The version is the ldflags-injected
// build version, recorded once at startup; until then (e.g. a bare `go run`) it
// reports "dev", which the backend can't parse and therefore lets through — the
// right fail-open for local development.

// userAgent is the formatted header value, set once via SetUserAgent at startup.
// A package var (not a constructor arg) so every Client built anywhere — login,
// client provisioning — carries it without threading the version through each
// command; it mirrors the build metadata that already lives as a global in main.
var userAgent string

// SetUserAgent records the CLI version used in the User-Agent on every backend
// request. Call once from cli.NewRootCmd with the ldflags-injected version.
func SetUserAgent(version string) {
if version == "" {
version = "dev"
}
userAgent = fmt.Sprintf("tracebloc-cli/%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH)
}

// currentUserAgent is the header value to send, with a "dev" fallback for builds
// that never called SetUserAgent (tests, `go run`).
func currentUserAgent() string {
if userAgent != "" {
return userAgent
}
return fmt.Sprintf("tracebloc-cli/dev (%s/%s)", runtime.GOOS, runtime.GOARCH)
}

// userAgentTransport injects the CLI User-Agent on every request that doesn't
// already set one. It wraps the real transport (which keeps proxy + system-CA
// behavior). Per the http.RoundTripper contract it clones the request before
// mutating headers rather than modifying the caller's request in place.
type userAgentTransport struct{ base http.RoundTripper }

func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") == "" {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", currentUserAgent())
}
return t.base.RoundTrip(req)
}

// BaseURL maps a CLIENT_ENV value to the backend base URL — kept in lock-step
// with the installer's `_backend_url` and client-runtime's CLIENT_ENV→backend
// mapping. Unknown / empty → prod.
Expand DownExpand Up@@ -73,8 +124,10 @@ func New(env string) *Client {
return &Client{
BaseURL: strings.TrimRight(BaseURL(env), "/"),
HTTP: &http.Client{
Timeout: defaultTimeout,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
Timeout: defaultTimeout,
// Wrap the proxy/CA-aware transport so every request carries the
// tracebloc-cli User-Agent (RFC-0001 §14 R11 / backend#888).
Transport: userAgentTransport{base: &http.Transport{Proxy: http.ProxyFromEnvironment}},
},
}
}
Expand All@@ -90,6 +143,38 @@ func (e *APIError) Error() string {
return fmt.Sprintf("%s returned HTTP %d: %s", e.URL, e.StatusCode, strings.TrimSpace(e.Body))
}

// UpgradeRequiredError is returned for an HTTP 426 from any endpoint: the CLI is
// below the backend's MIN_SUPPORTED_CLI_VERSION floor (RFC-0001 §14 R11 /
// backend#888). It's detected centrally (in post/get) so every call degrades to
// the same actionable "upgrade your CLI" message instead of a raw HTTP error.
type UpgradeRequiredError struct {
MinVersion string // the server's minimum supported version, when it tells us
}

func (e *UpgradeRequiredError) Error() string {
floor := "a newer version"
if e.MinVersion != "" {
floor = ">= " + e.MinVersion
}
return fmt.Sprintf(
"this tracebloc CLI is too old for the server (requires %s). Upgrade to the "+
"latest release — re-run the tracebloc install script, or download it from "+
"https://github.com/tracebloc/cli/releases/latest — then retry.",
floor,
)
}

// parseUpgradeRequired builds an *UpgradeRequiredError from a 426 body
// ({"error":"upgrade_required","min_version":"X"}). min_version is best-effort:
// the 426 status is the contract, so a body we can't parse still upgrades.
func parseUpgradeRequired(raw []byte) *UpgradeRequiredError {
var body struct {
MinVersion string `json:"min_version"`
}
_ = json.Unmarshal(raw, &body)
return &UpgradeRequiredError{MinVersion: body.MinVersion}
}

// post sends an optional JSON body and returns the status code + raw response.
func (c *Client) post(ctx context.Context, path string, body any) (int, []byte, error) {
var rdr io.Reader
Expand All@@ -116,6 +201,9 @@ func (c *Client) post(ctx context.Context, path string, body any) (int, []byte,
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("reading response from %s: %w", url, err)
}
if resp.StatusCode == http.StatusUpgradeRequired {
return resp.StatusCode, raw, parseUpgradeRequired(raw)
}
return resp.StatusCode, raw, nil
}

Expand DownExpand Up@@ -146,6 +234,9 @@ func (c *Client) get(ctx context.Context, path string) (int, []byte, error) {
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("reading response from %s: %w", url, err)
}
if resp.StatusCode == http.StatusUpgradeRequired {
return resp.StatusCode, raw, parseUpgradeRequired(raw)
}
return resp.StatusCode, raw, nil
}

Expand Down
100 changes: 100 additions & 0 deletions internal/api/client_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
)

Expand DownExpand Up@@ -253,3 +255,101 @@ func TestListClients_BareArrayUnpaginated(t *testing.T) {
t.Fatalf("want 2 clients [1,2] from a bare array, got %+v", got)
}
}

// ── cli#98: User-Agent version header + 426 Upgrade Required (RFC-0001 §14 R11) ──

// TestUserAgentHeaderSent proves the transport wrapper puts the configured
// version on the wire for a real request (here a GET via WhoAmI).
func TestUserAgentHeaderSent(t *testing.T) {
old := userAgent
defer func() { userAgent = old }()
SetUserAgent("7.7.7")

want := "tracebloc-cli/7.7.7 (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("User-Agent")
_, _ = w.Write([]byte(`{"email":"x","type":"y","account":"z"}`))
}))
defer srv.Close()
c := New("prod")
c.BaseURL = srv.URL
c.Token = "t"
if _, err := c.WhoAmI(context.Background()); err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("User-Agent = %q, want %q", got, want)
}
}

// TestUserAgentDevFallback: a build that never called SetUserAgent (or with an
// empty version) reports "dev" — which the backend can't parse, so it fails open.
func TestUserAgentDevFallback(t *testing.T) {
old := userAgent
defer func() { userAgent = old }()

want := "tracebloc-cli/dev (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
userAgent = ""
if got := currentUserAgent(); got != want {
t.Errorf("unset → currentUserAgent() = %q, want %q", got, want)
}
SetUserAgent("")
if got := currentUserAgent(); got != want {
t.Errorf(`SetUserAgent("") → %q, want %q`, got, want)
}
}

// TestUpgradeRequired426 pins the central 426 handling: a 426 from any endpoint
// (GET or POST) surfaces as a typed *UpgradeRequiredError carrying min_version,
// not a raw *APIError — so every command degrades to the same upgrade message.
func TestUpgradeRequired426(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUpgradeRequired) // 426
_, _ = w.Write([]byte(`{"error":"upgrade_required","min_version":"0.4.0"}`))
}))
defer srv.Close()
c := New("prod")
c.BaseURL = srv.URL
c.Token = "t"

var ue *UpgradeRequiredError
if _, err := c.WhoAmI(context.Background()); !errors.As(err, &ue) || ue.MinVersion != "0.4.0" {
t.Errorf("GET on 426: want *UpgradeRequiredError{0.4.0}, got %v", err)
}
ue = nil
if _, err := c.RequestDeviceCode(context.Background()); !errors.As(err, &ue) || ue.MinVersion != "0.4.0" {
t.Errorf("POST on 426: want *UpgradeRequiredError{0.4.0}, got %v", err)
}
}

// TestUpgradeRequiredErrorMessage: the message is actionable (names the floor)
// and stays sensible when the server didn't send a min_version.
func TestUpgradeRequiredErrorMessage(t *testing.T) {
msg := (&UpgradeRequiredError{MinVersion: "0.4.0"}).Error()
if !strings.Contains(msg, "0.4.0") || !strings.Contains(msg, "too old") {
t.Errorf("message not actionable: %q", msg)
}
if got := (&UpgradeRequiredError{}).Error(); strings.Contains(got, ">=") {
t.Errorf("empty min_version should not print a bare '>=': %q", got)
}
}

// TestUpgradeRequired426_UnparseableBody: a 426 whose body doesn't parse still
// yields an *UpgradeRequiredError (the status is the contract), with no min_version.
func TestUpgradeRequired426_UnparseableBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUpgradeRequired)
_, _ = w.Write([]byte(`not json`))
}))
defer srv.Close()
c := New("prod")
c.BaseURL = srv.URL
var ue *UpgradeRequiredError
if _, err := c.RequestDeviceCode(context.Background()); !errors.As(err, &ue) {
t.Fatalf("want *UpgradeRequiredError even on unparseable body, got %v", err)
}
if ue.MinVersion != "" {
t.Errorf("MinVersion = %q, want empty", ue.MinVersion)
}
}
5 changes: 5 additions & 0 deletions internal/cli/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import (

"github.com/spf13/cobra"

"github.com/tracebloc/cli/internal/api"
"github.com/tracebloc/cli/internal/ui"
)

Expand All@@ -32,6 +33,10 @@ type BuildInfo struct {
// global state has historically been a source of test interference,
// and constructing fresh trees per call is cheap.
func NewRootCmd(info BuildInfo) *cobra.Command {
// Record the CLI version for the User-Agent sent on every backend request
// (RFC-0001 §14 R11 / backend#888): "tracebloc-cli/<ver> (<os>/<arch>)".
api.SetUserAgent(info.Version)

root := &cobra.Command{
Use: "tracebloc",
Short: "tracebloc — interactive data ingestion for your cluster",
Expand Down
Loading