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
18 changes: 18 additions & 0 deletions cmd/entire/cli/auth/repo_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions cmd/entire/cli/auth/repo_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/repo_mirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
nodo marked this conversation as resolved.
fmt.Fprintf(out, "\nClone it:\n git clone %s\n", created.MirrorUrl)
Expand Down
32 changes: 32 additions & 0 deletions cmd/entire/cli/repo_mirror_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<repo> on
// clusterHost advertises a resolvable HEAD (the initial GitHub→EntireDB
// clone has landed) or the deadline expires. It probes the data plane's
Expand Down
64 changes: 64 additions & 0 deletions cmd/entire/cli/repo_mirror_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cli

import (
"bytes"
"context"
"errors"
"fmt"
"net"
"net/http"
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading