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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.10.25
0.10.26
33 changes: 26 additions & 7 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,22 @@ import (
"time"
)

// Backend environments (mirror CLIENT_ENV).
// Backend environments (mirror the stage env var — $TRACEBLOC_ENV, legacy $CLIENT_ENV).
const (
EnvDev = "dev"
EnvStg = "stg"
EnvProd = "prod"
)

// Stage-selecting environment variables (RFC-0076 settings-naming, backend#3391).
// TRACEBLOC_ENV is the canonical name; CLIENT_ENV is the legacy alias, read as a
// fallback so existing installs and $CLIENT_ENV exports keep working. Alias-first:
// read new-or-old, never break a deployment. Legacy alias remove_by: 2026-12-31.
const (
StageEnvVar = "TRACEBLOC_ENV"
LegacyStageEnvVar = "CLIENT_ENV"
)

const defaultTimeout = 30 * time.Second

// ── User-Agent: minimum-CLI-version handshake (RFC-0001 §13 / §14 R11 / C.1) ──
Expand Down Expand Up @@ -80,9 +89,9 @@ func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error)
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.
// BaseURL maps a stage value to the backend base URL — kept in lock-step with the
// installer's `_backend_url` and client-runtime's stage→backend mapping. Unknown /
// empty → prod.
func BaseURL(env string) string {
switch strings.ToLower(env) {
case EnvDev:
Expand All @@ -94,18 +103,28 @@ func BaseURL(env string) string {
}
}

// ResolveEnv picks the backend env: an explicit value (a --env flag) wins,
// then $CLIENT_ENV, then prod.
// ResolveEnv picks the backend env: an explicit value (a --env flag) wins, then
// the stage env var — canonical $TRACEBLOC_ENV, else legacy $CLIENT_ENV — then prod.
func ResolveEnv(explicit string) string {
if explicit != "" {
return strings.ToLower(explicit)
}
if e := os.Getenv("CLIENT_ENV"); e != "" {
if e := stageFromEnv(); e != "" {
return strings.ToLower(e)
}
return EnvProd
}

// stageFromEnv reads the deploy stage from the process environment, preferring the
// canonical TRACEBLOC_ENV over the legacy CLIENT_ENV alias (remove_by 2026-12-31).
// The single reader of both names, so the alias precedence lives in one place.
func stageFromEnv() string {
if e := os.Getenv(StageEnvVar); e != "" {
return e
}
return os.Getenv(LegacyStageEnvVar)
}

// IsKnownEnv reports whether env is one of the recognized backends (dev/stg/prod,
// case-insensitively). Callers that let a human PICK the env (e.g. `login`) use it
// to reject a typo up front — BaseURL deliberately falls unknown values back to
Expand Down
25 changes: 24 additions & 1 deletion internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,42 @@ func TestBaseURL(t *testing.T) {
}

func TestResolveEnv(t *testing.T) {
// Isolate both stage vars: the canonical name and the legacy alias.
t.Setenv("TRACEBLOC_ENV", "")
t.Setenv("CLIENT_ENV", "stg")
if got := ResolveEnv("dev"); got != "dev" {
t.Errorf("explicit should win: got %q", got)
}
if got := ResolveEnv(""); got != "stg" {
t.Errorf("CLIENT_ENV should be used: got %q", got)
t.Errorf("legacy $CLIENT_ENV should be used as the fallback: got %q", got)
}
t.Setenv("CLIENT_ENV", "")
if got := ResolveEnv(""); got != "prod" {
t.Errorf("default should be prod: got %q", got)
}
}

// TestResolveEnvStageAlias pins the RFC-0076 alias precedence (backend#3391): the
// canonical $TRACEBLOC_ENV is preferred, the legacy $CLIENT_ENV is read only as a
// fallback, and an explicit --env still beats both.
func TestResolveEnvStageAlias(t *testing.T) {
// Canonical alone is honoured.
t.Setenv("TRACEBLOC_ENV", "dev")
t.Setenv("CLIENT_ENV", "")
if got := ResolveEnv(""); got != "dev" {
t.Errorf("canonical $TRACEBLOC_ENV should be used: got %q", got)
}
// Canonical wins over the legacy alias when both are set.
t.Setenv("CLIENT_ENV", "prod")
if got := ResolveEnv(""); got != "dev" {
t.Errorf("canonical $TRACEBLOC_ENV should beat legacy $CLIENT_ENV: got %q", got)
}
// Explicit --env still wins over both.
if got := ResolveEnv("stg"); got != "stg" {
t.Errorf("explicit --env should beat the environment: got %q", got)
}
}

func TestIsKnownEnv(t *testing.T) {
known := []string{"dev", "stg", "prod", "DEV", "Prod"} // case-insensitive
for _, env := range known {
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks.`,
},
}
cmd.Flags().StringVar(&envFlag, "env", "",
"backend environment: dev|stg|prod (default: $CLIENT_ENV, then prod)")
"backend environment: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)")
return cmd
}

Expand All @@ -65,7 +65,7 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
if !api.IsKnownEnv(env) {
return &exitError{code: exitFailure, err: fmt.Errorf(
"unknown backend environment %q — valid values are dev, stg, prod (default). "+
"Check --env / $CLIENT_ENV", env)}
"Check --env / $TRACEBLOC_ENV (or legacy $CLIENT_ENV)", env)}
}
client := newAPIClient(env)
p.Detailf("backend %s — requesting a device code …", client.BaseURL)
Expand Down Expand Up @@ -461,7 +461,7 @@ func newAuthStatusCmd() *cobra.Command {
cmd.Flags().BoolVar(&check, "check", false,
"exit 0 only if signed in with a backend-valid token, else 1; silent unless --verbose")
cmd.Flags().StringVar(&envFlag, "env", "",
"backend environment the check targets: dev|stg|prod (default: $CLIENT_ENV, then prod)")
"backend environment the check targets: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)")
Comment thread
tracebloc-release-train[bot] marked this conversation as resolved.
return cmd
}

Expand Down
23 changes: 13 additions & 10 deletions internal/cli/env_resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,9 @@ func TestKnownSessionEnvGatesUnknownButKeepsKnown(t *testing.T) {
// internal/ — i.e. blind exactly where a new site is most likely to land, in a
// package written by someone who never reads internal/cli (Lukas on #551).
var resolutionSites = map[string]string{
// The primitive: ResolveEnv is the --env/$CLIENT_ENV/prod chain, and the only
// os.Getenv("CLIENT_ENV") in the module.
"internal/api/client.go": "api.ResolveEnv — the primitive chain, and the only $CLIENT_ENV read",
// The primitive: ResolveEnv is the --env/$TRACEBLOC_ENV/legacy $CLIENT_ENV/prod
// chain, and the only os.Getenv of the stage var in the module.
"internal/api/client.go": "api.ResolveEnv — the primitive chain, and the only $TRACEBLOC_ENV/$CLIENT_ENV read",
// The --env FLAG, a different question: the env the human/installer NAMED,
// which login persists (the one cfg.CurrentEnv WRITE) and `auth status --check`
// validates against the session.
Expand All @@ -441,9 +441,10 @@ var resolutionSites = map[string]string{
// Storage. Profiles are keyed by the RAW stored string, so this layer must not
// normalise; it hands the raw value out and sessionEnv normalises it.
"internal/config/config.go": "the on-disk current_env field, its accessors, and the v1 migration",
// The CLUSTER's CLIENT_ENV, read off the jobs-manager Deployment — a
// deliberately different question from this CLI's session env.
"internal/doctor/doctor.go": "the cluster's own CLIENT_ENV, for the egress probe's target host",
// The CLUSTER's stage (chart-written $TRACEBLOC_ENV, legacy $CLIENT_ENV), read
// off the jobs-manager Deployment — a deliberately different question from this
// CLI's session env.
"internal/doctor/doctor.go": "the cluster's own stage var, for the egress probe's target host",
// internal/cli/telemetry.go is deliberately ABSENT, and the staleness check
// below is what keeps it that way: telemetryEnv/signedInEnv delegate the whole
// chain to sessionEnv and name no needle, so an entry for it would be inert —
Expand All @@ -455,10 +456,12 @@ var resolutionSites = map[string]string{
// api.BaseURL/IsKnownEnv are pure mappings over an argument and are deliberately
// absent — they resolve nothing.
//
// Deliberately BROAD (bare identifiers, and CLIENT_ENV unquoted so it matches
// help text too): a false positive is a loud line in a diff, a false negative is
// the bug this guard exists to catch. Fail closed.
var envNeedles = []string{"CurrentEnv", "ResolveEnv", "CLIENT_ENV"}
// Deliberately BROAD (bare identifiers, and the stage-var names unquoted so they
// match help text too): a false positive is a loud line in a diff, a false negative
// is the bug this guard exists to catch. Fail closed. TRACEBLOC_ENV is the RFC-0076
// canonical stage var; CLIENT_ENV is its legacy alias (remove_by 2026-12-31) — both
// are needles so a new read of EITHER name lands in the allowlist.
var envNeedles = []string{"CurrentEnv", "ResolveEnv", "TRACEBLOC_ENV", "CLIENT_ENV"}

// matchesAnyNeedle is THE matcher, called from both directions — the detection
// sweep and the allowlist audit. One function on purpose: two copies of "does
Expand Down
21 changes: 21 additions & 0 deletions internal/cli/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cli

import (
"os"
"testing"
)

// TestMain clears BOTH stage-selecting env vars before the package's tests run, so
// no test inherits a developer's or CI runner's ambient stage from the process
// environment. This is load-bearing after RFC-0076 (backend#3391) made the reads
// alias-first: api.ResolveEnv now consults the canonical $TRACEBLOC_ENV BEFORE the
// legacy $CLIENT_ENV, so a test that pins the ambient stage by setting only one of
// the two names would be silently overridden by an ambient value of the other. A
// package-wide clean baseline fixes that for every current AND future site — the
// same class-not-instance guarantee the env-resolution guard in this package is
// built around — rather than relying on each test to neutralise both names.
func TestMain(m *testing.M) {
_ = os.Unsetenv("TRACEBLOC_ENV")
_ = os.Unsetenv("CLIENT_ENV")
os.Exit(m.Run())
}
4 changes: 4 additions & 0 deletions internal/cli/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ func testBuildInfo() BuildInfo {
func isolateConfig(t *testing.T) {
t.Helper()
t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir())
// Pin the ambient stage to prod, clearing the canonical name too: it outranks
// the legacy $CLIENT_ENV set below (api.stageFromEnv), so leaving it set would
// let it override this pin.
t.Setenv("TRACEBLOC_ENV", "")
t.Setenv("CLIENT_ENV", api.EnvProd)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/testdata/golden/07-login.golden
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Usage:
tracebloc login [flags]

Flags:
--env string backend environment: dev|stg|prod (default: $CLIENT_ENV, then prod)
--env string backend environment: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)
-h, --help help for login

Global Flags:
Expand Down Expand Up @@ -50,7 +50,7 @@ Usage:

Flags:
--check exit 0 only if signed in with a backend-valid token, else 1; silent unless --verbose
--env string backend environment the check targets: dev|stg|prod (default: $CLIENT_ENV, then prod)
--env string backend environment the check targets: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)
-h, --help help for status

Global Flags:
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/testdata/golden/zz-all-strings.golden
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,7 @@ screen. %s/%d are runtime placeholders.
"tracebloc-doctor-%s.txt"
"tracebloc-stage-%s-%s"
"unavailable"
"unknown backend environment %q — valid values are dev, stg, prod (default). Check --env / $CLIENT_ENV"
"unknown backend environment %q — valid values are dev, stg, prod (default). Check --env / $TRACEBLOC_ENV (or legacy $CLIENT_ENV)"
"unknown command %q for %q"
"unparseable MemTotal %q: %w"
"unparseable NCPU %q: %w"
Expand Down
30 changes: 23 additions & 7 deletions internal/doctor/doctor.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Package doctor implements the checks behind `tracebloc cluster doctor`:
// a read-only, best-effort health sweep of a running tracebloc client
// cluster. Each check reports ✔/⚠/✖ plus a one-line remedy, so a customer
Expand Down Expand Up @@ -570,7 +570,7 @@
// the backend at all.
func checkBackendEgress(ctx context.Context, env map[string]string, probe func(context.Context, string) error) Result {
const name = "Backend egress (from this machine)"
host := backendHost(env["CLIENT_ENV"])
host := backendHost(stageFromClusterSpec(env))
url := "https://" + host + "/"
if err := probe(ctx, url); err != nil {
return Result{
Expand All @@ -583,20 +583,36 @@
return Result{Name: name, Status: StatusOK, Detail: host + " reachable"}
}

// backendHost maps CLIENT_ENV to the backend API host, mirroring the edge
// stageFromClusterSpec picks the deploy stage out of the jobs-manager container's
// env, preferring the canonical TRACEBLOC_ENV over the legacy CLIENT_ENV alias.
// These keys are written by the edge CHART (this CLI is a consumer here, not the
// owner): reading both lets the chart adopt the canonical name on its own S3-edge
// timeline without a flag-day, and today — while the chart still writes CLIENT_ENV
// — the fallback keeps behaviour identical. Legacy alias remove_by: 2026-12-31.
// The literals are spelled out (not api.StageEnvVar) both because these are the
// chart's key names, not this CLI's process-env names, and so the env-resolution
// guard still sees this file as a sanctioned stage-var read site.
func stageFromClusterSpec(env map[string]string) string {
if v := env["TRACEBLOC_ENV"]; v != "" {
return v
}
return env["CLIENT_ENV"]
}

// backendHost maps a stage value to the backend API host, mirroring the edge
// runtime's own mapping (controller.py). Unset/unknown defaults to prod, the
// chart's CLIENT_ENV default.
// chart's stage default.
//
// DERIVED FROM api.BaseURL, not restated. The env→host mapping used to be a
// second copy of BaseURL's switch living in this package, which is how the two
// drift: the same three hosts written down twice, with nothing that fails when
// only one of them is edited. api.BaseURL already lower-cases, so TrimSpace is
// the only normalisation this adds — a CLIENT_ENV read off a container spec can
// the only normalisation this adds — a stage value read off a container spec can
// carry surrounding whitespace that a --env flag cannot.
//
// The input is the CLUSTER's CLIENT_ENV (read off the jobs-manager Deployment),
// not this CLI's session env — a deliberately different question, which is why
// this takes a string rather than calling into the session resolution.
// The input is the CLUSTER's stage (read off the jobs-manager Deployment), not
// this CLI's session env — a deliberately different question, which is why this
// takes a string rather than calling into the session resolution.
func backendHost(clientEnv string) string {
u, err := url.Parse(api.BaseURL(strings.TrimSpace(clientEnv)))
if err != nil || u.Host == "" {
Expand Down
36 changes: 35 additions & 1 deletion internal/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"k8s.io/client-go/kubernetes/fake"
k8stesting "k8s.io/client-go/testing"

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

Expand Down Expand Up @@ -404,13 +405,46 @@ func TestCheckBackendEgress(t *testing.T) {
failProbe := func(context.Context, string) error { return errors.New("dns failure") }

if r := checkBackendEgress(bg(), map[string]string{"CLIENT_ENV": "dev"}, okProbe); r.Status != StatusOK || !strings.Contains(r.Detail, "dev-api.tracebloc.io") {
t.Fatalf("reachable dev => %v / %q", r.Status, r.Detail)
t.Fatalf("reachable dev (legacy CLIENT_ENV) => %v / %q", r.Status, r.Detail)
}
// Canonical stage key on the cluster spec resolves the same host (RFC-0076).
if r := checkBackendEgress(bg(), map[string]string{"TRACEBLOC_ENV": "dev"}, okProbe); r.Status != StatusOK || !strings.Contains(r.Detail, "dev-api.tracebloc.io") {
t.Fatalf("reachable dev (canonical TRACEBLOC_ENV) => %v / %q", r.Status, r.Detail)
}
if r := checkBackendEgress(bg(), map[string]string{}, failProbe); r.Status != StatusFail || !strings.Contains(r.Detail, "api.tracebloc.io") {
t.Fatalf("unreachable default => %v / %q", r.Status, r.Detail)
}
}

// TestStageFromClusterSpec pins the alias-first read of the chart-written stage
// key: canonical TRACEBLOC_ENV preferred, legacy CLIENT_ENV as the fallback.
func TestStageFromClusterSpec(t *testing.T) {
// stageFromClusterSpec reads the key LITERALS (spelled out for the cli-package
// env-resolution guard), so nothing compile-couples them to the api consts. Pin
// the two names in lock-step here: if api's canonical/legacy stage-var names ever
// change, this fails instead of the doctor silently reading a stale key off the
// jobs-manager spec and probing the wrong host.
if api.StageEnvVar != "TRACEBLOC_ENV" || api.LegacyStageEnvVar != "CLIENT_ENV" {
t.Fatalf("stage-var names drifted from the literals stageFromClusterSpec reads: "+
"canonical api.StageEnvVar=%q, legacy api.LegacyStageEnvVar=%q", api.StageEnvVar, api.LegacyStageEnvVar)
}
tests := []struct {
name string
env map[string]string
want string
}{
{"canonical only", map[string]string{"TRACEBLOC_ENV": "dev"}, "dev"},
{"legacy only", map[string]string{"CLIENT_ENV": "stg"}, "stg"},
{"canonical wins", map[string]string{"TRACEBLOC_ENV": "dev", "CLIENT_ENV": "prod"}, "dev"},
{"neither", map[string]string{}, ""},
}
for _, tc := range tests {
if got := stageFromClusterSpec(tc.env); got != tc.want {
t.Errorf("%s: stageFromClusterSpec(%v) = %q, want %q", tc.name, tc.env, got, tc.want)
}
}
}

func TestBackendHost(t *testing.T) {
tests := map[string]string{
"dev": "dev-api.tracebloc.io",
Expand Down
Loading