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: 2 additions & 0 deletions internal/cli/auth.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
}
env := api.ResolveEnv(envFlag)
client := newAPIClient(env)
p.Detailf("backend %s — requesting a device code …", client.BaseURL)

dc, err := client.RequestDeviceCode(ctx)
if err != nil {
Expand DownExpand Up@@ -112,6 +113,7 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
// capture the account to show + store. Best-effort: don't fail a
// successful sign-in just because this lookup couldn't run.
client.Token = tok
p.Detailf("authorized — confirming the token with the backend …")
if id, werr := client.WhoAmI(ctx); werr == nil {
prof.Email = id.Email
}
Expand Down
72 changes: 71 additions & 1 deletion internal/cli/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,11 +130,38 @@ func authedClient() (*api.Client, *config.Config, error) {
return client, cfg, nil
}

func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clientCreateOpts) error {
func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clientCreateOpts) (err error) {
// Always leave a full provision trace on disk, even on a quiet/headless run
// (RFC-0001 §8.5). On any failure, point at the (idempotent) resume command
// + `cluster doctor`, so a zero-prompt connect that breaks isn't a dead end.
ilog, logPath := newInstallLog()
defer ilog.Close()
ilog.Logf("client create: name=%q location=%q", opts.name, opts.location)
defer func() {
if err != nil {
ilog.Logf("FAILED: %v", err)
p.Newline()
p.Hintf("Provisioning didn't complete. Re-running is safe — on the same cluster it adopts the existing client instead of minting a duplicate (idempotent):")
p.Hintf(" %s", resumeCommand(opts))
Comment thread
saadqbal marked this conversation as resolved.
p.Hintf("Diagnose auth / cluster problems with: tracebloc cluster doctor")
if logPath != "" {
p.Hintf("Full log: %s", logPath)
}
return
}
// Success/cancel: the terminal outcome was already logged at its own
// branch (minted / adopted / cancelled), so don't blanket-log "done"
// here — a declined confirm must not read as a successful provision.
if logPath != "" {
p.Detailf("full log: %s", logPath)
}
}()
Comment thread
saadqbal marked this conversation as resolved.

client, cfg, err := authedClient()
if err != nil {
return &exitError{code: 1, err: err}
}
ilog.Logf("authenticated; provisioning against the signed-in account")

name, location := opts.name, opts.location

Expand All@@ -158,6 +185,10 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien
return mapClientErr(err)
}
}
// Reflect the resolved (possibly prompted) name + location back into opts, so
// the failure-path resume command includes them — opts otherwise carries only
// the flags, omitting anything the user typed at a prompt (Bugbot).
opts.name, opts.location = name, location

// Read the cluster anchor (kube-system UID) so create is get-or-create keyed on
// it — re-running on the same cluster adopts the existing client instead of
Expand All@@ -168,6 +199,7 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien
if cidErr != nil {
p.Hintf("Couldn't read the target cluster's identity — provisioning without a cluster anchor, so re-running won't be idempotent. Point --kubeconfig/--context at the reachable cluster to enable that.")
}
ilog.Logf("cluster anchor: %q (read err: %v)", clusterID, cidErr)

// Derive the namespace slug from the name, avoiding collisions with existing
// clients (best-effort: if the list call fails we still derive a base slug).
Expand DownExpand Up@@ -198,6 +230,7 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien
return mapClientErr(cerr)
}
if !ok {
ilog.Logf("cancelled by user at the confirm prompt")
p.Hintf("Cancelled.")
return nil
}
Expand DownExpand Up@@ -241,6 +274,7 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien
// where the backend instead matches a live in-cluster TB_CLIENT_ID whose
// cluster_id is still null and the CLI backfills it via PATCH, is the
// installer's orchestration — #838 — not done here.)
ilog.Logf("adopted existing client id=%d namespace=%s", pc.ID, pc.Namespace)
p.Successf("This cluster is already registered as client %q (namespace %s) — adopted it.", pc.Name, pc.Namespace)
p.Hintf("No new credential issued; the existing one stands. This machine is set to enroll as client %d.", pc.ID)
if opts.credentialFile != "" {
Expand DownExpand Up@@ -287,12 +321,48 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien
p.Field("username", pc.Username)
p.Field("password", password)
}
ilog.Logf("minted client id=%d namespace=%s", pc.ID, pc.Namespace)
if serr := cfg.Save(); serr != nil {
p.Hintf("Couldn't save the active-client pointer (%v) — run `tracebloc client use %d` to set it.", serr, pc.ID)
}
return nil
}

// resumeCommand reconstructs the `tracebloc client create` invocation to retry a
// failed provision. Re-running is idempotent (RFC-0001 §7.2): on the same cluster
// it adopts the existing client rather than minting a duplicate.
func resumeCommand(opts clientCreateOpts) string {
parts := []string{"tracebloc client create"}
if opts.name != "" {
parts = append(parts, "--name "+shellArg(opts.name))
}
if opts.location != "" {
parts = append(parts, "--location "+shellArg(opts.location))
}
if opts.kubeconfigPath != "" {
parts = append(parts, "--kubeconfig "+shellArg(opts.kubeconfigPath))
}
if opts.contextOverride != "" {
parts = append(parts, "--context "+shellArg(opts.contextOverride))
}
if opts.credentialFile != "" {
parts = append(parts, "--credential-file "+shellArg(opts.credentialFile))
}
if opts.yes {
parts = append(parts, "--yes")
}
return strings.Join(parts, " ")
}

// shellArg single-quotes an argument containing whitespace so the resume command
// stays copy-pasteable for values like "Lab One".
func shellArg(s string) string {
if strings.ContainsAny(s, " \t") {
return "'" + s + "'"
}
return s
}

// writeClientCredential writes the machine credential to path (mode 0600) as a
// shell-sourceable env file — the installer (#838) sources it to feed the chart,
// so the secret lands in a 0600 file, never the terminal (RFC §9 never-show). The
Expand Down
108 changes: 102 additions & 6 deletions internal/cli/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,11 +2,14 @@ package cli

