diff --git a/internal/api/client.go b/internal/api/client.go index 11aba245..cb0dff1e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -177,6 +177,19 @@ func parseUpgradeRequired(raw []byte) *UpgradeRequiredError { // 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) { + return c.bodyRequest(ctx, http.MethodPost, path, body) +} + +// patch sends an authenticated PATCH (used for the adopt-backfill of cluster_id +// onto an existing client — RFC-0001 §7.2 / R7, backend#883). +func (c *Client) patch(ctx context.Context, path string, body any) (int, []byte, error) { + return c.bodyRequest(ctx, http.MethodPatch, path, body) +} + +// bodyRequest sends an authenticated JSON-body request (POST/PATCH) and returns +// the status code + raw response. Shared so POST and PATCH stay identical on +// auth, content-type, and the 426 upgrade-required handling. +func (c *Client) bodyRequest(ctx context.Context, method, path string, body any) (int, []byte, error) { var rdr io.Reader if body != nil { b, err := json.Marshal(body) @@ -186,7 +199,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (int, []byte, rdr = bytes.NewReader(b) } url := c.BaseURL + path - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rdr) + req, err := http.NewRequestWithContext(ctx, method, url, rdr) if err != nil { return 0, nil, fmt.Errorf("building request: %w", err) } @@ -194,7 +207,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (int, []byte, c.setAuth(req) resp, err := c.HTTP.Do(req) if err != nil { - return 0, nil, fmt.Errorf("POST %s: %w", url, err) + return 0, nil, fmt.Errorf("%s %s: %w", method, url, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) @@ -418,6 +431,31 @@ func (c *Client) CreateClient(ctx context.Context, req CreateClientRequest) (pc return &out, status == http.StatusOK, nil } +// PatchClientClusterID backfills the cluster anchor onto an existing client +// (RFC-0001 §7.2 / R7, backend#883). The existing fleet predates cluster_id, so +// a client that's already live in-cluster has a null anchor — and a plain create +// keyed on the freshly-read kube-system UID would match nothing and mint a +// duplicate. PATCH /edge-device/{id}/ stamps the UID onto the live client so it +// (not a new mint) owns this cluster. The backend enforces write-once: a 409 +// *APIError means the anchor is already set to a different value or is bound to +// another client (R6); a 403 *APIError means the caller lacks CLIENT_WRITE. +func (c *Client) PatchClientClusterID(ctx context.Context, id int, clusterID string) (*ProvisionedClient, error) { + path := fmt.Sprintf("/edge-device/%d/", id) + url := c.BaseURL + path + status, raw, err := c.patch(ctx, path, map[string]string{"cluster_id": clusterID}) + if err != nil { + return nil, err + } + if status < 200 || status >= 300 { + return nil, &APIError{StatusCode: status, Body: string(raw), URL: url} + } + var out ProvisionedClient + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decoding patch-client response: %w", err) + } + return &out, nil +} + // maxListPages bounds how many pages ListClients will follow — a backstop // against a misbehaving `next` chain, set well above any real account. const maxListPages = 100 diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 621c92f7..11ba575a 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -197,6 +197,50 @@ func TestCreateClientConflict(t *testing.T) { } } +func TestPatchClientClusterID(t *testing.T) { + var gotPath, gotMethod string + var sent map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + _ = json.NewDecoder(r.Body).Decode(&sent) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":7,"first_name":"c","username":"uuid-7","namespace":"ns7","cluster_id":"uid-9"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + pc, err := c.PatchClientClusterID(context.Background(), 7, "uid-9") + if err != nil { + t.Fatal(err) + } + if gotMethod != http.MethodPatch || gotPath != "/edge-device/7/" { + t.Errorf("sent %s %s, want PATCH /edge-device/7/", gotMethod, gotPath) + } + if sent["cluster_id"] != "uid-9" { + t.Errorf("body cluster_id = %q, want uid-9", sent["cluster_id"]) + } + if pc.ClusterID != "uid-9" || pc.Username != "uuid-7" { + t.Errorf("parsed = %+v, want cluster_id=uid-9 username=uuid-7", pc) + } +} + +func TestPatchClientClusterID_ConflictIsAPIError(t *testing.T) { + // Write-once / anchor-taken → 409, surfaced as a typed APIError the caller maps + // to the cross-account guidance (R6). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"cluster_conflict"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + var ae *APIError + _, err := c.PatchClientClusterID(context.Background(), 7, "uid-9") + if !errors.As(err, &ae) || ae.StatusCode != http.StatusConflict { + t.Errorf("want APIError 409, got %v", err) + } +} + // TestListClients_FollowsPagination guards that DRF pagination is still // followed end-to-end after the nextPath refactor (page 1 → page 2 → done). func TestListClients_FollowsPagination(t *testing.T) { diff --git a/internal/cli/client.go b/internal/cli/client.go index 74ad73a8..7b96542a 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -26,6 +26,11 @@ import ( // reachable cluster. var readClusterID = cluster.ClusterID +// readInClusterClient discovers a tracebloc client already live on the target +// cluster (its CLIENT_ID + namespace) — the RFC-0001 §7.2 / R7 adopt-backfill +// anchor. A package var so tests can stub it without a reachable cluster. +var readInClusterClient = cluster.DiscoverInClusterClient + // 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`. @@ -201,15 +206,44 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien } 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). - var existing []string - if clients, lerr := client.ListClients(ctx); lerr == nil { - for _, c := range clients { - // Skip the client already anchored to this cluster: a re-run adopts it - // (the backend keys on cluster_id), so its namespace isn't a collision. - // Counting it would bump the derived slug and show a namespace in the - // review that doesn't match the one actually adopted. + // One account-scoped client list, reused for R7 adopt-backfill and for + // namespace-collision avoidance on the mint path. A list failure is non-fatal + // for both (best-effort — we still derive a base slug / fall through to create). + accountClients, listErr := client.ListClients(ctx) + if listErr != nil { + ilog.Logf("client list failed (non-fatal): %v", listErr) + } + + var pc *api.ProvisionedClient + var adopted bool + // password is the freshly generated machine credential; set only on the mint + // path below and consumed only by the mint output branch (an adopt keeps the + // existing credential — §7.2). + var password string + + // R7 — existing-fleet adopt-backfill (RFC-0001 §7.2). If a client is already + // live on this cluster but its backend cluster_id is null (it predates the + // anchor), a create keyed on the freshly-read UID matches nothing and mints a + // DUPLICATE, orphaning the live client. Instead adopt the live client and + // backfill its anchor onto it. Needs a readable anchor (nothing to stamp + // otherwise); without one we fall through to a plain create (dual-mode). + if clusterID != "" { + adoptedPC, handled, aerr := adoptLiveInClusterClient(ctx, p, ilog, client, opts, accountClients, listErr, clusterID) + if aerr != nil { + return aerr + } + if handled { + pc, adopted = adoptedPC, true + } + } + + if pc == nil { + // No live client to adopt → mint (or adopt via the backend's cluster_id + // get-or-create). Derive the namespace slug, avoiding collisions with the + // account's OTHER clients — skip the one already anchored here (a re-run + // adopts it, so its namespace isn't a collision and must not bump the slug). + var existing []string + for _, c := range accountClients { if clusterID != "" && c.ClusterID == clusterID { continue } @@ -217,63 +251,61 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien existing = append(existing, c.Namespace) } } - } - namespace, err := slug.Derive(name, existing, "client-"+randHex(4)) - if err != nil { - return &exitError{code: 1, err: err} - } - - 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) + namespace, derr := slug.Derive(name, existing, "client-"+randHex(4)) + if derr != nil { + return &exitError{code: 1, err: derr} } - if !ok { - ilog.Logf("cancelled by user at the confirm prompt") - p.Hintf("Cancelled.") - return nil + + 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) + } + if !ok { + ilog.Logf("cancelled by user at the confirm prompt") + p.Hintf("Cancelled.") + return nil + } } - } - // The machine credential: the CLI generates the password, the backend stores - // 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, 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) { - 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)")} + // The machine credential: the CLI generates the password, the backend stores + // 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) + var cerr error + pc, adopted, cerr = client.CreateClient(ctx, api.CreateClientRequest{ + Name: name, + Namespace: namespace, + Location: location, + Password: password, + ClusterID: clusterID, + }) + if cerr != nil { + var ae *api.APIError + if errors.As(cerr, &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(crossAccountConflictMsg)} + } } + return &exitError{code: 1, err: cerr} } - return &exitError{code: 1, err: err} } cfg.Current().ActiveClientID = strconv.Itoa(pc.ID) 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.) + // Idempotent re-run: either the backend matched this cluster_id to an + // existing client (HTTP 200), or the R7 path above matched a live in-cluster + // client whose cluster_id was null, backfilled the anchor onto it, and + // adopted it. Either way — no new credential; the existing one stands. 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) @@ -337,6 +369,99 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien return nil } +// crossAccountConflictMsg is the guidance shown when this cluster — or the client +// already live on it — belongs to a different tracebloc account. Shared by the +// create 409 (R6) and the R7 not-owned / anchor-taken refusals so they read alike. +const crossAccountConflictMsg = "this cluster is already registered to a different tracebloc account — " + + "sign in to that account, or ask your admin (cluster_conflict)" + +// adoptLiveInClusterClient implements the RFC-0001 §7.2 / R7 adopt-backfill. It +// discovers a tracebloc client already live on the target cluster and, when the +// signed-in account owns it, backfills the cluster anchor onto it (PATCH) and +// returns it for adoption — so a re-run on a pre-anchor box reconciles the live +// client instead of minting a duplicate that orphans it. +// +// Returns (client, true, nil) when it handled provisioning (caller adopts and +// skips the mint); (nil, false, nil) when there's nothing live to adopt (caller +// mints as normal); and a non-nil error to abort — when the live client belongs +// to a DIFFERENT account (never silent-adopt across accounts — R6), when the +// anchor is already taken, or when ownership can't be verified. +func adoptLiveInClusterClient( + ctx context.Context, + p *ui.Printer, + ilog *installLog, + apiClient *api.Client, + opts clientCreateOpts, + accountClients []api.ProvisionedClient, + listErr error, + clusterID string, +) (*api.ProvisionedClient, bool, error) { + live, err := readInClusterClient(ctx, cluster.KubeconfigOptions{Path: opts.kubeconfigPath, Context: opts.contextOverride}) + if err != nil { + // Best-effort: couldn't inspect the cluster for a live client. Fall through + // to a plain create (the backend's cluster_id get-or-create still applies). + ilog.Logf("in-cluster client discovery failed (non-fatal): %v", err) + return nil, false, nil + } + if live == nil { + return nil, false, nil // fresh cluster — nothing installed to adopt + } + ilog.Logf("live in-cluster client: id=%s namespace=%s", live.ClientID, live.Namespace) + + // A client is live here — we must NOT mint over it. If the account couldn't be + // listed we can't verify ownership, so fail closed (re-run) rather than mint a + // duplicate (orphan) or adopt across accounts. + if listErr != nil { + return nil, false, &exitError{code: 1, err: fmt.Errorf( + "a tracebloc client is already running on this cluster, but listing your account to verify ownership failed (%w) — re-run once tracebloc is reachable, or resolve manually", listErr)} + } + + // Is the live client one of THIS account's? Match on the UUID auth username + // (the value stored in-cluster as CLIENT_ID); the numeric dashboard id isn't + // readable in-cluster. + var owner *api.ProvisionedClient + for i := range accountClients { + if accountClients[i].Username == live.ClientID { + owner = &accountClients[i] + break + } + } + if owner == nil { + // Live here, but not in the signed-in account — adopting it would be a silent + // cross-account takeover. Refuse (mirrors the create 409, R6). + ilog.Logf("live client %s not in this account — refusing cross-account adopt", live.ClientID) + return nil, false, &exitError{code: 1, err: errors.New(crossAccountConflictMsg)} + } + + switch { + case owner.ClusterID == "": + // The R7 case: backfill the freshly-read anchor onto the live client. + patched, perr := apiClient.PatchClientClusterID(ctx, owner.ID, clusterID) + if perr != nil { + var ae *api.APIError + switch { + case errors.As(perr, &ae) && ae.StatusCode == http.StatusConflict: + // Anchor already taken (write-once / bound elsewhere — R6). + return nil, false, &exitError{code: 1, err: errors.New(crossAccountConflictMsg)} + case errors.As(perr, &ae) && ae.StatusCode == http.StatusForbidden: + return nil, false, askAnAdmin(ctx, p, apiClient) + } + return nil, false, &exitError{code: 1, err: fmt.Errorf("backfilling the cluster anchor onto the existing client: %w", perr)} + } + ilog.Logf("backfilled cluster_id onto client id=%d", owner.ID) + owner = patched + case owner.ClusterID != clusterID: + // The live client is anchored to a DIFFERENT cluster than the one we're + // pointed at — the kubeconfig and the in-cluster client disagree. Don't + // re-anchor (write-once); surface it rather than guess. + return nil, false, &exitError{code: 1, err: fmt.Errorf( + "the client running in this namespace is anchored to a different cluster (%s) than --kubeconfig/--context points at (%s) — check you're targeting the right cluster", + owner.ClusterID, clusterID)} + } + + return owner, true, 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. diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index cf07021c..5222510e 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -44,6 +44,15 @@ func withClientBackend(t *testing.T, h http.HandlerFunc) { return "", errors.New("no cluster reachable (test default)") } t.Cleanup(func() { readClusterID = origCID }) + + // Default: no client live on the cluster, so the R7 adopt-backfill path is a + // no-op and create tests never touch a real kubeconfig. Tests that exercise R7 + // override it via stubInClusterClient. + origLive := readInClusterClient + readInClusterClient = func(context.Context, cluster.KubeconfigOptions) (*cluster.InClusterClient, error) { + return nil, nil + } + t.Cleanup(func() { readInClusterClient = origLive }) } // stubClusterID overrides the cluster-anchor read for a single test. @@ -56,6 +65,16 @@ func stubClusterID(t *testing.T, uid string, err error) { t.Cleanup(func() { readClusterID = orig }) } +// stubInClusterClient overrides the live in-cluster client discovery (R7). +func stubInClusterClient(t *testing.T, lc *cluster.InClusterClient, err error) { + t.Helper() + orig := readInClusterClient + readInClusterClient = func(context.Context, cluster.KubeconfigOptions) (*cluster.InClusterClient, error) { + return lc, err + } + t.Cleanup(func() { readInClusterClient = orig }) +} + func TestClientCreate_Success(t *testing.T) { var body api.CreateClientRequest withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { @@ -111,6 +130,102 @@ func TestClientCreate_AskAnAdmin(t *testing.T) { } } +// TestClientCreate_R7_AdoptBackfill: a client is live on this cluster with a null +// backend cluster_id (existing fleet). Create must backfill the anchor onto it +// (PATCH) and ADOPT it — never mint a duplicate (cli#131 / RFC-0001 §7.2). +func TestClientCreate_R7_AdoptBackfill(t *testing.T) { + var patchedCluster string + postCalled := false + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + // The live client is in this account, anchor still null. + _, _ = w.Write([]byte(`[{"id":7,"first_name":"box","username":"uuid-live","namespace":"ns-live","cluster_id":""}]`)) + case r.Method == http.MethodPatch && r.URL.Path == "/edge-device/7/": + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + patchedCluster = body["cluster_id"] + _, _ = w.Write([]byte(`{"id":7,"first_name":"box","username":"uuid-live","namespace":"ns-live","cluster_id":"uid-9"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/edge-device/": + postCalled = true + t.Error("mint POST must NOT be called on the R7 adopt-backfill path") + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + }) + stubClusterID(t, "uid-9", nil) + stubInClusterClient(t, &cluster.InClusterClient{ClientID: "uuid-live", Namespace: "ns-live"}, nil) + + credFile := filepath.Join(t.TempDir(), "cred.env") + var out bytes.Buffer + if err := runClientCreate(context.Background(), ui.New(&out), nil, + clientCreateOpts{name: "box", location: "DE", yes: true, credentialFile: credFile}); err != nil { + t.Fatalf("create: %v", err) + } + if postCalled { + t.Fatal("minted instead of adopting") + } + if patchedCluster != "uid-9" { + t.Errorf("backfilled cluster_id = %q, want uid-9", patchedCluster) + } + cfg, _ := config.Load() + if cfg.Current().ActiveClientID != "7" { + t.Errorf("active client = %q, want 7 (the adopted live client)", cfg.Current().ActiveClientID) + } + cred, _ := os.ReadFile(credFile) + if !strings.Contains(string(cred), "TRACEBLOC_CLIENT_ADOPTED=1") || + !strings.Contains(string(cred), "TRACEBLOC_CLIENT_ID=uuid-live") || + strings.Contains(string(cred), "TRACEBLOC_CLIENT_PASSWORD") { + t.Errorf("adopt credential file wrong (want id+ADOPTED, no password):\n%s", cred) + } +} + +// TestClientCreate_R7_AlreadyAnchored: the live client already carries this +// cluster's anchor → adopt directly, no PATCH, no mint. +func TestClientCreate_R7_AlreadyAnchored(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + _, _ = w.Write([]byte(`[{"id":7,"first_name":"box","username":"uuid-live","namespace":"ns-live","cluster_id":"uid-9"}]`)) + default: + t.Errorf("unexpected %s %s (no PATCH/POST expected)", r.Method, r.URL.Path) + } + }) + stubClusterID(t, "uid-9", nil) + stubInClusterClient(t, &cluster.InClusterClient{ClientID: "uuid-live", Namespace: "ns-live"}, nil) + + var out bytes.Buffer + if err := runClientCreate(context.Background(), ui.New(&out), nil, + clientCreateOpts{name: "box", location: "DE", yes: true}); err != nil { + t.Fatalf("create: %v", err) + } + cfg, _ := config.Load() + if cfg.Current().ActiveClientID != "7" { + t.Errorf("active client = %q, want 7", cfg.Current().ActiveClientID) + } +} + +// TestClientCreate_R7_CrossAccountRefuse: a client is live here but it isn't in +// the signed-in account — refuse rather than mint over it or silently adopt. +func TestClientCreate_R7_CrossAccountRefuse(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + _, _ = w.Write([]byte(`[]`)) // signed-in account owns no such client + default: + t.Errorf("unexpected %s %s (must refuse before PATCH/POST)", r.Method, r.URL.Path) + } + }) + stubClusterID(t, "uid-9", nil) + stubInClusterClient(t, &cluster.InClusterClient{ClientID: "uuid-foreign", Namespace: "ns"}, nil) + + err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, + clientCreateOpts{name: "box", location: "DE", yes: true}) + if err == nil || !strings.Contains(err.Error(), "different tracebloc account") { + t.Errorf("want cross-account refusal, got %v", err) + } +} + 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, clientCreateOpts{name: "x", location: "DE", yes: true}) diff --git a/internal/cluster/discover.go b/internal/cluster/discover.go index b1718f9a..5afdad0b 100644 --- a/internal/cluster/discover.go +++ b/internal/cluster/discover.go @@ -181,6 +181,60 @@ func DiscoverParentRelease(ctx context.Context, cs kubernetes.Interface, namespa return release, nil } +// InClusterClient identifies a tracebloc client already installed on the cluster: +// its CLIENT_ID (the UUID auth username the pod authenticates with) and the +// namespace its release occupies. +type InClusterClient struct { + ClientID string + Namespace string +} + +// clientChartSelector matches the chart-managed resources of a tracebloc client +// release (the same selector DiscoverParentRelease uses on Deployments). +const clientChartSelector = "app.kubernetes.io/name=client,app.kubernetes.io/managed-by=Helm" + +// DiscoverInClusterClientID finds a tracebloc client already installed on the +// cluster, if any, and returns its live CLIENT_ID + namespace (RFC-0001 §7.2 +// step 1). It locates the namespace hosting the client release (its jobs-manager +// Deployment), then reads CLIENT_ID from the chart's `-secrets` Secret +// there — scoping to that namespace avoids the node-agents mirror secret, which +// carries the same CLIENT_ID under the same labels. +// +// This anchors R7 adopt-backfill: a live client whose backend cluster_id is null +// must be adopted (and its anchor backfilled), never re-minted. Best-effort — it +// returns (nil, nil) when nothing is installed or the cluster can't be read +// (unreachable / restricted RBAC), so callers fall back to a plain create. +func DiscoverInClusterClientID(ctx context.Context, cs kubernetes.Interface) (*InClusterClient, error) { + deps, err := cs.AppsV1().Deployments(metav1.NamespaceAll).List(ctx, metav1.ListOptions{ + LabelSelector: clientChartSelector, + }) + if err != nil { + return nil, nil // best-effort: treat an unreadable cluster as "nothing installed" + } + ns := "" + for _, d := range deps.Items { + if d.Name == "jobs-manager" || strings.HasSuffix(d.Name, "-jobs-manager") { + ns = d.Namespace + break + } + } + if ns == "" { + return nil, nil // no client release on this cluster + } + secrets, err := cs.CoreV1().Secrets(ns).List(ctx, metav1.ListOptions{ + LabelSelector: clientChartSelector, + }) + if err != nil { + return nil, nil + } + for _, s := range secrets.Items { + if v, ok := s.Data["CLIENT_ID"]; ok && len(v) > 0 { + return &InClusterClient{ClientID: string(v), Namespace: ns}, nil + } + } + return nil, nil +} + // pickJobsManagerService probes for the chart's jobs-manager // Service. The chart's helper templates have used both names over // chart history: diff --git a/internal/cluster/discover_test.go b/internal/cluster/discover_test.go index 5c0921c0..b8452423 100644 --- a/internal/cluster/discover_test.go +++ b/internal/cluster/discover_test.go @@ -90,6 +90,57 @@ func TestDiscoverParentRelease_HappyPath(t *testing.T) { } } +// clientSecret builds the chart's `-secrets` Secret carrying CLIENT_ID +// (the live client's UUID username). extraLabels lets a test mimic the +// node-agents mirror, which shares the labels + CLIENT_ID in another namespace. +func clientSecret(release, namespace, clientID string, extraLabels map[string]string) *corev1.Secret { + labels := map[string]string{ + "app.kubernetes.io/name": "client", + "app.kubernetes.io/instance": release, + "app.kubernetes.io/managed-by": "Helm", + } + for k, v := range extraLabels { + labels[k] = v + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: release + "-secrets", Namespace: namespace, Labels: labels}, + Data: map[string][]byte{"CLIENT_ID": []byte(clientID)}, + } +} + +func TestDiscoverInClusterClientID_HappyPath(t *testing.T) { + const ns = "tracebloc" + cs := fake.NewClientset( + jobsManagerDeployment("tracebloc", ns, "client-1.3.5", "1.3.5", "sha256:x"), + clientSecret("tracebloc", ns, "uuid-live", nil), + // node-agents mirror: same labels + CLIENT_ID, DIFFERENT namespace. The read + // is scoped to the jobs-manager namespace, so this must be ignored. + clientSecret("tracebloc", "node-agents", "uuid-live", map[string]string{"app": "resource-monitor"}), + ) + got, err := DiscoverInClusterClientID(context.Background(), cs) + if err != nil { + t.Fatal(err) + } + if got == nil || got.ClientID != "uuid-live" || got.Namespace != ns { + t.Errorf("got %+v, want {ClientID:uuid-live Namespace:%s}", got, ns) + } +} + +func TestDiscoverInClusterClientID_NoRelease(t *testing.T) { + got, err := DiscoverInClusterClientID(context.Background(), fake.NewClientset()) + if err != nil || got != nil { + t.Errorf("empty cluster: want (nil,nil), got (%+v,%v)", got, err) + } +} + +func TestDiscoverInClusterClientID_ReleaseButNoSecret(t *testing.T) { + cs := fake.NewClientset(jobsManagerDeployment("tracebloc", "tracebloc", "client-1.3.5", "1.3.5", "d")) + got, err := DiscoverInClusterClientID(context.Background(), cs) + if err != nil || got != nil { + t.Errorf("release but no secret: want (nil,nil), got (%+v,%v)", got, err) + } +} + func TestDiscoverParentRelease_NoReleaseFound(t *testing.T) { cs := fake.NewClientset() // empty cluster diff --git a/internal/cluster/identity.go b/internal/cluster/identity.go index f33f14c6..b031491e 100644 --- a/internal/cluster/identity.go +++ b/internal/cluster/identity.go @@ -35,6 +35,24 @@ func ClusterID(ctx context.Context, opts KubeconfigOptions) (string, error) { return clusterIDFrom(ctx, cs) } +// DiscoverInClusterClient loads the target cluster from opts and discovers a live +// tracebloc client already installed on it (see DiscoverInClusterClientID — +// RFC-0001 §7.2 step 1, the anchor for R7 adopt-backfill). Mirrors ClusterID's +// best-effort, time-bounded read so `client create` never blocks on it: a load / +// connect failure returns the error and callers fall back to a plain create. +func DiscoverInClusterClient(ctx context.Context, opts KubeconfigOptions) (*InClusterClient, error) { + rc, err := Load(opts) + if err != nil { + return nil, err + } + rc.RestConfig.Timeout = clusterIDReadTimeout + cs, err := NewClientset(rc) + if err != nil { + return nil, err + } + return DiscoverInClusterClientID(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) {