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
16 changes: 16 additions & 0 deletions internal/cmd/plugin/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/plugin/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 1 addition & 2 deletions internal/cmd/plugin/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (

"github.com/spf13/cobra"

customerrors "go.datum.net/datumctl/internal/errors"
"go.datum.net/datumctl/internal/pluginstore"
)

Expand All @@ -33,7 +32,7 @@ Run 'datumctl plugin install <name>' 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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/plugin/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
53 changes: 47 additions & 6 deletions internal/pluginstore/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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 ""
}