import (
"context"
"fmt"
"errors"
"net/http"

"github.com/spf13/cobra"

"github.com/tracebloc/cli/internal/api"
"github.com/tracebloc/cli/internal/cluster"
"github.com/tracebloc/cli/internal/config"
"github.com/tracebloc/cli/internal/doctor"
"github.com/tracebloc/cli/internal/ui"
)
Expand DownExpand Up@@ -75,19 +78,31 @@ func runClusterDoctor(
) error {
p.Banner("tracebloc", "cluster doctor")

// Auth / config checks run FIRST and don't need a cluster — so `doctor` can
// diagnose a failed provision (bad/expired token, wrong env, no active
// client) even before any cluster is reachable (RFC-0001 §8.5).
authStatus := runAuthChecks(ctx, p)

resolved, err := cluster.Load(cluster.KubeconfigOptions{
Path: kubeconfigPath,
Context: contextOverride,
Namespace: nsOverride,
})
if err != nil {
// 3 = kubeconfig file/parse problem (same class as cluster info).
return &exitError{code: 3, err: fmt.Errorf("loading kubeconfig: %w", err)}
// 3 = kubeconfig file/parse problem (same class as cluster info). The
// auth section above already ran; if IT also failed, escalate to 2 so
// automation doesn't read a real auth failure as a kubeconfig-only one.
p.Section("Cluster")
p.Errorf("Kubeconfig — couldn't load it: %v", err)
p.Hintf(" point --kubeconfig / --context at your cluster, or fix ~/.kube/config")
return &exitError{code: kubeconfigExitCode(authStatus), err: nil}
}

cs, err := cluster.NewClientset(resolved)
if err != nil {
return &exitError{code: 3, err: err}
p.Section("Cluster")
p.Errorf("Kubeconfig — %v", err)
return &exitError{code: kubeconfigExitCode(authStatus), err: nil}
}

p.Section("Kubeconfig")
Expand DownExpand Up@@ -116,7 +131,9 @@ func runClusterDoctor(
}

p.Newline()
switch doctor.Worst(results) {
// Overall verdict folds in the auth section, so an auth ✖/⚠ counts even when
// the cluster itself is healthy.
switch worseStatus(authStatus, doctor.Worst(results)) {
case doctor.StatusFail:
p.Errorf("Problems found — fix the ✖ items above.")
p.Hintf("For deeper triage, send tracebloc a support bundle: ./install-k8s.sh --diagnose")
Expand All@@ -127,7 +144,86 @@ func runClusterDoctor(
p.Warnf("Completed with warnings — review the ⚠ items above.")
return nil
default:
p.Successf("All checks passed — the cluster looks healthy.")
p.Successf("All checks passed — auth and cluster look healthy.")
return nil
}
}

// runAuthChecks reports on the CLI's own auth/config state (~/.tracebloc): are
// we signed in, to which env, is an active client selected, and does the backend
// still accept the token. It's the half of `cluster doctor` that diagnoses a
// failed *provision* rather than a sick cluster (RFC-0001 §8.5). Returns the
// worst status seen so the caller can fold it into the overall verdict.
func runAuthChecks(ctx context.Context, p *ui.Printer) doctor.Status {
p.Section("Auth & config")

cfg, err := config.Load()
if err != nil {
p.Errorf("Config — couldn't read the CLI config: %v", err)
p.Hintf(" check ~/.tracebloc/config.json, or run `tracebloc login` to recreate it")
return doctor.StatusFail
}
if !cfg.SignedIn() {
p.Errorf("Sign-in — not signed in")
p.Hintf(" run `tracebloc login` (add --env dev|stg|prod for a non-prod backend)")
return doctor.StatusFail
}

env := cfg.CurrentEnv
prof := cfg.Current()
if prof.Email != "" {
p.Successf("Sign-in — signed in to %s as %s", env, prof.Email)
} else {
p.Successf("Sign-in — signed in to %s", env)
}

worst := doctor.StatusOK
if prof.ActiveClientID == "" {
p.Warnf("Active client — none selected for %s", env)
p.Hintf(" run `tracebloc client use <id>` (or `tracebloc client create`) to set the client this machine enrolls as")
worst = doctor.StatusWarn
} else {
p.Successf("Active client — %s", prof.ActiveClientID)
}

// Live token check. Best-effort: an explicit 401 is a failure (expired /
// revoked → must re-login); a network/proxy error is only a warning, since
// we can't conclude the token itself is bad.
p.Detailf("verifying the token against %s …", api.BaseURL(env))
client := newAPIClient(env)
client.Token = prof.Token
if _, werr := client.WhoAmI(ctx); werr != nil {
var ae *api.APIError
if errors.As(werr, &ae) && ae.StatusCode == http.StatusUnauthorized {
p.Errorf("Backend auth — %s rejected the token (401)", api.BaseURL(env))
p.Hintf(" your session expired or was revoked — run `tracebloc login`")
return doctor.StatusFail
}
p.Warnf("Backend auth — couldn't verify the token: %v", werr)
p.Hintf(" the backend may be unreachable from here — check your network / HTTP(S)_PROXY")
return worseStatus(worst, doctor.StatusWarn)
}
p.Successf("Backend auth — token valid at %s", api.BaseURL(env))
return worst
}

// worseStatus returns the more severe of two doctor statuses (Fail > Warn > OK).
func worseStatus(a, b doctor.Status) doctor.Status {
if a == doctor.StatusFail || b == doctor.StatusFail {
return doctor.StatusFail
}
if a == doctor.StatusWarn || b == doctor.StatusWarn {
return doctor.StatusWarn
}
return doctor.StatusOK
}

// kubeconfigExitCode is 3 ("kubeconfig could not be loaded") unless the auth
// section also failed — then it escalates to 2 ("a check failed"), so a bad
// token isn't masked behind a kubeconfig-only exit code (Bugbot).
func kubeconfigExitCode(authStatus doctor.Status) int {
if authStatus == doctor.StatusFail {
return 2
}
return 3
}
Loading
Loading