diff --git a/internal/cmd/plugin/helpers.go b/internal/cmd/plugin/helpers.go index 7c7ee37..d8be9c8 100644 --- a/internal/cmd/plugin/helpers.go +++ b/internal/cmd/plugin/helpers.go @@ -9,6 +9,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -23,9 +24,24 @@ import ( "golang.org/x/mod/semver" + customerrors "go.datum.net/datumctl/internal/errors" "go.datum.net/datumctl/internal/pluginstore" ) +// indexFetchUserError converts a RefreshIndex failure into a user-facing error, +// attaching actionable guidance when the cause is recognizable (e.g. a GitHub +// token in the environment being rejected by the index host). +func indexFetchUserError(err error) error { + msg := "could not fetch the plugin index: " + err.Error() + var fe *pluginstore.IndexFetchError + if errors.As(err, &fe) { + if hint := fe.Hint(); hint != "" { + return customerrors.NewUserErrorWithHint(msg, hint) + } + } + return customerrors.NewUserError(msg) +} + const ( pluginDownloadTimeout = 60 * time.Second manifestReadTimeout = 5 * time.Second diff --git a/internal/cmd/plugin/install.go b/internal/cmd/plugin/install.go index e411906..bfc77e5 100644 --- a/internal/cmd/plugin/install.go +++ b/internal/cmd/plugin/install.go @@ -76,7 +76,7 @@ The plugin binary is written to the managed plugins directory // Curated index path. idx, idxErr := loadOrRefreshIndex(cmd) if idxErr != nil { - return customerrors.NewUserError("could not fetch plugin index: " + idxErr.Error()) + return indexFetchUserError(idxErr) } entry, pluginName, binaryPath, installErr := installPlugin(cmd.Context(), pluginsDir, arg, "", currentVersion, idx) if installErr != nil { diff --git a/internal/cmd/plugin/search.go b/internal/cmd/plugin/search.go index c8a6943..9d5df5d 100644 --- a/internal/cmd/plugin/search.go +++ b/internal/cmd/plugin/search.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" - customerrors "go.datum.net/datumctl/internal/errors" "go.datum.net/datumctl/internal/pluginstore" ) @@ -33,7 +32,7 @@ Run 'datumctl plugin install ' to install a plugin listed here.`, idx, err = pluginstore.RefreshIndex(cmd.Context()) if err != nil { if idx == nil { - return customerrors.NewUserError("could not fetch plugin index: " + err.Error()) + return indexFetchUserError(err) } fmt.Fprintf(cmd.ErrOrStderr(), "warning: index refresh failed (%v), showing cached results\n", err) } diff --git a/internal/cmd/plugin/upgrade.go b/internal/cmd/plugin/upgrade.go index 0a2a400..d672700 100644 --- a/internal/cmd/plugin/upgrade.go +++ b/internal/cmd/plugin/upgrade.go @@ -54,7 +54,7 @@ compatibility validation.`, fmt.Fprintf(cmd.ErrOrStderr(), "warning: index refresh failed (%v), using cached index\n", refreshErr) default: // No cache at all. - return customerrors.NewUserError(fmt.Sprintf("could not fetch plugin index: %v", refreshErr)) + return indexFetchUserError(refreshErr) } var newEntry *pluginstore.InstalledPlugin diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 3a6241b..bcc6635 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + "net/http" "os" "path/filepath" "strings" @@ -14,6 +15,7 @@ import ( "github.com/spf13/cobra" activity "go.miloapis.com/activity/pkg/cmd" "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/transport" componentversion "k8s.io/component-base/version" "k8s.io/kubectl/pkg/cmd/apiresources" "k8s.io/kubectl/pkg/cmd/apply" @@ -108,6 +110,11 @@ Get started: // plugin dispatch logic can handle them before Cobra rejects them. Args: cobra.ArbitraryArgs, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Make the -v flag dump HTTP requests from bare net/http callers, + // as client-go already does for kubectl-backed commands. No-op + // below -v 6. + http.DefaultTransport = transport.DebugWrappers(http.DefaultTransport) + format, _ := cmd.Flags().GetString("error-format") switch format { case customerrors.FormatHuman, customerrors.FormatJSON, customerrors.FormatYAML: diff --git a/internal/pluginstore/index.go b/internal/pluginstore/index.go index 406f3ef..116112e 100644 --- a/internal/pluginstore/index.go +++ b/internal/pluginstore/index.go @@ -133,7 +133,8 @@ func RefreshIndex(ctx context.Context) (*CachedIndex, error) { } // Attach GitHub token if available. - if token := githubToken(); token != "" { + token, tokenSource := githubTokenWithSource() + if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.Header.Set("User-Agent", "datumctl-plugin-index") @@ -145,7 +146,12 @@ func RefreshIndex(ctx context.Context) (*CachedIndex, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return degradedFallback(fmt.Errorf("fetch plugin index: HTTP %s", resp.Status)) + return degradedFallback(&IndexFetchError{ + URL: IndexURL, + StatusCode: resp.StatusCode, + Status: resp.Status, + TokenSource: tokenSource, + }) } raw, err := io.ReadAll(resp.Body) @@ -202,10 +208,45 @@ func degradedFallback(origErr error) (*CachedIndex, error) { return cached, origErr } -// githubToken returns a GitHub personal access token from the environment. -func githubToken() string { +// githubTokenWithSource returns a GitHub personal access token from the +// environment along with the name of the variable it came from (empty when no +// token is set). +func githubTokenWithSource() (token, source string) { if t := os.Getenv("DATUMCTL_GITHUB_TOKEN"); t != "" { - return t + return t, "DATUMCTL_GITHUB_TOKEN" } - return os.Getenv("GITHUB_TOKEN") + if t := os.Getenv("GITHUB_TOKEN"); t != "" { + return t, "GITHUB_TOKEN" + } + return "", "" +} + +// IndexFetchError is returned by RefreshIndex when the index host responds with +// a non-OK HTTP status. It carries enough context for the command layer to +// render actionable guidance via Hint. +type IndexFetchError struct { + URL string + StatusCode int + Status string // HTTP status text, e.g. "404 Not Found" + TokenSource string // env var the Authorization token came from, "" if none +} + +func (e *IndexFetchError) Error() string { + return fmt.Sprintf("the plugin index host returned HTTP %s", e.Status) +} + +// Hint returns actionable guidance for resolving the failure, or "" when none +// applies. The common case: a GitHub token in the environment is sent to the +// public index host, which rejects it with a 401/403/404. +func (e *IndexFetchError) Hint() string { + switch e.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: + if e.TokenSource != "" { + return fmt.Sprintf( + "A GitHub token from $%s is being sent to the index host, which is the likely cause. "+ + "The public plugin index needs no authentication; unset that variable and retry.", + e.TokenSource) + } + } + return "" }