From d3e58eb40e0ff7506c4bda8c2ffc8fc3d06c79d0 Mon Sep 17 00:00:00 2001 From: Andrea Nodari Date: Wed, 3 Jun 2026 12:27:14 +0200 Subject: [PATCH 1/3] Explain suspended mirrors in `repo mirror create` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `entire repo mirror create` hits a suspended placement, create reports "Mirror already exists" (it's idempotent on (repo, cluster) and ignores suspended_at) while the clone probe's token exchange fails with the opaque `invalid_target: no mirror at this URL` — the data plane's auth gate hides suspended mirrors behind invalid_target as an enumeration guard. Surface the actionable cause instead of the raw OAuth error: - auth: add the ErrRepoTargetUnknown sentinel and wrap STS invalid_target responses with it (preserving the verbatim code + description via a second %w). Detection matches the RFC 8693 code in the rendered string because auth-go's sts package flattens the OAuth error with no typed code. - repo mirror create: when the clone probe fails for a placement we just confirmed exists, print the likely cause (suspended after upstream access loss) and the exact `entire-core admin mirrors resume ` recovery command, then return a SilentError. Unrelated probe errors pass through verbatim. No server or behavior change — suspension stays operator-gated. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/auth/repo_token.go | 28 +++++++++++++++ cmd/entire/cli/auth/repo_token_test.go | 48 ++++++++++++++++++++++++++ cmd/entire/cli/repo_mirror.go | 4 +++ cmd/entire/cli/repo_mirror_probe.go | 26 ++++++++++++++ cmd/entire/cli/repo_mirror_test.go | 46 ++++++++++++++++++++++++ 5 files changed, 152 insertions(+) diff --git a/cmd/entire/cli/auth/repo_token.go b/cmd/entire/cli/auth/repo_token.go index ce74269054..4992442677 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,7 +115,25 @@ func RepoScopedToken(ctx context.Context, clusterBaseURL, repoSlug, action strin Extra: url.Values{"client_id": {provider.ClientID}}, }) if err != nil { + if isInvalidTarget(err) { + // Preserve the verbatim STS text (second %w) so a caller that + // doesn't recognise the sentinel still sees the original code + + // description. + return "", fmt.Errorf("repo-scoped token exchange: %w: %w", ErrRepoTargetUnknown, err) + } return "", fmt.Errorf("repo-scoped token exchange: %w", err) } return set.AccessToken, nil } + +// isInvalidTarget reports whether err is an STS token-exchange failure +// carrying the RFC 8693 `invalid_target` error code. auth-go's sts package +// renders the OAuth error into the message as +// "token exchange: status : invalid_target[: ]" (sts.readAPIError) +// without a typed code we could errors.As on, so we match the code token in +// the rendered string. The code alphabet is constrained to [a-z_] and the +// server's descriptions don't contain the token, so the substring match +// won't false-positive on a description. +func isInvalidTarget(err error) bool { + return strings.Contains(err.Error(), "invalid_target") +} 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..893fae23c2 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, 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..6953c86c5f 100644 --- a/cmd/entire/cli/repo_mirror_probe.go +++ b/cmd/entire/cli/repo_mirror_probe.go @@ -130,6 +130,32 @@ 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. +// +// 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, err error) (bool, error) { + if !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..57f4220b41 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,53 @@ 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, 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("unrelated error passes through untouched", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + handled, serr := explainSuspendedMirror(&buf, id, 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) { From d02bee9ba288e9a0be2c2038d09be6d32b13a5e9 Mon Sep 17 00:00:00 2001 From: Andrea Nodari Date: Wed, 3 Jun 2026 13:22:48 +0200 Subject: [PATCH 2/3] auth: detect invalid_target via typed sts.ExchangeError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the substring match on the rendered STS error with errors.As on the typed sts.ExchangeError + Code == "invalid_target", dropping the coupling to auth-go's message format. Pulls auth-go as a pseudo-version of the sts-typed-exchange-error branch (to be repointed at v0.5.0 once tagged). That build raises the stdlib floor, so bump Go to 1.26.4 to match — which also picks up the GO-2026-5037 / GO-2026-5039 standard-library fixes. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/auth/repo_token.go | 22 ++++++---------------- go.mod | 4 ++-- go.sum | 4 ++-- mise.toml | 2 +- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/cmd/entire/cli/auth/repo_token.go b/cmd/entire/cli/auth/repo_token.go index 4992442677..0371107333 100644 --- a/cmd/entire/cli/auth/repo_token.go +++ b/cmd/entire/cli/auth/repo_token.go @@ -115,25 +115,15 @@ func RepoScopedToken(ctx context.Context, clusterBaseURL, repoSlug, action strin Extra: url.Values{"client_id": {provider.ClientID}}, }) if err != nil { - if isInvalidTarget(err) { - // Preserve the verbatim STS text (second %w) so a caller that - // doesn't recognise the sentinel still sees the original code + - // description. + // 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 } - -// isInvalidTarget reports whether err is an STS token-exchange failure -// carrying the RFC 8693 `invalid_target` error code. auth-go's sts package -// renders the OAuth error into the message as -// "token exchange: status : invalid_target[: ]" (sts.readAPIError) -// without a typed code we could errors.As on, so we match the code token in -// the rendered string. The code alphabet is constrained to [a-z_] and the -// server's descriptions don't contain the token, so the substring match -// won't false-positive on a description. -func isInvalidTarget(err error) bool { - return strings.Contains(err.Error(), "invalid_target") -} 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' From 928dd85fcaa27510d141f23aee24b3e89df38ed0 Mon Sep 17 00:00:00 2001 From: Andrea Nodari Date: Wed, 3 Jun 2026 15:20:36 +0200 Subject: [PATCH 3/3] repo mirror: only diagnose suspended mirror on existing placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh create that races into invalid_target is propagation lag, not suspension — a mirror created moments ago cannot be suspended. Gate the suspended-mirror diagnosis on a non-fresh create so we don't misdirect users to a `mirrors resume` command that does nothing; let the raw error surface instead. The precondition is enforced inside explainSuspendedMirror so it can't be missed by a future caller. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: e5f7e2236cbb --- cmd/entire/cli/repo_mirror.go | 2 +- cmd/entire/cli/repo_mirror_probe.go | 10 ++++++++-- cmd/entire/cli/repo_mirror_test.go | 22 ++++++++++++++++++++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index 893fae23c2..ec839a246f 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -156,7 +156,7 @@ 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, err); handled { + if handled, serr := explainSuspendedMirror(cmd.ErrOrStderr(), created.MirrorId, created.Created, err); handled { cmd.SilenceUsage = true return serr } diff --git a/cmd/entire/cli/repo_mirror_probe.go b/cmd/entire/cli/repo_mirror_probe.go index 6953c86c5f..911fc5db04 100644 --- a/cmd/entire/cli/repo_mirror_probe.go +++ b/cmd/entire/cli/repo_mirror_probe.go @@ -139,11 +139,17 @@ func checkProbeRedirect(req *http.Request, via []*http.Request) error { // 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, err error) (bool, error) { - if !errors.Is(err, auth.ErrRepoTargetUnknown) { +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, diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index 57f4220b41..75a79dd40f 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -30,7 +30,7 @@ func TestExplainSuspendedMirror(t *testing.T) { // 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, err) + handled, serr := explainSuspendedMirror(&buf, id, false, err) if !handled { t.Fatal("expected handled=true for ErrRepoTargetUnknown") } @@ -47,10 +47,28 @@ func TestExplainSuspendedMirror(t *testing.T) { } }) + 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, errors.New("timed out waiting for initial clone")) + 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") }