diff --git a/cmd/entire/cli/auth/repo_token.go b/cmd/entire/cli/auth/repo_token.go index ce74269054..0371107333 100644 --- a/cmd/entire/cli/auth/repo_token.go +++ b/cmd/entire/cli/auth/repo_token.go @@ -12,6 +12,16 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" ) +// ErrRepoTargetUnknown reports that the cluster's STS refused the exchange +// with RFC 8693 `invalid_target`: it has no servable mirror at the +// requested audience. The placement row may well exist but be suspended — +// the data plane's auth gate deliberately hides suspended mirrors behind +// invalid_target rather than disclosing their state (an enumeration guard; +// see entiredb's validateMirrorRepoExchange). Callers that already know the +// mirror exists (e.g. the create flow's clone probe) use this to render an +// actionable message instead of the raw OAuth error. +var ErrRepoTargetUnknown = errors.New("cluster has no servable mirror at this audience") + // repoExchangeTransportForTest, when non-nil, is used as the sts.Client // transport by RepoScopedToken instead of the default. Test-only seam so // the wire form (audience / scope / client_id) can be asserted without a @@ -105,6 +115,14 @@ func RepoScopedToken(ctx context.Context, clusterBaseURL, repoSlug, action strin Extra: url.Values{"client_id": {provider.ClientID}}, }) if err != nil { + // A typed invalid_target means the cluster has no servable mirror at + // this audience (commonly a suspended placement). Surface the + // sentinel for callers that branch on it, preserving the verbatim STS + // text (second %w) for those that don't. + var xe *sts.ExchangeError + if errors.As(err, &xe) && xe.Code == "invalid_target" { + return "", fmt.Errorf("repo-scoped token exchange: %w: %w", ErrRepoTargetUnknown, err) + } return "", fmt.Errorf("repo-scoped token exchange: %w", err) } return set.AccessToken, nil diff --git a/cmd/entire/cli/auth/repo_token_test.go b/cmd/entire/cli/auth/repo_token_test.go index fb30635764..5840bc6eda 100644 --- a/cmd/entire/cli/auth/repo_token_test.go +++ b/cmd/entire/cli/auth/repo_token_test.go @@ -3,6 +3,7 @@ package auth import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -12,6 +13,53 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" ) +// statusTransport returns a canned non-200 response with the given body so +// the STS error-decoding path (sts.readAPIError) can be exercised offline. +type statusTransport struct { + status int + body string +} + +func (s statusTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: s.status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(s.body)), + Request: req, + }, nil +} + +// TestRepoScopedToken_InvalidTarget asserts that a 400 invalid_target STS +// response — what the data plane returns for a suspended (or otherwise +// non-servable) mirror — surfaces as ErrRepoTargetUnknown while still +// preserving the verbatim OAuth code + description for callers that don't +// branch on the sentinel. +func TestRepoScopedToken_InvalidTarget(t *testing.T) { + t.Setenv(api.AuthBaseURLEnvVar, "https://us.auth.entire.io") + + prevBackend := chooseBackend + chooseBackend = func() tokenBackend { return fakeBackend{token: "login.jwt.value"} } + t.Cleanup(func() { chooseBackend = prevBackend }) + + t.Cleanup(SetRepoExchangeTransportForTest(statusTransport{ + status: http.StatusBadRequest, + body: `{"error":"invalid_target","error_description":"no mirror at this URL"}`, + })) + + _, err := RepoScopedToken(context.Background(), + "https://aws-us-east-2.entire.io", "/gh/octocat/hello", "pull") + if err == nil { + t.Fatal("RepoScopedToken: expected error, got nil") + } + if !errors.Is(err, ErrRepoTargetUnknown) { + t.Errorf("error %v does not wrap ErrRepoTargetUnknown", err) + } + // Verbatim STS detail must remain in the chain. + if !strings.Contains(err.Error(), "no mirror at this URL") { + t.Errorf("error %q dropped the STS description", err) + } +} + // captureTransport records the last request's parsed form body and // returns a canned RFC 8693 token-exchange success response. type captureTransport struct { diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index d0198e6414..ec839a246f 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -156,6 +156,10 @@ func newRepoMirrorCreateCmd() *cobra.Command { return nil } if err := waitForMirrorClone(ctx, out, clusterHost, owner, repo, waitTimeout); err != nil { + if handled, serr := explainSuspendedMirror(cmd.ErrOrStderr(), created.MirrorId, created.Created, err); handled { + cmd.SilenceUsage = true + return serr + } return err } fmt.Fprintf(out, "\nClone it:\n git clone %s\n", created.MirrorUrl) diff --git a/cmd/entire/cli/repo_mirror_probe.go b/cmd/entire/cli/repo_mirror_probe.go index 0b7427d451..911fc5db04 100644 --- a/cmd/entire/cli/repo_mirror_probe.go +++ b/cmd/entire/cli/repo_mirror_probe.go @@ -130,6 +130,38 @@ func checkProbeRedirect(req *http.Request, via []*http.Request) error { return nil } +// explainSuspendedMirror translates a clone-probe failure into a clear, +// actionable message when the cluster reports no servable mirror +// (auth.ErrRepoTargetUnknown) for a placement create just confirmed exists. +// That pairing — "Mirror already exists" from create, then a refused token +// exchange — is the signature of a suspended placement: create is idempotent +// on (repo, cluster) and ignores suspended_at, while the auth gate hides +// suspended mirrors behind invalid_target. Recovery is operator-side, so we +// name the exact resume command rather than leaking the raw OAuth error. +// +// freshCreate gates the diagnosis: a mirror created moments ago cannot be +// suspended (suspension only happens after upstream access is lost), so an +// invalid_target on a fresh create is propagation lag, not suspension. +// Diagnosing that as "suspended" would misdirect the user to a resume command +// that does nothing, so we decline (handled=false) and let the raw error surface. +// +// Returns handled=false for any other error so the caller surfaces it +// verbatim. When handled, the message is already written to w and the +// returned error is a SilentError so main.go won't reprint it. +func explainSuspendedMirror(w io.Writer, mirrorID string, freshCreate bool, err error) (bool, error) { + if freshCreate || !errors.Is(err, auth.ErrRepoTargetUnknown) { + return false, nil + } + fmt.Fprintf(w, + "\nMirror %s is registered but the cluster won't issue clone tokens for it.\n"+ + "This usually means the placement is suspended after upstream GitHub access\n"+ + "was lost (App uninstalled, the repo went private, or a transient API error).\n"+ + "An operator can re-enable it once access is restored:\n"+ + " entire-core admin mirrors resume %s\n", + mirrorID, mirrorID) + return true, NewSilentError(fmt.Errorf("mirror %s is suspended", mirrorID)) +} + // waitForMirrorClone blocks until the mirror at /gh// on // clusterHost advertises a resolvable HEAD (the initial GitHub→EntireDB // clone has landed) or the deadline expires. It probes the data plane's diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index a699368c49..75a79dd40f 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -1,7 +1,9 @@ package cli import ( + "bytes" "context" + "errors" "fmt" "net" "net/http" @@ -14,9 +16,71 @@ import ( "github.com/stretchr/testify/require" + "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/internal/coreapi" ) +func TestExplainSuspendedMirror(t *testing.T) { + t.Parallel() + const id = "01KS6KFJR2XS6PZ188MVYE07AN" + + t.Run("suspended mirror is explained with resume command", func(t *testing.T) { + t.Parallel() + // Wrap the sentinel the way RepoScopedToken/waitForMirrorClone do, to + // prove detection survives the wrapping chain. + err := fmt.Errorf("authorize clone probe: %w", fmt.Errorf("repo-scoped token exchange: %w", auth.ErrRepoTargetUnknown)) + var buf bytes.Buffer + handled, serr := explainSuspendedMirror(&buf, id, false, err) + if !handled { + t.Fatal("expected handled=true for ErrRepoTargetUnknown") + } + var silent *SilentError + if !errors.As(serr, &silent) { + t.Errorf("expected a SilentError, got %T: %v", serr, serr) + } + out := buf.String() + if !strings.Contains(out, id) { + t.Errorf("message %q omits the mirror id", out) + } + if !strings.Contains(out, "entire-core admin mirrors resume "+id) { + t.Errorf("message %q omits the resume command", out) + } + }) + + t.Run("fresh create passes invalid_target through as propagation lag", func(t *testing.T) { + t.Parallel() + // Same invalid_target signature, but on a just-created placement it's + // eventual-consistency lag, not suspension — don't misdirect to resume. + err := fmt.Errorf("authorize clone probe: %w", fmt.Errorf("repo-scoped token exchange: %w", auth.ErrRepoTargetUnknown)) + var buf bytes.Buffer + handled, serr := explainSuspendedMirror(&buf, id, true, err) + if handled { + t.Error("expected handled=false for a fresh create") + } + if serr != nil { + t.Errorf("expected nil error, got %v", serr) + } + if buf.Len() != 0 { + t.Errorf("expected no output, got %q", buf.String()) + } + }) + + t.Run("unrelated error passes through untouched", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + handled, serr := explainSuspendedMirror(&buf, id, false, errors.New("timed out waiting for initial clone")) + if handled { + t.Error("expected handled=false for an unrelated error") + } + if serr != nil { + t.Errorf("expected nil error, got %v", serr) + } + if buf.Len() != 0 { + t.Errorf("expected no output, got %q", buf.String()) + } + }) +} + // TestParseGitHubURL is ported from entiredb's cmd/entire-repo/cli // mirror_test.go, since parseGitHubURL was carried over verbatim. func TestParseGitHubURL(t *testing.T) { diff --git a/go.mod b/go.mod index 47fa750e07..23c4e3c977 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/entireio/cli -go 1.26.3 +go 1.26.4 require ( charm.land/bubbles/v2 v2.1.0 @@ -12,7 +12,7 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 github.com/creack/pty v1.1.24 github.com/denisbrodbeck/machineid v1.0.1 - github.com/entireio/auth-go v0.4.0 + github.com/entireio/auth-go v0.4.1-0.20260603125945-62cd5140d2d4 github.com/go-faster/errors v0.7.1 github.com/go-faster/jx v1.2.0 github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 diff --git a/go.sum b/go.sum index a781f55086..a3642cf871 100644 --- a/go.sum +++ b/go.sum @@ -109,8 +109,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/entireio/auth-go v0.4.0 h1:2Z12fsIKOEoDNMsk77AWDLu45z54rZzs1rBozLF2ddM= -github.com/entireio/auth-go v0.4.0/go.mod h1:TGgA/d21dPPNL4yYO+gqU+ZfS1Hcr8dU2303nLcVz4U= +github.com/entireio/auth-go v0.4.1-0.20260603125945-62cd5140d2d4 h1:apyo1X5SUGjbvFJyzaw2WSTTq7suvtYZ5JMA83lbx7M= +github.com/entireio/auth-go v0.4.1-0.20260603125945-62cd5140d2d4/go.mod h1:eqFYgiNSBw6HXYR3j8DRW0/WTV1dX3SWxr2D6YCYNQ0= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/semgroup v1.2.0 h1:h/OLXwEM+3NNyAdZEpMiH1OzfplU09i2qXPVThGZvyg= diff --git a/mise.toml b/mise.toml index 8cdfceecc4..2a93ca19a6 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,6 @@ [tools] # Please also keep the version aligned in the go.mod file -go = { version = '1.26.3', postinstall = "go install github.com/go-delve/delve/cmd/dlv@latest && go install gotest.tools/gotestsum@latest" } +go = { version = '1.26.4', postinstall = "go install github.com/go-delve/delve/cmd/dlv@latest && go install gotest.tools/gotestsum@latest" } golangci-lint = '2.11.3' shellcheck = 'latest' tmux = 'latest'