Skip to content

Commit d2ef9cc

Browse files
committed
feat(errors): add suggestions and options to errors
1 parent 3949e46 commit d2ef9cc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+635
-125
lines changed

api/config/extension.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"slices"
89

910
infrastructure "github.com/ninech/apis/infrastructure/v1alpha1"
11+
"github.com/ninech/nctl/internal/cli"
1012
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1113
"k8s.io/apimachinery/pkg/runtime"
1214
"k8s.io/client-go/tools/clientcmd"
@@ -97,7 +99,7 @@ func readExtension(kubeconfigContent []byte, contextName string) (*Extension, er
9799
}
98100
context, exists := kubeconfig.Contexts[contextName]
99101
if !exists {
100-
return nil, fmt.Errorf("could not find context %q in kubeconfig", contextName)
102+
return nil, contextNotFoundError(contextName, kubeconfig.Contexts)
101103
}
102104
extension, exists := context.Extensions[NctlExtensionContext]
103105
if !exists {
@@ -127,7 +129,7 @@ func SetContextOrganization(kubeconfigPath string, contextName string, organizat
127129
}
128130
context, exists := kubeconfig.Contexts[contextName]
129131
if !exists {
130-
return fmt.Errorf("could not find context %q in kubeconfig", contextName)
132+
return contextNotFoundError(contextName, kubeconfig.Contexts)
131133
}
132134
extension, exists := context.Extensions[NctlExtensionContext]
133135
if !exists {
@@ -164,7 +166,7 @@ func SetContextProject(kubeconfigPath string, contextName string, project string
164166
}
165167
context, exists := kubeconfig.Contexts[contextName]
166168
if !exists {
167-
return fmt.Errorf("could not find context %q in kubeconfig", contextName)
169+
return contextNotFoundError(contextName, kubeconfig.Contexts)
168170
}
169171
context.Namespace = project
170172
return clientcmd.WriteToFile(*kubeconfig, kubeconfigPath)
@@ -178,7 +180,7 @@ func RemoveClusterFromKubeConfig(kubeconfigPath, clusterContext string) error {
178180
}
179181

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

184186
delete(kubeconfig.Clusters, clusterContext)
@@ -194,3 +196,36 @@ func RemoveClusterFromKubeConfig(kubeconfigPath, clusterContext string) error {
194196
func ContextName(cluster *infrastructure.KubernetesCluster) string {
195197
return fmt.Sprintf("%s/%s", cluster.Name, cluster.Namespace)
196198
}
199+
200+
// contextNotFoundError returns an error with available contexts listed.
201+
func contextNotFoundError[T any](contextName string, contexts map[string]T) error {
202+
available := make([]string, 0, len(contexts))
203+
for name := range contexts {
204+
available = append(available, name)
205+
}
206+
slices.Sort(available)
207+
208+
return cli.ErrorWithContext(fmt.Errorf("could not find context %q in kubeconfig", contextName)).
209+
WithExitCode(cli.ExitUsageError).
210+
WithAvailable(available...).
211+
WithSuggestions(
212+
"List available contexts: kubectl config get-contexts",
213+
"Login to the API: nctl auth login",
214+
)
215+
}
216+
217+
// clusterNotFoundError returns an error with available clusters listed.
218+
func clusterNotFoundError[T any](clusterName string, clusters map[string]T) error {
219+
available := make([]string, 0, len(clusters))
220+
for name := range clusters {
221+
available = append(available, name)
222+
}
223+
slices.Sort(available)
224+
225+
return cli.ErrorWithContext(fmt.Errorf("could not find cluster %q in kubeconfig", clusterName)).
226+
WithExitCode(cli.ExitUsageError).
227+
WithAvailable(available...).
228+
WithSuggestions(
229+
"List available clusters: kubectl config get-clusters",
230+
)
231+
}

api/list.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ package api
33
import (
44
"cmp"
55
"context"
6-
"errors"
76
"fmt"
87
"reflect"
98
"slices"
109
"strings"
1110

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

6666
func (opts *ListOpts) namedResourceNotFound(project string, foundInProjects ...string) error {
6767
if opts.allProjects {
68-
return fmt.Errorf("resource %q was not found in any project", opts.searchForName)
68+
return cli.ErrorWithContext(fmt.Errorf("resource %q was not found in any project", opts.searchForName)).
69+
WithExitCode(cli.ExitUsageError).
70+
WithSuggestions("Verify the resource name is correct")
6971
}
70-
errorMessage := fmt.Sprintf("resource %q was not found in project %s", opts.searchForName, project)
72+
7173
if len(foundInProjects) > 0 {
72-
errorMessage = errorMessage + fmt.Sprintf(
73-
", but it was found in project(s): %s. "+
74-
"Maybe you want to use the '--project' flag to specify one of these projects?",
75-
strings.Join(foundInProjects, " ,"),
76-
)
74+
return cli.ErrorWithContext(fmt.Errorf(
75+
"resource %q was not found in project %q, but was found in: %s",
76+
opts.searchForName, project, strings.Join(foundInProjects, ", "),
77+
)).
78+
WithExitCode(cli.ExitUsageError).
79+
WithContext("Project", project).
80+
WithAvailable(foundInProjects...).
81+
WithSuggestions("Use --project=<project> to specify a different project")
7782
}
78-
return errors.New(errorMessage)
83+
84+
return cli.ErrorWithContext(fmt.Errorf("resource %q was not found in project %q", opts.searchForName, project)).
85+
WithExitCode(cli.ExitUsageError).
86+
WithContext("Project", project)
7987
}
8088

8189
// ListObjects lists objects in the current client project with some
@@ -198,7 +206,7 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList,
198206
// we found the named object at least in one different project,
199207
// so we return a hint to the user to search in these projects
200208
var identifiedProjects []string
201-
for i := 0; i < items.Len(); i++ {
209+
for i := range items.Len() {
202210
// the "Items" field of a list type is a slice of types and not
203211
// a slice of pointer types (e.g. "[]corev1.Pod" and not
204212
// "[]*corev1.Pod"), but the clientruntime.Object interface is
@@ -219,6 +227,7 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList,
219227
}
220228
identifiedProjects = append(identifiedProjects, obj.GetNamespace())
221229
}
230+
222231
return opts.namedResourceNotFound(c.Project, identifiedProjects...)
223232
}
224233

api/util/authentication.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
meta "github.com/ninech/apis/meta/v1alpha1"
88
"github.com/ninech/nctl/api"
9+
"github.com/ninech/nctl/internal/cli"
910
corev1 "k8s.io/api/core/v1"
1011
)
1112

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

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

4049
return &BasicAuth{

api/util/bucket.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
meta "github.com/ninech/apis/meta/v1alpha1"
1212
storage "github.com/ninech/apis/storage/v1alpha1"
13+
"github.com/ninech/nctl/internal/cli"
1314
)
1415

1516
const (
@@ -90,7 +91,10 @@ func normalizeRole(r string) (string, error) {
9091
case string(storage.BucketRoleReader), string(storage.BucketRoleWriter):
9192
return r, nil
9293
default:
93-
return "", fmt.Errorf("unknown %s %q (allowed: %s, %s)", PermKeyRole, r, string(storage.BucketRoleReader), string(storage.BucketRoleWriter))
94+
return "", cli.ErrorWithContext(fmt.Errorf("unknown %s %q", PermKeyRole, r)).
95+
WithExitCode(cli.ExitUsageError).
96+
WithAvailable(string(storage.BucketRoleReader), string(storage.BucketRoleWriter)).
97+
WithSuggestions("Example: --permissions=reader=user1,user2")
9498
}
9599
}
96100

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

api/util/bucket_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,14 +281,14 @@ func TestParseCORSLooseWithMask(t *testing.T) {
281281
"origins=https://example.com",
282282
"method=GET",
283283
},
284-
wantErr: "unknown key",
284+
wantErr: "unknown CORS key",
285285
},
286286
{
287287
name: "error: bad segment format (loose tokenizer yields unknown key here)",
288288
chunks: []string{
289289
"origins:https://example.com",
290290
},
291-
wantErr: "unknown key",
291+
wantErr: "unknown CORS key",
292292
},
293293
}
294294

