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
47 changes: 39 additions & 8 deletions api/config/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import (
"encoding/json"
"fmt"
"os"
"slices"

infrastructure "github.com/ninech/apis/infrastructure/v1alpha1"
"github.com/ninech/nctl/internal/cli"
"github.com/ninech/nctl/internal/format"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/clientcmd"
Expand All @@ -19,10 +22,8 @@ const (
NctlExtensionContext = "nctl"
)

var (
// ErrExtensionNotFound describes a missing extension in the kubeconfig
ErrExtensionNotFound extensionError = "nctl config not found"
)
// ErrExtensionNotFound describes a missing extension in the kubeconfig
var ErrExtensionNotFound extensionError = "nctl config not found"

type extensionError string

Expand Down Expand Up @@ -97,7 +98,7 @@ func readExtension(kubeconfigContent []byte, contextName string) (*Extension, er
}
context, exists := kubeconfig.Contexts[contextName]
if !exists {
return nil, fmt.Errorf("could not find context %q in kubeconfig", contextName)
return nil, contextNotFoundError(contextName, kubeconfig.Contexts)
}
extension, exists := context.Extensions[NctlExtensionContext]
if !exists {
Expand Down Expand Up @@ -127,7 +128,7 @@ func SetContextOrganization(kubeconfigPath string, contextName string, organizat
}
context, exists := kubeconfig.Contexts[contextName]
if !exists {
return fmt.Errorf("could not find context %q in kubeconfig", contextName)
return contextNotFoundError(contextName, kubeconfig.Contexts)
}
extension, exists := context.Extensions[NctlExtensionContext]
if !exists {
Expand Down Expand Up @@ -164,7 +165,7 @@ func SetContextProject(kubeconfigPath string, contextName string, project string
}
context, exists := kubeconfig.Contexts[contextName]
if !exists {
return fmt.Errorf("could not find context %q in kubeconfig", contextName)
return contextNotFoundError(contextName, kubeconfig.Contexts)
}
context.Namespace = project
return clientcmd.WriteToFile(*kubeconfig, kubeconfigPath)
Expand All @@ -178,7 +179,7 @@ func RemoveClusterFromKubeConfig(kubeconfigPath, clusterContext string) error {
}

if _, ok := kubeconfig.Clusters[clusterContext]; !ok {
return fmt.Errorf("could not find cluster %q in kubeconfig", clusterContext)
return clusterNotFoundError(clusterContext, kubeconfig.Clusters)
}

delete(kubeconfig.Clusters, clusterContext)
Expand All @@ -194,3 +195,33 @@ func RemoveClusterFromKubeConfig(kubeconfigPath, clusterContext string) error {
func ContextName(cluster *infrastructure.KubernetesCluster) string {
return fmt.Sprintf("%s/%s", cluster.Name, cluster.Namespace)
}

// contextNotFoundError returns an error with available contexts listed.
func contextNotFoundError[T any](contextName string, contexts map[string]T) error {
available := make([]string, 0, len(contexts))
for name := range contexts {
available = append(available, name)
}
slices.Sort(available)

return cli.ErrorWithContext(fmt.Errorf("could not find context %q in kubeconfig", contextName)).
WithExitCode(cli.ExitUsageError).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some helpers here (under api/...) start returning internal/cli.Error directly. That's totally fine as our api is internal (for now). But it would be good to clarify this intention somewhere (and be consistent).
Or maybe we can have api return more semantic errors that get wrapped later?

WithAvailable(available...).
WithSuggestions("Login to the API: " + format.Command().Login())
}

// clusterNotFoundError returns an error with available clusters listed.
func clusterNotFoundError[T any](clusterName string, clusters map[string]T) error {
available := make([]string, 0, len(clusters))
for name := range clusters {
available = append(available, name)
}
slices.Sort(available)

return cli.ErrorWithContext(fmt.Errorf("could not find cluster %q in kubeconfig", clusterName)).
WithExitCode(cli.ExitUsageError).
WithAvailable(available...).
WithSuggestions(
"List available clusters: kubectl config get-clusters",
)
}
29 changes: 19 additions & 10 deletions api/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ package api
import (
"cmp"
"context"
"errors"
"fmt"
"reflect"
"slices"
"strings"

management "github.com/ninech/apis/management/v1alpha1"
"github.com/ninech/nctl/internal/cli"
"golang.org/x/sync/errgroup"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/conversion"
Expand Down Expand Up @@ -65,17 +65,25 @@ func Watch(f WatchFunc) ListOpt {

func (opts *ListOpts) namedResourceNotFound(project string, foundInProjects ...string) error {
if opts.allProjects {
return fmt.Errorf("resource %q was not found in any project", opts.searchForName)
return cli.ErrorWithContext(fmt.Errorf("resource %q was not found in any project", opts.searchForName)).
WithExitCode(cli.ExitUsageError).
WithSuggestions("Verify the resource name is correct")
}
errorMessage := fmt.Sprintf("resource %q was not found in project %s", opts.searchForName, project)

if len(foundInProjects) > 0 {
errorMessage = errorMessage + fmt.Sprintf(
", but it was found in project(s): %s. "+
"Maybe you want to use the '--project' flag to specify one of these projects?",
strings.Join(foundInProjects, " ,"),
)
return cli.ErrorWithContext(fmt.Errorf(
"resource %q was not found in project %q, but was found in: %s",
opts.searchForName, project, strings.Join(foundInProjects, ", "),
)).
WithExitCode(cli.ExitUsageError).
WithContext("Project", project).
WithAvailable(foundInProjects...).
WithSuggestions("Use --project=<project> to specify a different project")
}
return errors.New(errorMessage)

return cli.ErrorWithContext(fmt.Errorf("resource %q was not found in project %q", opts.searchForName, project)).
WithExitCode(cli.ExitUsageError).
WithContext("Project", project)
}

// ListObjects lists objects in the current client project with some
Expand Down Expand Up @@ -198,7 +206,7 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList,
// we found the named object at least in one different project,
// so we return a hint to the user to search in these projects
var identifiedProjects []string
for i := 0; i < items.Len(); i++ {
for i := range items.Len() {
// the "Items" field of a list type is a slice of types and not
// a slice of pointer types (e.g. "[]corev1.Pod" and not
// "[]*corev1.Pod"), but the clientruntime.Object interface is
Expand All @@ -219,6 +227,7 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList,
}
identifiedProjects = append(identifiedProjects, obj.GetNamespace())
}

return opts.namedResourceNotFound(c.Project, identifiedProjects...)
}

Expand Down
13 changes: 11 additions & 2 deletions api/util/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

meta "github.com/ninech/apis/meta/v1alpha1"
"github.com/ninech/nctl/api"
"github.com/ninech/nctl/internal/cli"
corev1 "k8s.io/api/core/v1"
)

Expand All @@ -30,11 +31,19 @@ func NewBasicAuthFromSecret(ctx context.Context, secret meta.Reference, client *
return nil, fmt.Errorf("error when retrieving secret: %w", err)
}
if _, ok := basicAuthSecret.Data[BasicAuthUsernameKey]; !ok {
return nil, fmt.Errorf("key %s not found in basic auth secret %s", BasicAuthUsernameKey, secret.Name)
return nil, cli.ErrorWithContext(fmt.Errorf("key %q not found in basic auth secret %q", BasicAuthUsernameKey, secret.Name)).
WithExitCode(cli.ExitUsageError).
WithContext("Secret", secret.Name).
WithAvailable(BasicAuthUsernameKey, BasicAuthPasswordKey).
WithSuggestions("Ensure the secret contains both basicAuthUsername and basicAuthPassword keys")
}

if _, ok := basicAuthSecret.Data[BasicAuthPasswordKey]; !ok {
return nil, fmt.Errorf("key %s not found in basic auth secret %s", BasicAuthPasswordKey, secret.Name)
return nil, cli.ErrorWithContext(fmt.Errorf("key %q not found in basic auth secret %q", BasicAuthPasswordKey, secret.Name)).
WithExitCode(cli.ExitUsageError).
WithContext("Secret", secret.Name).
WithAvailable(BasicAuthUsernameKey, BasicAuthPasswordKey).
WithSuggestions("Ensure the secret contains both basicAuthUsername and basicAuthPassword keys")
}

return &BasicAuth{
Expand Down
12 changes: 9 additions & 3 deletions api/util/bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

meta "github.com/ninech/apis/meta/v1alpha1"
storage "github.com/ninech/apis/storage/v1alpha1"
"github.com/ninech/nctl/internal/cli"
)

const (
Expand Down Expand Up @@ -90,7 +91,10 @@ func normalizeRole(r string) (string, error) {
case string(storage.BucketRoleReader), string(storage.BucketRoleWriter):
return r, nil
default:
return "", fmt.Errorf("unknown %s %q (allowed: %s, %s)", PermKeyRole, r, string(storage.BucketRoleReader), string(storage.BucketRoleWriter))
return "", cli.ErrorWithContext(fmt.Errorf("unknown %s %q", PermKeyRole, r)).
WithExitCode(cli.ExitUsageError).
WithAvailable(string(storage.BucketRoleReader), string(storage.BucketRoleWriter)).
WithSuggestions("Example: --permissions=reader=user1,user2")
}
}

Expand Down Expand Up @@ -619,8 +623,10 @@ func parseCORSLooseWithMask(chunks []string) (storage.CORSConfig, CORSFieldMask,
}
}
default:
return out, mask, fmt.Errorf("unknown key %q (expected %s, %s or %s)",
p.key, corsKeyOrigins, corsKeyResponseHeaders, corsKeyMaxAge)
return out, mask, cli.ErrorWithContext(fmt.Errorf("unknown CORS key %q", p.key)).
WithExitCode(cli.ExitUsageError).
WithAvailable(corsKeyOrigins, corsKeyResponseHeaders, corsKeyMaxAge).
WithSuggestions("Example: --cors='origins=https://example.com;max-age=3600'")
}
}

Expand Down
7 changes: 4 additions & 3 deletions api/util/bucket_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package util

import (
"strings"
"testing"

storage "github.com/ninech/apis/storage/v1alpha1"
Expand Down Expand Up @@ -281,14 +282,14 @@ func TestParseCORSLooseWithMask(t *testing.T) {
"origins=https://example.com",
"method=GET",
},
wantErr: "unknown key",
wantErr: "unknown CORS key",
},
{
name: "error: bad segment format (loose tokenizer yields unknown key here)",
chunks: []string{
"origins:https://example.com",
},
wantErr: "unknown key",
wantErr: "unknown CORS key",
},
}

Expand All @@ -298,7 +299,7 @@ func TestParseCORSLooseWithMask(t *testing.T) {

if tt.wantErr != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tt.wantErr))
return
}
assert.NoError(t, err)
Expand Down
11 changes: 9 additions & 2 deletions auth/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/ninech/nctl/api"
"github.com/ninech/nctl/api/config"
"github.com/ninech/nctl/api/util"
"github.com/ninech/nctl/internal/cli"
"github.com/ninech/nctl/internal/format"

"k8s.io/apimachinery/pkg/types"
Expand All @@ -36,12 +37,18 @@ func (a *ClusterCmd) Run(ctx context.Context, client *api.Client) error {

apiEndpoint, err := url.Parse(cluster.Status.AtProvider.APIEndpoint)
if err != nil {
return fmt.Errorf("invalid cluster API endpoint: %w", err)
return cli.ErrorWithContext(fmt.Errorf("invalid cluster API endpoint: %w", err)).
WithExitCode(cli.ExitUsageError).
WithContext("Endpoint", cluster.Status.AtProvider.APIEndpoint).
WithSuggestions("The cluster API endpoint should be a valid URL")
}

issuerURL, err := url.Parse(cluster.Status.AtProvider.OIDCIssuerURL)
if err != nil {
return fmt.Errorf("invalid cluster OIDC issuer url: %w", err)
return cli.ErrorWithContext(fmt.Errorf("invalid cluster OIDC issuer URL: %w", err)).
WithExitCode(cli.ExitUsageError).
WithContext("IssuerURL", cluster.Status.AtProvider.OIDCIssuerURL).
WithSuggestions("The OIDC issuer URL should be a valid URL")
}

caCert, err := base64.StdEncoding.DecodeString(cluster.Status.AtProvider.APICACert)
Expand Down
3 changes: 1 addition & 2 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,7 @@ func printAvailableOrgsString(w format.Writer, currentorg string, orgs []string)
w.Printf("%s\t%s\n", activeMarker, org)
}

w.Printf("\nTo switch the organization use the following command:\n")
w.Printf("$ nctl auth set-org <org-name>\n")
w.Println()
}

func (cmd *LoginCmd) tokenGetter() api.TokenGetter {
Expand Down
11 changes: 7 additions & 4 deletions auth/set_org.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ func (cmd *SetOrgCmd) Run(ctx context.Context, client *api.Client) error {
// permissions in the API might still allow access even if the organization
// is not listed in the JWT (e.g. for support staff or cross-org permissions).
if !slices.Contains(userInfo.Orgs, cmd.Organization) {
cmd.Warningf(
"%s is not in list of available Organizations, you might not have access to all resources.",
cmd.Organization,
)
cmd.Println()
cmd.Warningf("%s is not in list of organizations, you might not have access to all resources.", cmd.Organization)
printAvailableOrgsString(cmd.Writer, cmd.Organization, userInfo.Orgs)
}

// Show default project info (which is the same as the organization name by default)
cmd.Successf("📝", "Default project set to: %q", cmd.Organization)
cmd.Printf("\nTo set a different project: %s\n", format.Command().SetProject(""))
cmd.Printf("To list available projects: %s\n", format.Command().GetProjects())

return nil
}
14 changes: 10 additions & 4 deletions auth/set_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
management "github.com/ninech/apis/management/v1alpha1"
"github.com/ninech/nctl/api"
"github.com/ninech/nctl/api/config"
"github.com/ninech/nctl/internal/cli"
"github.com/ninech/nctl/internal/format"

kerrors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -36,12 +37,12 @@ func (s *SetProjectCmd) Run(ctx context.Context, client *api.Client) error {
return fmt.Errorf("failed to set project %s: %w", s.Name, err)
}

s.Warningf("Project %q does not exist in organization %q, checking other organizations...",
s.Warningf("Project %q does not exist in organization %q, checking other organizations...\n",
s.Name,
org,
)
if err := trySwitchOrg(ctx, client, s.Name); err != nil {
return fmt.Errorf("failed to switch organization: %w", err)
return err
}

org, err = client.Organization()
Expand Down Expand Up @@ -99,7 +100,10 @@ func orgFromProject(ctx context.Context, client *api.Client, project string) (st
return project, nil
}

return "", fmt.Errorf("could not find project %s in any available organization", project)
return "", cli.ErrorWithContext(fmt.Errorf("project %q not found", project)).
WithExitCode(cli.ExitUsageError).
WithAvailable(userInfo.Orgs...).
WithSuggestions(fmt.Sprintf("Project names always contain the organization name:\n%s", format.Command().SetProject("<org>-<project>")))
}

// Filter the organizations to check by only considering those that match the project prefix.
Expand Down Expand Up @@ -130,5 +134,7 @@ func orgFromProject(ctx context.Context, client *api.Client, project string) (st
return org, nil
}

return "", fmt.Errorf("could not find project %s in any available organization", project)
return "", cli.ErrorWithContext(fmt.Errorf("could not find project %q in any available organization", project)).
WithExitCode(cli.ExitUsageError).
WithSuggestions(format.Command().GetProjects())
}
3 changes: 2 additions & 1 deletion auth/set_project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"strings"
"testing"

management "github.com/ninech/apis/management/v1alpha1"
Expand Down Expand Up @@ -116,7 +117,7 @@ func TestOrgFromProjectAPIErrors(t *testing.T) {
is.NoError(err)

_, err = orgFromProject(t.Context(), apiClient, "test-prod")
is.ErrorContains(err, tc.wantErrContain)
is.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.wantErrContain))
})
}
}
Expand Down
9 changes: 5 additions & 4 deletions auth/whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ func (cmd *WhoAmICmd) Run(ctx context.Context, client *api.Client) error {
return err
}

cmd.printUserInfo(userInfo, org)
cmd.printUserInfo(userInfo, org, client.Project)

return nil
}

func (cmd *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org string) {
cmd.Infof("👤", "You are currently logged in with the following account: %q", userInfo.User)
cmd.Infof("🏢", "Your current organization: %q", org)
func (cmd *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org, project string) {
cmd.Printf("Account: %s\n", userInfo.User)
cmd.Printf("Organization: %s\n", org)
cmd.Printf("Project: %s\n", project)

if len(userInfo.Orgs) > 0 {
printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs)
Expand Down
Loading