diff --git a/internal/cli/client.go b/internal/cli/client.go index b211b29f..74ad73a8 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -283,7 +283,12 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien // installer reconciles the existing release rather than expecting a fresh // credential (#838). if werr := writeClientCredential(opts.credentialFile, []string{ - "TRACEBLOC_CLIENT_ID=" + strconv.Itoa(pc.ID), + // TRACEBLOC_CLIENT_ID is the *auth username* the client pod sends to + // api-token-auth (cred → helm clientId → secret CLIENT_ID → + // controller getenv("CLIENT_ID") as username). The backend + // authenticates an EdgeDevice by its UUID username, NOT the numeric + // dashboard id — so write pc.Username, not pc.ID (id is display-only). + "TRACEBLOC_CLIENT_ID=" + pc.Username, "TB_NAMESPACE=" + pc.Namespace, "TRACEBLOC_CLIENT_ADOPTED=1", }); werr != nil { @@ -304,7 +309,9 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien p.Successf("Provisioned client %q (namespace %s).", pc.Name, pc.Namespace) if opts.credentialFile != "" { if werr := writeClientCredential(opts.credentialFile, []string{ - "TRACEBLOC_CLIENT_ID=" + strconv.Itoa(pc.ID), + // The auth username (UUID), NOT the numeric dashboard id — see the + // adopt path above. api-token-auth authenticates by username. + "TRACEBLOC_CLIENT_ID=" + pc.Username, "TRACEBLOC_CLIENT_PASSWORD=" + password, "TB_NAMESPACE=" + pc.Namespace, }); werr != nil { @@ -317,8 +324,10 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien // Print the credential FIRST — it's the only copy (the backend stores only // the hash), so a later config-save failure must never cost it. p.Section("Machine credential — needed by the installer to connect this client") - p.Field("client id", strconv.Itoa(pc.ID)) - p.Field("username", pc.Username) + // The installer's "Client ID" prompt takes the auth username (UUID); + // that IS TRACEBLOC_CLIENT_ID; the numeric id is a dashboard reference only. + p.Field("client id", pc.Username) + p.Field("dashboard id", strconv.Itoa(pc.ID)) // human reference at ai.tracebloc.io/clients — NOT an installer input p.Field("password", password) } ilog.Logf("minted client id=%d namespace=%s", pc.ID, pc.Namespace) diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index 0c49dd03..cf07021c 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -279,6 +279,12 @@ func TestClientCreate_AnchorMint(t *testing.T) { if !strings.Contains(out.String(), "Machine credential") { t.Errorf("mint should print the credential, got:\n%s", out.String()) } + // The printed "client id" is what the installer's Client ID prompt consumes — + // it must be the UUID username (u-5), the same value written to the credential + // file, not the numeric dashboard id. Assert the username is shown as the id. + if !strings.Contains(out.String(), "client id") || !strings.Contains(out.String(), "u-5") { + t.Errorf("mint should print the username (u-5) as the client id, got:\n%s", out.String()) + } } func TestClientCreate_AdoptIdempotent(t *testing.T) { @@ -359,8 +365,12 @@ func TestClientCreate_CredentialFileMint(t *testing.T) { t.Errorf("credential file mode = %o, want 600", perm) } kv := parseEnvFile(t, credPath) - if kv["TRACEBLOC_CLIENT_ID"] != "5" || kv["TB_NAMESPACE"] != "my-ns" || kv["TRACEBLOC_CLIENT_PASSWORD"] == "" { - t.Errorf("credential file = %v (want id=5, ns=my-ns, non-empty password)", kv) + // TRACEBLOC_CLIENT_ID must be the UUID *username* (here "u-5"), NOT the numeric + // dashboard id (5): it becomes the pod's CLIENT_ID, which controller.py sends to + // api-token-auth as the login username. The backend authenticates an EdgeDevice + // by its username, so writing the id crash-loops the client on "Unable to log in". + if kv["TRACEBLOC_CLIENT_ID"] != "u-5" || kv["TB_NAMESPACE"] != "my-ns" || kv["TRACEBLOC_CLIENT_PASSWORD"] == "" { + t.Errorf("credential file = %v (want id=u-5 [the username, not id 5], ns=my-ns, non-empty password)", kv) } // never-show, the real invariant: the minted password VALUE must not appear // in stdout under any label (the string checks above are just a proxy). @@ -448,10 +458,12 @@ func TestClientCreate_CredentialFileAdopt(t *testing.T) { t.Fatalf("adopt: %v", err) } kv := parseEnvFile(t, credPath) - // adopt emits id + namespace + the ADOPTED marker, but NO password (the - // existing one stands; it's write-only on the backend). - if kv["TRACEBLOC_CLIENT_ID"] != "8" || kv["TB_NAMESPACE"] != "ex-ns" || kv["TRACEBLOC_CLIENT_ADOPTED"] != "1" { - t.Errorf("adopt credential file = %v (want id=8, ns=ex-ns, ADOPTED=1)", kv) + // adopt emits the username + namespace + the ADOPTED marker, but NO password + // (the existing one stands; it's write-only on the backend). Same invariant as + // the mint path: TRACEBLOC_CLIENT_ID is the UUID username ("u-8"), not id 8 — + // it's the login username the adopted client reconnects with. + if kv["TRACEBLOC_CLIENT_ID"] != "u-8" || kv["TB_NAMESPACE"] != "ex-ns" || kv["TRACEBLOC_CLIENT_ADOPTED"] != "1" { + t.Errorf("adopt credential file = %v (want id=u-8 [the username, not id 8], ns=ex-ns, ADOPTED=1)", kv) } if _, hasPw := kv["TRACEBLOC_CLIENT_PASSWORD"]; hasPw { t.Errorf("adopt must not write a password (none issued), got:\n%v", kv)