auth/cluster.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/ninech/nctl/api"
1313
"github.com/ninech/nctl/api/config"
1414
"github.com/ninech/nctl/api/util"
15+
"github.com/ninech/nctl/internal/cli"
1516
"github.com/ninech/nctl/internal/format"
1617

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

3738
apiEndpoint, err := url.Parse(cluster.Status.AtProvider.APIEndpoint)
3839
if err != nil {
39-
return fmt.Errorf("invalid cluster API endpoint: %w", err)
40+
return cli.ErrorWithContext(fmt.Errorf("invalid cluster API endpoint: %w", err)).
41+
WithExitCode(cli.ExitUsageError).
42+
WithContext("Endpoint", cluster.Status.AtProvider.APIEndpoint).
43+
WithSuggestions("The cluster API endpoint should be a valid URL")
4044
}
4145

4246
issuerURL, err := url.Parse(cluster.Status.AtProvider.OIDCIssuerURL)
4347
if err != nil {
44-
return fmt.Errorf("invalid cluster OIDC issuer url: %w", err)
48+
return cli.ErrorWithContext(fmt.Errorf("invalid cluster OIDC issuer URL: %w", err)).
49+
WithExitCode(cli.ExitUsageError).
50+
WithContext("IssuerURL", cluster.Status.AtProvider.OIDCIssuerURL).
51+
WithSuggestions("The OIDC issuer URL should be a valid URL")
4552
}
4653

