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
62 changes: 53 additions & 9 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -356,9 +357,10 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien
case http.StatusForbidden:
return askAnAdmin(ctx, p, client, "provision a client", "provisioning")
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)}
// A 409 on POST /edge-device/ is a cross-account cluster_conflict
// (R6) or a same-account cluster_in_use; conflictMessage picks the
// right guidance (and names the owner when the backend supplies it).
return &exitError{code: 1, err: errors.New(conflictMessage(ae))}
}
}
return &exitError{code: 1, err: cerr}
Expand Down Expand Up @@ -437,10 +439,49 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien
}

// 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)"
// already live on it — belongs to another tracebloc account, and we don't have the
// owner's contact (a client-side refusal, or a backend without the owner_email in
// its 409 body). Contact-the-owner first (never "delete the cluster" — it isn't
// ours to wipe). conflictMessage() enriches this with the owner's email when the
// backend supplies it.
const crossAccountConflictMsg = "this cluster is already registered to another tracebloc account — " +
"ask its owner to release it, or sign in as that account (cluster_conflict)"

// conflictMessage turns a provisioning 409 body into user guidance. Fix #2 has the
// backend distinguish a genuine cross-account conflict (cluster_conflict, now
// carrying the owner's contact email so the user knows who to ask) from a
// same-account live sibling (cluster_in_use — two clients fighting over one
// cluster). Degrades to the generic cross-account text when the body isn't
// parseable (a backend predating fix #2) or carries no owner.
func conflictMessage(ae *api.APIError) string {
var body struct {
Error string `json:"error"`
OwnerEmail string `json:"owner_email"`
HolderName string `json:"holder_name"`
}
if ae != nil {
_ = json.Unmarshal([]byte(ae.Body), &body)
}
switch body.Error {
case "cluster_in_use":
who := body.HolderName
if who == "" {
who = "another of your clients"
}
return fmt.Sprintf(
"another tracebloc client (%s) in your account is already live on this cluster — "+
"offboard it first with `tracebloc delete`, or provision on a separate machine (cluster_in_use)",
who)
default: // cluster_conflict, or an unrecognized / empty body
if body.OwnerEmail != "" {
return fmt.Sprintf(
"this cluster is already registered to another tracebloc account (%s) — "+
"ask them to release it, or sign in as that account (cluster_conflict)",
body.OwnerEmail)
}
return crossAccountConflictMsg
}
}

// adoptLiveInClusterClient implements the RFC-0001 §7.2 / R7 adopt-backfill. It
// discovers a tracebloc client already live on the target cluster and, when the
Expand Down Expand Up @@ -518,8 +559,11 @@ func adoptLiveInClusterClient(
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)}
// Anchor held by another client: cross-account (cluster_conflict, now
// naming the owner) or a same-account live sibling (cluster_in_use).
// Fix #2's same-account reclaim means this no longer fires for a stale
// same-account holder — that path now succeeds (200).
return nil, false, &exitError{code: 1, err: errors.New(conflictMessage(ae))}
case errors.As(perr, &ae) && ae.StatusCode == http.StatusForbidden:
return nil, false, askAnAdmin(ctx, p, apiClient, "provision a client", "provisioning")
}
Expand Down
50 changes: 48 additions & 2 deletions internal/cli/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ func TestClientCreate_R7_CrossAccountRefuse(t *testing.T) {

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") {
if err == nil || !strings.Contains(err.Error(), "registered to another tracebloc account") {
t.Errorf("want cross-account refusal, got %v", err)
}
}
Expand Down Expand Up @@ -658,11 +658,57 @@ func TestClientCreate_ClusterConflict(t *testing.T) {
})
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") {
if err == nil || !strings.Contains(err.Error(), "registered to another tracebloc account") {
t.Errorf("want a cluster_conflict error, got %v", err)
}
}

// TestClientCreate_ClusterConflict_RevealsOwnerEmail: fix #2 has the backend put the
// owning account's contact email in the cross-account 409 body; the CLI surfaces it
// so the user knows who to ask to release the cluster.
func TestClientCreate_ClusterConflict_RevealsOwnerEmail(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)
_, _ = w.Write([]byte(`{"error":"cluster_conflict","cluster_id":"uid-1","owner_email":"owner@other.test"}`))
}
})
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(), "owner@other.test") {
t.Errorf("want the owner email surfaced, got %v", err)
}
if !strings.Contains(err.Error(), "ask them to release it") {
t.Errorf("want contact-the-owner guidance, got %v", err)
}
}

// TestClientCreate_ClusterInUse: a same-account live sibling already holds the
// anchor (fix #2's cluster_in_use). The message names the live client and points at
// offboarding it — NOT a cross-account "different account" message.
func TestClientCreate_ClusterInUse(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)
_, _ = w.Write([]byte(`{"error":"cluster_in_use","cluster_id":"uid-1","holder_client_id":42,"holder_name":"other-box"}`))
}
})
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(), "other-box") || !strings.Contains(err.Error(), "cluster_in_use") {
t.Errorf("want a cluster_in_use message naming the live client, got %v", err)
}
if strings.Contains(err.Error(), "another tracebloc account") {
t.Errorf("cluster_in_use must NOT read as a cross-account conflict, got %v", err)
}
}

func TestClientCreate_NoClusterAnchorWarns(t *testing.T) {
var body api.CreateClientRequest
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading