diff --git a/internal/api/client.go b/internal/api/client.go index a6366202..fbd97bb6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -262,6 +262,9 @@ type ProvisionedClient struct { Namespace string `json:"namespace"` Location string `json:"location"` Status int `json:"status"` + // ClusterID is the kube-system namespace UID this client is anchored to + // (RFC-0001 §6.3 / backend#883). Empty on legacy / not-yet-backfilled clients. + ClusterID string `json:"cluster_id"` } // CreateClientRequest is the POST /edge-device/ body. The account is stamped @@ -272,6 +275,10 @@ type CreateClientRequest struct { Namespace string `json:"namespace"` Location string `json:"location"` Password string `json:"password"` + // ClusterID anchors the client to this cluster (the kube-system namespace UID) + // so create is get-or-create keyed on it (RFC-0001 §7.2 / backend#883). Omitted + // when the cluster identity can't be read (dual-mode / legacy → plain mint). + ClusterID string `json:"cluster_id,omitempty"` } // AdminContact is one "ask an admin" entry from GET /edge-device/admins/. @@ -280,23 +287,27 @@ type AdminContact struct { Email string `json:"email"` } -// CreateClient provisions a client. A 403 *APIError means the caller lacks -// CLIENT_WRITE — callers fall back to ListClientAdmins for the ask-an-admin -// path (backend#836 Q4). -func (c *Client) CreateClient(ctx context.Context, req CreateClientRequest) (*ProvisionedClient, error) { +// CreateClient provisions a client — get-or-create keyed on cluster_id when one +// is supplied (RFC-0001 §7.2 / backend#883). The returned `adopted` is true when +// the backend matched an existing client for this cluster (HTTP 200, an idempotent +// re-run) and false when it minted a new one (HTTP 201). A 403 *APIError means the +// caller lacks CLIENT_WRITE (→ ask-an-admin, backend#836 Q4); a 409 *APIError means +// the cluster is bound to another account (cluster_conflict, R6). +func (c *Client) CreateClient(ctx context.Context, req CreateClientRequest) (pc *ProvisionedClient, adopted bool, err error) { url := c.BaseURL + "/edge-device/" status, raw, err := c.post(ctx, "/edge-device/", req) if err != nil { - return nil, err + return nil, false, err } if status < 200 || status >= 300 { - return nil, &APIError{StatusCode: status, Body: string(raw), URL: url} + return nil, false, &APIError{StatusCode: status, Body: string(raw), URL: url} } var out ProvisionedClient if err := json.Unmarshal(raw, &out); err != nil { - return nil, fmt.Errorf("decoding create-client response: %w", err) + return nil, false, fmt.Errorf("decoding create-client response: %w", err) } - return &out, nil + // 200 = adopted an existing client for this cluster_id; 201 = freshly minted. + return &out, status == http.StatusOK, nil } // maxListPages bounds how many pages ListClients will follow — a backstop diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 41af4648..f5fbdcd1 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -138,3 +139,58 @@ func TestWhoAmIUnauthorized(t *testing.T) { t.Errorf("want APIError 401, got %v", err) } } + +func TestCreateClientMintAndAdopt(t *testing.T) { + for _, tc := range []struct { + name string + code int + wantAdopted bool + }{ + {"mint", http.StatusCreated, false}, + {"adopt", http.StatusOK, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var sent CreateClientRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/edge-device/" || r.Method != http.MethodPost { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&sent) + w.WriteHeader(tc.code) + _, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","cluster_id":"uid-1"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + pc, adopted, err := c.CreateClient(context.Background(), + CreateClientRequest{Name: "c", Namespace: "c", Password: "pw", ClusterID: "uid-1"}) + if err != nil { + t.Fatal(err) + } + if adopted != tc.wantAdopted { + t.Errorf("adopted = %v, want %v", adopted, tc.wantAdopted) + } + if sent.ClusterID != "uid-1" { + t.Errorf("cluster_id sent = %q, want uid-1", sent.ClusterID) + } + if pc.ClusterID != "uid-1" { + t.Errorf("cluster_id parsed = %q, want uid-1", pc.ClusterID) + } + }) + } +} + +func TestCreateClientConflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"cluster_conflict","cluster_id":"uid-1"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + var ae *APIError + _, _, err := c.CreateClient(context.Background(), CreateClientRequest{ClusterID: "uid-1"}) + if !errors.As(err, &ae) || ae.StatusCode != http.StatusConflict { + t.Errorf("want APIError 409, got %v", err) + } +} diff --git a/internal/cli/client.go b/internal/cli/client.go index be66b411..481ddf7f 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -13,11 +13,17 @@ import ( "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/slug" "github.com/tracebloc/cli/internal/ui" ) +// readClusterID reads the cluster's kube-system UID — the RFC-0001 idempotency +// anchor (§7.2 / backend#883). A package var so tests can stub it without a +// reachable cluster. +var readClusterID = cluster.ClusterID + // newClientCmd wires the `tracebloc client` subtree — provisioning + selecting // the client (machine) this host enrolls as. Consumes the backend provisioning // endpoints (backend#836) with the user token from `tracebloc login`. @@ -33,24 +39,35 @@ in your account. Requires sign-in first (` + "`tracebloc login`" + `).`, } func newClientCreateCmd() *cobra.Command { - var name, location string + var name, location, kubeconfigPath, contextOverride string var yes bool cmd := &cobra.Command{ Use: "create", Short: "Provision a new client for this machine (--name, --location)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return runClientCreate(cmd.Context(), printerFor(cmd), clientPrompter(), name, location, yes) + return runClientCreate(cmd.Context(), printerFor(cmd), clientPrompter(), + clientCreateOpts{name: name, location: location, kubeconfigPath: kubeconfigPath, contextOverride: contextOverride, yes: yes}) }, } cmd.Flags().StringVar(&name, "name", "", "human-readable client name (shown on your dashboard + carbon reports)") cmd.Flags().StringVar(&location, "location", "", "location zone for carbon footprint (e.g. DE); prompted if omitted") + cmd.Flags().StringVar(&kubeconfigPath, "kubeconfig", "", + "path to the kubeconfig for the target cluster (default: $KUBECONFIG, then ~/.kube/config) — read to anchor the client to this cluster") + cmd.Flags().StringVar(&contextOverride, "context", "", + "kubeconfig context for the target cluster (default: current-context)") cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt") return cmd } +// clientCreateOpts bundles the `client create` inputs (flags + resolved prompts). +type clientCreateOpts struct { + name, location, kubeconfigPath, contextOverride string + yes bool +} + func newClientListCmd() *cobra.Command { return &cobra.Command{ Use: "list", @@ -102,12 +119,14 @@ func authedClient() (*api.Client, *config.Config, error) { return client, cfg, nil } -func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, name, location string, yes bool) error { +func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clientCreateOpts) error { client, cfg, err := authedClient() if err != nil { return &exitError{code: 1, err: err} } + name, location := opts.name, opts.location + // Gather inputs first (flags win; prompt only what's missing, and only on a // TTY), then show one review + confirm — matching the dataset-push flow. if name == "" { @@ -129,6 +148,16 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, name, loca } } + // 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 + // minting a duplicate (RFC-0001 §7.2 / backend#883). Best-effort + never silent: + // if the cluster isn't reachable we provision WITHOUT an anchor (a plain mint) + // and say so, rather than blocking. + clusterID, cidErr := readClusterID(ctx, cluster.KubeconfigOptions{Path: opts.kubeconfigPath, Context: opts.contextOverride}) + 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.") + } + // 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). var existing []string @@ -144,8 +173,8 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, name, loca return &exitError{code: 1, err: err} } - if pr != nil && !yes { - renderClientReview(p, name, namespace, location) + if pr != nil && !opts.yes { + renderClientReview(p, name, namespace, location, clusterID) ok, cerr := pr.Confirm("Provision this client?", true) if cerr != nil { return mapClientErr(cerr) @@ -157,33 +186,62 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, name, loca } // The machine credential: the CLI generates the password, the backend stores - // it (write-only). The client-runtime authenticates with username+password. + // it (write-only). Sent on every create but used only when minting — on an + // idempotent adopt the backend keeps the existing client's credential (§7.2), + // so the generated value is never printed in that case. password := randHex(24) - pc, err := client.CreateClient(ctx, api.CreateClientRequest{ + pc, adopted, err := client.CreateClient(ctx, api.CreateClientRequest{ Name: name, Namespace: namespace, Location: location, Password: password, + ClusterID: clusterID, }) if err != nil { var ae *api.APIError - if errors.As(err, &ae) && ae.StatusCode == http.StatusForbidden { - return askAnAdmin(ctx, p, client) + if errors.As(err, &ae) { + switch ae.StatusCode { + case http.StatusForbidden: + return askAnAdmin(ctx, p, client) + case http.StatusConflict: + // Per RFC C.3 the only 409 on POST /edge-device/ is cluster_conflict + // (R6): this cluster_id is bound to another account. + return &exitError{code: 1, err: errors.New( + "this cluster is already registered to a different tracebloc account — " + + "sign in to that account, or ask your admin (cluster_conflict)")} + } } return &exitError{code: 1, err: err} } cfg.ActiveClientID = strconv.Itoa(pc.ID) - if serr := cfg.Save(); serr != nil { - return &exitError{code: 1, err: serr} - } p.Newline() + if adopted { + // Idempotent re-run: the backend matched this cluster_id to an existing + // client and returned it — no new credential. (The existing-fleet R7 case, + // 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.) + 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) + // Mirror the mint path: a config-save failure shouldn't bury the result — + // hint how to set the pointer by hand and still exit clean. + 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 + } + // Mint: 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.Successf("Provisioned client %q (namespace %s).", pc.Name, pc.Namespace) 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) p.Field("password", password) + 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 } @@ -255,11 +313,14 @@ func runClientUse(ctx context.Context, p *ui.Printer, id string) error { // renderClientReview shows the assembled inputs before the confirm prompt, so // the user sees the derived namespace and location before anything is created. -func renderClientReview(p *ui.Printer, name, namespace, location string) { +func renderClientReview(p *ui.Printer, name, namespace, location, clusterID string) { p.Section("Review") p.Field("name", name) p.Field("namespace", namespace) p.Field("location", location) + if clusterID != "" { + p.Field("cluster", clusterID+" (anchors this client — re-runs adopt it)") + } } // errMissingFlag reports a required flag absent in a non-interactive run (no TTY diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index d4ac0e44..5d77cb7b 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -11,12 +12,15 @@ import ( "testing" "github.com/tracebloc/cli/internal/api" + "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/config" "github.com/tracebloc/cli/internal/ui" ) // withClientBackend points the client commands at an httptest server (via the -// newAPIClient seam) and writes a signed-in config to a temp dir. +// newAPIClient seam) and writes a signed-in config to a temp dir. It also stubs +// readClusterID to "no cluster" by default, so create tests never touch a real +// kubeconfig/cluster — tests that exercise the anchor override it via stubClusterID. func withClientBackend(t *testing.T, h http.HandlerFunc) { t.Helper() srv := httptest.NewServer(h) @@ -30,6 +34,22 @@ func withClientBackend(t *testing.T, h http.HandlerFunc) { return &api.Client{BaseURL: srv.URL, HTTP: srv.Client()} } t.Cleanup(func() { newAPIClient = orig }) + + origCID := readClusterID + readClusterID = func(context.Context, cluster.KubeconfigOptions) (string, error) { + return "", errors.New("no cluster reachable (test default)") + } + t.Cleanup(func() { readClusterID = origCID }) +} + +// stubClusterID overrides the cluster-anchor read for a single test. +func stubClusterID(t *testing.T, uid string, err error) { + t.Helper() + orig := readClusterID + readClusterID = func(context.Context, cluster.KubeconfigOptions) (string, error) { + return uid, err + } + t.Cleanup(func() { readClusterID = orig }) } func TestClientCreate_Success(t *testing.T) { @@ -50,7 +70,7 @@ func TestClientCreate_Success(t *testing.T) { } }) var out bytes.Buffer - if err := runClientCreate(context.Background(), ui.New(&out), nil, "my-client", "DE", true); err != nil { + if err := runClientCreate(context.Background(), ui.New(&out), nil, clientCreateOpts{name: "my-client", location: "DE", yes: true}); err != nil { t.Fatalf("create: %v", err) } if body.Namespace != "my-client" || body.Location != "DE" || body.Password == "" { @@ -78,7 +98,7 @@ func TestClientCreate_AskAnAdmin(t *testing.T) { } }) var out bytes.Buffer - err := runClientCreate(context.Background(), ui.New(&out), nil, "my-client", "DE", true) + err := runClientCreate(context.Background(), ui.New(&out), nil, clientCreateOpts{name: "my-client", location: "DE", yes: true}) if err == nil || !strings.Contains(err.Error(), "CLIENT_WRITE") { t.Errorf("want permission error, got %v", err) } @@ -89,7 +109,7 @@ func TestClientCreate_AskAnAdmin(t *testing.T) { func TestClientCreate_RequiresLogin(t *testing.T) { t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) // no config → not signed in - err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, "x", "DE", true) + err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, clientCreateOpts{name: "x", location: "DE", yes: true}) if err == nil || !strings.Contains(err.Error(), "login") { t.Errorf("want not-signed-in error, got %v", err) } @@ -149,7 +169,7 @@ func TestClientCreate_Interactive(t *testing.T) { confirm: &confirmYes, } var out bytes.Buffer - if err := runClientCreate(context.Background(), ui.New(&out), pr, "", "", false); err != nil { + if err := runClientCreate(context.Background(), ui.New(&out), pr, clientCreateOpts{}); err != nil { t.Fatalf("interactive create: %v", err) } if !posted { @@ -180,7 +200,7 @@ func TestClientCreate_InteractiveCancel(t *testing.T) { confirm: &confirmNo, } var out bytes.Buffer - if err := runClientCreate(context.Background(), ui.New(&out), pr, "", "", false); err != nil { + if err := runClientCreate(context.Background(), ui.New(&out), pr, clientCreateOpts{}); err != nil { t.Fatalf("declining the confirm should be a clean exit, got: %v", err) } if posted { @@ -224,10 +244,129 @@ func TestClientCreate_CollisionSuffix(t *testing.T) { _, _ = w.Write([]byte(`{"id":2,"first_name":"My Client","username":"u-2","namespace":"my-client-2","location":"DE"}`)) } }) - if err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, "My Client", "DE", true); err != nil { + if err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, clientCreateOpts{name: "My Client", location: "DE", yes: true}); err != nil { t.Fatal(err) } if body.Namespace != "my-client-2" { t.Errorf("namespace = %q, want my-client-2 (collision suffix not applied)", body.Namespace) } } + +func TestClientCreate_AnchorMint(t *testing.T) { + var body api.CreateClientRequest + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet: + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) // 201 = minted + _, _ = w.Write([]byte(`{"id":5,"first_name":"c","username":"u-5","namespace":"c","location":"DE","cluster_id":"uid-1"}`)) + } + }) + stubClusterID(t, "uid-1", nil) + var out bytes.Buffer + if err := runClientCreate(context.Background(), ui.New(&out), nil, clientCreateOpts{name: "c", location: "DE", yes: true}); err != nil { + t.Fatalf("create: %v", err) + } + if body.ClusterID != "uid-1" { + t.Errorf("cluster_id sent = %q, want uid-1 (anchor not wired into the request)", body.ClusterID) + } + if !strings.Contains(out.String(), "Machine credential") { + t.Errorf("mint should print the credential, got:\n%s", out.String()) + } +} + +func TestClientCreate_AdoptIdempotent(t *testing.T) { + posts := 0 + var lastBody api.CreateClientRequest + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet: + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodPost: + posts++ + _ = json.NewDecoder(r.Body).Decode(&lastBody) + w.WriteHeader(http.StatusOK) // 200 = adopted an existing client + _, _ = w.Write([]byte(`{"id":8,"first_name":"existing","username":"u-8","namespace":"existing","location":"DE","cluster_id":"uid-1"}`)) + } + }) + stubClusterID(t, "uid-1", nil) + + run := func() string { + var out bytes.Buffer + if err := runClientCreate(context.Background(), ui.New(&out), nil, clientCreateOpts{name: "c", location: "DE", yes: true}); err != nil { + t.Fatalf("adopt: %v", err) + } + return out.String() + } + // Two runs against the same cluster — real re-run idempotency: each POSTs once, + // both adopt the SAME existing client, neither prints a credential. + first, second := run(), run() + if posts != 2 { + t.Fatalf("posts = %d, want 2 (each run POSTs once)", posts) + } + for i, out := range []string{first, second} { + if !strings.Contains(out, "adopted") { + t.Errorf("run %d: adopt should say so, got:\n%s", i, out) + } + if strings.Contains(out, "Machine credential") { + t.Errorf("run %d: adopt must NOT print a credential, got:\n%s", i, out) + } + } + // The credential is still SENT on every create (the backend uses it only on a + // mint, §7.2) even though it's never printed on adopt. + if lastBody.Password == "" { + t.Error("password should still be sent in the adopt POST body") + } + cfg, _ := config.Load() + if cfg.ActiveClientID != "8" { + t.Errorf("active client = %q, want 8 (adopted id)", cfg.ActiveClientID) + } +} + +func TestClientCreate_ClusterConflict(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet: + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusConflict) // 409 = bound to another account (R6) + _, _ = w.Write([]byte(`{"error":"cluster_conflict","cluster_id":"uid-1"}`)) + } + }) + stubClusterID(t, "uid-1", nil) + err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, clientCreateOpts{name: "c", location: "DE", yes: true}) + if err == nil || !strings.Contains(err.Error(), "different tracebloc account") { + t.Errorf("want a cluster_conflict error, got %v", err) + } +} + +func TestClientCreate_NoClusterAnchorWarns(t *testing.T) { + var body api.CreateClientRequest + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet: + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":3,"first_name":"c","username":"u-3","namespace":"c","location":"DE"}`)) + } + }) + // readClusterID left at the withClientBackend default (returns an error). + var out bytes.Buffer + if err := runClientCreate(context.Background(), ui.New(&out), nil, clientCreateOpts{name: "c", location: "DE", yes: true}); err != nil { + t.Fatalf("create: %v", err) + } + if body.ClusterID != "" { + t.Errorf("cluster_id sent = %q, want empty (no anchor when cluster unreadable)", body.ClusterID) + } + if !strings.Contains(out.String(), "without a cluster anchor") { + t.Errorf("expected a never-silent hint about the missing anchor, got:\n%s", out.String()) + } + // The no-anchor path must still complete a full mint — the credential is shown. + if !strings.Contains(out.String(), "Machine credential") { + t.Errorf("no-anchor fallback should still print the credential, got:\n%s", out.String()) + } +} diff --git a/internal/cluster/identity.go b/internal/cluster/identity.go new file mode 100644 index 00000000..f33f14c6 --- /dev/null +++ b/internal/cluster/identity.go @@ -0,0 +1,50 @@ +package cluster + +import ( + "context" + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// clusterIDReadTimeout bounds the best-effort anchor read in ClusterID: a +// kubeconfig pointing at an unreachable API server would otherwise hang the +// kube-system GET for the OS TCP timeout, stalling a `client create` that is +// meant to degrade to a non-anchored mint instead. +const clusterIDReadTimeout = 8 * time.Second + +// ClusterID reads the kube-system namespace UID — the stable per-cluster +// fingerprint RFC-0001 keys client idempotency on (§6.3 / §7.2; backend#883). +// It needs a reachable cluster and RBAC to GET namespaces/kube-system. Callers +// treat a failure as "couldn't read the cluster identity" and fall back to a +// non-anchored (dual-mode) provision — they must not block on it. +func ClusterID(ctx context.Context, opts KubeconfigOptions) (string, error) { + rc, err := Load(opts) + if err != nil { + return "", err + } + // Best-effort read — callers must not block on it (see the doc above), so cap + // it; otherwise an unreachable API server hangs the GET below. + rc.RestConfig.Timeout = clusterIDReadTimeout + cs, err := NewClientset(rc) + if err != nil { + return "", err + } + return clusterIDFrom(ctx, cs) +} + +// clusterIDFrom reads the kube-system UID from a clientset. Split out so it can be +// exercised with a fake clientset without a real cluster. +func clusterIDFrom(ctx context.Context, cs kubernetes.Interface) (string, error) { + ns, err := cs.CoreV1().Namespaces().Get(ctx, "kube-system", metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("reading kube-system namespace UID: %w", err) + } + uid := string(ns.UID) + if uid == "" { + return "", fmt.Errorf("kube-system namespace has no UID") + } + return uid, nil +} diff --git a/internal/cluster/identity_test.go b/internal/cluster/identity_test.go new file mode 100644 index 00000000..63c6d461 --- /dev/null +++ b/internal/cluster/identity_test.go @@ -0,0 +1,30 @@ +package cluster + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestClusterIDFrom(t *testing.T) { + cs := fake.NewClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "kube-system", UID: "uid-abc-123"}, + }) + got, err := clusterIDFrom(context.Background(), cs) + if err != nil { + t.Fatal(err) + } + if got != "uid-abc-123" { + t.Errorf("cluster id = %q, want uid-abc-123", got) + } +} + +func TestClusterIDFrom_NoNamespace(t *testing.T) { + cs := fake.NewClientset() // empty cluster — no kube-system + if _, err := clusterIDFrom(context.Background(), cs); err == nil { + t.Error("expected an error when kube-system is absent") + } +}