4754
caCert, err := base64.StdEncoding.DecodeString(cluster.Status.AtProvider.APICACert)

auth/set_org.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package auth
33
import (
44
"context"
55
"slices"
6+
"strings"
67

78
"github.com/ninech/nctl/api"
89
"github.com/ninech/nctl/api/config"
@@ -43,12 +44,17 @@ func (cmd *SetOrgCmd) Run(ctx context.Context, client *api.Client) error {
4344
// permissions in the API might still allow access even if the organization
4445
// is not listed in the JWT (e.g. for support staff or cross-org permissions).
4546
if !slices.Contains(userInfo.Orgs, cmd.Organization) {
46-
cmd.Warningf(
47-
"%s is not in list of available Organizations, you might not have access to all resources.",
47+
cmd.Warningf("%s is not in list of available Organizations, you might not have access to all resources. Available:\n%s",
4848
cmd.Organization,
49+
strings.Join(userInfo.Orgs, ", "),
4950
)
5051
printAvailableOrgsString(cmd.Writer, cmd.Organization, userInfo.Orgs)
5152
}
5253

54+
// Show default project info (which is the same as the organization name by default)
55+
cmd.Successf("📝", "Default project set to: %q", cmd.Organization)
56+
cmd.Printf("\nTo set a different project: %s\n", format.Command().SetProject(""))
57+
cmd.Printf("To list available projects: %s\n", format.Command().GetProjects())
58+
5359
return nil
5460
}

auth/set_project.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
management "github.com/ninech/apis/management/v1alpha1"
1010
"github.com/ninech/nctl/api"
1111
"github.com/ninech/nctl/api/config"
12+
"github.com/ninech/nctl/internal/cli"
1213
"github.com/ninech/nctl/internal/format"
1314

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

39-
s.Warningf("Project %q does not exist in organization %q, checking other organizations...",
40+
s.Warningf("Project %q does not exist in organization %q, checking other organizations...\n",
4041
s.Name,
4142
org,
4243
)
@@ -99,7 +100,10 @@ func orgFromProject(ctx context.Context, client *api.Client, project string) (st
99100
return project, nil
100101
}
101102

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

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

133-
return "", fmt.Errorf("could not find project %s in any available organization", project)
137+
return "", cli.ErrorWithContext(fmt.Errorf("could not find project %q in any available organization", project)).
138+
WithExitCode(cli.ExitUsageError).
139+
WithSuggestions(format.Command().GetProjects())
134140
}

auth/whoami.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ func (cmd *WhoAmICmd) Run(ctx context.Context, client *api.Client) error {
2525
return err
2626
}
2727

28-
cmd.printUserInfo(userInfo, org)
28+
cmd.printUserInfo(userInfo, org, client.Project)
2929

3030
return nil
3131
}
3232

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

3738
if len(userInfo.Orgs) > 0 {
3839
printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs)

create/application.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error {
263263
"could not gather basic auth credentials: %w\n"+
264264
"Please use %q to gather credentials manually",
265265
err,
266-
format.Command().GetApplication(newApp.Name, "--basic-auth-credentials"),
266+
format.Command().Get(apps.ApplicationKind, newApp.Name, "--basic-auth-credentials"),
267267
)
268268
}
269269
cmd.printCredentials(basicAuth)

0 commit comments

Comments
 (0)