From b1a4a699086ae0f3f60c0f7222dd9639aa345721 Mon Sep 17 00:00:00 2001 From: thde Date: Wed, 4 Feb 2026 17:30:45 +0100 Subject: [PATCH 01/19] refactor: configure output centrally to allow testing output --- api/client.go | 12 ++ api/list.go | 3 +- api/util/{stringutil.go => string.go} | 0 api/validation/apps.go | 6 +- apply/apply.go | 10 +- apply/file.go | 12 +- apply/file_test.go | 6 +- auth/cluster.go | 5 +- auth/login.go | 37 ++-- auth/logout.go | 24 ++- auth/print_access_token.go | 10 +- auth/set_org.go | 24 ++- auth/set_project.go | 27 ++- auth/whoami.go | 24 +-- copy/application.go | 82 +++++---- copy/copy.go | 4 + create/apiserviceaccount.go | 14 +- create/application.go | 233 +++++++++++++++----------- create/application_test.go | 2 + create/bucket.go | 2 +- create/bucketuser.go | 12 +- create/cloudvm.go | 12 +- create/create.go | 37 ++-- create/create_test.go | 3 +- create/file.go | 4 +- create/keyvaluestore.go | 2 +- create/mysql.go | 5 +- create/mysqldatabase.go | 8 +- create/opensearch.go | 2 +- create/postgres.go | 5 +- create/postgresdatabase.go | 12 +- create/project.go | 14 +- create/project_config.go | 15 +- create/serviceconnection.go | 10 +- create/vcluster.go | 33 ++-- delete/apiserviceaccount.go | 10 +- delete/application.go | 23 ++- delete/bucket.go | 2 +- delete/bucketuser.go | 2 +- delete/cloudvm.go | 2 +- delete/delete.go | 33 +++- delete/delete_test.go | 4 +- delete/file.go | 4 +- delete/keyvaluestore.go | 2 +- delete/mysql.go | 2 +- delete/mysqldatabase.go | 2 +- delete/opensearch.go | 2 +- delete/postgres.go | 2 +- delete/postgresdatabase.go | 2 +- delete/project.go | 10 +- delete/project_config.go | 22 ++- delete/serviceconnection.go | 2 +- delete/vcluster.go | 18 +- edit/edit.go | 30 +++- get/all.go | 36 ++-- get/all_test.go | 9 +- get/apiserviceaccount.go | 37 +++- get/application.go | 29 ++-- get/application_test.go | 22 +-- get/bucket.go | 14 +- get/bucket_test.go | 6 +- get/bucketuser.go | 26 ++- get/bucketuser_test.go | 4 +- get/build.go | 12 +- get/build_test.go | 4 +- get/cloudvm.go | 4 +- get/cloudvm_test.go | 4 +- get/clusters.go | 7 +- get/database.go | 30 +++- get/database_test.go | 5 +- get/get.go | 77 +++++++-- get/get_test.go | 4 +- get/keyvaluestore.go | 4 +- get/keyvaluestore_test.go | 4 +- get/mysql_test.go | 4 +- get/mysqldatabase_test.go | 4 +- get/opensearch.go | 85 ++++++++-- get/opensearch_test.go | 4 +- get/postgres_test.go | 4 +- get/postgresdatabase_test.go | 4 +- get/project.go | 4 +- get/project_config.go | 7 +- get/project_config_test.go | 3 +- get/project_test.go | 9 +- get/releases.go | 4 +- get/releases_test.go | 13 +- get/serviceconnection_test.go | 4 +- internal/format/command.go | 5 +- internal/format/print.go | 84 +++------- internal/format/writer.go | 92 ++++++++++ internal/test/bucket.go | 2 + logs/logs.go | 23 ++- main.go | 26 ++- update/apiserviceaccount.go | 2 +- update/application.go | 34 ++-- update/application_test.go | 5 +- update/bucket.go | 2 +- update/bucketuser.go | 2 +- update/cloudvm.go | 4 +- update/keyvaluestore.go | 2 +- update/mysql.go | 2 +- update/mysqldatabase.go | 5 +- update/opensearch.go | 2 +- update/postgres.go | 2 +- update/postgresdatabase.go | 5 +- update/project.go | 2 +- update/project_config.go | 15 +- update/serviceconnection.go | 2 +- update/update.go | 14 +- 109 files changed, 1098 insertions(+), 606 deletions(-) rename api/util/{stringutil.go => string.go} (100%) create mode 100644 internal/format/writer.go diff --git a/api/client.go b/api/client.go index 98923247..12829c5d 100644 --- a/api/client.go +++ b/api/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "io" "os" "os/user" "path/filepath" @@ -31,6 +32,8 @@ type Client struct { Project string Log *log.Client KubeconfigContext string + + writer format.Writer } type ClientOpt func(c *Client) error @@ -43,6 +46,7 @@ func New(ctx context.Context, apiClusterContext, project string, opts ...ClientO client := &Client{ Project: project, KubeconfigContext: apiClusterContext, + writer: format.NewWriter(os.Stdout), } if err := client.loadConfig(apiClusterContext); err != nil { return nil, err @@ -70,6 +74,14 @@ func New(ctx context.Context, apiClusterContext, project string, opts ...ClientO return client, nil } +// OutputWriter sets the writer for the client. +func OutputWriter(w io.Writer) ClientOpt { + return func(c *Client) error { + c.writer = format.NewWriter(w) + return nil + } +} + // LogClient sets up a log client connected to the provided address. func LogClient(ctx context.Context, address string, insecure bool) ClientOpt { return func(c *Client) error { diff --git a/api/list.go b/api/list.go index 804fb137..7615f2bf 100644 --- a/api/list.go +++ b/api/list.go @@ -11,7 +11,6 @@ import ( "sync" management "github.com/ninech/apis/management/v1alpha1" - "github.com/ninech/nctl/internal/format" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/conversion" runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -150,7 +149,7 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList, tempList := reflect.New(reflect.TypeOf(list).Elem()).Interface().(runtimeclient.ObjectList) tempList.GetObjectKind().SetGroupVersionKind(list.GetObjectKind().GroupVersionKind()) if err := c.List(ctx, tempList, append(tempOpts, runtimeclient.InNamespace(proj.Name))...); err != nil { - format.PrintWarningf("error when searching in project %s: %s\n", proj.Name, err) + c.writer.Warningf("error when searching in project %s: %s\n", proj.Name, err) return } tempListItems := reflect.ValueOf(tempList).Elem().FieldByName("Items") diff --git a/api/util/stringutil.go b/api/util/string.go similarity index 100% rename from api/util/stringutil.go rename to api/util/string.go diff --git a/api/validation/apps.go b/api/validation/apps.go index 5e2e50ae..15a07669 100644 --- a/api/validation/apps.go +++ b/api/validation/apps.go @@ -1,3 +1,5 @@ +// Package validation provides functionality to validate git repositories +// and their access configurations. package validation import ( @@ -15,6 +17,8 @@ import ( // RepositoryValidator validates a git repository type RepositoryValidator struct { + format.Writer + GitInformationServiceURL string Token string Debug bool @@ -27,7 +31,7 @@ func (v *RepositoryValidator) Validate(ctx context.Context, git *apps.GitTarget, return err } msg := " testing repository access 🔐" - spinner, err := format.NewSpinner(msg, msg) + spinner, err := v.Spinner(msg, msg) if err != nil { return err } diff --git a/apply/apply.go b/apply/apply.go index 52153d02..0b6c0df2 100644 --- a/apply/apply.go +++ b/apply/apply.go @@ -1,8 +1,16 @@ +// Package apply provides the implementation for the apply command, +// allowing users to apply resources from files. package apply -import "os" +import ( + "os" + + "github.com/ninech/nctl/internal/format" +) type Cmd struct { + format.Writer + Filename *os.File `short:"f" completion-predictor:"file"` FromFile fromFile `cmd:"" default:"1" name:"-f " help:"Apply any resource from a yaml or json file."` } diff --git a/apply/file.go b/apply/file.go index 96438c62..72055a0d 100644 --- a/apply/file.go +++ b/apply/file.go @@ -14,11 +14,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -type fromFile struct { -} +type fromFile struct{} func (cmd *Cmd) Run(ctx context.Context, client *api.Client, apply *Cmd) error { - return File(ctx, client, apply.Filename, UpdateOnExists()) + return File(ctx, cmd.Writer, client, apply.Filename, UpdateOnExists()) } type Option func(*config) @@ -40,7 +39,7 @@ func Delete() Option { } } -func File(ctx context.Context, client *api.Client, file *os.File, opts ...Option) error { +func File(ctx context.Context, w format.Writer, client *api.Client, file *os.File, opts ...Option) error { if file == nil { return fmt.Errorf("missing flag -f, --filename=STRING") } @@ -60,7 +59,7 @@ func File(ctx context.Context, client *api.Client, file *os.File, opts ...Option if err := client.Delete(ctx, obj); err != nil { return err } - format.PrintSuccessf("🗑", "deleted %s", formatObj(obj)) + w.Successf("🗑", "deleted %s", formatObj(obj)) return nil } @@ -72,7 +71,7 @@ func File(ctx context.Context, client *api.Client, file *os.File, opts ...Option return err } - format.PrintSuccessf("🏗", "created %s", formatObj(obj)) + w.Successf("🏗", "created %s", formatObj(obj)) return nil } @@ -99,7 +98,6 @@ func update(ctx context.Context, client *api.Client, obj *unstructured.Unstructu return err } - format.PrintSuccessf("🏗", "applied %s", formatObj(obj)) return nil } diff --git a/apply/file_test.go b/apply/file_test.go index e9874a78..61fd9251 100644 --- a/apply/file_test.go +++ b/apply/file_test.go @@ -8,6 +8,7 @@ import ( runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" iam "github.com/ninech/apis/iam/v1alpha1" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -59,6 +60,7 @@ func TestFile(t *testing.T) { ctx := context.Background() apiClient, err := test.SetupClient() require.NoError(t, err) + w := format.NewWriter(t.Output()) tests := map[string]struct { name string @@ -128,7 +130,7 @@ func TestFile(t *testing.T) { if tc.delete || tc.update { fileToCreate, err := os.Open(f.Name()) require.NoError(t, err) - err = File(ctx, apiClient, fileToCreate) // This will close fileToCreate + err = File(ctx, w, apiClient, fileToCreate) // This will close fileToCreate require.NoError(t, err) } @@ -150,7 +152,7 @@ func TestFile(t *testing.T) { fileToApply, err := os.Open(f.Name()) require.NoError(t, err) - if err := File(ctx, apiClient, fileToApply, opts...); err != nil { + if err := File(ctx, w, apiClient, fileToApply, opts...); err != nil { if tc.expectedErr { return } diff --git a/auth/cluster.go b/auth/cluster.go index e2dd31e8..1859e866 100644 --- a/auth/cluster.go +++ b/auth/cluster.go @@ -12,10 +12,13 @@ import ( "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/config" "github.com/ninech/nctl/api/util" + "github.com/ninech/nctl/internal/format" + "k8s.io/apimachinery/pkg/types" ) type ClusterCmd struct { + format.Writer Name string `arg:"" help:"Name of the cluster to authenticate with. Also accepts 'name/project' format."` ExecPlugin bool `help:"Automatically run exec plugin after writing the kubeconfig."` } @@ -91,7 +94,7 @@ func (a *ClusterCmd) Run(ctx context.Context, client *api.Client) error { } } - if err := login(cfg, client.KubeconfigPath, userInfo.User, "", switchCurrentContext()); err != nil { + if err := login(a.Writer, cfg, client.KubeconfigPath, userInfo.User, "", switchCurrentContext()); err != nil { return fmt.Errorf("error logging in to cluster %s: %w", name, err) } diff --git a/auth/login.go b/auth/login.go index 91cb5d51..26aa11e6 100644 --- a/auth/login.go +++ b/auth/login.go @@ -26,6 +26,7 @@ const ( ) type LoginCmd struct { + format.Writer API API `embed:"" prefix:"api-"` Organization string `help:"Name of your organization to use when providing an API client ID/secret." env:"NCTL_ORGANIZATION"` IssuerURL string `help:"OIDC issuer URL of the API." default:"${issuer_url}" hidden:""` @@ -69,7 +70,7 @@ func (l *LoginCmd) Run(ctx context.Context) error { if err != nil { return err } - return login(cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(l.Organization)) + return login(l.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(l.Organization)) } if l.API.ClientID != "" { @@ -92,7 +93,7 @@ func (l *LoginCmd) Run(ctx context.Context) error { if userInfo.Project != "" { proj = userInfo.Project } - return login(cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(proj)) + return login(l.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(proj)) } if !l.ForceInteractiveEnvOverride && !format.IsInteractiveEnvironment(os.Stdout) { @@ -117,9 +118,9 @@ func (l *LoginCmd) Run(ctx context.Context) error { org := userInfo.Orgs[0] if len(userInfo.Orgs) > 1 { - fmt.Printf("Multiple organizations found for the account %q.\n", userInfo.User) - fmt.Printf("Defaulting to %q\n", org) - printAvailableOrgsString(org, userInfo.Orgs) + l.Printf("Multiple organizations found for the account %q.\n", userInfo.User) + l.Printf("Defaulting to %q\n", org) + l.printAvailableOrgsString(org, userInfo.Orgs) } cfg, err := newAPIConfig(apiURL, issuerURL, command, l.ClientID, withOrganization(org)) @@ -127,7 +128,23 @@ func (l *LoginCmd) Run(ctx context.Context) error { return err } - return login(cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) + return login(l.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) +} + +func (l *LoginCmd) printAvailableOrgsString(currentorg string, orgs []string) { + l.Println("\nAvailable Organizations:") + + for _, org := range orgs { + activeMarker := "" + if currentorg == org { + activeMarker = "*" + } + + l.Printf("%s\t%s\n", activeMarker, org) + } + + l.Printf("\nTo switch the organization use the following command:\n") + l.Printf("$ nctl auth set-org \n") } func (l *LoginCmd) tokenGetter() api.TokenGetter { @@ -254,7 +271,7 @@ func switchCurrentContext() loginOption { } } -func login(newConfig *clientcmdapi.Config, kubeconfigPath, userName string, toOrg string, opts ...loginOption) error { +func login(w format.Writer, newConfig *clientcmdapi.Config, kubeconfigPath, userName string, toOrg string, opts ...loginOption) error { loginConfig := &loginConfig{} for _, opt := range opts { opt(loginConfig) @@ -284,15 +301,15 @@ func login(newConfig *clientcmdapi.Config, kubeconfigPath, userName string, toOr } if toOrg != "" { - format.PrintSuccessf("🏢", "switched to the organization %q", toOrg) + w.Successf("🏢", "switched to the organization %q\n", toOrg) } - format.PrintSuccessf("📋", "added %s to kubeconfig", newConfig.CurrentContext) + w.Successf("📋", "added %s to kubeconfig\n", newConfig.CurrentContext) loginMessage := fmt.Sprintf("logged into cluster %s", newConfig.CurrentContext) if strings.TrimSpace(userName) != "" { loginMessage = fmt.Sprintf("logged into cluster %s as %s", newConfig.CurrentContext, userName) } - format.PrintSuccess("🚀", loginMessage) + w.Success("🚀", loginMessage+"\n") return nil } diff --git a/auth/logout.go b/auth/logout.go index 14c3555a..b0e23978 100644 --- a/auth/logout.go +++ b/auth/logout.go @@ -22,6 +22,7 @@ import ( ) type LogoutCmd struct { + format.Writer APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` @@ -43,12 +44,15 @@ func (l *LogoutCmd) Run(ctx context.Context) error { filePath := path.Join(homedir.HomeDir(), api.DefaultTokenCachePath, filename) if _, err = os.Stat(filePath); err != nil { - format.PrintFailuref("🤔", "seems like you are already logged out from %s", l.APIURL) + l.Failuref("🤔", "seems like you are already logged out from %s\n", l.APIURL) return nil } r := repository.Repository{} - cache, err := r.FindByKey(tokencache.Config{Directory: path.Join(homedir.HomeDir(), api.DefaultTokenCachePath)}, key) + cache, err := r.FindByKey( + tokencache.Config{Directory: path.Join(homedir.HomeDir(), api.DefaultTokenCachePath)}, + key, + ) if err != nil { return fmt.Errorf("error finding cache file: %w", err) } @@ -57,9 +61,17 @@ func (l *LogoutCmd) Run(ctx context.Context) error { form.Add("client_id", l.ClientID) form.Add("refresh_token", cache.RefreshToken) - logoutEndpoint := strings.Join([]string{l.IssuerURL, "protocol", "openid-connect", "logout"}, "/") - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, logoutEndpoint, strings.NewReader(form.Encode())) + logoutEndpoint := strings.Join( + []string{l.IssuerURL, "protocol", "openid-connect", "logout"}, + "/", + ) + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + logoutEndpoint, + strings.NewReader(form.Encode()), + ) if err != nil { return fmt.Errorf("error creating request: %w", err) } @@ -87,7 +99,7 @@ func (l *LogoutCmd) Run(ctx context.Context) error { return fmt.Errorf("error removing the local cache: %w", err) } - format.PrintSuccessf("👋", "logged out from %s", l.APIURL) + l.Successf("👋", "logged out from %s\n", l.APIURL) return nil } diff --git a/auth/print_access_token.go b/auth/print_access_token.go index 3c92f0f5..7f7c8b10 100644 --- a/auth/print_access_token.go +++ b/auth/print_access_token.go @@ -2,14 +2,16 @@ package auth import ( "context" - "fmt" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" ) -type PrintAccessTokenCmd struct{} +type PrintAccessTokenCmd struct { + format.Writer +} -func (o *PrintAccessTokenCmd) Run(ctx context.Context, client *api.Client) error { - fmt.Println(client.Token(ctx)) +func (cmd *PrintAccessTokenCmd) Run(ctx context.Context, client *api.Client) error { + cmd.Println(client.Token(ctx)) return nil } diff --git a/auth/set_org.go b/auth/set_org.go index ff6aacb3..fafdd5a3 100644 --- a/auth/set_org.go +++ b/auth/set_org.go @@ -2,7 +2,6 @@ package auth import ( "context" - "fmt" "slices" "github.com/ninech/nctl/api" @@ -11,15 +10,21 @@ import ( ) type SetOrgCmd struct { + format.Writer Organization string `arg:"" help:"Name of the organization to login to." default:""` APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` } -func (s *SetOrgCmd) Run(ctx context.Context, client *api.Client) error { - if s.Organization == "" { - whoamicmd := WhoAmICmd{APIURL: s.APIURL, IssuerURL: s.IssuerURL, ClientID: s.ClientID} +func (cmd *SetOrgCmd) Run(ctx context.Context, client *api.Client) error { + if cmd.Organization == "" { + whoamicmd := WhoAmICmd{ + Writer: cmd.Writer, + APIURL: cmd.APIURL, + IssuerURL: cmd.IssuerURL, + ClientID: cmd.ClientID, + } return whoamicmd.Run(ctx, client) } @@ -28,14 +33,17 @@ func (s *SetOrgCmd) Run(ctx context.Context, client *api.Client) error { return err } - if err := config.SetContextOrganization(client.KubeconfigPath, client.KubeconfigContext, s.Organization); err != nil { + if err := config.SetContextOrganization(client.KubeconfigPath, client.KubeconfigContext, cmd.Organization); err != nil { return err } - if !slices.Contains(userInfo.Orgs, s.Organization) { - format.PrintWarningf("%s is not in list of available Organizations, you might not have access to all resources.\n", s.Organization) + 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.\n", + cmd.Organization, + ) } - fmt.Println(format.SuccessMessagef("📝", "set active Organization to %s", s.Organization)) + cmd.Successf("📝", "set active Organization to %s\n", cmd.Organization) return nil } diff --git a/auth/set_project.go b/auth/set_project.go index 3a048ae8..e65ccd85 100644 --- a/auth/set_project.go +++ b/auth/set_project.go @@ -10,11 +10,13 @@ import ( "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/config" "github.com/ninech/nctl/internal/format" + "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" ) type SetProjectCmd struct { + format.Writer Name string `arg:"" help:"Name of the default project to be used." completion-predictor:"project_name"` } @@ -25,12 +27,19 @@ func (s *SetProjectCmd) Run(ctx context.Context, client *api.Client) error { } // Ensure the project exists. Try switching otherwise. - if err := client.Get(ctx, types.NamespacedName{Name: s.Name, Namespace: org}, &management.Project{}); err != nil { + if err := client.Get( + ctx, + types.NamespacedName{Name: s.Name, Namespace: org}, + &management.Project{}, + ); err != nil { if !errors.IsNotFound(err) && !errors.IsForbidden(err) { return fmt.Errorf("failed to set project %s: %w", s.Name, err) } - format.PrintWarningf("Project does not exist in organization %s, checking other organizations...\n", org) + s.Warningf( + "Project does not exist in organization %s, checking other organizations...\n", + org, + ) if err := trySwitchOrg(ctx, client, s.Name); err != nil { return fmt.Errorf("failed to switch organization: %w", err) } @@ -41,11 +50,15 @@ func (s *SetProjectCmd) Run(ctx context.Context, client *api.Client) error { } } - if err := config.SetContextProject(client.KubeconfigPath, client.KubeconfigContext, s.Name); err != nil { + if err := config.SetContextProject( + client.KubeconfigPath, + client.KubeconfigContext, + s.Name, + ); err != nil { return err } - fmt.Println(format.SuccessMessagef("📝", "set active Project to %s in organization %s", s.Name, org)) + s.Successf("📝", "set active Project to %s in organization %s\n", s.Name, org) return nil } @@ -57,7 +70,11 @@ func trySwitchOrg(ctx context.Context, client *api.Client, project string) error return err } - if err := config.SetContextOrganization(client.KubeconfigPath, client.KubeconfigContext, org); err != nil { + if err := config.SetContextOrganization( + client.KubeconfigPath, + client.KubeconfigContext, + org, + ); err != nil { return err } diff --git a/auth/whoami.go b/auth/whoami.go index 6de832bc..706af025 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -2,12 +2,14 @@ package auth import ( "context" - "fmt" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" ) type WhoAmICmd struct { + format.Writer + APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` @@ -24,22 +26,22 @@ func (s *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { return err } - printUserInfo(userInfo, org) + s.printUserInfo(userInfo, org) return nil } -func printUserInfo(userInfo *api.UserInfo, org string) { - fmt.Printf("You are currently logged in with the following account: %q\n", userInfo.User) - fmt.Printf("Your current organization: %q\n", org) +func (s *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org string) { + s.Printf("You are currently logged in with the following account: %q\n", userInfo.User) + s.Printf("Your current organization: %q\n", org) if len(userInfo.Orgs) > 0 { - printAvailableOrgsString(org, userInfo.Orgs) + s.printAvailableOrgsString(org, userInfo.Orgs) } } -func printAvailableOrgsString(currentorg string, orgs []string) { - fmt.Println("\nAvailable Organizations:") +func (s *WhoAmICmd) printAvailableOrgsString(currentorg string, orgs []string) { + s.Println("\nAvailable Organizations:") for _, org := range orgs { activeMarker := "" @@ -47,9 +49,9 @@ func printAvailableOrgsString(currentorg string, orgs []string) { activeMarker = "*" } - fmt.Printf("%s\t%s\n", activeMarker, org) + s.Printf("%s\t%s\n", activeMarker, org) } - fmt.Print("\nTo switch the organization use the following command:\n") - fmt.Print("$ nctl auth set-org \n") + s.Printf("\nTo switch the organization use the following command:\n") + s.Printf("$ nctl auth set-org \n") } diff --git a/copy/application.go b/copy/application.go index 4488f6b3..ca09bd6e 100644 --- a/copy/application.go +++ b/copy/application.go @@ -3,13 +3,14 @@ package copy import ( "context" "fmt" + "strings" apps "github.com/ninech/apis/apps/v1alpha1" meta "github.com/ninech/apis/meta/v1alpha1" networking "github.com/ninech/apis/networking/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/util" - "github.com/ninech/nctl/internal/format" + corev1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -22,8 +23,8 @@ type applicationCmd struct { staticEgressCopied bool } -func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { - newApp, err := app.newCopy(ctx, client) +func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { + newApp, err := cmd.newCopy(ctx, client) if err != nil { return fmt.Errorf("unable to copy app: %w", err) } @@ -32,67 +33,80 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { return fmt.Errorf("unable to create Application %q: %w", newApp.GetName(), err) } - app.printCopyMessage(client, newApp) + cmd.printCopyMessage(client, newApp) return nil } -func (app *applicationCmd) targetNamespace(client *api.Client) string { - if app.TargetProject != "" { - return app.TargetProject +func (cmd *applicationCmd) targetNamespace(client *api.Client) string { + if cmd.TargetProject != "" { + return cmd.TargetProject } return client.Project } -func (app *applicationCmd) newCopy(ctx context.Context, client *api.Client) (*apps.Application, error) { +func (cmd *applicationCmd) newCopy( + ctx context.Context, + client *api.Client, +) (*apps.Application, error) { oldApp := &apps.Application{} - if err := client.Get(ctx, client.Name(app.Name), oldApp); err != nil { + if err := client.Get(ctx, client.Name(cmd.Name), oldApp); err != nil { return nil, err } newApp := &apps.Application{ ObjectMeta: metav1.ObjectMeta{ - Name: getName(app.TargetName), - Namespace: app.targetNamespace(client), + Name: getName(cmd.TargetName), + Namespace: cmd.targetNamespace(client), }, Spec: oldApp.Spec, } - newApp.Spec.ForProvider.Paused = !app.Start - if !app.CopyHosts { + newApp.Spec.ForProvider.Paused = !cmd.Start + if !cmd.CopyHosts { newApp.Spec.ForProvider.Hosts = []string{} } - if err := app.copyGitAuth(ctx, client, oldApp, newApp); err != nil { + if err := cmd.copyGitAuth(ctx, client, oldApp, newApp); err != nil { return nil, fmt.Errorf("copying git auth of app: %w", err) } - if err := app.copyStaticEgress(ctx, client, oldApp, newApp); err != nil { + if err := cmd.copyStaticEgress(ctx, client, oldApp, newApp); err != nil { return nil, fmt.Errorf("copying static egress of app: %w", err) } return newApp, nil } -func (app *applicationCmd) printCopyMessage(client *api.Client, newApp *apps.Application) { - format.PrintSuccessf("🏗", "Application %q in project %q has been copied to %q in project %q.", - app.Name, client.Project, newApp.Name, app.targetNamespace(client)) - msg := "" - if !app.CopyHosts { - msg += "\nCustom hosts have not been copied and need to be migrated manually.\n" +func (cmd *applicationCmd) printCopyMessage(client *api.Client, newApp *apps.Application) { + cmd.Successf("🏗", "Application %q in project %q has been copied to %q in project %q.", + cmd.Name, client.Project, newApp.Name, cmd.targetNamespace(client)) + + msg := strings.Builder{} + if !cmd.CopyHosts { + msg.WriteString("\nCustom hosts have not been copied and need to be migrated manually.\n") } - if !app.Start { - msg += "\nNote that the app is paused and you need to unpause it to make it available.\n" + if !cmd.Start { + msg.WriteString("\nNote that the app is paused and you need to unpause it to make it available.\n") } - if app.staticEgressCopied { - msg += "\nStatic egress has been copied to the new app. Note that the new static egress will get a new IP assigned.\n" + if cmd.staticEgressCopied { + msg.WriteString("\nStatic egress has been copied to the new app. Note that the new static egress will get a new IP assigned.\n") } - if msg != "" { - fmt.Println(msg) + if msg.Len() > 0 { + cmd.Println(msg.String()) } } -func (app *applicationCmd) copyGitAuth(ctx context.Context, client *api.Client, oldApp, newApp *apps.Application) error { - if oldApp.Spec.ForProvider.Git.Auth == nil || oldApp.Spec.ForProvider.Git.Auth.FromSecret == nil { +func (cmd *applicationCmd) copyGitAuth( + ctx context.Context, + client *api.Client, + oldApp, newApp *apps.Application, +) error { + if oldApp.Spec.ForProvider.Git.Auth == nil || + oldApp.Spec.ForProvider.Git.Auth.FromSecret == nil { return nil } secret := &corev1.Secret{} - if err := client.Get(ctx, client.Name(oldApp.Spec.ForProvider.Git.Auth.FromSecret.Name), secret); err != nil { + if err := client.Get( + ctx, + client.Name(oldApp.Spec.ForProvider.Git.Auth.FromSecret.Name), + secret, + ); err != nil { return err } newSecret := &corev1.Secret{ @@ -114,7 +128,11 @@ func (app *applicationCmd) copyGitAuth(ctx context.Context, client *api.Client, return nil } -func (app *applicationCmd) copyStaticEgress(ctx context.Context, client *api.Client, oldApp, newApp *apps.Application) error { +func (cmd *applicationCmd) copyStaticEgress( + ctx context.Context, + client *api.Client, + oldApp, newApp *apps.Application, +) error { egresses, err := util.ApplicationStaticEgresses(ctx, client, api.ObjectName(oldApp)) if err != nil { return err @@ -145,6 +163,6 @@ func (app *applicationCmd) copyStaticEgress(ctx context.Context, client *api.Cli if err := client.Create(ctx, newEgress); err != nil { return err } - app.staticEgressCopied = true + cmd.staticEgressCopied = true return nil } diff --git a/copy/copy.go b/copy/copy.go index e39ef042..9e7bac11 100644 --- a/copy/copy.go +++ b/copy/copy.go @@ -1,3 +1,4 @@ +// Package copy provides commands to copy resources. package copy import ( @@ -5,6 +6,7 @@ import ( "time" "github.com/lucasepe/codename" + "github.com/ninech/nctl/internal/format" ) type Cmd struct { @@ -12,6 +14,8 @@ type Cmd struct { } type resourceCmd struct { + format.Writer + Name string `arg:"" help:"Name of the resource to copy." default:"" completion-predictor:"resource_name"` TargetName string `help:"Target name of the new resource. A random name is generated if omitted." default:""` TargetProject string `help:"Target project of the new resource. The current project is used if omitted." default:"" completion-predictor:"project_name"` diff --git a/create/apiserviceaccount.go b/create/apiserviceaccount.go index 127ddc45..3dad20b2 100644 --- a/create/apiserviceaccount.go +++ b/create/apiserviceaccount.go @@ -14,16 +14,16 @@ type apiServiceAccountCmd struct { OrganizationAccess bool `help:"When enabled, this service account has access to all projects in the organization. Only valid for service accounts in the organization project."` } -func (asa *apiServiceAccountCmd) Run(ctx context.Context, client *api.Client) error { - c := newCreator(client, asa.newAPIServiceAccount(client.Project), iam.APIServiceAccountKind) - ctx, cancel := context.WithTimeout(ctx, asa.WaitTimeout) +func (cmd *apiServiceAccountCmd) Run(ctx context.Context, client *api.Client) error { + c := cmd.newCreator(client, cmd.newAPIServiceAccount(client.Project), iam.APIServiceAccountKind) + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() if err := c.createResource(ctx); err != nil { return err } - if !asa.Wait { + if !cmd.Wait { return nil } @@ -33,8 +33,8 @@ func (asa *apiServiceAccountCmd) Run(ctx context.Context, client *api.Client) er }) } -func (asa *apiServiceAccountCmd) newAPIServiceAccount(project string) *iam.APIServiceAccount { - name := getName(asa.Name) +func (cmd *apiServiceAccountCmd) newAPIServiceAccount(project string) *iam.APIServiceAccount { + name := getName(cmd.Name) return &iam.APIServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -42,7 +42,7 @@ func (asa *apiServiceAccountCmd) newAPIServiceAccount(project string) *iam.APISe }, Spec: iam.APIServiceAccountSpec{ ForProvider: iam.APIServiceAccountParameters{ - OrganizationAccess: asa.OrganizationAccess, + OrganizationAccess: cmd.OrganizationAccess, Version: iam.APIServiceAccountV2, }, ResourceSpec: runtimev1.ResourceSpec{ diff --git a/create/application.go b/create/application.go index 1fca5da0..e1803a23 100644 --- a/create/application.go +++ b/create/application.go @@ -21,6 +21,7 @@ import ( "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/logbox" "github.com/ninech/nctl/logs" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -124,27 +125,31 @@ const ( releaseStatusReplicaFailure = "replicaFailure" ) -func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { - fmt.Println("Creating new application") - newApp := app.newApplication(client.Project) +func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { + cmd.Printf("Creating new application\n") + newApp := cmd.newApplication(client.Project) - sshPrivateKey, err := app.Git.sshPrivateKey() + sshPrivateKey, err := cmd.Git.sshPrivateKey() if err != nil { return fmt.Errorf("error when reading SSH private key: %w", err) } auth := util.GitAuth{ - Username: app.Git.Username, - Password: app.Git.Password, + Username: cmd.Git.Username, + Password: cmd.Git.Password, SSHPrivateKey: sshPrivateKey, } - if !app.SkipRepoAccessCheck { + if !cmd.SkipRepoAccessCheck { validator := &validation.RepositoryValidator{ - GitInformationServiceURL: app.GitInformationServiceURL, + GitInformationServiceURL: cmd.GitInformationServiceURL, Token: client.Token(ctx), - Debug: app.Debug, + Debug: cmd.Debug, } - if err := validator.Validate(ctx, &newApp.Spec.ForProvider.Git.GitTarget, auth); err != nil { + if err := validator.Validate( + ctx, + &newApp.Spec.ForProvider.Git.GitTarget, + auth, + ); err != nil { return err } } @@ -159,8 +164,9 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { if err := client.Create(ctx, secret); err != nil { if kerrors.IsAlreadyExists(err) { // only update the secret if it is managed by nctl in the first place - if v, exists := newApp.Annotations[util.ManagedByAnnotation]; exists && v == util.NctlName { - fmt.Println("updating git auth credentials") + if v, exists := newApp.Annotations[util.ManagedByAnnotation]; exists && + v == util.NctlName { + cmd.Printf("updating git auth credentials\n") if err := client.Get(ctx, client.Name(secret.Name), secret); err != nil { return err } @@ -191,8 +197,8 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { } } - c := newCreator(client, newApp, strings.ToLower(apps.ApplicationKind)) - appWaitCtx, cancel := context.WithTimeout(ctx, app.WaitTimeout) + c := cmd.newCreator(client, newApp, strings.ToLower(apps.ApplicationKind)) + appWaitCtx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() if err := c.createResource(appWaitCtx); err != nil { @@ -206,7 +212,7 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { return err } - if !app.Wait { + if !cmd.Wait { return nil } @@ -218,15 +224,8 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { ); err != nil { printCtx, cancel := context.WithTimeout(context.Background(), logPrintTimeout) defer cancel() - if buildErr, ok := err.(buildError); ok { - if err := buildErr.printMessage(printCtx, client); err != nil { - return fmt.Errorf("%s: %w", buildErr, err) - } - } - if releaseErr, ok := err.(releaseError); ok { - if err := releaseErr.printMessage(printCtx, client); err != nil { - return fmt.Errorf("%s: %w", releaseErr, err) - } + if printErr := cmd.printErrorDetails(printCtx, client, err); printErr != nil { + return fmt.Errorf("%s: %w", err, printErr) } return err } @@ -235,12 +234,16 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { return err } - if err := spinnerMessage("CO₂ compensating the app", "🌳", 2*time.Second); err != nil { + if err := cmd.spinnerMessage("CO₂ compensating the app", "🌳", 2*time.Second); err != nil { return err } - fmt.Printf("\nYour application %q is now available at:\n https://%s\n\n", newApp.Name, newApp.Status.AtProvider.CNAMETarget) - printUnverifiedHostsMessage(newApp) + cmd.Printf( + "\nYour application %q is now available at:\n https://%s\n\n", + newApp.Name, + newApp.Status.AtProvider.CNAMETarget, + ) + cmd.printUnverifiedHostsMessage(newApp) basicAuthEnabled, err := latestReleaseHasBasicAuthEnabled(ctx, client, newApp) if err != nil { @@ -263,14 +266,14 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { format.Command().GetApplication(newApp.Name, "--basic-auth-credentials"), ) } - printCredentials(basicAuth) + cmd.printCredentials(basicAuth) return nil } -func spinnerMessage(msg, icon string, sleepTime time.Duration) error { - fullMsg := format.ProgressMessage(icon, msg) - spinner, err := format.NewSpinner(fullMsg, fullMsg) +func (cmd *applicationCmd) spinnerMessage(msg, icon string, sleepTime time.Duration) error { + fullMsg := format.Progress(icon, msg) + spinner, err := cmd.Spinner(fullMsg, fullMsg) if err != nil { return err } @@ -288,69 +291,70 @@ func combineEnvVars(plain, sensitive map[string]string) apps.EnvVars { ) } -func (app *applicationCmd) config() apps.Config { +func (cmd *applicationCmd) config() apps.Config { var deployJob *apps.DeployJob - if len(app.DeployJob.Command) != 0 && len(app.DeployJob.Name) != 0 { + if len(cmd.DeployJob.Command) != 0 && len(cmd.DeployJob.Name) != 0 { deployJob = &apps.DeployJob{ Job: apps.Job{ - Name: app.DeployJob.Name, - Command: app.DeployJob.Command, + Name: cmd.DeployJob.Name, + Command: cmd.DeployJob.Command, }, FiniteJob: apps.FiniteJob{ - Retries: ptr.To(app.DeployJob.Retries), - Timeout: &metav1.Duration{Duration: app.DeployJob.Timeout}, + Retries: ptr.To(cmd.DeployJob.Retries), + Timeout: &metav1.Duration{Duration: cmd.DeployJob.Timeout}, }, } } config := apps.Config{ - EnableBasicAuth: app.BasicAuth, - Env: combineEnvVars(app.Env, app.SensitiveEnv), + EnableBasicAuth: cmd.BasicAuth, + Env: combineEnvVars(cmd.Env, cmd.SensitiveEnv), DeployJob: deployJob, } - if len(app.WorkerJob.Command) != 0 && len(app.WorkerJob.Name) != 0 { + if len(cmd.WorkerJob.Command) != 0 && len(cmd.WorkerJob.Name) != 0 { workerJob := apps.WorkerJob{ Job: apps.Job{ - Name: app.WorkerJob.Name, - Command: app.WorkerJob.Command, + Name: cmd.WorkerJob.Name, + Command: cmd.WorkerJob.Command, }, } - if app.WorkerJob.Size != nil { - workerJob.Size = ptr.To(apps.ApplicationSize(*app.WorkerJob.Size)) + if cmd.WorkerJob.Size != nil { + workerJob.Size = ptr.To(apps.ApplicationSize(*cmd.WorkerJob.Size)) } config.WorkerJobs = append(config.WorkerJobs, workerJob) } - if len(app.ScheduledJob.Command) != 0 && len(app.ScheduledJob.Name) != 0 && len(app.ScheduledJob.Schedule) != 0 { + if len(cmd.ScheduledJob.Command) != 0 && len(cmd.ScheduledJob.Name) != 0 && + len(cmd.ScheduledJob.Schedule) != 0 { scheduledJob := apps.ScheduledJob{ FiniteJob: apps.FiniteJob{ - Retries: &app.ScheduledJob.Retries, - Timeout: &metav1.Duration{Duration: app.ScheduledJob.Timeout}, + Retries: &cmd.ScheduledJob.Retries, + Timeout: &metav1.Duration{Duration: cmd.ScheduledJob.Timeout}, }, Job: apps.Job{ - Name: app.ScheduledJob.Name, - Command: app.ScheduledJob.Command, + Name: cmd.ScheduledJob.Name, + Command: cmd.ScheduledJob.Command, }, - Schedule: app.ScheduledJob.Schedule, + Schedule: cmd.ScheduledJob.Schedule, } - if app.ScheduledJob.Size != nil { - scheduledJob.Size = ptr.To(apps.ApplicationSize(*app.ScheduledJob.Size)) + if cmd.ScheduledJob.Size != nil { + scheduledJob.Size = ptr.To(apps.ApplicationSize(*cmd.ScheduledJob.Size)) } config.ScheduledJobs = append(config.ScheduledJobs, scheduledJob) } - if app.Size != nil { - config.Size = apps.ApplicationSize(*app.Size) + if cmd.Size != nil { + config.Size = apps.ApplicationSize(*cmd.Size) } - if app.Port != nil { - config.Port = app.Port + if cmd.Port != nil { + config.Port = cmd.Port } - if app.Replicas != nil { - config.Replicas = app.Replicas + if cmd.Replicas != nil { + config.Replicas = cmd.Replicas } - app.HealthProbe.applyCreate(&config) + cmd.HealthProbe.applyCreate(&config) return config } @@ -371,8 +375,8 @@ func (h healthProbe) applyCreate(cfg *apps.Config) { util.ApplyProbePatch(cfg, h.ToProbePatch()) } -func (app *applicationCmd) newApplication(project string) *apps.Application { - name := getName(app.Name) +func (cmd *applicationCmd) newApplication(project string) *apps.Application { + name := getName(cmd.Name) return &apps.Application{ ObjectMeta: metav1.ObjectMeta{ @@ -381,21 +385,21 @@ func (app *applicationCmd) newApplication(project string) *apps.Application { }, Spec: apps.ApplicationSpec{ ForProvider: apps.ApplicationParameters{ - Language: apps.Language(app.Language), + Language: apps.Language(cmd.Language), Git: apps.ApplicationGitConfig{ GitTarget: apps.GitTarget{ - URL: app.Git.URL, - SubPath: app.Git.SubPath, - Revision: app.Git.Revision, + URL: cmd.Git.URL, + SubPath: cmd.Git.SubPath, + Revision: cmd.Git.Revision, }, }, - Hosts: app.Hosts, - Config: app.config(), - BuildEnv: combineEnvVars(app.BuildEnv, app.SensitiveBuildEnv), + Hosts: cmd.Hosts, + Config: cmd.config(), + BuildEnv: combineEnvVars(cmd.BuildEnv, cmd.SensitiveBuildEnv), DockerfileBuild: apps.DockerfileBuild{ - Enabled: app.DockerfileBuild.Enabled, - DockerfilePath: app.DockerfileBuild.Path, - BuildContext: app.DockerfileBuild.BuildContext, + Enabled: cmd.DockerfileBuild.Enabled, + DockerfilePath: cmd.DockerfileBuild.Path, + BuildContext: cmd.DockerfileBuild.BuildContext, }, }, }, @@ -410,11 +414,8 @@ func (b buildError) Error() string { return fmt.Sprintf("build failed with status %s", b.build.Status.AtProvider.BuildStatus) } -func (b buildError) printMessage(ctx context.Context, client *api.Client) error { - fmt.Printf("\nYour build has failed with status %q. Here are the last %v lines of the log:\n\n", - b.build.Status.AtProvider.BuildStatus, errorLogLines) - return printBuildLogs(ctx, client, b.build) -} +// Build returns the build that caused the error. +func (b buildError) Build() *apps.Build { return b.build } func waitForBuildStart(app *apps.Application) waitStage { return waitStage{ @@ -451,7 +452,11 @@ func waitForBuildStart(app *apps.Application) waitStage { } } -func waitForBuildFinish(ctx context.Context, app *apps.Application, logClient *log.Client) waitStage { +func waitForBuildFinish( + ctx context.Context, + app *apps.Application, + logClient *log.Client, +) waitStage { msg := message{icon: "📦", text: "building application"} p := tea.NewProgram( logbox.New(15, msg.progress()), @@ -542,11 +547,8 @@ func (r releaseError) Error() string { return fmt.Sprintf("release failed with status %s", r.release.Status.AtProvider.ReleaseStatus) } -func (r releaseError) printMessage(ctx context.Context, client *api.Client) error { - fmt.Printf("\nYour release has failed with status %q. Here are the last %v lines of the log:\n\n", - r.release.Status.AtProvider.ReleaseStatus, errorLogLines) - return printReleaseLogs(ctx, client, r.release) -} +// Release returns the release that caused the error. +func (r releaseError) Release() *apps.Release { return r.release } func waitForRelease(app *apps.Application) waitStage { return waitStage{ @@ -582,12 +584,20 @@ func waitForRelease(app *apps.Application) waitStage { } } -func latestReleaseHasBasicAuthEnabled(ctx context.Context, c runtimeclient.Reader, app *apps.Application) (bool, error) { +func latestReleaseHasBasicAuthEnabled( + ctx context.Context, + c runtimeclient.Reader, + app *apps.Application, +) (bool, error) { if app.Status.AtProvider.LatestRelease == "" { return false, errors.New("can not find latest release") } release := &apps.Release{} - if err := c.Get(ctx, types.NamespacedName{Name: app.Status.AtProvider.LatestRelease, Namespace: app.Namespace}, release); err != nil { + if err := c.Get( + ctx, + types.NamespacedName{Name: app.Status.AtProvider.LatestRelease, Namespace: app.Namespace}, + release, + ); err != nil { return false, err } config := release.Spec.ForProvider.Configuration @@ -597,22 +607,22 @@ func latestReleaseHasBasicAuthEnabled(ctx context.Context, c runtimeclient.Reade return config.EnableBasicAuth.Value, nil } -func printUnverifiedHostsMessage(app *apps.Application) { +func (cmd *applicationCmd) printUnverifiedHostsMessage(app *apps.Application) { unverifiedHosts := util.UnverifiedAppHosts(app) if len(unverifiedHosts) != 0 { dnsDetails := util.GatherDNSDetails([]apps.Application{*app}) - fmt.Println("You configured the following hosts:") + cmd.Printf("You configured the following hosts:\n") for _, name := range unverifiedHosts { - fmt.Printf(" %s\n", name) + cmd.Printf(" %s\n", name) } - fmt.Print("\nYour DNS details are:\n") - fmt.Printf(" TXT record:\t%s\n", dnsDetails[0].TXTRecord) - fmt.Printf(" DNS TARGET:\t%s\n", dnsDetails[0].CNAMETarget) + cmd.Printf("\nYour DNS details are:\n") + cmd.Printf(" TXT record:\t%s\n", dnsDetails[0].TXTRecord) + cmd.Printf(" DNS TARGET:\t%s\n", dnsDetails[0].CNAMETarget) - fmt.Printf("\nTo make your app available on your custom hosts, please use \n"+ + cmd.Printf("\nTo make your app available on your custom hosts, please use \n"+ "the DNS details and visit %s\n"+ "for further instructions.\n", util.DNSSetupURL, @@ -633,13 +643,17 @@ func printReleaseLogs(ctx context.Context, client *api.Client, release *apps.Rel tailCtx, cancel := context.WithTimeout(ctx, errorTailTimeout) defer cancel() return client.Log.TailQuery( - tailCtx, 0, client.Log.StdOut, - errorLogQuery(logs.ApplicationQuery(release.Labels[util.ApplicationNameLabel], release.Namespace)), + tailCtx, + 0, + client.Log.StdOut, + errorLogQuery( + logs.ApplicationQuery(release.Labels[util.ApplicationNameLabel], release.Namespace), + ), ) } -func printCredentials(basicAuth *util.BasicAuth) { - fmt.Printf("\nYou can login with the following credentials:\n"+ +func (cmd *applicationCmd) printCredentials(basicAuth *util.BasicAuth) { + cmd.Printf("\nYou can login with the following credentials:\n"+ " username: %s\n"+ " password: %s\n", basicAuth.Username, @@ -647,6 +661,35 @@ func printCredentials(basicAuth *util.BasicAuth) { ) } +// printErrorDetails prints detailed error information for build and release errors. +func (cmd *applicationCmd) printErrorDetails( + ctx context.Context, + client *api.Client, + err error, +) error { + var buildErr buildError + if errors.As(err, &buildErr) { + cmd.Printf( + "\nYour build has failed with status %q. Here are the last %v lines of the log:\n\n", + buildErr.Build().Status.AtProvider.BuildStatus, + errorLogLines, + ) + return printBuildLogs(ctx, client, buildErr.Build()) + } + + var releaseErr releaseError + if errors.As(err, &releaseErr) { + cmd.Printf( + "\nYour release has failed with status %q. Here are the last %v lines of the log:\n\n", + releaseErr.Release().Status.AtProvider.ReleaseStatus, + errorLogLines, + ) + return printReleaseLogs(ctx, client, releaseErr.Release()) + } + + return nil +} + // we print the last 40 lines of the log. In most cases this should be // enough to give a hint about the problem but we might need to tweak this // value a bit in the future. diff --git a/create/application_test.go b/create/application_test.go index e82572e8..659981f9 100644 --- a/create/application_test.go +++ b/create/application_test.go @@ -19,6 +19,7 @@ import ( "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -36,6 +37,7 @@ func createTempKeyFile(content string) (string, error) { } return file.Name(), nil } + func TestApplication(t *testing.T) { apiClient, err := test.SetupClient() if err != nil { diff --git a/create/bucket.go b/create/bucket.go index 6a6ac2d0..71637734 100644 --- a/create/bucket.go +++ b/create/bucket.go @@ -37,7 +37,7 @@ func (cmd *bucketCmd) Run(ctx context.Context, client *api.Client) error { return err } - c := newCreator(client, bucket, "bucket") + c := cmd.newCreator(client, bucket, "bucket") ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/bucketuser.go b/create/bucketuser.go index 16257824..d877638f 100644 --- a/create/bucketuser.go +++ b/create/bucketuser.go @@ -2,18 +2,16 @@ package create import ( "context" - "fmt" "strings" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/watch" - "github.com/alecthomas/kong" runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" - "github.com/ninech/nctl/api" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" ) type bucketUserCmd struct { @@ -22,10 +20,10 @@ type bucketUserCmd struct { } func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client) error { - fmt.Println("Creating new bucketuser.") + cmd.Printf("Creating new bucketuser.\n") bucketuser := cmd.newBucketUser(client.Project) - c := newCreator(client, bucketuser, "bucketuser") + c := cmd.newCreator(client, bucketuser, "bucketuser") ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/cloudvm.go b/create/cloudvm.go index 68dfb3e0..7c1ca125 100644 --- a/create/cloudvm.go +++ b/create/cloudvm.go @@ -12,6 +12,7 @@ import ( infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" meta "github.com/ninech/apis/meta/v1alpha1" "github.com/ninech/nctl/api" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" @@ -39,7 +40,7 @@ func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client) error { return err } - c := newCreator(client, cloudVM, infrastructure.CloudVirtualMachineKind) + c := cmd.newCreator(client, cloudVM, infrastructure.CloudVirtualMachineKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -59,12 +60,13 @@ func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client) error { return isAvailable(c), nil } return false, nil - }}, + }, + }, ); err != nil { return err } - fmt.Printf("\nYour Cloud VM %s is now available, you can now connect with:\n ssh root@%s\n\n", cloudVM.Name, cloudVM.Status.AtProvider.FQDN) + cmd.Printf("\nYour Cloud VM %s is now available, you can now connect with:\n ssh root@%s\n\n", cloudVM.Name, cloudVM.Status.AtProvider.FQDN) return nil } @@ -137,8 +139,8 @@ func (cmd *cloudVMCmd) newCloudVM(namespace string) (*infrastructure.CloudVirtua return cloudVM, nil } -// ApplicationKongVars returns all variables which are used in the application -// create command +// CloudVMKongVars returns all variables which are used in the application +// create command. func CloudVMKongVars() kong.Vars { result := make(kong.Vars) result["cloudvm_os_flavors"] = strings.Join(stringSlice(infrastructure.CloudVirtualMachineOperatingSystems), ", ") diff --git a/create/create.go b/create/create.go index f84c5e28..faa7669f 100644 --- a/create/create.go +++ b/create/create.go @@ -1,3 +1,4 @@ +// Package create provides functionality to create resources in the nine.ch API. package create import ( @@ -13,6 +14,7 @@ import ( "github.com/ninech/nctl/api" "github.com/ninech/nctl/internal/format" "github.com/theckman/yacspin" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/watch" @@ -41,6 +43,8 @@ type Cmd struct { } type resourceCmd struct { + format.Writer + Name string `arg:"" help:"Name of the new resource. A random name is generated if omitted." default:""` Wait bool `default:"true" help:"Wait until resource is fully created."` WaitTimeout time.Duration `default:"30m" help:"Duration to wait for resource getting ready. Only relevant if wait is set."` @@ -51,12 +55,16 @@ type resourceCmd struct { type resultFunc func(watch.Event) (bool, error) type creator struct { + format.Writer + client *api.Client mg resource.Managed kind string } type waitStage struct { + format.Writer + kind string waitMessage *message doneMessage *message @@ -89,19 +97,11 @@ func (m *message) progress() string { return "" } - return format.ProgressMessage(m.icon, m.text) -} - -func (m *message) printSuccess() { - if m.disabled { - return - } - - format.PrintSuccess(m.icon, m.text) + return format.Progress(m.icon, m.text) } -func newCreator(client *api.Client, mg resource.Managed, resourceName string) *creator { - return &creator{client: client, mg: mg, kind: resourceName} +func (cmd *resourceCmd) newCreator(client *api.Client, mg resource.Managed, resourceName string) *creator { + return &creator{client: client, mg: mg, kind: resourceName, Writer: cmd.Writer} } func (c *creator) createResource(ctx context.Context) error { @@ -109,7 +109,7 @@ func (c *creator) createResource(ctx context.Context) error { return fmt.Errorf("unable to create %s %q: %w", c.kind, c.mg.GetName(), err) } - format.PrintSuccessf("🏗", "created %s %q in project %q", c.kind, c.mg.GetName(), c.mg.GetNamespace()) + c.Successf("🏗", "created %s %q in project %q\n", c.kind, c.mg.GetName(), c.mg.GetNamespace()) return nil } @@ -120,8 +120,9 @@ func (c *creator) wait(ctx context.Context, stages ...waitStage) error { } stage.setDefaults(c) + stage.Writer = c.Writer - spinner, err := format.NewSpinner( + spinner, err := c.Spinner( stage.waitMessage.progress(), stage.waitMessage.progress(), ) @@ -178,7 +179,10 @@ type watchError struct { } func (werr watchError) Error() string { - return fmt.Sprintf("error watching %s, the API might be experiencing connectivity issues", werr.kind) + return fmt.Sprintf( + "error watching %s, the API might be experiencing connectivity issues", + werr.kind, + ) } func isWatchError(err error) bool { @@ -219,8 +223,7 @@ func (w *waitStage) watch(ctx context.Context, client *api.Client) error { if done { wa.Stop() _ = w.spinner.Stop() - // print out the done message directly - w.doneMessage.printSuccess() + w.Successf(w.doneMessage.icon, "%s\n", w.doneMessage.text) return nil } @@ -228,7 +231,7 @@ func (w *waitStage) watch(ctx context.Context, client *api.Client) error { switch ctx.Err() { case context.DeadlineExceeded: msg := "timeout waiting for %s" - w.spinner.StopFailMessage(format.ProgressMessagef("", msg, w.kind)) + w.spinner.StopFailMessage(format.Progressf("", msg, w.kind)) _ = w.spinner.StopFail() return fmt.Errorf(msg, w.kind) diff --git a/create/create_test.go b/create/create_test.go index a5604462..0aee1432 100644 --- a/create/create_test.go +++ b/create/create_test.go @@ -32,7 +32,8 @@ func TestCreate(t *testing.T) { apiClient, err := test.SetupClient() require.NoError(t, err) - c := newCreator(apiClient, asa, "apiserviceaccount") + cmd := &apiServiceAccountCmd{} + c := cmd.newCreator(apiClient, asa, "apiserviceaccount") ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() diff --git a/create/file.go b/create/file.go index f54964f6..4b437a3d 100644 --- a/create/file.go +++ b/create/file.go @@ -5,11 +5,13 @@ import ( "github.com/ninech/nctl/api" "github.com/ninech/nctl/apply" + "github.com/ninech/nctl/internal/format" ) type fromFile struct { + format.Writer } func (cmd *fromFile) Run(ctx context.Context, client *api.Client, create *Cmd) error { - return apply.File(ctx, client, create.Filename) + return apply.File(ctx, cmd.Writer, client, create.Filename) } diff --git a/create/keyvaluestore.go b/create/keyvaluestore.go index 252c1c9f..fc35487b 100644 --- a/create/keyvaluestore.go +++ b/create/keyvaluestore.go @@ -31,7 +31,7 @@ func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error return err } - c := newCreator(client, keyValueStore, "keyvaluestore") + c := cmd.newCreator(client, keyValueStore, "keyvaluestore") ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/mysql.go b/create/mysql.go index c1dc8b84..222056e4 100644 --- a/create/mysql.go +++ b/create/mysql.go @@ -46,10 +46,10 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error { cmd.SSHKeys = keys } - fmt.Printf("Creating new mysql. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) + cmd.Printf("Creating new mysql. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) mysql := cmd.newMySQL(client.Project) - c := newCreator(client, mysql, "mysql") + c := cmd.newCreator(client, mysql, "mysql") ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -62,6 +62,7 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error { } return c.wait(ctx, waitStage{ + Writer: cmd.Writer, objectList: &storage.MySQLList{}, onResult: func(event watch.Event) (bool, error) { if c, ok := event.Object.(*storage.MySQL); ok { diff --git a/create/mysqldatabase.go b/create/mysqldatabase.go index 3d58bc74..da6623f6 100644 --- a/create/mysqldatabase.go +++ b/create/mysqldatabase.go @@ -2,7 +2,6 @@ package create import ( "context" - "fmt" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -24,10 +23,10 @@ type mysqlDatabaseCmd struct { } func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error { - fmt.Printf("Creating new MySQLDatabase. (waiting up to %s).\n", cmd.WaitTimeout) + cmd.Printf("Creating new MySQLDatabase. (waiting up to %s).\n", cmd.WaitTimeout) mysqlDatabase := cmd.newMySQLDatabase(client.Project) - c := newCreator(client, mysqlDatabase, "mysqldatabase") + c := cmd.newCreator(client, mysqlDatabase, storage.MySQLDatabaseKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -40,6 +39,7 @@ func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error } if err := c.wait(ctx, waitStage{ + Writer: cmd.Writer, objectList: &storage.MySQLDatabaseList{}, onResult: func(event watch.Event) (bool, error) { if mdb, ok := event.Object.(*storage.MySQLDatabase); ok { @@ -51,7 +51,7 @@ func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error return err } - fmt.Printf("\n Your MySQLDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get mysqldatabase %s --print-connection-string\n\n", mysqlDatabase.Name, mysqlDatabase.Name) + cmd.Printf("\n Your MySQLDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get mysqldatabase %s --print-connection-string\n\n", mysqlDatabase.Name, mysqlDatabase.Name) return nil } diff --git a/create/opensearch.go b/create/opensearch.go index b9bb6bc8..2ebd5aae 100644 --- a/create/opensearch.go +++ b/create/opensearch.go @@ -35,7 +35,7 @@ func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { return err } - c := newCreator(client, openSearch, "opensearch") + c := cmd.newCreator(client, openSearch, "opensearch") ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/postgres.go b/create/postgres.go index 0f5422ba..952cd350 100644 --- a/create/postgres.go +++ b/create/postgres.go @@ -40,10 +40,10 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { cmd.SSHKeys = keys } - fmt.Printf("Creating new postgres. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) + cmd.Printf("Creating new postgres. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) postgres := cmd.newPostgres(client.Project) - c := newCreator(client, postgres, "postgres") + c := cmd.newCreator(client, postgres, "postgres") ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -56,6 +56,7 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { } return c.wait(ctx, waitStage{ + Writer: cmd.Writer, objectList: &storage.PostgresList{}, onResult: func(event watch.Event) (bool, error) { if c, ok := event.Object.(*storage.Postgres); ok { diff --git a/create/postgresdatabase.go b/create/postgresdatabase.go index 5060d0c7..56f7b899 100644 --- a/create/postgresdatabase.go +++ b/create/postgresdatabase.go @@ -2,7 +2,6 @@ package create import ( "context" - "fmt" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,10 +22,10 @@ type postgresDatabaseCmd struct { } func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) error { - fmt.Printf("Creating new PostgresDatabase. (waiting up to %s).\n", cmd.WaitTimeout) + cmd.Printf("Creating new PostgresDatabase. (waiting up to %s).\n", cmd.WaitTimeout) postgresDatabase := cmd.newPostgresDatabase(client.Project) - c := newCreator(client, postgresDatabase, "postgresdatabase") + c := cmd.newCreator(client, postgresDatabase, "postgresdatabase") ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -39,6 +38,7 @@ func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) err } if err := c.wait(ctx, waitStage{ + Writer: cmd.Writer, objectList: &storage.PostgresDatabaseList{}, onResult: func(event watch.Event) (bool, error) { if pdb, ok := event.Object.(*storage.PostgresDatabase); ok { @@ -50,7 +50,11 @@ func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) err return err } - fmt.Printf("\n Your PostgresDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get postgresdatabase %s --print-connection-string\n\n", postgresDatabase.Name, postgresDatabase.Name) + cmd.Printf( + "\n Your PostgresDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get postgresdatabase %s --print-connection-string\n\n", + postgresDatabase.Name, + postgresDatabase.Name, + ) return nil } diff --git a/create/project.go b/create/project.go index 735a25f4..02dee2ea 100644 --- a/create/project.go +++ b/create/project.go @@ -2,7 +2,6 @@ package create import ( "context" - "fmt" "strings" management "github.com/ninech/apis/management/v1alpha1" @@ -15,27 +14,28 @@ type projectCmd struct { DisplayName string `default:"" help:"Display Name of the project."` } -func (proj *projectCmd) Run(ctx context.Context, client *api.Client) error { +func (cmd *projectCmd) Run(ctx context.Context, client *api.Client) error { org, err := client.Organization() if err != nil { return err } - p := newProject(proj.Name, org, proj.DisplayName) - fmt.Printf("Creating new project %s for organization %s\n", p.Name, org) - c := newCreator(client, p, strings.ToLower(management.ProjectKind)) - ctx, cancel := context.WithTimeout(ctx, proj.WaitTimeout) + p := newProject(cmd.Name, org, cmd.DisplayName) + cmd.Printf("Creating new project %s for organization %s\n", p.Name, org) + c := cmd.newCreator(client, p, strings.ToLower(management.ProjectKind)) + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() if err := c.createResource(ctx); err != nil { return err } - if !proj.Wait { + if !cmd.Wait { return nil } return c.wait(ctx, waitStage{ + Writer: cmd.Writer, objectList: &management.ProjectList{}, onResult: resourceAvailable, }) diff --git a/create/project_config.go b/create/project_config.go index 97cb2356..49b5c231 100644 --- a/create/project_config.go +++ b/create/project_config.go @@ -3,9 +3,12 @@ package create import ( "context" + "github.com/crossplane/crossplane-runtime/pkg/resource" apps "github.com/ninech/apis/apps/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/util" + "github.com/ninech/nctl/internal/format" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) @@ -13,6 +16,8 @@ import ( // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { + format.Writer + Size string `help:"Size of the application."` Port *int32 `help:"Port the application is listening on."` Replicas *int32 `help:"Amount of replicas of the running application."` @@ -21,8 +26,16 @@ type configCmd struct { DeployJob deployJob `embed:"" prefix:"deploy-job-"` } +func (cmd *configCmd) newCreator( + client *api.Client, + mg resource.Managed, + resourceName string, +) *creator { + return &creator{client: client, mg: mg, kind: resourceName, Writer: cmd.Writer} +} + func (cmd *configCmd) Run(ctx context.Context, client *api.Client) error { - c := newCreator(client, cmd.newProjectConfig(client.Project), apps.ProjectConfigGroupKind) + c := cmd.newCreator(client, cmd.newProjectConfig(client.Project), apps.ProjectConfigGroupKind) return c.createResource(ctx) } diff --git a/create/serviceconnection.go b/create/serviceconnection.go index 8e4e21d6..cde174ab 100644 --- a/create/serviceconnection.go +++ b/create/serviceconnection.go @@ -15,7 +15,6 @@ import ( meta "github.com/ninech/apis/meta/v1alpha1" networking "github.com/ninech/apis/networking/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/format" ) type serviceConnectionCmd struct { @@ -26,7 +25,7 @@ type serviceConnectionCmd struct { KubernetesClusterOptions KubernetesClusterOptions `embed:"" prefix:"source-"` } -// KubernetesClusterOptions +// KubernetesClusterOptions contains options for a KubernetesCluster source. // https://pkg.go.dev/github.com/ninech/apis@v0.0.0-20250708054129-4d49f7a6c606/networking/v1alpha1#KubernetesClusterOptions type KubernetesClusterOptions struct { PodSelector *LabelSelector `placeholder:"${label_selector_placeholder}" help:"${label_selector_requirements} Restrict which pods of the KubernetesCluster can connect to the service connection destination. If left empty, all pods are allowed. If the namespace selector is also set, then the pod selector as a whole selects the pods matching pod selector in the namespaces selected by namespace selector.\n\n${label_selector_usage}."` @@ -106,7 +105,7 @@ func (cmd *serviceConnectionCmd) Run(ctx context.Context, client *api.Client) er } params := sc.Spec.ForProvider.DeepCopy() - c := newCreator(client, sc, networking.ServiceConnectionKind) + c := cmd.newCreator(client, sc, networking.ServiceConnectionKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -128,16 +127,17 @@ func (cmd *serviceConnectionCmd) Run(ctx context.Context, client *api.Client) er } if !sourceExists || !destinationExists { if !sourceExists { - format.PrintWarningf("source %q does not yet exist\n", sc.Spec.ForProvider.Source.Reference) + cmd.Warningf("source %q does not yet exist\n", sc.Spec.ForProvider.Source.Reference) } if !destinationExists { - format.PrintWarningf("destination %q does not yet exist\n", sc.Spec.ForProvider.Destination) + cmd.Warningf("destination %q does not yet exist\n", sc.Spec.ForProvider.Destination) } return nil } return c.wait(ctx, waitStage{ + Writer: cmd.Writer, objectList: &networking.ServiceConnectionList{}, onResult: resourceAvailable, }, diff --git a/create/vcluster.go b/create/vcluster.go index f6ef401e..40e853f1 100644 --- a/create/vcluster.go +++ b/create/vcluster.go @@ -23,17 +23,17 @@ type vclusterCmd struct { NodePoolName string `default:"worker" help:"Name of the default node pool in the vCluster."` } -func (vc *vclusterCmd) Run(ctx context.Context, client *api.Client) error { - cluster := vc.newCluster(client.Project) - c := newCreator(client, cluster, "vcluster") - ctx, cancel := context.WithTimeout(ctx, vc.WaitTimeout) +func (cmd *vclusterCmd) Run(ctx context.Context, client *api.Client) error { + cluster := cmd.newCluster(client.Project) + c := cmd.newCreator(client, cluster, "vcluster") + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() if err := c.createResource(ctx); err != nil { return err } - if !vc.Wait { + if !cmd.Wait { return nil } @@ -41,10 +41,11 @@ func (vc *vclusterCmd) Run(ctx context.Context, client *api.Client) error { objectList: &infrastructure.KubernetesClusterList{}, onResult: func(event watch.Event) (bool, error) { if c, ok := event.Object.(*infrastructure.KubernetesCluster); ok { - return vc.isAvailable(c), nil + return cmd.isAvailable(c), nil } return false, nil - }}, + }, + }, ); err != nil { return err } @@ -53,12 +54,12 @@ func (vc *vclusterCmd) Run(ctx context.Context, client *api.Client) error { return clustercmd.Run(ctx, client) } -func (vc *vclusterCmd) isAvailable(cluster *infrastructure.KubernetesCluster) bool { +func (cmd *vclusterCmd) isAvailable(cluster *infrastructure.KubernetesCluster) bool { return isAvailable(cluster) && len(cluster.Status.AtProvider.APIEndpoint) != 0 } -func (vc *vclusterCmd) newCluster(project string) *infrastructure.KubernetesCluster { - name := getName(vc.Name) +func (cmd *vclusterCmd) newCluster(project string) *infrastructure.KubernetesCluster { + name := getName(cmd.Name) return &infrastructure.KubernetesCluster{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -73,15 +74,15 @@ func (vc *vclusterCmd) newCluster(project string) *infrastructure.KubernetesClus }, ForProvider: infrastructure.KubernetesClusterParameters{ VCluster: &infrastructure.VClusterSettings{ - Version: vc.KubernetesVersion, + Version: cmd.KubernetesVersion, }, - Location: vc.Location, + Location: cmd.Location, NodePools: []infrastructure.NodePool{ { - Name: vc.NodePoolName, - MinNodes: vc.MinNodes, - MaxNodes: vc.MaxNodes, - MachineType: infrastructure.NewMachineType(vc.MachineType), + Name: cmd.NodePoolName, + MinNodes: cmd.MinNodes, + MaxNodes: cmd.MaxNodes, + MachineType: infrastructure.NewMachineType(cmd.MachineType), }, }, }, diff --git a/delete/apiserviceaccount.go b/delete/apiserviceaccount.go index c9eb7c44..93e49412 100644 --- a/delete/apiserviceaccount.go +++ b/delete/apiserviceaccount.go @@ -13,18 +13,18 @@ type apiServiceAccountCmd struct { resourceCmd } -func (asa *apiServiceAccountCmd) Run(ctx context.Context, client *api.Client) error { - ctx, cancel := context.WithTimeout(ctx, asa.WaitTimeout) +func (cmd *apiServiceAccountCmd) Run(ctx context.Context, client *api.Client) error { + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() sa := &iam.APIServiceAccount{ObjectMeta: metav1.ObjectMeta{ - Name: asa.Name, + Name: cmd.Name, Namespace: client.Project, }} - d := newDeleter(sa, iam.APIServiceAccountKind) + d := cmd.newDeleter(sa, iam.APIServiceAccountKind) - if err := d.deleteResource(ctx, client, asa.WaitTimeout, asa.Wait, asa.Force); err != nil { + if err := d.deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force); err != nil { return fmt.Errorf("error while deleting %s: %w", iam.APIServiceAccountKind, err) } diff --git a/delete/application.go b/delete/application.go index cf8aab0f..eb61b2c2 100644 --- a/delete/application.go +++ b/delete/application.go @@ -37,14 +37,14 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { return fmt.Errorf("finding static egresses of app: %w", err) } - d := newDeleter(a, apps.ApplicationKind) + d := app.newDeleter(a, apps.ApplicationKind) if err := d.deleteResource(ctx, client, app.WaitTimeout, app.Wait, app.Force); err != nil { return fmt.Errorf("error while deleting %s: %w", apps.ApplicationKind, err) } var deleteErrors []error for _, s := range gitAuthSecrets { - if err := deleteGitAuthSecret(ctx, client, s); err != nil { + if err := app.deleteGitAuthSecret(ctx, client, s); err != nil { deleteErrors = append(deleteErrors, err) } } @@ -99,15 +99,21 @@ func findGitAuthSecrets(ctx context.Context, client *api.Client, a *apps.Applica return gitAuthSecrets, nil } -// deleteGitAuthSecrets tries to delete the passed git auth secret. It checks +// deleteGitAuthSecret tries to delete the passed git auth secret. It checks // if the secret is referenced in any other application, before deleting it. // It will only delete secrets which have been created by nctl itself. -func deleteGitAuthSecret(ctx context.Context, client *api.Client, secret corev1.Secret) error { +func (app *applicationCmd) deleteGitAuthSecret( + ctx context.Context, + client *api.Client, + secret corev1.Secret, +) error { if err := client.Get(ctx, api.ObjectName(&secret), &secret); err != nil { if kerrors.IsNotFound(err) { return nil } - return checkManuallyError(fmt.Errorf("error when checking git auth secret %q for application", secret.Name)) + return checkManuallyError( + fmt.Errorf("error when checking git auth secret %q for application", secret.Name), + ) } managedBy, exists := secret.Annotations[util.ManagedByAnnotation] if !exists || managedBy != util.NctlName { @@ -118,7 +124,8 @@ func deleteGitAuthSecret(ctx context.Context, client *api.Client, secret corev1. appsList := &apps.ApplicationList{} if err := client.List(ctx, appsList, runtimeclient.InNamespace(client.Project)); err != nil { return checkManuallyError( - fmt.Errorf("error when checking for applications which might reference git authentication secret %q: %w", + fmt.Errorf( + "error when checking for applications which might reference git authentication secret %q: %w", secret.Name, err, ), @@ -128,8 +135,8 @@ func deleteGitAuthSecret(ctx context.Context, client *api.Client, secret corev1. if item.Spec.ForProvider.Git.Auth != nil && item.Spec.ForProvider.Git.Auth.FromSecret != nil && item.Spec.ForProvider.Git.Auth.FromSecret.Name == secret.Name { - fmt.Printf( - "will not delete git auth secret %q as it is still referenced in application %q", + app.Printf( + "will not delete git auth secret %q as it is still referenced in application %q\n", secret.Name, item.Name, ) diff --git a/delete/bucket.go b/delete/bucket.go index 5569537f..e1dfd013 100644 --- a/delete/bucket.go +++ b/delete/bucket.go @@ -17,5 +17,5 @@ func (cmd *bucketCmd) Run(ctx context.Context, client *api.Client) error { defer cancel() bu := &storage.Bucket{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(bu, storage.BucketKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(bu, storage.BucketKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/bucketuser.go b/delete/bucketuser.go index 4d464fed..956995b1 100644 --- a/delete/bucketuser.go +++ b/delete/bucketuser.go @@ -17,5 +17,5 @@ func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client) error { defer cancel() bu := &storage.BucketUser{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(bu, storage.BucketUserKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(bu, storage.BucketUserKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/cloudvm.go b/delete/cloudvm.go index e10ac5ff..11ff6f57 100644 --- a/delete/cloudvm.go +++ b/delete/cloudvm.go @@ -17,5 +17,5 @@ func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client) error { defer cancel() cloudVM := &infrastructure.CloudVirtualMachine{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(cloudVM, infrastructure.CloudVirtualMachineKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(cloudVM, infrastructure.CloudVirtualMachineKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/delete.go b/delete/delete.go index 1c04ba8a..fdf949a9 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -1,3 +1,4 @@ +// Package delete provides functionality to delete resources in the nine.ch API. package delete import ( @@ -33,6 +34,8 @@ type Cmd struct { } type resourceCmd struct { + format.Writer + Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to delete."` Force bool `default:"false" help:"Do not ask for confirmation of deletion."` Wait bool `default:"true" help:"Wait until resource is fully deleted."` @@ -47,6 +50,8 @@ type cleanupFunc func(client *api.Client) error type promptFunc func(kind, name string) string type deleter struct { + format.Writer + kind string mg resource.Managed cleanup cleanupFunc @@ -56,8 +61,13 @@ type deleter struct { // deleterOption allows to set options for the deletion type deleterOption func(*deleter) -func newDeleter(mg resource.Managed, kind string, opts ...deleterOption) *deleter { +func (cmd *resourceCmd) newDeleter( + mg resource.Managed, + kind string, + opts ...deleterOption, +) *deleter { d := &deleter{ + Writer: cmd.Writer, kind: kind, mg: mg, cleanup: noCleanup, @@ -92,7 +102,12 @@ func defaultPrompt(kind, name string) string { return fmt.Sprintf("do you really want to delete the %s %q?", kind, name) } -func (d *deleter) deleteResource(ctx context.Context, client *api.Client, waitTimeout time.Duration, wait, force bool) error { +func (d *deleter) deleteResource( + ctx context.Context, + client *api.Client, + waitTimeout time.Duration, + wait, force bool, +) error { ctx, cancel := context.WithTimeout(ctx, waitTimeout) defer cancel() @@ -102,12 +117,12 @@ func (d *deleter) deleteResource(ctx context.Context, client *api.Client, waitTi } if !force { - ok, err := format.Confirm(d.prompt(d.kind, d.mg.GetName())) + ok, err := d.Confirm(d.prompt(d.kind, d.mg.GetName())) if err != nil { return err } if !ok { - format.PrintFailuref("", "%s deletion canceled", d.kind) + d.Failuref("", "%s deletion canceled", d.kind) return nil } } @@ -121,16 +136,16 @@ func (d *deleter) deleteResource(ctx context.Context, client *api.Client, waitTi return err } } else { - format.PrintSuccessf("🗑", "%s deletion started", d.kind) + d.Successf("🗑", "%s deletion started", d.kind) } return d.cleanup(client) } func (d *deleter) waitForDeletion(ctx context.Context, client *api.Client) error { - spinner, err := format.NewSpinner( - format.ProgressMessagef("⏳", "%s is being deleted", d.kind), - format.ProgressMessagef("🗑", "%s deleted", d.kind), + spinner, err := d.Spinner( + format.Progressf("⏳", "%s is being deleted", d.kind), + format.Progressf("🗑", "%s deleted", d.kind), ) if err != nil { return err @@ -157,7 +172,7 @@ func (d *deleter) waitForDeletion(ctx context.Context, client *api.Client) error switch ctx.Err() { case context.DeadlineExceeded: msg := "timeout waiting for %s" - spinner.StopFailMessage(format.ProgressMessagef("", msg, d.kind)) + spinner.StopFailMessage(format.Progressf("", msg, d.kind)) _ = spinner.StopFail() return fmt.Errorf(msg, d.kind) diff --git a/delete/delete_test.go b/delete/delete_test.go index 27510a14..4b51a604 100644 --- a/delete/delete_test.go +++ b/delete/delete_test.go @@ -7,6 +7,7 @@ import ( apps "github.com/ninech/apis/apps/v1alpha1" iam "github.com/ninech/apis/iam/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -26,8 +27,9 @@ func TestDeleter(t *testing.T) { test.WithObjects(asa), ) require.NoError(t, err) + cmd := &apiServiceAccountCmd{resourceCmd{Writer: format.NewWriter(t.Output())}} - d := newDeleter(asa, iam.APIServiceAccountKind) + d := cmd.newDeleter(asa, iam.APIServiceAccountKind) if err := d.deleteResource(ctx, apiClient, 0, false, true); err != nil { t.Fatalf("error while deleting %s: %s", apps.ApplicationKind, err) diff --git a/delete/file.go b/delete/file.go index 6ca45d9d..1fc58065 100644 --- a/delete/file.go +++ b/delete/file.go @@ -5,11 +5,13 @@ import ( "github.com/ninech/nctl/api" "github.com/ninech/nctl/apply" + "github.com/ninech/nctl/internal/format" ) type fromFile struct { + format.Writer } func (cmd *fromFile) Run(ctx context.Context, client *api.Client, delete *Cmd) error { - return apply.File(ctx, client, delete.Filename, apply.Delete()) + return apply.File(ctx, cmd.Writer, client, delete.Filename, apply.Delete()) } diff --git a/delete/keyvaluestore.go b/delete/keyvaluestore.go index 59a0b7c5..2f1d9dd4 100644 --- a/delete/keyvaluestore.go +++ b/delete/keyvaluestore.go @@ -17,5 +17,5 @@ func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error defer cancel() keyValueStore := &storage.KeyValueStore{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(keyValueStore, storage.KeyValueStoreKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(keyValueStore, storage.KeyValueStoreKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/mysql.go b/delete/mysql.go index b9e08012..8ad21cb9 100644 --- a/delete/mysql.go +++ b/delete/mysql.go @@ -17,5 +17,5 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error { defer cancel() mysql := &storage.MySQL{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(mysql, storage.MySQLKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(mysql, storage.MySQLKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/mysqldatabase.go b/delete/mysqldatabase.go index 40731c21..d74cac5a 100644 --- a/delete/mysqldatabase.go +++ b/delete/mysqldatabase.go @@ -14,6 +14,6 @@ type mysqlDatabaseCmd struct { func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error { mysqlDatabase := &storage.MySQLDatabase{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(mysqlDatabase, storage.MySQLDatabaseKind). + return cmd.newDeleter(mysqlDatabase, storage.MySQLDatabaseKind). deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/opensearch.go b/delete/opensearch.go index 60d041b5..e20971c0 100644 --- a/delete/opensearch.go +++ b/delete/opensearch.go @@ -17,5 +17,5 @@ func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { defer cancel() openSearch := &storage.OpenSearch{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(openSearch, storage.OpenSearchKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(openSearch, storage.OpenSearchKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/postgres.go b/delete/postgres.go index 48eb7618..d2e32310 100644 --- a/delete/postgres.go +++ b/delete/postgres.go @@ -17,5 +17,5 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { defer cancel() postgres := &storage.Postgres{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(postgres, storage.PostgresKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(postgres, storage.PostgresKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/postgresdatabase.go b/delete/postgresdatabase.go index f9c230c6..685f789a 100644 --- a/delete/postgresdatabase.go +++ b/delete/postgresdatabase.go @@ -14,5 +14,5 @@ type postgresDatabaseCmd struct { func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) error { postgresDatabase := &storage.PostgresDatabase{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(postgresDatabase, storage.PostgresDatabaseKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(postgresDatabase, storage.PostgresDatabaseKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/project.go b/delete/project.go index 017d14f8..24cdb182 100644 --- a/delete/project.go +++ b/delete/project.go @@ -13,8 +13,8 @@ type projectCmd struct { resourceCmd } -func (proj *projectCmd) Run(ctx context.Context, client *api.Client) error { - ctx, cancel := context.WithTimeout(ctx, proj.WaitTimeout) +func (cmd *projectCmd) Run(ctx context.Context, client *api.Client) error { + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() org, err := client.Organization() @@ -22,10 +22,10 @@ func (proj *projectCmd) Run(ctx context.Context, client *api.Client) error { return err } - d := newDeleter( + d := cmd.newDeleter( &management.Project{ ObjectMeta: metav1.ObjectMeta{ - Name: proj.Name, + Name: cmd.Name, Namespace: org, }, }, @@ -37,7 +37,7 @@ func (proj *projectCmd) Run(ctx context.Context, client *api.Client) error { // main organization namespace client.Project = org - if err := d.deleteResource(ctx, client, proj.WaitTimeout, proj.Wait, proj.Force); err != nil { + if err := d.deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force); err != nil { return fmt.Errorf("error while deleting %s: %w", management.ProjectKind, err) } diff --git a/delete/project_config.go b/delete/project_config.go index 6d0eaa35..eaded910 100644 --- a/delete/project_config.go +++ b/delete/project_config.go @@ -5,17 +5,37 @@ import ( "fmt" "time" + "github.com/crossplane/crossplane-runtime/pkg/resource" apps "github.com/ninech/apis/apps/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type configCmd struct { + format.Writer + Force bool `default:"false" help:"Do not ask for confirmation of deletion."` Wait bool `default:"true" help:"Wait until Project Configuration is fully deleted."` WaitTimeout time.Duration `default:"10s" help:"Duration to wait for the deletion. Only relevant if wait is set."` } +func (cmd *configCmd) newDeleter(mg resource.Managed, kind string, opts ...deleterOption) *deleter { + d := &deleter{ + Writer: cmd.Writer, + kind: kind, + mg: mg, + cleanup: noCleanup, + prompt: defaultPrompt, + } + for _, opt := range opts { + opt(d) + } + + return d +} + func (cmd *configCmd) Run(ctx context.Context, client *api.Client) error { ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -27,7 +47,7 @@ func (cmd *configCmd) Run(ctx context.Context, client *api.Client) error { }, } - d := newDeleter(c, apps.ProjectConfigKind) + d := cmd.newDeleter(c, apps.ProjectConfigKind) if err := d.deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force); err != nil { return fmt.Errorf("error while deleting %s: %w", apps.ProjectConfigKind, err) diff --git a/delete/serviceconnection.go b/delete/serviceconnection.go index f1befbaf..0e8dc4f2 100644 --- a/delete/serviceconnection.go +++ b/delete/serviceconnection.go @@ -18,5 +18,5 @@ func (cmd *serviceConnectionCmd) Run(ctx context.Context, client *api.Client) er defer cancel() sc := &networking.ServiceConnection{ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: client.Project}} - return newDeleter(sc, networking.ServiceConnectionKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) + return cmd.newDeleter(sc, networking.ServiceConnectionKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) } diff --git a/delete/vcluster.go b/delete/vcluster.go index 3f7e41d2..eee8c77f 100644 --- a/delete/vcluster.go +++ b/delete/vcluster.go @@ -7,7 +7,6 @@ import ( infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/config" - "github.com/ninech/nctl/internal/format" "k8s.io/apimachinery/pkg/types" ) @@ -15,12 +14,12 @@ type vclusterCmd struct { resourceCmd } -func (vc *vclusterCmd) Run(ctx context.Context, client *api.Client) error { - ctx, cancel := context.WithTimeout(ctx, vc.WaitTimeout) +func (cmd *vclusterCmd) Run(ctx context.Context, client *api.Client) error { + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() cluster := &infrastructure.KubernetesCluster{} - clusterName := types.NamespacedName{Name: vc.Name, Namespace: client.Project} + clusterName := types.NamespacedName{Name: cmd.Name, Namespace: client.Project} if err := client.Get(ctx, clusterName, cluster); err != nil { return fmt.Errorf("unable to get vcluster %q: %w", cluster.Name, err) } @@ -29,15 +28,18 @@ func (vc *vclusterCmd) Run(ctx context.Context, client *api.Client) error { return fmt.Errorf("supplied cluster %q is not a vcluster", config.ContextName(cluster)) } - d := newDeleter(cluster, "vcluster", cleanup( + d := cmd.newDeleter(cluster, "vcluster", cleanup( func(client *api.Client) error { - if err := config.RemoveClusterFromKubeConfig(client.KubeconfigPath, config.ContextName(cluster)); err != nil { - format.PrintWarningf("unable to remove cluster from kubeconfig: %s\n", err) + if err := config.RemoveClusterFromKubeConfig( + client.KubeconfigPath, + config.ContextName(cluster), + ); err != nil { + cmd.Warningf("unable to remove cluster from kubeconfig: %s\n", err) } return nil })) - if err := d.deleteResource(ctx, client, vc.WaitTimeout, vc.Wait, vc.Force); err != nil { + if err := d.deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force); err != nil { return fmt.Errorf("unable to delete vcluster: %w", err) } diff --git a/edit/edit.go b/edit/edit.go index b765a611..9fe36116 100644 --- a/edit/edit.go +++ b/edit/edit.go @@ -1,3 +1,4 @@ +// Package edit provides functionality to edit resources in a text editor. package edit import ( @@ -39,6 +40,8 @@ type Cmd struct { } type resourceCmd struct { + format.Writer + Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to edit." required:""` } @@ -74,7 +77,10 @@ func (cmd *resourceCmd) Run(kong *kong.Context, ctx context.Context, c *api.Clie }() writeHeader(f, obj) - if err := format.PrettyPrintObjects([]client.Object{obj}, format.PrintOpts{Out: f, AllFields: true}); err != nil { + if err := format.PrettyPrintObjects( + []client.Object{obj}, + format.PrintOpts{Out: f, AllFields: true}, + ); err != nil { return err } oldModTime, err := modTime(f) @@ -133,9 +139,9 @@ func (cmd *resourceCmd) Run(kong *kong.Context, ctx context.Context, c *api.Clie return editError } if modified { - format.PrintSuccessf("🏗", "updated %s", formatObj(obj)) + cmd.Successf("🏗", "updated %s", formatObj(obj)) } else { - fmt.Printf("no changes made to %s\n", formatObj(obj)) + cmd.Printf("no changes made to %s\n", formatObj(obj)) } return nil } @@ -162,7 +168,9 @@ func writeError(fileName string, editError error, obj client.Object) error { scanner := bufio.NewScanner(f) var newFileContents bytes.Buffer writeHeader(&newFileContents, obj) - if _, err := newFileContents.WriteString(fmt.Sprintf("# %s\n", printStatusErrorDetails(editError))); err != nil { + if _, err := newFileContents.WriteString( + fmt.Sprintf("# %s\n", printStatusErrorDetails(editError)), + ); err != nil { return err } @@ -191,7 +199,12 @@ func writeError(fileName string, editError error, obj client.Object) error { } func formatObj(obj client.Object) string { - return fmt.Sprintf("%s %s/%s", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), obj.GetNamespace()) + return fmt.Sprintf( + "%s %s/%s", + obj.GetObjectKind().GroupVersionKind().Kind, + obj.GetName(), + obj.GetNamespace(), + ) } func findGVK(scheme *runtime.Scheme, names ...string) (schema.GroupVersionKind, error) { @@ -221,5 +234,10 @@ func printStatusErrorDetails(err error) string { } causes = append(causes, fmt.Sprintf("# * %s", msg)) } - return fmt.Sprintf("%s %q is invalid:\n%s", s.Status().Details.Kind, s.Status().Details.Name, strings.Join(causes, "\n")) + return fmt.Sprintf( + "%s %q is invalid:\n%s", + s.Status().Details.Kind, + s.Status().Details.Name, + strings.Join(causes, "\n"), + ) } diff --git a/get/all.go b/get/all.go index d6dd6e62..c1b17fc6 100644 --- a/get/all.go +++ b/get/all.go @@ -3,8 +3,6 @@ package get import ( "context" "fmt" - "io" - "os" "sort" "strings" @@ -13,6 +11,7 @@ import ( meta "github.com/ninech/apis/meta/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/internal/format" + kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -21,7 +20,6 @@ import ( ) type allCmd struct { - stdErr io.Writer Kinds []string `help:"Specify the kind of resources which should be listed."` IncludeNineResources bool `help:"Show resources which are owned by Nine." default:"false"` } @@ -42,7 +40,7 @@ func (cmd *allCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error } // we now print all warnings to stderr for _, w := range warnings { - fmt.Fprintf(defaultStdError(cmd.stdErr), "warning: %s\n", w) + get.Warningf("%s\n", w) } if len(items) == 0 { @@ -55,9 +53,12 @@ func (cmd *allCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error case noHeader: return printItems(items, *get, false) case yamlOut: - return format.PrettyPrintObjects(items, format.PrintOpts{Out: get.writer}) + return format.PrettyPrintObjects(items, format.PrintOpts{Out: get.Writer}) case jsonOut: - return format.PrettyPrintObjects(items, format.PrintOpts{Out: get.writer, Format: format.OutputFormatTypeJSON}) + return format.PrettyPrintObjects( + items, + format.PrintOpts{Out: get.Writer, Format: format.OutputFormatTypeJSON}, + ) } return nil @@ -72,7 +73,11 @@ func projectNames(projects []management.Project) []string { return result } -func (cmd *allCmd) getProjectContent(ctx context.Context, client *api.Client, projNames []string) ([]*unstructured.Unstructured, []string, error) { +func (cmd *allCmd) getProjectContent( + ctx context.Context, + client *api.Client, + projNames []string, +) ([]*unstructured.Unstructured, []string, error) { var warnings []string var result []*unstructured.Unstructured listTypes, err := filteredListTypes(client.Scheme(), cmd.Kinds) @@ -100,7 +105,8 @@ func (cmd *allCmd) getProjectContent(ctx context.Context, client *api.Client, pr result = append(result, &item) continue } - if value, exists := item.GetLabels()[meta.NineOwnedLabelKey]; exists && value == meta.NineOwnedLabelValue { + if value, exists := item.GetLabels()[meta.NineOwnedLabelKey]; exists && + value == meta.NineOwnedLabelValue { continue } result = append(result, &item) @@ -134,7 +140,12 @@ func printItems(items []*unstructured.Unstructured, get Cmd, header bool) error get.writeHeader("NAME", "KIND", "GROUP") } for _, item := range items { - get.writeTabRow(item.GetNamespace(), item.GetName(), item.GroupVersionKind().Kind, item.GroupVersionKind().Group) + get.writeTabRow( + item.GetNamespace(), + item.GetName(), + item.GroupVersionKind().Kind, + item.GroupVersionKind().Group, + ) } return get.tabWriter.Flush() @@ -193,10 +204,3 @@ func nineListTypes(s *runtime.Scheme) []schema.GroupVersionKind { return lists } - -func defaultStdError(out io.Writer) io.Writer { - if out == nil { - return os.Stderr - } - return out -} diff --git a/get/all_test.go b/get/all_test.go index 5224a79b..9a8fdc95 100644 --- a/get/all_test.go +++ b/get/all_test.go @@ -290,13 +290,8 @@ dev pear Release apps.nine.ch testCase := testCase outputBuffer := &bytes.Buffer{} - get := &Cmd{ - output: output{ - Format: testCase.outputFormat, - AllProjects: testCase.allProjects, - writer: outputBuffer, - }, - } + get := NewTestCmd(outputBuffer, testCase.outputFormat) + get.AllProjects = testCase.allProjects scheme, err := api.NewScheme() if err != nil { diff --git a/get/apiserviceaccount.go b/get/apiserviceaccount.go index 544981e3..a548ccd6 100644 --- a/get/apiserviceaccount.go +++ b/get/apiserviceaccount.go @@ -43,7 +43,13 @@ func (asa *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, return err } if asa.PrintCredentials { - return asa.printCredentials(ctx, client, sa, out, func(key string) bool { return key == iam.APIServiceAccountKubeconfigKey }) + return asa.printCredentials( + ctx, + client, + sa, + out, + func(key string) bool { return key == iam.APIServiceAccountKubeconfigKey }, + ) } key := "" switch sa.Spec.ForProvider.Version { @@ -68,7 +74,7 @@ func (asa *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, key = iam.APIServiceAccountKubeconfigKey } } - return asa.printSecret(ctx, client, sa, key) + return asa.printSecret(ctx, client, sa, key, out) } switch out.Format { @@ -77,11 +83,12 @@ func (asa *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, case noHeader: return asa.printAsa(asaList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(asaList.GetItems(), format.PrintOpts{}) + return format.PrettyPrintObjects(asaList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: return format.PrettyPrintObjects( asaList.GetItems(), format.PrintOpts{ + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: asa.Name != "", @@ -100,17 +107,25 @@ func (asa *apiServiceAccountsCmd) validPrintFlags(sa *iam.APIServiceAccount) err } default: if asa.PrintClientID || asa.PrintClientSecret || asa.PrintTokenURL { - return fmt.Errorf("client_id/client_secret/token_url printing is not supported for v1 APIServiceAccount") + return fmt.Errorf( + "client_id/client_secret/token_url printing is not supported for v1 APIServiceAccount", + ) } } return nil } func (asa *apiServiceAccountsCmd) printFlagSet() bool { - return asa.PrintToken || asa.PrintKubeconfig || asa.PrintClientID || asa.PrintClientSecret || asa.PrintTokenURL || asa.PrintCredentials + return asa.PrintToken || asa.PrintKubeconfig || asa.PrintClientID || asa.PrintClientSecret || + asa.PrintTokenURL || + asa.PrintCredentials } -func (asa *apiServiceAccountsCmd) printAsa(sas []iam.APIServiceAccount, out *output, header bool) error { +func (asa *apiServiceAccountsCmd) printAsa( + sas []iam.APIServiceAccount, + out *output, + header bool, +) error { if header { out.writeHeader("NAME", "ROLE") } @@ -122,11 +137,17 @@ func (asa *apiServiceAccountsCmd) printAsa(sas []iam.APIServiceAccount, out *out return out.tabWriter.Flush() } -func (asa *apiServiceAccountsCmd) printSecret(ctx context.Context, client *api.Client, sa *iam.APIServiceAccount, key string) error { +func (asa *apiServiceAccountsCmd) printSecret( + ctx context.Context, + client *api.Client, + sa *iam.APIServiceAccount, + key string, + out *output, +) error { data, err := getConnectionSecret(ctx, client, key, sa) if err != nil { return err } - fmt.Printf("%s\n", data) + out.Printf("%s\n", data) return nil } diff --git a/get/application.go b/get/application.go index 6e79e75d..d1d3f514 100644 --- a/get/application.go +++ b/get/application.go @@ -42,7 +42,7 @@ func (cmd *applicationsCmd) print(ctx context.Context, client *api.Client, list if cmd.BasicAuthCredentials { creds, err := gatherCredentials(ctx, appList.Items, client) if len(creds) == 0 { - fmt.Fprintf(out.writer, "no application with basic auth enabled found\n") + out.Printf("no application with basic auth enabled found\n") return err } if printErr := printCredentials(creds, out); printErr != nil { @@ -61,12 +61,12 @@ func (cmd *applicationsCmd) print(ctx context.Context, client *api.Client, list case noHeader: return printApplication(appList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(appList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(appList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: return format.PrettyPrintObjects( appList.GetItems(), format.PrintOpts{ - Out: out.writer, + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", @@ -115,10 +115,13 @@ func printApplication(apps []apps.Application, out *output, header bool) error { func printCredentials(creds []appCredentials, out *output) error { if out.Format == yamlOut { - return format.PrettyPrintObjects(creds, format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(creds, format.PrintOpts{Out: &out.Writer}) } if out.Format == jsonOut { - return format.PrettyPrintObjects(creds, format.PrintOpts{Out: out.writer, Format: format.OutputFormatTypeJSON}) + return format.PrettyPrintObjects( + creds, + format.PrintOpts{Out: &out.Writer, Format: format.OutputFormatTypeJSON}, + ) } return printCredentialsTabRow(creds, out) } @@ -176,10 +179,10 @@ func join(list []string) string { func printDNSDetails(items []util.DNSDetail, out *output) error { if out.Format == yamlOut { - return format.PrettyPrintObjects(items, format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(items, format.PrintOpts{Out: &out.Writer}) } if out.Format == jsonOut { - return format.PrettyPrintObjects(items, format.PrintOpts{Out: out.writer, Format: format.OutputFormatTypeJSON}) + return format.PrettyPrintObjects(items, format.PrintOpts{Out: &out.Writer, Format: format.OutputFormatTypeJSON}) } return printDNSDetailsTabRow(items, out) } @@ -196,7 +199,7 @@ func printDNSDetailsTabRow(items []util.DNSDetail, out *output) error { return err } - fmt.Fprintf(out.writer, "\nVisit %s to see instructions on how to setup custom hosts\n", util.DNSSetupURL) + out.Printf("\nVisit %s to see instructions on how to setup custom hosts\n", util.DNSSetupURL) return nil } @@ -239,7 +242,7 @@ func (cmd *applicationsCmd) printStats(ctx context.Context, c *api.Client, appLi for _, app := range appList { rel, err := util.ApplicationLatestRelease(ctx, c, api.ObjectName(&app)) if err != nil { - format.PrintWarningf("unable to get latest release for app %s\n", c.Name(app.Name)) + out.Warningf("unable to get latest release for app %s\n", c.Name(app.Name)) continue } @@ -285,8 +288,12 @@ func (cmd *applicationsCmd) printStats(ctx context.Context, c *api.Client, appLi for _, statsObservation := range observations { podMetrics := metricsv1beta1.PodMetrics{} - if err := runtimeClient.Get(ctx, api.NamespacedName(statsObservation.ReplicaName, app.Namespace), &podMetrics); err != nil { - format.PrintWarningf("unable to get metrics for replica %s\n", statsObservation.ReplicaName) + if err := runtimeClient.Get( + ctx, + api.NamespacedName(statsObservation.ReplicaName, app.Namespace), + &podMetrics, + ); err != nil { + out.Warningf("unable to get metrics for replica %s\n", statsObservation.ReplicaName) } maxResources := apps.AppResources[statsObservation.size] diff --git a/get/application_test.go b/get/application_test.go index cf2d22b0..ca5edc2e 100644 --- a/get/application_test.go +++ b/get/application_test.go @@ -33,9 +33,7 @@ func TestApplication(t *testing.T) { app3.Namespace = otherProject buf := &bytes.Buffer{} - get := &Cmd{ - output: output{writer: buf, Format: full}, - } + get := NewTestCmd(buf, full) apiClient, err := test.SetupClient( test.WithNameIndexFor(&apps.Application{}), @@ -295,13 +293,8 @@ dev dev-second dev-second sample-second t.Run(name, func(t *testing.T) { testCase := testCase buf := &bytes.Buffer{} - get := &Cmd{ - output: output{ - Format: testCase.outputFormat, - AllProjects: testCase.project == "", - writer: buf, - }, - } + get := NewTestCmd(buf, testCase.outputFormat) + get.AllProjects = testCase.project == "" apiClient, err := test.SetupClient( test.WithProjectsFromResources(testCase.resources...), @@ -472,13 +465,8 @@ Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup cust t.Run(name, func(t *testing.T) { testCase := testCase buf := &bytes.Buffer{} - get := &Cmd{ - output: output{ - Format: testCase.outputFormat, - AllProjects: testCase.project == "", - writer: buf, - }, - } + get := NewTestCmd(buf, testCase.outputFormat) + get.AllProjects = testCase.project == "" apiClient, err := test.SetupClient( test.WithProjectsFromResources(testCase.apps...), test.WithObjects(testCase.apps...), diff --git a/get/bucket.go b/get/bucket.go index e1c1b788..baf8744b 100644 --- a/get/bucket.go +++ b/get/bucket.go @@ -73,12 +73,12 @@ func (cmd *bucketCmd) print(ctx context.Context, client *api.Client, list client case noHeader: return printBucket(bucketList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(bucketList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(bucketList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: return format.PrettyPrintObjects( bucketList.GetItems(), format.PrintOpts{ - Out: out.writer, + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", @@ -115,7 +115,7 @@ func printBucket(buckets []storage.Bucket, out *output, header bool) error { func printBucketPermissions(b *storage.Bucket, out *output) error { perms := b.Spec.ForProvider.Permissions if len(perms) == 0 { - fmt.Fprintf(out.writer, "No permissions defined for bucket %q\n", b.Name) + out.Printf("No permissions defined for bucket %q\n", b.Name) return nil } @@ -141,7 +141,7 @@ func printBucketPermissions(b *storage.Bucket, out *output) error { func printBucketLifecyclePolicies(b *storage.Bucket, out *output) error { rules := b.Spec.ForProvider.LifecyclePolicies if len(rules) == 0 { - fmt.Fprintf(out.writer, "No lifecycle policies defined for bucket %q\n", b.Name) + out.Printf("No lifecycle policies defined for bucket %q\n", b.Name) return nil } @@ -166,7 +166,7 @@ func printBucketLifecyclePolicies(b *storage.Bucket, out *output) error { func printBucketCORS(b *storage.Bucket, out *output) error { cfg := b.Spec.ForProvider.CORS if cfg == nil { - fmt.Fprintf(out.writer, "No CORS configuration defined for bucket %q\n", b.Name) + out.Printf("No CORS configuration defined for bucket %q\n", b.Name) return nil } @@ -185,7 +185,7 @@ func printBucketCORS(b *storage.Bucket, out *output) error { func printBucketCustomHostnames(b *storage.Bucket, out *output) error { hosts := b.Spec.ForProvider.CustomHostnames if len(hosts) == 0 { - fmt.Fprintf(out.writer, "No custom hostnames defined for bucket %q\n", b.Name) + out.Printf("No custom hostnames defined for bucket %q\n", b.Name) return nil } @@ -254,12 +254,14 @@ func hostHasAnyEntry(vsl meta.DNSVerificationStatusEntries, host string) bool { } return false } + func joinOrDash(ss []string) string { if len(ss) == 0 { return "-" } return strings.Join(ss, ",") } + func dashIfEmpty(s string) string { if strings.TrimSpace(s) == "" { return "-" diff --git a/get/bucket_test.go b/get/bucket_test.go index 5a881fd1..1c5cbc97 100644 --- a/get/bucket_test.go +++ b/get/bucket_test.go @@ -251,9 +251,9 @@ func TestBucketGet(t *testing.T) { buf := &bytes.Buffer{} cmd := tt.getCmd - err = cmd.Run(ctx, apiClient, &Cmd{ - output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}, - }) + get := NewTestCmd(buf, tt.out) + get.AllProjects = tt.inAllProjects + err = cmd.Run(ctx, apiClient, get) if tt.wantErr { require.Error(t, err) diff --git a/get/bucketuser.go b/get/bucketuser.go index 49e7c26d..e63e8b57 100644 --- a/get/bucketuser.go +++ b/get/bucketuser.go @@ -7,6 +7,7 @@ import ( storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/internal/format" + "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -51,7 +52,7 @@ func (bu *bucketUserCmd) print(ctx context.Context, client *api.Client, list cli if bu.PrintSecretKey { key = storage.BucketUserCredentialSecretKey } - return bu.printSecret(ctx, client, user, key) + return bu.printSecret(ctx, client, user, key, out) } switch out.Format { @@ -60,12 +61,15 @@ func (bu *bucketUserCmd) print(ctx context.Context, client *api.Client, list cli case noHeader: return bu.printBucketUserInstances(bucketUserList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(bucketUserList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects( + bucketUserList.GetItems(), + format.PrintOpts{Out: &out.Writer}, + ) case jsonOut: return format.PrettyPrintObjects( bucketUserList.GetItems(), format.PrintOpts{ - Out: out.writer, + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: bu.Name != "", @@ -76,7 +80,11 @@ func (bu *bucketUserCmd) print(ctx context.Context, client *api.Client, list cli return nil } -func (bu *bucketUserCmd) printBucketUserInstances(list []storage.BucketUser, out *output, header bool) error { +func (bu *bucketUserCmd) printBucketUserInstances( + list []storage.BucketUser, + out *output, + header bool, +) error { if header { out.writeHeader("NAME", "LOCATION") } @@ -92,11 +100,17 @@ func (bu *bucketUserCmd) printFlagSet() bool { return bu.PrintCredentials || bu.PrintAccessKey || bu.PrintSecretKey } -func (bu *bucketUserCmd) printSecret(ctx context.Context, client *api.Client, user *storage.BucketUser, key string) error { +func (bu *bucketUserCmd) printSecret( + ctx context.Context, + client *api.Client, + user *storage.BucketUser, + key string, + out *output, +) error { data, err := getConnectionSecret(ctx, client, key, user) if err != nil { return err } - fmt.Printf("%s\n", data) + out.Printf("%s\n", data) return nil } diff --git a/get/bucketuser_test.go b/get/bucketuser_test.go index ae954a4c..a6188311 100644 --- a/get/bucketuser_test.go +++ b/get/bucketuser_test.go @@ -127,7 +127,9 @@ func TestBucketUser(t *testing.T) { require.NoError(t, err) buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("bucketUserCmd.Run() error = %v, wantErr %v", err, tt.wantErr) t.Log(buf.String()) } diff --git a/get/build.go b/get/build.go index c71558c2..76a330ab 100644 --- a/get/build.go +++ b/get/build.go @@ -55,7 +55,7 @@ func (cmd *buildCmd) print(ctx context.Context, client *api.Client, list runtime return fmt.Errorf("build name has to be specified for pulling an image") } - return pullImage(ctx, client, &buildList.Items[0]) + return pullImage(ctx, client, &buildList.Items[0], out) } switch out.Format { @@ -64,12 +64,12 @@ func (cmd *buildCmd) print(ctx context.Context, client *api.Client, list runtime case noHeader: return printBuild(buildList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(buildList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(buildList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: return format.PrettyPrintObjects( buildList.GetItems(), format.PrintOpts{ - Out: out.writer, + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", @@ -95,7 +95,7 @@ func printBuild(builds []apps.Build, out *output, header bool) error { return out.tabWriter.Flush() } -func pullImage(ctx context.Context, apiClient *api.Client, build *apps.Build) error { +func pullImage(ctx context.Context, apiClient *api.Client, build *apps.Build, out *output) error { cli, err := client.NewClientWithOpts(client.WithVersion(dockerAPIVersion), client.FromEnv) if err != nil { return err @@ -110,7 +110,7 @@ func pullImage(ctx context.Context, apiClient *api.Client, build *apps.Build) er return err } - fmt.Printf("Pulling image of build %s\n", build.Name) + out.Printf("Pulling image of build %s\n", build.Name) reader, err := cli.ImagePull(ctx, ImageRef(build.Spec.ForProvider.Image), image.PullOptions{ RegistryAuth: registryAuth, @@ -129,7 +129,7 @@ func pullImage(ctx context.Context, apiClient *api.Client, build *apps.Build) er return fmt.Errorf("unable to tag image: %w", err) } - format.PrintSuccessf("💾", "Pulled image %s", imageName(build.Spec.ForProvider.Image)) + out.Successf("💾", "Pulled image %s\n", imageName(build.Spec.ForProvider.Image)) return nil } diff --git a/get/build_test.go b/get/build_test.go index 96b9f505..9cd8ad7a 100644 --- a/get/build_test.go +++ b/get/build_test.go @@ -29,9 +29,7 @@ func TestBuild(t *testing.T) { build2.Name = build2.Name + "-2" buf := &bytes.Buffer{} - get := &Cmd{ - output: output{Format: full, writer: buf}, - } + get := NewTestCmd(buf, full) apiClient, err := test.SetupClient( test.WithNameIndexFor(&apps.Build{}), diff --git a/get/cloudvm.go b/get/cloudvm.go index 6734ca93..403ef36e 100644 --- a/get/cloudvm.go +++ b/get/cloudvm.go @@ -34,12 +34,12 @@ func (cmd *cloudVMCmd) print(ctx context.Context, client *api.Client, list clien case noHeader: return cmd.printCloudVirtualMachineInstances(cloudVMList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(cloudVMList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(cloudVMList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: return format.PrettyPrintObjects( cloudVMList.GetItems(), format.PrintOpts{ - Out: out.writer, + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", diff --git a/get/cloudvm_test.go b/get/cloudvm_test.go index 5d8ab82e..0326d1fc 100644 --- a/get/cloudvm_test.go +++ b/get/cloudvm_test.go @@ -126,7 +126,9 @@ func TestCloudVM(t *testing.T) { require.NoError(t, err) buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("cloudVMCmd.Run() error = %v, wantErr %v", err, tt.wantErr) t.Log(buf.String()) } diff --git a/get/clusters.go b/get/clusters.go index 9b45f82d..9987815b 100644 --- a/get/clusters.go +++ b/get/clusters.go @@ -2,7 +2,6 @@ package get import ( "context" - "fmt" "strconv" infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" @@ -36,12 +35,12 @@ func (cmd *clustersCmd) print(ctx context.Context, client *api.Client, list clie case noHeader: return printClusters(clusterList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(clusterList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(clusterList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: return format.PrettyPrintObjects( clusterList.GetItems(), format.PrintOpts{ - Out: out.writer, + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", @@ -49,7 +48,7 @@ func (cmd *clustersCmd) print(ctx context.Context, client *api.Client, list clie }) case contexts: for _, cluster := range clusterList.Items { - fmt.Printf("%s\n", config.ContextName(&cluster)) + out.Printf("%s\n", config.ContextName(&cluster)) } } diff --git a/get/database.go b/get/database.go index 2096e56d..08160c43 100644 --- a/get/database.go +++ b/get/database.go @@ -2,7 +2,6 @@ package get import ( "context" - "fmt" "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/ninech/nctl/api" @@ -28,11 +27,23 @@ func (cmd *databaseCmd) run(ctx context.Context, client *api.Client, get *Cmd, } if cmd.Name != "" && cmd.PrintUser { - return cmd.printSecret(get.writer, ctx, client, databaseResources.GetItems()[0], func(db, _ string) string { return db }) + return cmd.printSecret( + ctx, + client, + databaseResources.GetItems()[0], + &get.output, + func(db, _ string) string { return db }, + ) } if cmd.Name != "" && cmd.PrintPassword { - return cmd.printSecret(get.writer, ctx, client, databaseResources.GetItems()[0], func(_, pw string) string { return pw }) + return cmd.printSecret( + ctx, + client, + databaseResources.GetItems()[0], + &get.output, + func(_, pw string) string { return pw }, + ) } if cmd.Name != "" && cmd.PrintConnectionString { @@ -46,8 +57,8 @@ func (cmd *databaseCmd) run(ctx context.Context, client *api.Client, get *Cmd, return err } - _, err = fmt.Fprintln(get.writer, str) - return err + get.Println(str) + return nil } if cmd.Name != "" && cmd.PrintCACert { @@ -55,7 +66,7 @@ func (cmd *databaseCmd) run(ctx context.Context, client *api.Client, get *Cmd, if err != nil { return err } - return printBase64(get.writer, ca) + return printBase64(&get.Writer, ca) } switch get.Format { @@ -64,12 +75,15 @@ func (cmd *databaseCmd) run(ctx context.Context, client *api.Client, get *Cmd, case noHeader: return printList(databaseResources, get, false) case yamlOut: - return format.PrettyPrintObjects(databaseResources.GetItems(), format.PrintOpts{Out: get.writer}) + return format.PrettyPrintObjects( + databaseResources.GetItems(), + format.PrintOpts{Out: get.Writer}, + ) case jsonOut: return format.PrettyPrintObjects( databaseResources.GetItems(), format.PrintOpts{ - Out: get.writer, + Out: get.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", diff --git a/get/database_test.go b/get/database_test.go index ceb4d7ed..a889802d 100644 --- a/get/database_test.go +++ b/get/database_test.go @@ -138,7 +138,6 @@ func TestDatabase(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - objects := []client.Object{} for _, database := range tt.databases { created := test.PostgresDatabase(database.name, database.project, "nine-es34") @@ -163,7 +162,9 @@ func TestDatabase(t *testing.T) { tt.out = full } buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("postgresDatabaseCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/get/get.go b/get/get.go index 87adfb76..178821e5 100644 --- a/get/get.go +++ b/get/get.go @@ -17,6 +17,8 @@ import ( "github.com/gobuffalo/flect" "github.com/liggitt/tabwriter" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" ) @@ -44,15 +46,16 @@ type Cmd struct { } type output struct { + format.Writer Format outputFormat `help:"Configures list output. ${enum}" name:"output" short:"o" enum:"full,no-header,contexts,yaml,stats,json" default:"full"` AllProjects bool `help:"apply the get over all projects." short:"A" xor:"watch"` AllNamespaces bool `help:"apply the get over all namespaces." hidden:"" xor:"watch"` Watch bool `help:"Watch resource(s) for changes and print the updated resource." short:"w" xor:"watch"` tabWriter *tabwriter.Writer - writer io.Writer } type resourceCmd struct { + format.Writer Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to get. If omitted all in the project will be listed." default:""` } @@ -67,11 +70,38 @@ const ( jsonOut outputFormat = "json" ) +// AfterApply is called by Kong after parsing to initialize the output. func (cmd *Cmd) AfterApply() error { cmd.initOut() return nil } +// NewTestCmd creates a Cmd for testing with the given writer and output format. +// This is a convenience helper that properly initializes the output writer. +func NewTestCmd(w io.Writer, outFormat outputFormat, opts ...func(*Cmd)) *Cmd { + cmd := &Cmd{ + output: output{ + Writer: format.NewWriter(w), + Format: outFormat, + }, + } + cmd.initOut() + for _, opt := range opts { + opt(cmd) + } + return cmd +} + +// WithAllProjects is a test option that sets AllProjects to true. +func WithAllProjects() func(*Cmd) { + return func(cmd *Cmd) { cmd.AllProjects = true } +} + +// WithWatch is a test option that sets Watch to true. +func WithWatch() func(*Cmd) { + return func(cmd *Cmd) { cmd.Watch = true } +} + // listPrinter needs to be implemented by all resources. type listPrinter interface { print(context.Context, *api.Client, runtimeclient.ObjectList, *output) error @@ -140,29 +170,29 @@ func (out *output) printEmptyMessage(kind, project string) error { out.initOut() if out.Format == jsonOut { - _, err := fmt.Fprintf(out.writer, "[]") - return err + out.Printf("[]") + return nil } if out.AllProjects { - _, err := fmt.Fprintf(out.writer, "no %s found in any project\n", flect.Pluralize(kind)) - return err + out.Printf("no %s found in any project\n", flect.Pluralize(kind)) + return nil } if project == "" { - _, err := fmt.Fprintf(out.writer, "no %s found\n", flect.Pluralize(kind)) - return err + out.Printf("no %s found\n", flect.Pluralize(kind)) + return nil } - _, err := fmt.Fprintf(out.writer, "no %s found in project %s\n", flect.Pluralize(kind), project) - return err + out.Printf("no %s found in project %s\n", flect.Pluralize(kind), project) + return nil } func (out *output) initOut() { - if out.writer == nil { - out.writer = os.Stdout + if out.Writer.Writer == nil { + out.Writer = format.NewWriter(os.Stdout) } if out.tabWriter == nil { - out.tabWriter = tabwriter.NewWriter(out.writer, 0, 0, 2, ' ', tabwriter.RememberWidths) + out.tabWriter = tabwriter.NewWriter(&out.Writer, 0, 0, 2, ' ', tabwriter.RememberWidths) } } @@ -189,21 +219,32 @@ func getConnectionSecret(ctx context.Context, client *api.Client, key string, mg return string(content), nil } -func (cmd *resourceCmd) printSecret(out io.Writer, ctx context.Context, client *api.Client, mg resource.Managed, field func(string, string) string) error { +func (cmd *resourceCmd) printSecret( + ctx context.Context, + client *api.Client, + mg resource.Managed, + out *output, + field func(string, string) string, +) error { secrets, err := getConnectionSecretMap(ctx, client, mg) if err != nil { return err } for k, v := range secrets { - _, err = fmt.Fprintln(out, field(k, string(v))) - return err + out.Println(field(k, string(v))) } return nil } -func (cmd *resourceCmd) printCredentials(ctx context.Context, client *api.Client, mg resource.Managed, out *output, filter func(key string) bool) error { +func (cmd *resourceCmd) printCredentials( + ctx context.Context, + client *api.Client, + mg resource.Managed, + out *output, + filter func(key string) bool, +) error { data, err := getConnectionSecretMap(ctx, client, mg) if err != nil { return err @@ -230,13 +271,13 @@ func (cmd *resourceCmd) printCredentials(ctx context.Context, client *api.Client if err != nil { return err } - fmt.Print(string(b)) + out.Printf("%s", string(b)) case jsonOut: b, err := json.MarshalIndent(stringData, "", " ") if err != nil { return err } - fmt.Println(string(b)) + out.Printf("%s\n", string(b)) } return nil } diff --git a/get/get_test.go b/get/get_test.go index dd7ed1f1..c80ec45d 100644 --- a/get/get_test.go +++ b/get/get_test.go @@ -77,7 +77,9 @@ func TestListPrint(t *testing.T) { require.NoError(t, err) buf := &bytes.Buffer{} - cmd := &Cmd{output: output{Format: tc.out, AllProjects: tc.inAllProjects, writer: buf, Watch: tc.watch}} + cmd := NewTestCmd(buf, tc.out) + cmd.AllProjects = tc.inAllProjects + cmd.Watch = tc.watch ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) defer cancel() diff --git a/get/keyvaluestore.go b/get/keyvaluestore.go index f043a761..8692dd8c 100644 --- a/get/keyvaluestore.go +++ b/get/keyvaluestore.go @@ -34,10 +34,10 @@ func (cmd *keyValueStoreCmd) print(ctx context.Context, client *api.Client, list } if cmd.Name != "" && cmd.PrintToken { - return cmd.printSecret(out.writer, ctx, client, &keyValueStoreList.Items[0], func(_, pw string) string { return pw }) + return cmd.printSecret(ctx, client, &keyValueStoreList.Items[0], out, func(_, pw string) string { return pw }) } if cmd.Name != "" && cmd.PrintCACert { - return printBase64(out.writer, keyValueStoreList.Items[0].Status.AtProvider.CACert) + return printBase64(&out.Writer, keyValueStoreList.Items[0].Status.AtProvider.CACert) } switch out.Format { diff --git a/get/keyvaluestore_test.go b/get/keyvaluestore_test.go index dacc73ad..c36a4fe6 100644 --- a/get/keyvaluestore_test.go +++ b/get/keyvaluestore_test.go @@ -167,7 +167,9 @@ func TestKeyValueStore(t *testing.T) { require.NoError(t, err) buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("keyValueStoreCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/get/mysql_test.go b/get/mysql_test.go index dcfc1412..9d5ad50d 100644 --- a/get/mysql_test.go +++ b/get/mysql_test.go @@ -162,7 +162,9 @@ func TestMySQL(t *testing.T) { tt.out = full } buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("mySQLCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/get/mysqldatabase_test.go b/get/mysqldatabase_test.go index 558cf876..ce720703 100644 --- a/get/mysqldatabase_test.go +++ b/get/mysqldatabase_test.go @@ -91,7 +91,9 @@ func TestMySQLDatabase(t *testing.T) { } buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("mysqlDatabaseCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/get/opensearch.go b/get/opensearch.go index 732b2c26..fad7deac 100644 --- a/get/opensearch.go +++ b/get/opensearch.go @@ -3,11 +3,11 @@ package get import ( "context" "fmt" - "io" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/internal/format" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -15,9 +15,9 @@ import ( type openSearchCmd struct { resourceCmd PrintPassword bool `help:"Print the password of the OpenSearch BasicAuth User. Requires name to be set." xor:"print"` - PrintUser bool `help:"Print the name of the OpenSearch BasicAuth User. Requires name to be set." xor:"print"` - PrintCACert bool `help:"Print the ca certificate. Requires name to be set." xor:"print"` - PrintSnapshotBucket bool `help:"Print the URL of the snapshot bucket." xor:"print"` + PrintUser bool `help:"Print the name of the OpenSearch BasicAuth User. Requires name to be set." xor:"print"` + PrintCACert bool `help:"Print the ca certificate. Requires name to be set." xor:"print"` + PrintSnapshotBucket bool `help:"Print the URL of the snapshot bucket." xor:"print"` } func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { @@ -28,7 +28,12 @@ func (cmd *openSearchCmd) list() client.ObjectList { return &storage.OpenSearchList{} } -func (cmd *openSearchCmd) print(ctx context.Context, client *api.Client, list client.ObjectList, out *output) error { +func (cmd *openSearchCmd) print( + ctx context.Context, + client *api.Client, + list client.ObjectList, + out *output, +) error { openSearchList, ok := list.(*storage.OpenSearchList) if !ok { return fmt.Errorf("expected %T, got %T", &storage.OpenSearchList{}, list) @@ -38,19 +43,31 @@ func (cmd *openSearchCmd) print(ctx context.Context, client *api.Client, list cl } if cmd.Name != "" && cmd.PrintUser { - return cmd.printSecret(out.writer, ctx, client, &openSearchList.Items[0], func(user, _ string) string { return user }) + return cmd.printSecret( + ctx, + client, + &openSearchList.Items[0], + out, + func(user, _ string) string { return user }, + ) } if cmd.Name != "" && cmd.PrintPassword { - return cmd.printSecret(out.writer, ctx, client, &openSearchList.Items[0], func(_, pw string) string { return pw }) + return cmd.printSecret( + ctx, + client, + &openSearchList.Items[0], + out, + func(_, pw string) string { return pw }, + ) } if cmd.Name != "" && cmd.PrintCACert { - return printBase64(out.writer, openSearchList.Items[0].Status.AtProvider.CACert) + return printBase64(&out.Writer, openSearchList.Items[0].Status.AtProvider.CACert) } if cmd.Name != "" && cmd.PrintSnapshotBucket { - return cmd.printSnapshotBucket(ctx, client, &openSearchList.Items[0], out.writer) + return cmd.printSnapshotBucket(ctx, client, &openSearchList.Items[0], out) } switch out.Format { @@ -59,11 +76,15 @@ func (cmd *openSearchCmd) print(ctx context.Context, client *api.Client, list cl case noHeader: return cmd.printOpenSearchInstances(openSearchList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(openSearchList.GetItems(), format.PrintOpts{}) + return format.PrettyPrintObjects( + openSearchList.GetItems(), + format.PrintOpts{Out: &out.Writer}, + ) case jsonOut: return format.PrettyPrintObjects( openSearchList.GetItems(), format.PrintOpts{ + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", @@ -74,9 +95,23 @@ func (cmd *openSearchCmd) print(ctx context.Context, client *api.Client, list cl return nil } -func (cmd *openSearchCmd) printOpenSearchInstances(list []storage.OpenSearch, out *output, header bool) error { +func (cmd *openSearchCmd) printOpenSearchInstances( + list []storage.OpenSearch, + out *output, + header bool, +) error { if header { - out.writeHeader("NAME", "LOCATION", "VERSION", "PRIVATE URL", "PUBLIC URL", "MACHINE TYPE", "CLUSTER TYPE", "DISK SIZE", "HEALTH") + out.writeHeader( + "NAME", + "LOCATION", + "VERSION", + "PRIVATE URL", + "PUBLIC URL", + "MACHINE TYPE", + "CLUSTER TYPE", + "DISK SIZE", + "HEALTH", + ) } for _, os := range list { @@ -97,7 +132,9 @@ func (cmd *openSearchCmd) printOpenSearchInstances(list []storage.OpenSearch, ou return out.tabWriter.Flush() } -func (cmd *openSearchCmd) getClusterHealth(clusterHealth storage.OpenSearchClusterHealth) storage.OpenSearchHealthStatus { +func (cmd *openSearchCmd) getClusterHealth( + clusterHealth storage.OpenSearchClusterHealth, +) storage.OpenSearchHealthStatus { worstStatus := storage.OpenSearchHealthStatusGreen // If no indices, assume healthy @@ -117,14 +154,26 @@ func (cmd *openSearchCmd) getClusterHealth(clusterHealth storage.OpenSearchClust return worstStatus } -func (cmd *openSearchCmd) printSnapshotBucket(ctx context.Context, client *api.Client, openSearch *storage.OpenSearch, writer io.Writer) error { +func (cmd *openSearchCmd) printSnapshotBucket( + ctx context.Context, + client *api.Client, + openSearch *storage.OpenSearch, + out *output, +) error { bucketName := openSearch.Status.AtProvider.SnapshotsBucket.Name if bucketName == "" { - return fmt.Errorf("no snapshot bucket configured for OpenSearch instance %s", openSearch.Name) + return fmt.Errorf( + "no snapshot bucket configured for OpenSearch instance %s", + openSearch.Name, + ) } bucket := &storage.Bucket{} - if err := client.Get(ctx, types.NamespacedName{Name: bucketName, Namespace: client.Project}, bucket); err != nil { + if err := client.Get( + ctx, + types.NamespacedName{Name: bucketName, Namespace: client.Project}, + bucket, + ); err != nil { return err } @@ -133,6 +182,6 @@ func (cmd *openSearchCmd) printSnapshotBucket(ctx context.Context, client *api.C return fmt.Errorf("no URL found in ObjectsBucket %s status", bucketName) } - _, err := fmt.Fprintln(writer, bucketURL) - return err + out.Println(bucketURL) + return nil } diff --git a/get/opensearch_test.go b/get/opensearch_test.go index 484922ff..68d0b5fa 100644 --- a/get/opensearch_test.go +++ b/get/opensearch_test.go @@ -241,7 +241,9 @@ func TestOpenSearch(t *testing.T) { if tt.out == "" { tt.out = full } - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("openSearchCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/get/postgres_test.go b/get/postgres_test.go index ce733881..8317a63f 100644 --- a/get/postgres_test.go +++ b/get/postgres_test.go @@ -161,7 +161,9 @@ func TestPostgres(t *testing.T) { tt.out = full } buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("postgresCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/get/postgresdatabase_test.go b/get/postgresdatabase_test.go index 5ffb5a9d..6c416892 100644 --- a/get/postgresdatabase_test.go +++ b/get/postgresdatabase_test.go @@ -88,7 +88,9 @@ func TestPostgresDatabase(t *testing.T) { tt.out = full } buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("postgresDatabaseCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/get/project.go b/get/project.go index d99408be..8eec4803 100644 --- a/get/project.go +++ b/get/project.go @@ -40,7 +40,7 @@ func (proj *projectCmd) Run(ctx context.Context, client *api.Client, get *Cmd) e return format.PrettyPrintObjects( (&management.ProjectList{Items: projectList}).GetItems(), format.PrintOpts{ - Out: get.writer, + Out: get.Writer, ExcludeAdditional: projectExcludes(), }, ) @@ -48,7 +48,7 @@ func (proj *projectCmd) Run(ctx context.Context, client *api.Client, get *Cmd) e return format.PrettyPrintObjects( (&management.ProjectList{Items: projectList}).GetItems(), format.PrintOpts{ - Out: get.writer, + Out: get.Writer, ExcludeAdditional: projectExcludes(), Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ diff --git a/get/project_config.go b/get/project_config.go index 735d3313..00a29f48 100644 --- a/get/project_config.go +++ b/get/project_config.go @@ -13,8 +13,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -type configsCmd struct { -} +type configsCmd struct{} func (cmd *configsCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { return get.listPrint(ctx, client, cmd) @@ -36,9 +35,9 @@ func (cmd *configsCmd) print(ctx context.Context, client *api.Client, list clien case noHeader: return printProjectConfigs(projectConfigList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(projectConfigList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(projectConfigList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: - return format.PrettyPrintObjects(projectConfigList.GetItems(), format.PrintOpts{Out: out.writer, Format: format.OutputFormatTypeJSON}) + return format.PrettyPrintObjects(projectConfigList.GetItems(), format.PrintOpts{Out: &out.Writer, Format: format.OutputFormatTypeJSON}) } return nil diff --git a/get/project_config_test.go b/get/project_config_test.go index e828f857..e72efc97 100644 --- a/get/project_config_test.go +++ b/get/project_config_test.go @@ -8,6 +8,7 @@ import ( apps "github.com/ninech/apis/apps/v1alpha1" "github.com/ninech/nctl/api/util" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -134,7 +135,7 @@ func TestProjectConfigs(t *testing.T) { require.NoError(t, err) buf := &bytes.Buffer{} - tc.get.writer = buf + tc.get.Writer = format.NewWriter(buf) cmd := configsCmd{} if err := cmd.Run(ctx, apiClient, tc.get); err != nil { diff --git a/get/project_test.go b/get/project_test.go index bbabc9e4..b2415f0c 100644 --- a/get/project_test.go +++ b/get/project_test.go @@ -139,13 +139,8 @@ dev testCase := testCase buf := &bytes.Buffer{} - get := &Cmd{ - output: output{ - Format: testCase.outputFormat, - AllProjects: testCase.allProjects, - writer: buf, - }, - } + get := NewTestCmd(buf, testCase.outputFormat) + get.AllProjects = testCase.allProjects projects := testCase.projects for i, proj := range projects { diff --git a/get/releases.go b/get/releases.go index d077cce4..15144aa8 100644 --- a/get/releases.go +++ b/get/releases.go @@ -45,12 +45,12 @@ func (cmd *releasesCmd) print(ctx context.Context, client *api.Client, list clie case noHeader: return cmd.printReleases(releaseList.Items, out, false) case yamlOut: - return format.PrettyPrintObjects(releaseList.GetItems(), format.PrintOpts{Out: out.writer}) + return format.PrettyPrintObjects(releaseList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: return format.PrettyPrintObjects( releaseList.GetItems(), format.PrintOpts{ - Out: out.writer, + Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ PrintSingleItem: cmd.Name != "", diff --git a/get/releases_test.go b/get/releases_test.go index ac20e9d2..00ec04a2 100644 --- a/get/releases_test.go +++ b/get/releases_test.go @@ -20,9 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -var ( - defaultCreationTime = metav1.NewTime(test.MustParseTime(time.RFC3339, "2023-03-13T14:00:00Z")) -) +var defaultCreationTime = metav1.NewTime(test.MustParseTime(time.RFC3339, "2023-03-13T14:00:00Z")) func TestReleases(t *testing.T) { const project = test.DefaultProject @@ -175,13 +173,8 @@ func TestReleases(t *testing.T) { tc.output = full } buf := &bytes.Buffer{} - get := &Cmd{ - output: output{ - Format: tc.output, - AllProjects: tc.inAllProjects, - writer: buf, - }, - } + get := NewTestCmd(buf, tc.output) + get.AllProjects = tc.inAllProjects if err := tc.cmd.Run(ctx, apiClient, get); (err != nil) != tc.wantErr { t.Errorf("releasesCmd.Run() error = %v, wantErr %v", err, tc.wantErr) diff --git a/get/serviceconnection_test.go b/get/serviceconnection_test.go index ef8486d4..5de06a51 100644 --- a/get/serviceconnection_test.go +++ b/get/serviceconnection_test.go @@ -124,7 +124,9 @@ func TestServiceConnection(t *testing.T) { tt.out = full } buf := &bytes.Buffer{} - if err := tt.get.Run(ctx, apiClient, &Cmd{output: output{Format: tt.out, AllProjects: tt.inAllProjects, writer: buf}}); (err != nil) != tt.wantErr { + cmd := NewTestCmd(buf, tt.out) + cmd.AllProjects = tt.inAllProjects + if err := tt.get.Run(ctx, apiClient, cmd); (err != nil) != tt.wantErr { t.Errorf("serviceConnectionCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { diff --git a/internal/format/command.go b/internal/format/command.go index c41c0097..2b2c77a9 100644 --- a/internal/format/command.go +++ b/internal/format/command.go @@ -3,6 +3,7 @@ package format import ( "errors" "fmt" + "io" "os" "strings" @@ -62,7 +63,7 @@ func MissingChildren(node *kong.Node) bool { } // ExitIfErrorf prints Usage + friendly message on error (and exits). -func ExitIfErrorf(err error, args ...any) error { +func ExitIfErrorf(w io.Writer, err error, args ...any) error { if err == nil { return nil } @@ -84,7 +85,7 @@ func ExitIfErrorf(err error, args ...any) error { } } - fmt.Printf("\n💡 Your command: %q: %s\n", command, msg) + fmt.Fprintf(w, "\n💡 Your command: %q: %s\n", command, msg) parseErr.Context.Exit(1) diff --git a/internal/format/print.go b/internal/format/print.go index e29e799c..535d1f14 100644 --- a/internal/format/print.go +++ b/internal/format/print.go @@ -1,3 +1,5 @@ +// Package format contains utilities for formatting and printing output, +// including support for spinners, colored YAML/JSON, and resource stripping. package format import ( @@ -14,6 +16,7 @@ import ( "github.com/goccy/go-yaml/printer" "github.com/mattn/go-isatty" "github.com/theckman/yacspin" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" @@ -39,84 +42,45 @@ type JSONOutputOptions struct { var spinnerCharset = yacspin.CharSets[24] -// ProgressMessagef is a formatted message for use with a spinner.Suffix. An +// Progress is a formatted message for use with a spinner.Suffix. An // icon can be added which is displayed at the end of the message. -func ProgressMessagef(icon, format string, a ...any) string { - return fmt.Sprintf(" %s %s", fmt.Sprintf(format, a...), icon) +func Progress(icon, message string) string { + return fmt.Sprintf(" %s %s", message, icon) } -// ProgressMessage is a formatted message for use with a spinner.Suffix. An +// Progressf is a formatted message for use with a spinner.Suffix. An // icon can be added which is displayed at the end of the message. -func ProgressMessage(icon, message string) string { - return fmt.Sprintf(" %s %s", message, icon) +func Progressf(icon, format string, a ...any) string { + return fmt.Sprintf(" %s %s", fmt.Sprintf(format, a...), icon) } -// SuccessMessagef is a formatted message for indicating a successful step. -func SuccessMessagef(icon, format string, a ...any) string { +// successf is a formatted message for indicating a successful step. +func successf(icon, format string, a ...any) string { return fmt.Sprintf(" %s %s %s", SuccessChar, fmt.Sprintf(format, a...), icon) } -// SuccessMessage returns a message for indicating a successful step. -func SuccessMessage(icon, message string) string { +// success returns a message for indicating a successful step. +func success(icon, message string) string { return fmt.Sprintf(" %s %s %s", SuccessChar, message, icon) } -// PrintSuccessf prints a success message. -func PrintSuccessf(icon, format string, a ...any) { - fmt.Print(SuccessMessagef(icon, format, a...) + "\n") -} - -// PrintSuccess prints a success message. -func PrintSuccess(icon, message string) { - fmt.Print(SuccessMessage(icon, message) + "\n") -} - -// FailureMessagef is a formatted message for indicating a failed step. -func FailureMessagef(icon, format string, a ...any) string { +// failuref is a formatted message for indicating a failed step. +func failuref(icon, format string, a ...any) string { return fmt.Sprintf(" %s %s %s", FailureChar, fmt.Sprintf(format, a...), icon) } -// PrintFailuref prints a failure message. -func PrintFailuref(icon, format string, a ...any) { - fmt.Print(FailureMessagef(icon, format, a...) + "\n") -} - -func PrintWarningf(msg string, a ...any) { - fmt.Printf(color.YellowString("Warning: ")+msg, a...) -} - -// Confirm prints a confirm dialog using the supplied message and then waits -// until prompt is confirmed or denied. Only y and yes are accepted for -// confirmation. -func Confirm(message string) (bool, error) { - var input string - - fmt.Printf("%s [y|n]: ", message) - _, err := fmt.Scanln(&input) - if err != nil { - return false, err - } - input = strings.ToLower(input) - - if input == "y" || input == "yes" { - return true, nil - } - return false, nil -} - -// Confirmf prints a confirm dialog using format and then waits until prompt -// is confirmed or denied. Only y and yes are accepted for confirmation. -func Confirmf(format string, a ...any) (bool, error) { - return Confirm(fmt.Sprintf(format, a...)) +func warningf(msg string, a ...any) string { + return fmt.Sprintf(color.YellowString("Warning: ")+msg, a...) } // NewSpinner returns a new spinner with the default config -func NewSpinner(message, stopMessage string) (*yacspin.Spinner, error) { - return yacspin.New(spinnerConfig(message, stopMessage)) +func newSpinner(w io.Writer, message, stopMessage string) (*yacspin.Spinner, error) { + return yacspin.New(spinnerConfig(w, message, stopMessage)) } -func spinnerConfig(message, stopMessage string) yacspin.Config { +func spinnerConfig(w io.Writer, message, stopMessage string) yacspin.Config { return yacspin.Config{ + Writer: w, Frequency: spinnerFrequency, CharSet: spinnerCharset, Prefix: spinnerPrefix, @@ -269,7 +233,6 @@ func printResource(obj any, opts PrintOpts) error { _, err = opts.Out.Write(output) return err } - } // getPrinter returns a printer for printing tokens. It will have color output @@ -343,7 +306,10 @@ func stripObj(obj resource.Object, excludeAdditional [][]string) (resource.Objec for _, exclude := range excludeAdditional { unstructured.RemoveNestedField(unstructuredObj, exclude...) } - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj, obj); err != nil { + if err := runtime.DefaultUnstructuredConverter.FromUnstructured( + unstructuredObj, + obj, + ); err != nil { return nil, err } diff --git a/internal/format/writer.go b/internal/format/writer.go new file mode 100644 index 00000000..ef9472d6 --- /dev/null +++ b/internal/format/writer.go @@ -0,0 +1,92 @@ +package format + +import ( + "fmt" + "io" + "strings" + + "github.com/theckman/yacspin" +) + +// Writer is a wrapper around an io.Writer that provides helper methods for +// printing formatted messages. +type Writer struct { + io.Writer +} + +// NewWriter returns a new [Writer]. +func NewWriter(w io.Writer) Writer { + return Writer{Writer: w} +} + +// writer returns the underlying writer, or [io.Discard] if the receiver is nil. +func (w *Writer) writer() io.Writer { + if w == nil || w.Writer == nil { + return io.Discard + } + + return w.Writer +} + +// BeforeApply ensures that Kong initializes the writer. +func (w *Writer) BeforeApply(writer io.Writer) error { + if w != nil && writer != nil { + w.Writer = writer + } + + return nil +} + +// Spinner creates a new spinner with the given message and stop message. +func (w *Writer) Spinner(message, stopMessage string) (*yacspin.Spinner, error) { + return newSpinner(w.writer(), message, stopMessage) +} + +// Successf is a formatted message for indicating a successful step. +func (w *Writer) Successf(icon string, format string, a ...any) { + fmt.Fprint(w.writer(), successf(icon, format, a...)) +} + +// Success returns a message for indicating a successful step. +func (w *Writer) Success(icon string, message string) { + fmt.Fprint(w.writer(), success(icon, message)) +} + +// Warningf is a formatted message for indicating a warning. +func (w *Writer) Warningf(format string, a ...any) { + fmt.Fprint(w.writer(), warningf(format, a...)) +} + +// Failuref is a formatted message for indicating a failure. +func (w *Writer) Failuref(icon string, format string, a ...any) { + fmt.Fprint(w.writer(), failuref(icon, format, a...)) +} + +// Printf formats according to a format specifier and writes to the underlying writer. +func (w *Writer) Printf(format string, a ...any) { + fmt.Fprintf(w.writer(), format, a...) +} + +// Println prints a formatted message to the underlying writer. +func (w *Writer) Println(a ...any) { + fmt.Fprintln(w.writer(), a...) +} + +// Confirm prints a confirm dialog using the supplied message and then waits +// until prompt is confirmed or denied. Only y and yes are accepted for +// confirmation. +func (w *Writer) Confirm(message string) (bool, error) { + var input string + + w.Printf("%s [y|n]: ", message) + _, err := fmt.Scanln(&input) + if err != nil { + return false, err + } + input = strings.ToLower(input) + + if input == "y" || input == "yes" { + return true, nil + } + return false, nil +} diff --git a/internal/test/bucket.go b/internal/test/bucket.go index fb5be26c..1fac1026 100644 --- a/internal/test/bucket.go +++ b/internal/test/bucket.go @@ -2,6 +2,7 @@ package test import ( "context" + "io" "testing" "time" @@ -49,6 +50,7 @@ func RunNamedWithFlags( kong.Name("nctl-test"), vars, kong.BindTo(t.Context(), (*context.Context)(nil)), + kong.BindTo(t.Output(), (*io.Writer)(nil)), ) kctx, err := parser.Parse(args) diff --git a/logs/logs.go b/logs/logs.go index 2f077f9b..2655823c 100644 --- a/logs/logs.go +++ b/logs/logs.go @@ -1,3 +1,4 @@ +// Package logs provides commands to retrieve and tail logs from deplo.io resources. package logs import ( @@ -10,6 +11,7 @@ import ( "github.com/grafana/loki/v3/pkg/logproto" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/log" + "github.com/ninech/nctl/internal/format" ) type Cmd struct { @@ -22,6 +24,7 @@ type resourceCmd struct { } type logsCmd struct { + format.Writer Follow bool `help:"Follow the logs by live tailing." short:"f"` Lines int `help:"Amount of lines to output." default:"50" short:"l"` Since time.Duration `help:"Duration how long to look back for logs." short:"s" default:"${log_retention}"` @@ -36,7 +39,12 @@ type logsCmd struct { // deplo.io. We'll need to revisit this if we ever make this configurable. var logRetention = time.Duration(time.Hour * 24 * 30) -func (cmd *logsCmd) Run(ctx context.Context, client *api.Client, queryString string, labels ...string) error { +func (cmd *logsCmd) Run( + ctx context.Context, + client *api.Client, + queryString string, + labels ...string, +) error { now := time.Now() start, end := now.Add(-cmd.Since), now if !cmd.From.IsZero() { @@ -46,7 +54,10 @@ func (cmd *logsCmd) Run(ctx context.Context, client *api.Client, queryString str end = cmd.To } if now.Sub(start) > logRetention { - return fmt.Errorf("the logs requested exceed the retention period of %.f days", logRetention.Hours()/24) + return fmt.Errorf( + "the logs requested exceed the retention period of %.f days", + logRetention.Hours()/24, + ) } query := log.Query{ @@ -58,7 +69,7 @@ func (cmd *logsCmd) Run(ctx context.Context, client *api.Client, queryString str Quiet: true, } - out, err := log.NewStdOut(log.Mode(cmd.Output), cmd.NoLabels, labels...) + out, err := log.NewOutput(cmd.Writer, log.Mode(cmd.Output), cmd.NoLabels, labels...) if err != nil { return err } @@ -75,7 +86,11 @@ func (cmd *logsCmd) Run(ctx context.Context, client *api.Client, queryString str return err } if out.LineCount() == 0 { - return fmt.Errorf("no logs found between %s and %s", start.Format(time.RFC3339), end.Format(time.RFC3339)) + return fmt.Errorf( + "no logs found between %s and %s", + start.Format(time.RFC3339), + end.Format(time.RFC3339), + ) } return nil diff --git a/main.go b/main.go index 65023a6b..354cdff7 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/signal" "reflect" @@ -64,6 +65,8 @@ var ( version string commit string date string + + writer = os.Stdout ) func main() { @@ -73,18 +76,21 @@ func main() { kongVars, err := kongVariables() if err != nil { - fmt.Println(err) + fmt.Fprintln(writer, err) os.Exit(1) } nctl := &rootCommand{} parser := kong.Must( nctl, kong.Name(util.NctlName), - kong.Description("Interact with Nine API resources. See https://docs.nineapis.ch for the full API docs."), + kong.Description( + "Interact with Nine API resources. See https://docs.nineapis.ch for the full API docs.", + ), kong.UsageOnError(), kong.PostBuild(format.InterpolateFlagPlaceholders(kongVars)), kongVars, kong.BindTo(ctx, (*context.Context)(nil)), + kong.BindTo(writer, (*io.Writer)(nil)), ) apiClientRequired := !noAPIClientRequired(strings.Join(os.Args[1:], " ")) @@ -105,7 +111,7 @@ func main() { node = parseErr.Context.Model.Node } if format.MissingChildren(node) { - err = format.ExitIfErrorf(err, parseErr.Context.Command()) + err = format.ExitIfErrorf(writer, err, parseErr.Context.Command()) } } } @@ -113,12 +119,18 @@ func main() { parser.FatalIfErrorf(err) } - binds := []any{ctx} + binds := []any{ctx, kong.BindTo(writer, (*io.Writer)(nil))} if apiClientRequired { - client, err := api.New(ctx, nctl.APICluster, nctl.Project, api.LogClient(ctx, nctl.LogAPIAddress, nctl.LogAPIInsecure)) + client, err := api.New( + ctx, + nctl.APICluster, + nctl.Project, + api.LogClient(ctx, nctl.LogAPIAddress, nctl.LogAPIInsecure), + api.OutputWriter(writer), + ) if err != nil { - fmt.Println(err) - fmt.Printf("\nUnable to get API client, are you logged in?\n\nUse `%s` to login.\n", format.Command().Login()) + fmt.Fprintln(writer, err) + fmt.Fprintf(writer, "\nUnable to get API client, are you logged in?\n\nUse `%s` to login.\n", format.Command().Login()) os.Exit(1) } binds = append(binds, client) diff --git a/update/apiserviceaccount.go b/update/apiserviceaccount.go index e2570d8e..6830f262 100644 --- a/update/apiserviceaccount.go +++ b/update/apiserviceaccount.go @@ -22,7 +22,7 @@ func (cmd *apiServiceAccountCmd) Run(ctx context.Context, client *api.Client) er Namespace: client.Project, }, } - return newUpdater(client, asa, iam.APIServiceAccountKind, func(current resource.Managed) error { + return cmd.newUpdater(client, asa, iam.APIServiceAccountKind, func(current resource.Managed) error { asa, ok := current.(*iam.APIServiceAccount) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, iam.APIServiceAccount{}) diff --git a/update/application.go b/update/application.go index 69a78309..d477bba7 100644 --- a/update/application.go +++ b/update/application.go @@ -144,7 +144,7 @@ func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { }, } - upd := newUpdater(client, app, apps.ApplicationKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, app, apps.ApplicationKind, func(current resource.Managed) error { app, ok := current.(*apps.Application) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, apps.Application{}) @@ -266,16 +266,16 @@ func (cmd *applicationCmd) applyUpdates(app *apps.Application) { cmd.DeployJob.applyUpdates(&app.Spec.ForProvider.Config) } if cmd.WorkerJob != nil && cmd.WorkerJob.changesGiven() { - cmd.WorkerJob.applyUpdates(&app.Spec.ForProvider.Config) + cmd.WorkerJob.applyUpdates(cmd.Writer, &app.Spec.ForProvider.Config) } if cmd.DeleteWorkerJob != nil { - deleteWorkerJob(*cmd.DeleteWorkerJob, &app.Spec.ForProvider.Config) + deleteWorkerJob(cmd.Writer, *cmd.DeleteWorkerJob, &app.Spec.ForProvider.Config) } if cmd.ScheduledJob != nil && cmd.ScheduledJob.changesGiven() { - cmd.ScheduledJob.applyUpdates(&app.Spec.ForProvider.Config) + cmd.ScheduledJob.applyUpdates(cmd.Writer, &app.Spec.ForProvider.Config) } if cmd.DeleteScheduledJob != nil { - deleteScheduledJob(*cmd.DeleteScheduledJob, &app.Spec.ForProvider.Config) + deleteScheduledJob(cmd.Writer, *cmd.DeleteScheduledJob, &app.Spec.ForProvider.Config) } if cmd.Language != nil { app.Spec.ForProvider.Language = apps.Language(*cmd.Language) @@ -334,12 +334,12 @@ func (cmd *applicationCmd) applyUpdates(app *apps.Application) { if cmd.DockerfileBuild.Path != nil { app.Spec.ForProvider.DockerfileBuild.DockerfilePath = *cmd.DockerfileBuild.Path - warnIfDockerfileNotEnabled(app, "path") + warnIfDockerfileNotEnabled(cmd.Writer, app, "path") } if cmd.DockerfileBuild.BuildContext != nil { app.Spec.ForProvider.DockerfileBuild.BuildContext = *cmd.DockerfileBuild.BuildContext - warnIfDockerfileNotEnabled(app, "build context") + warnIfDockerfileNotEnabled(cmd.Writer, app, "build context") } } @@ -400,9 +400,9 @@ func ensureDeployJob(cfg *apps.Config) *apps.Config { return cfg } -func (job workerJob) applyUpdates(cfg *apps.Config) { +func (job workerJob) applyUpdates(w format.Writer, cfg *apps.Config) { if job.Name == nil { - format.PrintWarningf("you need to pass a job name to update the command or size\n") + w.Warningf("you need to pass a job name to update the command or size\n") return } for i := range cfg.WorkerJobs { @@ -427,7 +427,7 @@ func (job workerJob) applyUpdates(cfg *apps.Config) { cfg.WorkerJobs = append(cfg.WorkerJobs, newJob) } -func deleteWorkerJob(name string, cfg *apps.Config) { +func deleteWorkerJob(w format.Writer, name string, cfg *apps.Config) { newJobs := []apps.WorkerJob{} for _, wj := range cfg.WorkerJobs { if wj.Name != name { @@ -435,15 +435,15 @@ func deleteWorkerJob(name string, cfg *apps.Config) { } } if len(cfg.WorkerJobs) == len(newJobs) { - format.PrintWarningf("did not find a worker job with the name %q\n", name) + w.Warningf("did not find a worker job with the name %q\n", name) return } cfg.WorkerJobs = newJobs } -func (job scheduledJob) applyUpdates(cfg *apps.Config) { +func (job scheduledJob) applyUpdates(w format.Writer, cfg *apps.Config) { if job.Name == nil { - format.PrintWarningf("you need to pass a job name to update the command, schedule or size\n") + w.Warningf("you need to pass a job name to update the command, schedule or size\n") return } @@ -478,7 +478,7 @@ func (job scheduledJob) applyUpdates(cfg *apps.Config) { cfg.ScheduledJobs = append(cfg.ScheduledJobs, newJob) } -func deleteScheduledJob(name string, cfg *apps.Config) { +func deleteScheduledJob(w format.Writer, name string, cfg *apps.Config) { newJobs := []apps.ScheduledJob{} for _, sj := range cfg.ScheduledJobs { if sj.Name != name { @@ -486,14 +486,14 @@ func deleteScheduledJob(name string, cfg *apps.Config) { } } if len(cfg.ScheduledJobs) == len(newJobs) { - format.PrintWarningf("did not find a scheduled job with the name %q\n", name) + w.Warningf("did not find a scheduled job with the name %q\n", name) return } cfg.ScheduledJobs = newJobs } -func warnIfDockerfileNotEnabled(app *apps.Application, flag string) { +func warnIfDockerfileNotEnabled(w format.Writer, app *apps.Application, flag string) { if !app.Spec.ForProvider.DockerfileBuild.Enabled { - format.PrintWarningf("updating %s has no effect as dockerfile builds are not enabled on this app\n", flag) + w.Warningf("updating %s has no effect as dockerfile builds are not enabled on this app\n", flag) } } diff --git a/update/application_test.go b/update/application_test.go index df2b8630..d31dfe9c 100644 --- a/update/application_test.go +++ b/update/application_test.go @@ -2,6 +2,7 @@ package update import ( "context" + "io" "strings" "testing" "time" @@ -636,7 +637,7 @@ func TestApplicationFlags(t *testing.T) { nilFlags := &applicationCmd{} vars, err := create.ApplicationKongVars() require.NoError(t, err) - _, err = kong.Must(nilFlags, vars).Parse([]string{`testname`}) + _, err = kong.Must(nilFlags, vars, kong.BindTo(t.Output(), (*io.Writer)(nil))).Parse([]string{`testname`}) require.NoError(t, err) assert.Nil(t, nilFlags.Hosts) @@ -644,7 +645,7 @@ func TestApplicationFlags(t *testing.T) { assert.Nil(t, nilFlags.BuildEnv) emptyFlags := &applicationCmd{} - _, err = kong.Must(emptyFlags, vars).Parse([]string{`testname`, `--hosts=""`, `--env=`, `--build-env=`}) + _, err = kong.Must(emptyFlags, vars, kong.BindTo(t.Output(), (*io.Writer)(nil))).Parse([]string{`testname`, `--hosts=""`, `--env=`, `--build-env=`}) require.NoError(t, err) assert.NotNil(t, emptyFlags.Hosts) diff --git a/update/bucket.go b/update/bucket.go index 3d084223..876a95fa 100644 --- a/update/bucket.go +++ b/update/bucket.go @@ -37,7 +37,7 @@ func (cmd *bucketCmd) Run(ctx context.Context, client *api.Client) error { }, } - upd := newUpdater(client, b, storage.BucketKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, b, storage.BucketKind, func(current resource.Managed) error { b, ok := current.(*storage.Bucket) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.Bucket{}) diff --git a/update/bucketuser.go b/update/bucketuser.go index ee1c4a97..9581df7d 100644 --- a/update/bucketuser.go +++ b/update/bucketuser.go @@ -23,7 +23,7 @@ func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client) error { }, } - upd := newUpdater(client, bu, storage.BucketUserKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, bu, storage.BucketUserKind, func(current resource.Managed) error { bu, ok := current.(*storage.BucketUser) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.BucketUser{}) diff --git a/update/cloudvm.go b/update/cloudvm.go index 5e46aacd..96c41fd9 100644 --- a/update/cloudvm.go +++ b/update/cloudvm.go @@ -37,7 +37,7 @@ func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client) error { }, } - if err := newUpdater(client, cloudvm, infrastructure.CloudVirtualMachineKind, func(current resource.Managed) error { + if err := cmd.newUpdater(client, cloudvm, infrastructure.CloudVirtualMachineKind, func(current resource.Managed) error { cloudvm, ok := current.(*infrastructure.CloudVirtualMachine) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, infrastructure.CloudVirtualMachine{}) @@ -49,7 +49,7 @@ func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client) error { } if cmd.BootRescue != nil && *cmd.BootRescue { - fmt.Println("Booting CloudVM into rescue mode. It can take a few minutes for the VM to be reachable.") + cmd.Println("Booting CloudVM into rescue mode. It can take a few minutes for the VM to be reachable.") } return nil diff --git a/update/keyvaluestore.go b/update/keyvaluestore.go index 74305baf..f2c53622 100644 --- a/update/keyvaluestore.go +++ b/update/keyvaluestore.go @@ -30,7 +30,7 @@ func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error }, } - return newUpdater(client, keyValueStore, storage.KeyValueStoreKind, func(current resource.Managed) error { + return cmd.newUpdater(client, keyValueStore, storage.KeyValueStoreKind, func(current resource.Managed) error { keyValueStore, ok := current.(*storage.KeyValueStore) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.KeyValueStore{}) diff --git a/update/mysql.go b/update/mysql.go index 8eebeeab..466104ed 100644 --- a/update/mysql.go +++ b/update/mysql.go @@ -37,7 +37,7 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error { }, } - upd := newUpdater(client, mysql, storage.MySQLKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, mysql, storage.MySQLKind, func(current resource.Managed) error { mysql, ok := current.(*storage.MySQL) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.MySQL{}) diff --git a/update/mysqldatabase.go b/update/mysqldatabase.go index 25303730..aa86bf4a 100644 --- a/update/mysqldatabase.go +++ b/update/mysqldatabase.go @@ -7,7 +7,6 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/resource" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/format" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -23,7 +22,7 @@ func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error }, } - upd := newUpdater(client, mysqlDatabase, storage.MySQLDatabaseKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, mysqlDatabase, storage.MySQLDatabaseKind, func(current resource.Managed) error { mysqlDatabase, ok := current.(*storage.MySQLDatabase) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.MySQLDatabase{}) @@ -37,5 +36,5 @@ func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error } func (cmd *mysqlDatabaseCmd) applyUpdates(_ *storage.MySQLDatabase) { - format.PrintWarningf("there are no attributes for mysqldatabase which can be updated after creation. Applying update without any changes.\n") + cmd.Warningf("there are no attributes for mysqldatabase which can be updated after creation. Applying update without any changes.\n") } diff --git a/update/opensearch.go b/update/opensearch.go index f2e2aa43..9559f3b8 100644 --- a/update/opensearch.go +++ b/update/opensearch.go @@ -32,7 +32,7 @@ func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { }, } - return newUpdater(client, openSearch, storage.OpenSearchKind, func(current resource.Managed) error { + return cmd.newUpdater(client, openSearch, storage.OpenSearchKind, func(current resource.Managed) error { openSearch, ok := current.(*storage.OpenSearch) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.OpenSearch{}) diff --git a/update/postgres.go b/update/postgres.go index ea453d21..8d103ea1 100644 --- a/update/postgres.go +++ b/update/postgres.go @@ -31,7 +31,7 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { }, } - upd := newUpdater(client, postgres, storage.PostgresKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, postgres, storage.PostgresKind, func(current resource.Managed) error { postgres, ok := current.(*storage.Postgres) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.Postgres{}) diff --git a/update/postgresdatabase.go b/update/postgresdatabase.go index 1c59c15c..ae4c8169 100644 --- a/update/postgresdatabase.go +++ b/update/postgresdatabase.go @@ -7,7 +7,6 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/resource" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/format" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -23,7 +22,7 @@ func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) err }, } - upd := newUpdater(client, postgresDatabase, storage.PostgresDatabaseKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, postgresDatabase, storage.PostgresDatabaseKind, func(current resource.Managed) error { postgresDatabase, ok := current.(*storage.PostgresDatabase) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, storage.PostgresDatabase{}) @@ -37,5 +36,5 @@ func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) err } func (cmd *postgresDatabaseCmd) applyUpdates(_ *storage.PostgresDatabase) { - format.PrintWarningf("there are no attributes for postgresdatabase which can be updated after creation. Applying update without any changes.\n") + cmd.Warningf("there are no attributes for postgresdatabase which can be updated after creation. Applying update without any changes.\n") } diff --git a/update/project.go b/update/project.go index 9112af47..66d9af15 100644 --- a/update/project.go +++ b/update/project.go @@ -28,7 +28,7 @@ func (cmd *projectCmd) Run(ctx context.Context, client *api.Client) error { }, } - upd := newUpdater(client, project, management.ProjectKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, project, management.ProjectKind, func(current resource.Managed) error { project, ok := current.(*management.Project) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, management.Project{}) diff --git a/update/project_config.go b/update/project_config.go index 7edc1fbe..afc6b71e 100644 --- a/update/project_config.go +++ b/update/project_config.go @@ -8,12 +8,16 @@ import ( apps "github.com/ninech/apis/apps/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/util" + "github.com/ninech/nctl/internal/format" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { + format.Writer + Size *string `help:"Size of the app."` Port *int32 `help:"Port the app is listening on."` Replicas *int32 `help:"Amount of replicas of the running app."` @@ -22,6 +26,15 @@ type configCmd struct { DeployJob *deployJob `embed:"" prefix:"deploy-job-"` } +func (cmd *configCmd) newUpdater( + client *api.Client, + mg resource.Managed, + kind string, + f updateFunc, +) *updater { + return &updater{Writer: cmd.Writer, client: client, mg: mg, kind: kind, updateFunc: f} +} + func (cmd *configCmd) Run(ctx context.Context, client *api.Client) error { cfg := &apps.ProjectConfig{ ObjectMeta: v1.ObjectMeta{ @@ -30,7 +43,7 @@ func (cmd *configCmd) Run(ctx context.Context, client *api.Client) error { }, } - upd := newUpdater(client, cfg, apps.ProjectConfigKind, func(current resource.Managed) error { + upd := cmd.newUpdater(client, cfg, apps.ProjectConfigKind, func(current resource.Managed) error { cfg, ok := current.(*apps.ProjectConfig) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, apps.ProjectConfig{}) diff --git a/update/serviceconnection.go b/update/serviceconnection.go index da98a7f3..a71076f3 100644 --- a/update/serviceconnection.go +++ b/update/serviceconnection.go @@ -25,7 +25,7 @@ func (cmd *serviceConnectionCmd) Run(ctx context.Context, client *api.Client) er }, } - return newUpdater(client, sc, networking.ServiceConnectionKind, func(current resource.Managed) error { + return cmd.newUpdater(client, sc, networking.ServiceConnectionKind, func(current resource.Managed) error { sc, ok := current.(*networking.ServiceConnection) if !ok { return fmt.Errorf("resource is of type %T, expected %T", current, networking.ServiceConnection{}) diff --git a/update/update.go b/update/update.go index 2546aead..d6b8cac4 100644 --- a/update/update.go +++ b/update/update.go @@ -1,3 +1,4 @@ +// Package update contains the commands for updating resources. package update import ( @@ -26,10 +27,12 @@ type Cmd struct { } type resourceCmd struct { + format.Writer Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to update."` } type updater struct { + format.Writer mg resource.Managed client *api.Client kind string @@ -38,8 +41,13 @@ type updater struct { type updateFunc func(current resource.Managed) error -func newUpdater(client *api.Client, mg resource.Managed, kind string, f updateFunc) *updater { - return &updater{client: client, mg: mg, kind: kind, updateFunc: f} +func (cmd *resourceCmd) newUpdater( + client *api.Client, + mg resource.Managed, + kind string, + f updateFunc, +) *updater { + return &updater{Writer: cmd.Writer, client: client, mg: mg, kind: kind, updateFunc: f} } func (u *updater) Update(ctx context.Context) error { @@ -55,6 +63,6 @@ func (u *updater) Update(ctx context.Context) error { return err } - format.PrintSuccessf("⬆️", "updated %s %q", u.kind, u.mg.GetName()) + u.Successf("⬆️", "updated %s %q\n", u.kind, u.mg.GetName()) return nil } From 069145f6d568d18a55fd9e435934246487e63d16 Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 09:53:43 +0100 Subject: [PATCH 02/19] refactor: align resource kind --- create/application.go | 2 +- create/bucket.go | 2 +- create/bucketuser.go | 2 +- create/create.go | 12 +++++++++--- create/keyvaluestore.go | 2 +- create/mysql.go | 3 +-- create/mysqldatabase.go | 1 - create/opensearch.go | 2 +- create/postgres.go | 3 +-- create/postgresdatabase.go | 3 +-- delete/delete.go | 2 +- 11 files changed, 18 insertions(+), 16 deletions(-) diff --git a/create/application.go b/create/application.go index e1803a23..8b291096 100644 --- a/create/application.go +++ b/create/application.go @@ -197,7 +197,7 @@ func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { } } - c := cmd.newCreator(client, newApp, strings.ToLower(apps.ApplicationKind)) + c := cmd.newCreator(client, newApp, apps.ApplicationKind) appWaitCtx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/bucket.go b/create/bucket.go index 71637734..229d8698 100644 --- a/create/bucket.go +++ b/create/bucket.go @@ -37,7 +37,7 @@ func (cmd *bucketCmd) Run(ctx context.Context, client *api.Client) error { return err } - c := cmd.newCreator(client, bucket, "bucket") + c := cmd.newCreator(client, bucket, storage.BucketKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/bucketuser.go b/create/bucketuser.go index d877638f..f1f46fb3 100644 --- a/create/bucketuser.go +++ b/create/bucketuser.go @@ -23,7 +23,7 @@ func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client) error { cmd.Printf("Creating new bucketuser.\n") bucketuser := cmd.newBucketUser(client.Project) - c := cmd.newCreator(client, bucketuser, "bucketuser") + c := cmd.newCreator(client, bucketuser, storage.BucketUserKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/create.go b/create/create.go index faa7669f..f71b123d 100644 --- a/create/create.go +++ b/create/create.go @@ -60,6 +60,8 @@ type creator struct { client *api.Client mg resource.Managed kind string + + timeout time.Duration } type waitStage struct { @@ -100,8 +102,8 @@ func (m *message) progress() string { return format.Progress(m.icon, m.text) } -func (cmd *resourceCmd) newCreator(client *api.Client, mg resource.Managed, resourceName string) *creator { - return &creator{client: client, mg: mg, kind: resourceName, Writer: cmd.Writer} +func (cmd *resourceCmd) newCreator(client *api.Client, mg resource.Managed, kind string) *creator { + return &creator{client: client, mg: mg, kind: kind, timeout: cmd.WaitTimeout, Writer: cmd.Writer} } func (c *creator) createResource(ctx context.Context) error { @@ -157,6 +159,10 @@ func (w *waitStage) setDefaults(c *creator) { text: fmt.Sprintf("waiting for %s to be ready", w.kind), icon: "⏳", } + + if c.timeout > 0 { + w.waitMessage.text = w.waitMessage.text + fmt.Sprintf(" (%s)", c.timeout) + } } if w.doneMessage == nil { @@ -223,7 +229,7 @@ func (w *waitStage) watch(ctx context.Context, client *api.Client) error { if done { wa.Stop() _ = w.spinner.Stop() - w.Successf(w.doneMessage.icon, "%s\n", w.doneMessage.text) + w.Successf(w.doneMessage.icon, "%s", w.doneMessage.text) return nil } diff --git a/create/keyvaluestore.go b/create/keyvaluestore.go index fc35487b..3292257b 100644 --- a/create/keyvaluestore.go +++ b/create/keyvaluestore.go @@ -31,7 +31,7 @@ func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error return err } - c := cmd.newCreator(client, keyValueStore, "keyvaluestore") + c := cmd.newCreator(client, keyValueStore, storage.KeyValueStoreKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/mysql.go b/create/mysql.go index 222056e4..ad10668a 100644 --- a/create/mysql.go +++ b/create/mysql.go @@ -46,10 +46,9 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error { cmd.SSHKeys = keys } - cmd.Printf("Creating new mysql. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) mysql := cmd.newMySQL(client.Project) - c := cmd.newCreator(client, mysql, "mysql") + c := cmd.newCreator(client, mysql, storage.MySQLKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/mysqldatabase.go b/create/mysqldatabase.go index da6623f6..69ca4168 100644 --- a/create/mysqldatabase.go +++ b/create/mysqldatabase.go @@ -23,7 +23,6 @@ type mysqlDatabaseCmd struct { } func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error { - cmd.Printf("Creating new MySQLDatabase. (waiting up to %s).\n", cmd.WaitTimeout) mysqlDatabase := cmd.newMySQLDatabase(client.Project) c := cmd.newCreator(client, mysqlDatabase, storage.MySQLDatabaseKind) diff --git a/create/opensearch.go b/create/opensearch.go index 2ebd5aae..9304fb7a 100644 --- a/create/opensearch.go +++ b/create/opensearch.go @@ -35,7 +35,7 @@ func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { return err } - c := cmd.newCreator(client, openSearch, "opensearch") + c := cmd.newCreator(client, openSearch, storage.OpenSearchKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/postgres.go b/create/postgres.go index 952cd350..85ec439c 100644 --- a/create/postgres.go +++ b/create/postgres.go @@ -40,10 +40,9 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { cmd.SSHKeys = keys } - cmd.Printf("Creating new postgres. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) postgres := cmd.newPostgres(client.Project) - c := cmd.newCreator(client, postgres, "postgres") + c := cmd.newCreator(client, postgres, storage.PostgresKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/postgresdatabase.go b/create/postgresdatabase.go index 56f7b899..43886a84 100644 --- a/create/postgresdatabase.go +++ b/create/postgresdatabase.go @@ -22,10 +22,9 @@ type postgresDatabaseCmd struct { } func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) error { - cmd.Printf("Creating new PostgresDatabase. (waiting up to %s).\n", cmd.WaitTimeout) postgresDatabase := cmd.newPostgresDatabase(client.Project) - c := cmd.newCreator(client, postgresDatabase, "postgresdatabase") + c := cmd.newCreator(client, postgresDatabase, storage.PostgresDatabaseKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/delete/delete.go b/delete/delete.go index fdf949a9..02e6eab2 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -99,7 +99,7 @@ func noCleanup(client *api.Client) error { } func defaultPrompt(kind, name string) string { - return fmt.Sprintf("do you really want to delete the %s %q?", kind, name) + return fmt.Sprintf("Do you really want to delete the %s %q?", kind, name) } func (d *deleter) deleteResource( From 24c77eafe9e6573b0321bd57f80cf2fcbdffa2e2 Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 10:01:23 +0100 Subject: [PATCH 03/19] docs: add package comments --- api/client.go | 1 + api/config/extension.go | 1 + api/log/client.go | 1 + api/util/apps.go | 1 + exec/exec.go | 1 + internal/logbox/logbox.go | 1 + internal/test/client.go | 1 + main.go | 1 + 8 files changed, 8 insertions(+) diff --git a/api/client.go b/api/client.go index 12829c5d..7fe2c33a 100644 --- a/api/client.go +++ b/api/client.go @@ -1,3 +1,4 @@ +// Package api provides the Kubernetes client and configuration for the nine.ch API. package api import ( diff --git a/api/config/extension.go b/api/config/extension.go index c5ac8359..0c9007bb 100644 --- a/api/config/extension.go +++ b/api/config/extension.go @@ -1,3 +1,4 @@ +// Package config provides the nctl specific configuration stored in the kubeconfig. package config import ( diff --git a/api/log/client.go b/api/log/client.go index 7ddeaa61..169c1977 100644 --- a/api/log/client.go +++ b/api/log/client.go @@ -1,3 +1,4 @@ +// Package log provides functionality to interact with the Nine logging API. package log import ( diff --git a/api/util/apps.go b/api/util/apps.go index f7fc8cad..75f69496 100644 --- a/api/util/apps.go +++ b/api/util/apps.go @@ -1,3 +1,4 @@ +// Package util provides utility functions for interacting with various Nine resources. package util import ( diff --git a/exec/exec.go b/exec/exec.go index ce0cdaf4..f37d6a41 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -1,3 +1,4 @@ +// Package exec provides the implementation for the exec command. package exec type Cmd struct { diff --git a/internal/logbox/logbox.go b/internal/logbox/logbox.go index 41d3384b..eec65d56 100644 --- a/internal/logbox/logbox.go +++ b/internal/logbox/logbox.go @@ -1,3 +1,4 @@ +// Package logbox provides a UI component to display logs with a spinner. package logbox import ( diff --git a/internal/test/client.go b/internal/test/client.go index d658d2ba..8f3cb432 100644 --- a/internal/test/client.go +++ b/internal/test/client.go @@ -1,3 +1,4 @@ +// Package test provides utilities and helpers for testing nctl. package test import ( diff --git a/main.go b/main.go index 354cdff7..65fae474 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,4 @@ +// Package main is the entry point for nctl. package main import ( From 21f7dd7ee0ca1d95ba61b732132d14ed2e784b4d Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 10:38:11 +0100 Subject: [PATCH 04/19] feat(create): add interactive wait timer and show elapsed time --- create/create.go | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/create/create.go b/create/create.go index f71b123d..6d67dbe1 100644 --- a/create/create.go +++ b/create/create.go @@ -75,6 +75,7 @@ type waitStage struct { onResult resultFunc spinner *yacspin.Spinner disableSpinner bool + startTime time.Time // beforeWait is a hook that is called just before the wait is being run. beforeWait func() // afterWait is a hook that is called after the wait to clean up. @@ -94,6 +95,8 @@ var watchBackoff = wait.Backoff{ Jitter: 0.1, } +const remainingTimeUpdateInterval = time.Second + func (m *message) progress() string { if m.disabled { return "" @@ -102,6 +105,18 @@ func (m *message) progress() string { return format.Progress(m.icon, m.text) } +// progressWithRemaining returns the progress message with the remaining time +// from the context deadline appended. +func (w *waitStage) progressWithRemaining(ctx context.Context) string { + text := w.waitMessage.text + if deadline, ok := ctx.Deadline(); ok { + if remaining := time.Until(deadline); remaining > 0 { + text += fmt.Sprintf(" (%s)", remaining.Truncate(time.Second)) + } + } + return format.Progress(w.waitMessage.icon, text) +} + func (cmd *resourceCmd) newCreator(client *api.Client, mg resource.Managed, kind string) *creator { return &creator{client: client, mg: mg, kind: kind, timeout: cmd.WaitTimeout, Writer: cmd.Writer} } @@ -125,8 +140,8 @@ func (c *creator) wait(ctx context.Context, stages ...waitStage) error { stage.Writer = c.Writer spinner, err := c.Spinner( - stage.waitMessage.progress(), - stage.waitMessage.progress(), + stage.progressWithRemaining(ctx), + stage.progressWithRemaining(ctx), ) if err != nil { return err @@ -137,6 +152,7 @@ func (c *creator) wait(ctx context.Context, stages ...waitStage) error { stage.beforeWait() } + stage.startTime = time.Now() if err := retry.OnError(watchBackoff, isWatchError, func() error { return stage.wait(ctx, c.client) }); err != nil { @@ -159,10 +175,6 @@ func (w *waitStage) setDefaults(c *creator) { text: fmt.Sprintf("waiting for %s to be ready", w.kind), icon: "⏳", } - - if c.timeout > 0 { - w.waitMessage.text = w.waitMessage.text + fmt.Sprintf(" (%s)", c.timeout) - } } if w.doneMessage == nil { @@ -213,6 +225,9 @@ func (w *waitStage) watch(ctx context.Context, client *api.Client) error { return watchError{kind: w.kind} } + ticker := time.NewTicker(remainingTimeUpdateInterval) + defer ticker.Stop() + for { select { case res := <-wa.ResultChan(): @@ -228,11 +243,16 @@ func (w *waitStage) watch(ctx context.Context, client *api.Client) error { if done { wa.Stop() + elapsed := time.Since(w.startTime).Truncate(time.Second) + w.spinner.StopMessage(w.waitMessage.progress()) _ = w.spinner.Stop() - w.Successf(w.doneMessage.icon, "%s", w.doneMessage.text) + w.Successf(w.doneMessage.icon, "%s (%s)", w.doneMessage.text, elapsed) + w.Println() return nil } + case <-ticker.C: + w.spinner.Message(w.progressWithRemaining(ctx)) case <-ctx.Done(): switch ctx.Err() { case context.DeadlineExceeded: From 67d2eeb16b2f761ede2b121f1baebcb5360a033b Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 16:15:18 +0100 Subject: [PATCH 05/19] refactor: improve newline output --- api/list.go | 2 +- auth/login.go | 6 +++--- auth/logout.go | 4 ++-- auth/set_org.go | 7 +++++++ auth/set_project.go | 13 +++++++------ create/create.go | 3 +-- create/serviceconnection.go | 4 ++-- delete/vcluster.go | 2 +- get/all.go | 2 +- get/application.go | 4 ++-- get/build.go | 2 +- internal/format/interpolation.go | 3 +-- internal/format/writer.go | 8 ++++---- update/application.go | 10 +++++----- update/mysqldatabase.go | 2 +- update/postgresdatabase.go | 2 +- update/update.go | 2 +- 17 files changed, 41 insertions(+), 35 deletions(-) diff --git a/api/list.go b/api/list.go index 7615f2bf..8a3151cb 100644 --- a/api/list.go +++ b/api/list.go @@ -149,7 +149,7 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList, tempList := reflect.New(reflect.TypeOf(list).Elem()).Interface().(runtimeclient.ObjectList) tempList.GetObjectKind().SetGroupVersionKind(list.GetObjectKind().GroupVersionKind()) if err := c.List(ctx, tempList, append(tempOpts, runtimeclient.InNamespace(proj.Name))...); err != nil { - c.writer.Warningf("error when searching in project %s: %s\n", proj.Name, err) + c.writer.Warningf("error when searching in project %s: %s", proj.Name, err) return } tempListItems := reflect.ValueOf(tempList).Elem().FieldByName("Items") diff --git a/auth/login.go b/auth/login.go index 26aa11e6..a7f3b207 100644 --- a/auth/login.go +++ b/auth/login.go @@ -301,15 +301,15 @@ func login(w format.Writer, newConfig *clientcmdapi.Config, kubeconfigPath, user } if toOrg != "" { - w.Successf("🏢", "switched to the organization %q\n", toOrg) + w.Successf("🏢", "switched to the organization %q", toOrg) } - w.Successf("📋", "added %s to kubeconfig\n", newConfig.CurrentContext) + w.Successf("📋", "added %s to kubeconfig", newConfig.CurrentContext) loginMessage := fmt.Sprintf("logged into cluster %s", newConfig.CurrentContext) if strings.TrimSpace(userName) != "" { loginMessage = fmt.Sprintf("logged into cluster %s as %s", newConfig.CurrentContext, userName) } - w.Success("🚀", loginMessage+"\n") + w.Success("🚀", loginMessage) return nil } diff --git a/auth/logout.go b/auth/logout.go index b0e23978..ce8e281a 100644 --- a/auth/logout.go +++ b/auth/logout.go @@ -44,7 +44,7 @@ func (l *LogoutCmd) Run(ctx context.Context) error { filePath := path.Join(homedir.HomeDir(), api.DefaultTokenCachePath, filename) if _, err = os.Stat(filePath); err != nil { - l.Failuref("🤔", "seems like you are already logged out from %s\n", l.APIURL) + l.Failuref("🤔", "seems like you are already logged out from %s", l.APIURL) return nil } @@ -99,7 +99,7 @@ func (l *LogoutCmd) Run(ctx context.Context) error { return fmt.Errorf("error removing the local cache: %w", err) } - l.Successf("👋", "logged out from %s\n", l.APIURL) + l.Successf("👋", "logged out from %s", l.APIURL) return nil } diff --git a/auth/set_org.go b/auth/set_org.go index fafdd5a3..f3d4b8da 100644 --- a/auth/set_org.go +++ b/auth/set_org.go @@ -3,6 +3,7 @@ package auth import ( "context" "slices" + "strings" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/config" @@ -37,6 +38,12 @@ func (cmd *SetOrgCmd) Run(ctx context.Context, client *api.Client) error { return err } + cmd.Successf("📝", "set active Organization to %s", cmd.Organization) + cmd.Println() + + // We only warn if the organization is not in the user's token, as RBAC + // 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.\n", diff --git a/auth/set_project.go b/auth/set_project.go index e65ccd85..58aa221c 100644 --- a/auth/set_project.go +++ b/auth/set_project.go @@ -9,9 +9,10 @@ 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" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" ) @@ -32,12 +33,12 @@ func (s *SetProjectCmd) Run(ctx context.Context, client *api.Client) error { types.NamespacedName{Name: s.Name, Namespace: org}, &management.Project{}, ); err != nil { - if !errors.IsNotFound(err) && !errors.IsForbidden(err) { + if !kerrors.IsNotFound(err) && !kerrors.IsForbidden(err) { return fmt.Errorf("failed to set project %s: %w", s.Name, err) } - s.Warningf( - "Project does not exist in organization %s, checking other organizations...\n", + s.Warningf("Project %q does not exist in organization %q, checking other organizations...", + s.Name, org, ) if err := trySwitchOrg(ctx, client, s.Name); err != nil { @@ -58,7 +59,7 @@ func (s *SetProjectCmd) Run(ctx context.Context, client *api.Client) error { return err } - s.Successf("📝", "set active Project to %s in organization %s\n", s.Name, org) + s.Successf("📝", "set active Project to %s in organization %s", s.Name, org) return nil } @@ -120,7 +121,7 @@ func orgFromProject(ctx context.Context, client *api.Client, project string) (st for org := range orgs { proj := &management.Project{} err := client.Get(ctx, types.NamespacedName{Name: project, Namespace: org}, proj) - if errors.IsNotFound(err) || errors.IsForbidden(err) { + if kerrors.IsNotFound(err) || kerrors.IsForbidden(err) { continue } if err != nil { diff --git a/create/create.go b/create/create.go index 6d67dbe1..0514db52 100644 --- a/create/create.go +++ b/create/create.go @@ -126,7 +126,7 @@ func (c *creator) createResource(ctx context.Context) error { return fmt.Errorf("unable to create %s %q: %w", c.kind, c.mg.GetName(), err) } - c.Successf("🏗", "created %s %q in project %q\n", c.kind, c.mg.GetName(), c.mg.GetNamespace()) + c.Successf("🏗", "created %s %q in project %q", c.kind, c.mg.GetName(), c.mg.GetNamespace()) return nil } @@ -247,7 +247,6 @@ func (w *waitStage) watch(ctx context.Context, client *api.Client) error { w.spinner.StopMessage(w.waitMessage.progress()) _ = w.spinner.Stop() w.Successf(w.doneMessage.icon, "%s (%s)", w.doneMessage.text, elapsed) - w.Println() return nil } diff --git a/create/serviceconnection.go b/create/serviceconnection.go index cde174ab..12468ba0 100644 --- a/create/serviceconnection.go +++ b/create/serviceconnection.go @@ -127,10 +127,10 @@ func (cmd *serviceConnectionCmd) Run(ctx context.Context, client *api.Client) er } if !sourceExists || !destinationExists { if !sourceExists { - cmd.Warningf("source %q does not yet exist\n", sc.Spec.ForProvider.Source.Reference) + cmd.Warningf("source %q does not yet exist", sc.Spec.ForProvider.Source.Reference) } if !destinationExists { - cmd.Warningf("destination %q does not yet exist\n", sc.Spec.ForProvider.Destination) + cmd.Warningf("destination %q does not yet exist", sc.Spec.ForProvider.Destination) } return nil diff --git a/delete/vcluster.go b/delete/vcluster.go index eee8c77f..4ef46ff9 100644 --- a/delete/vcluster.go +++ b/delete/vcluster.go @@ -34,7 +34,7 @@ func (cmd *vclusterCmd) Run(ctx context.Context, client *api.Client) error { client.KubeconfigPath, config.ContextName(cluster), ); err != nil { - cmd.Warningf("unable to remove cluster from kubeconfig: %s\n", err) + cmd.Warningf("unable to remove cluster from kubeconfig: %s", err) } return nil })) diff --git a/get/all.go b/get/all.go index c1b17fc6..c84cf8c7 100644 --- a/get/all.go +++ b/get/all.go @@ -40,7 +40,7 @@ func (cmd *allCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error } // we now print all warnings to stderr for _, w := range warnings { - get.Warningf("%s\n", w) + get.Warningf("%s", w) } if len(items) == 0 { diff --git a/get/application.go b/get/application.go index d1d3f514..02c03501 100644 --- a/get/application.go +++ b/get/application.go @@ -242,7 +242,7 @@ func (cmd *applicationsCmd) printStats(ctx context.Context, c *api.Client, appLi for _, app := range appList { rel, err := util.ApplicationLatestRelease(ctx, c, api.ObjectName(&app)) if err != nil { - out.Warningf("unable to get latest release for app %s\n", c.Name(app.Name)) + out.Warningf("unable to get latest release for app %s", c.Name(app.Name)) continue } @@ -293,7 +293,7 @@ func (cmd *applicationsCmd) printStats(ctx context.Context, c *api.Client, appLi api.NamespacedName(statsObservation.ReplicaName, app.Namespace), &podMetrics, ); err != nil { - out.Warningf("unable to get metrics for replica %s\n", statsObservation.ReplicaName) + out.Warningf("unable to get metrics for replica %s", statsObservation.ReplicaName) } maxResources := apps.AppResources[statsObservation.size] diff --git a/get/build.go b/get/build.go index 76a330ab..07f19b6c 100644 --- a/get/build.go +++ b/get/build.go @@ -129,7 +129,7 @@ func pullImage(ctx context.Context, apiClient *api.Client, build *apps.Build, ou return fmt.Errorf("unable to tag image: %w", err) } - out.Successf("💾", "Pulled image %s\n", imageName(build.Spec.ForProvider.Image)) + out.Successf("💾", "Pulled image %s", imageName(build.Spec.ForProvider.Image)) return nil } diff --git a/internal/format/interpolation.go b/internal/format/interpolation.go index efbf039c..2ac12d71 100644 --- a/internal/format/interpolation.go +++ b/internal/format/interpolation.go @@ -14,8 +14,7 @@ import ( var interpolationRegex = regexp.MustCompile(`(\$\$)|((?:\${([[:alpha:]_][[:word:]]*))(?:=([^}]+))?})|(\$)|([^$]+)`) // interpolate interpolates the given string s with variables from vars -// The [upstream -// function](https://github.com/alecthomas/kong/blob/v0.8.0/interpolate.go#L22) +// The [upstream function](https://github.com/alecthomas/kong/blob/v0.8.0/interpolate.go#L22) // was sadly not exported, so we had to copy it. func interpolate(s string, vars kong.Vars) (string, error) { var out strings.Builder diff --git a/internal/format/writer.go b/internal/format/writer.go index ef9472d6..ea07f727 100644 --- a/internal/format/writer.go +++ b/internal/format/writer.go @@ -44,22 +44,22 @@ func (w *Writer) Spinner(message, stopMessage string) (*yacspin.Spinner, error) // Successf is a formatted message for indicating a successful step. func (w *Writer) Successf(icon string, format string, a ...any) { - fmt.Fprint(w.writer(), successf(icon, format, a...)) + fmt.Fprint(w.writer(), successf(icon, format, a...)+"\n") } // Success returns a message for indicating a successful step. func (w *Writer) Success(icon string, message string) { - fmt.Fprint(w.writer(), success(icon, message)) + fmt.Fprint(w.writer(), success(icon, message)+"\n") } // Warningf is a formatted message for indicating a warning. func (w *Writer) Warningf(format string, a ...any) { - fmt.Fprint(w.writer(), warningf(format, a...)) + fmt.Fprint(w.writer(), warningf(format, a...)+"\n") } // Failuref is a formatted message for indicating a failure. func (w *Writer) Failuref(icon string, format string, a ...any) { - fmt.Fprint(w.writer(), failuref(icon, format, a...)) + fmt.Fprint(w.writer(), failuref(icon, format, a...)+"\n") } // Printf formats according to a format specifier and writes to the underlying writer. diff --git a/update/application.go b/update/application.go index d477bba7..cf497c1e 100644 --- a/update/application.go +++ b/update/application.go @@ -402,7 +402,7 @@ func ensureDeployJob(cfg *apps.Config) *apps.Config { func (job workerJob) applyUpdates(w format.Writer, cfg *apps.Config) { if job.Name == nil { - w.Warningf("you need to pass a job name to update the command or size\n") + w.Warningf("you need to pass a job name to update the command or size") return } for i := range cfg.WorkerJobs { @@ -435,7 +435,7 @@ func deleteWorkerJob(w format.Writer, name string, cfg *apps.Config) { } } if len(cfg.WorkerJobs) == len(newJobs) { - w.Warningf("did not find a worker job with the name %q\n", name) + w.Warningf("did not find a worker job with the name %q", name) return } cfg.WorkerJobs = newJobs @@ -443,7 +443,7 @@ func deleteWorkerJob(w format.Writer, name string, cfg *apps.Config) { func (job scheduledJob) applyUpdates(w format.Writer, cfg *apps.Config) { if job.Name == nil { - w.Warningf("you need to pass a job name to update the command, schedule or size\n") + w.Warningf("you need to pass a job name to update the command, schedule or size") return } @@ -486,7 +486,7 @@ func deleteScheduledJob(w format.Writer, name string, cfg *apps.Config) { } } if len(cfg.ScheduledJobs) == len(newJobs) { - w.Warningf("did not find a scheduled job with the name %q\n", name) + w.Warningf("did not find a scheduled job with the name %q", name) return } cfg.ScheduledJobs = newJobs @@ -494,6 +494,6 @@ func deleteScheduledJob(w format.Writer, name string, cfg *apps.Config) { func warnIfDockerfileNotEnabled(w format.Writer, app *apps.Application, flag string) { if !app.Spec.ForProvider.DockerfileBuild.Enabled { - w.Warningf("updating %s has no effect as dockerfile builds are not enabled on this app\n", flag) + w.Warningf("updating %s has no effect as dockerfile builds are not enabled on this app", flag) } } diff --git a/update/mysqldatabase.go b/update/mysqldatabase.go index aa86bf4a..db790a9e 100644 --- a/update/mysqldatabase.go +++ b/update/mysqldatabase.go @@ -36,5 +36,5 @@ func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error } func (cmd *mysqlDatabaseCmd) applyUpdates(_ *storage.MySQLDatabase) { - cmd.Warningf("there are no attributes for mysqldatabase which can be updated after creation. Applying update without any changes.\n") + cmd.Warningf("there are no attributes for mysqldatabase which can be updated after creation. Applying update without any changes.") } diff --git a/update/postgresdatabase.go b/update/postgresdatabase.go index ae4c8169..2451726b 100644 --- a/update/postgresdatabase.go +++ b/update/postgresdatabase.go @@ -36,5 +36,5 @@ func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) err } func (cmd *postgresDatabaseCmd) applyUpdates(_ *storage.PostgresDatabase) { - cmd.Warningf("there are no attributes for postgresdatabase which can be updated after creation. Applying update without any changes.\n") + cmd.Warningf("there are no attributes for postgresdatabase which can be updated after creation. Applying update without any changes.") } diff --git a/update/update.go b/update/update.go index d6b8cac4..058c05b6 100644 --- a/update/update.go +++ b/update/update.go @@ -63,6 +63,6 @@ func (u *updater) Update(ctx context.Context) error { return err } - u.Successf("⬆️", "updated %s %q\n", u.kind, u.mg.GetName()) + u.Successf("⬆️", "updated %s %q", u.kind, u.mg.GetName()) return nil } From 97cbd18630e8c435ef14fb939a97dcced76c90ca Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 16:51:56 +0100 Subject: [PATCH 06/19] refactor: ensure Writer is not used as CLI flag --- apply/apply.go | 7 +++---- auth/cluster.go | 6 +++--- auth/login.go | 2 +- auth/logout.go | 10 +++++----- auth/set_org.go | 11 +++++------ auth/set_project.go | 5 ++--- auth/whoami.go | 9 ++++----- copy/copy.go | 3 +-- create/create.go | 9 ++++----- create/project_config.go | 15 +++++++-------- delete/delete.go | 22 ++++++++++------------ delete/project_config.go | 9 ++++----- edit/edit.go | 5 ++--- get/get.go | 6 +++--- logs/logs.go | 18 +++++++++--------- update/application.go | 8 ++++---- update/project_config.go | 15 +++++++-------- update/update.go | 4 ++-- 18 files changed, 76 insertions(+), 88 deletions(-) diff --git a/apply/apply.go b/apply/apply.go index 0b6c0df2..eb16be48 100644 --- a/apply/apply.go +++ b/apply/apply.go @@ -9,8 +9,7 @@ import ( ) type Cmd struct { - format.Writer - - Filename *os.File `short:"f" completion-predictor:"file"` - FromFile fromFile `cmd:"" default:"1" name:"-f " help:"Apply any resource from a yaml or json file."` + format.Writer `kong:"-"` + Filename *os.File `short:"f" completion-predictor:"file"` + FromFile fromFile `cmd:"" default:"1" name:"-f " help:"Apply any resource from a yaml or json file."` } diff --git a/auth/cluster.go b/auth/cluster.go index 1859e866..28d6158d 100644 --- a/auth/cluster.go +++ b/auth/cluster.go @@ -18,9 +18,9 @@ import ( ) type ClusterCmd struct { - format.Writer - Name string `arg:"" help:"Name of the cluster to authenticate with. Also accepts 'name/project' format."` - ExecPlugin bool `help:"Automatically run exec plugin after writing the kubeconfig."` + format.Writer `kong:"-"` + Name string `arg:"" help:"Name of the cluster to authenticate with. Also accepts 'name/project' format."` + ExecPlugin bool `help:"Automatically run exec plugin after writing the kubeconfig."` } func (a *ClusterCmd) Run(ctx context.Context, client *api.Client) error { diff --git a/auth/login.go b/auth/login.go index a7f3b207..dba7cd35 100644 --- a/auth/login.go +++ b/auth/login.go @@ -26,7 +26,7 @@ const ( ) type LoginCmd struct { - format.Writer + format.Writer `kong:"-"` API API `embed:"" prefix:"api-"` Organization string `help:"Name of your organization to use when providing an API client ID/secret." env:"NCTL_ORGANIZATION"` IssuerURL string `help:"OIDC issuer URL of the API." default:"${issuer_url}" hidden:""` diff --git a/auth/logout.go b/auth/logout.go index ce8e281a..3eb0b3d7 100644 --- a/auth/logout.go +++ b/auth/logout.go @@ -22,11 +22,11 @@ import ( ) type LogoutCmd struct { - format.Writer - APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` - IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` - ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` - tk api.TokenGetter + format.Writer `kong:"-"` + APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` + IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` + ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` + tk api.TokenGetter } func (l *LogoutCmd) Run(ctx context.Context) error { diff --git a/auth/set_org.go b/auth/set_org.go index f3d4b8da..e4b626ee 100644 --- a/auth/set_org.go +++ b/auth/set_org.go @@ -3,7 +3,6 @@ package auth import ( "context" "slices" - "strings" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/config" @@ -11,11 +10,11 @@ import ( ) type SetOrgCmd struct { - format.Writer - Organization string `arg:"" help:"Name of the organization to login to." default:""` - APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` - IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` - ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` + format.Writer `kong:"-"` + Organization string `arg:"" help:"Name of the organization to login to." default:""` + APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` + IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` + ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` } func (cmd *SetOrgCmd) Run(ctx context.Context, client *api.Client) error { diff --git a/auth/set_project.go b/auth/set_project.go index 58aa221c..c786e371 100644 --- a/auth/set_project.go +++ b/auth/set_project.go @@ -9,7 +9,6 @@ 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" @@ -17,8 +16,8 @@ import ( ) type SetProjectCmd struct { - format.Writer - Name string `arg:"" help:"Name of the default project to be used." completion-predictor:"project_name"` + format.Writer `kong:"-"` + Name string `arg:"" help:"Name of the default project to be used." completion-predictor:"project_name"` } func (s *SetProjectCmd) Run(ctx context.Context, client *api.Client) error { diff --git a/auth/whoami.go b/auth/whoami.go index 706af025..001f0917 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -8,11 +8,10 @@ import ( ) type WhoAmICmd struct { - format.Writer - - APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` - IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` - ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` + format.Writer `kong:"-"` + APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` + IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` + ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` } func (s *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { diff --git a/copy/copy.go b/copy/copy.go index 9e7bac11..59067637 100644 --- a/copy/copy.go +++ b/copy/copy.go @@ -14,8 +14,7 @@ type Cmd struct { } type resourceCmd struct { - format.Writer - + format.Writer `kong:"-"` Name string `arg:"" help:"Name of the resource to copy." default:"" completion-predictor:"resource_name"` TargetName string `help:"Target name of the new resource. A random name is generated if omitted." default:""` TargetProject string `help:"Target project of the new resource. The current project is used if omitted." default:"" completion-predictor:"project_name"` diff --git a/create/create.go b/create/create.go index 0514db52..5d02a532 100644 --- a/create/create.go +++ b/create/create.go @@ -43,11 +43,10 @@ type Cmd struct { } type resourceCmd struct { - format.Writer - - Name string `arg:"" help:"Name of the new resource. A random name is generated if omitted." default:""` - Wait bool `default:"true" help:"Wait until resource is fully created."` - WaitTimeout time.Duration `default:"30m" help:"Duration to wait for resource getting ready. Only relevant if wait is set."` + format.Writer `kong:"-"` + Name string `arg:"" help:"Name of the new resource. A random name is generated if omitted." default:""` + Wait bool `default:"true" help:"Wait until resource is fully created."` + WaitTimeout time.Duration `default:"30m" help:"Duration to wait for resource getting ready. Only relevant if wait is set."` } // resultFunc is the function called on a watch event during creation. It diff --git a/create/project_config.go b/create/project_config.go index 49b5c231..9d10ad38 100644 --- a/create/project_config.go +++ b/create/project_config.go @@ -16,14 +16,13 @@ import ( // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { - format.Writer - - Size string `help:"Size of the application."` - Port *int32 `help:"Port the application is listening on."` - Replicas *int32 `help:"Amount of replicas of the running application."` - Env *map[string]string `help:"Environment variables which are passed to the application at runtime."` - BasicAuth *bool `help:"Enable/Disable basic authentication for applications."` - DeployJob deployJob `embed:"" prefix:"deploy-job-"` + format.Writer `kong:"-"` + Size string `help:"Size of the application."` + Port *int32 `help:"Port the application is listening on."` + Replicas *int32 `help:"Amount of replicas of the running application."` + Env *map[string]string `help:"Environment variables which are passed to the application at runtime."` + BasicAuth *bool `help:"Enable/Disable basic authentication for applications."` + DeployJob deployJob `embed:"" prefix:"deploy-job-"` } func (cmd *configCmd) newCreator( diff --git a/delete/delete.go b/delete/delete.go index 02e6eab2..4efc5bfb 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -34,12 +34,11 @@ type Cmd struct { } type resourceCmd struct { - format.Writer - - Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to delete."` - Force bool `default:"false" help:"Do not ask for confirmation of deletion."` - Wait bool `default:"true" help:"Wait until resource is fully deleted."` - WaitTimeout time.Duration `default:"5m" help:"Duration to wait for the deletion. Only relevant if wait is set."` + format.Writer `kong:"-"` + Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to delete."` + Force bool `default:"false" help:"Do not ask for confirmation of deletion."` + Wait bool `default:"true" help:"Wait until resource is fully deleted."` + WaitTimeout time.Duration `default:"5m" help:"Duration to wait for the deletion. Only relevant if wait is set."` } // cleanupFunc is called after the resource has been deleted in order to do @@ -50,12 +49,11 @@ type cleanupFunc func(client *api.Client) error type promptFunc func(kind, name string) string type deleter struct { - format.Writer - - kind string - mg resource.Managed - cleanup cleanupFunc - prompt promptFunc + format.Writer `kong:"-"` + kind string + mg resource.Managed + cleanup cleanupFunc + prompt promptFunc } // deleterOption allows to set options for the deletion diff --git a/delete/project_config.go b/delete/project_config.go index eaded910..65436b06 100644 --- a/delete/project_config.go +++ b/delete/project_config.go @@ -14,11 +14,10 @@ import ( ) type configCmd struct { - format.Writer - - Force bool `default:"false" help:"Do not ask for confirmation of deletion."` - Wait bool `default:"true" help:"Wait until Project Configuration is fully deleted."` - WaitTimeout time.Duration `default:"10s" help:"Duration to wait for the deletion. Only relevant if wait is set."` + format.Writer `kong:"-"` + Force bool `default:"false" help:"Do not ask for confirmation of deletion."` + Wait bool `default:"true" help:"Wait until Project Configuration is fully deleted."` + WaitTimeout time.Duration `default:"10s" help:"Duration to wait for the deletion. Only relevant if wait is set."` } func (cmd *configCmd) newDeleter(mg resource.Managed, kind string, opts ...deleterOption) *deleter { diff --git a/edit/edit.go b/edit/edit.go index 9fe36116..85cb2ed4 100644 --- a/edit/edit.go +++ b/edit/edit.go @@ -40,9 +40,8 @@ type Cmd struct { } type resourceCmd struct { - format.Writer - - Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to edit." required:""` + format.Writer `kong:"-"` + Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to edit." required:""` } const header = `# Please edit the %s below. diff --git a/get/get.go b/get/get.go index 178821e5..d4c70908 100644 --- a/get/get.go +++ b/get/get.go @@ -46,7 +46,7 @@ type Cmd struct { } type output struct { - format.Writer + format.Writer `kong:"-"` Format outputFormat `help:"Configures list output. ${enum}" name:"output" short:"o" enum:"full,no-header,contexts,yaml,stats,json" default:"full"` AllProjects bool `help:"apply the get over all projects." short:"A" xor:"watch"` AllNamespaces bool `help:"apply the get over all namespaces." hidden:"" xor:"watch"` @@ -55,8 +55,8 @@ type output struct { } type resourceCmd struct { - format.Writer - Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to get. If omitted all in the project will be listed." default:""` + format.Writer `kong:"-"` + Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to get. If omitted all in the project will be listed." default:""` } type outputFormat string diff --git a/logs/logs.go b/logs/logs.go index 2655823c..5b2dcc9e 100644 --- a/logs/logs.go +++ b/logs/logs.go @@ -24,15 +24,15 @@ type resourceCmd struct { } type logsCmd struct { - format.Writer - Follow bool `help:"Follow the logs by live tailing." short:"f"` - Lines int `help:"Amount of lines to output." default:"50" short:"l"` - Since time.Duration `help:"Duration how long to look back for logs." short:"s" default:"${log_retention}"` - From time.Time `help:"Ignore since flag and start looking for logs at this absolute time (RFC3339)." placeholder:"2025-01-01T14:00:00+01:00"` - To time.Time `help:"Ignore since flag and stop looking for logs at this absolute time (RFC3339)." placeholder:"2025-01-01T15:00:00+01:00"` - Output string `help:"Configures the log output format. ${enum}" short:"o" enum:"default,json" default:"default"` - NoLabels bool `help:"Disable labels in log output."` - out log.Output + format.Writer `kong:"-"` + Follow bool `help:"Follow the logs by live tailing." short:"f"` + Lines int `help:"Amount of lines to output." default:"50" short:"l"` + Since time.Duration `help:"Duration how long to look back for logs." short:"s" default:"${log_retention}"` + From time.Time `help:"Ignore since flag and start looking for logs at this absolute time (RFC3339)." placeholder:"2025-01-01T14:00:00+01:00"` + To time.Time `help:"Ignore since flag and stop looking for logs at this absolute time (RFC3339)." placeholder:"2025-01-01T15:00:00+01:00"` + Output string `help:"Configures the log output format. ${enum}" short:"o" enum:"default,json" default:"default"` + NoLabels bool `help:"Disable labels in log output."` + out log.Output } // 30 days, we hardcode this for now as it's not possible to customize this on diff --git a/update/application.go b/update/application.go index cf497c1e..2fdf0c78 100644 --- a/update/application.go +++ b/update/application.go @@ -114,8 +114,8 @@ type workerJob struct { Size *string `help:"Size of the worker (defaults to \"${app_default_size}\")." placeholder:"${app_default_size}"` } -func (wj workerJob) changesGiven() bool { - return wj.Command != nil || wj.Size != nil +func (job workerJob) changesGiven() bool { + return job.Command != nil || job.Size != nil } type scheduledJob struct { @@ -127,8 +127,8 @@ type scheduledJob struct { Timeout *time.Duration `help:"Timeout of the job." placeholder:"${app_default_scheduled_job_timeout}"` } -func (sj scheduledJob) changesGiven() bool { - return sj.Command != nil || sj.Size != nil || sj.Schedule != nil +func (job scheduledJob) changesGiven() bool { + return job.Command != nil || job.Size != nil || job.Schedule != nil } type dockerfileBuild struct { diff --git a/update/project_config.go b/update/project_config.go index afc6b71e..1faa4112 100644 --- a/update/project_config.go +++ b/update/project_config.go @@ -16,14 +16,13 @@ import ( // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { - format.Writer - - Size *string `help:"Size of the app."` - Port *int32 `help:"Port the app is listening on."` - Replicas *int32 `help:"Amount of replicas of the running app."` - Env map[string]string `help:"Environment variables which are passed to the app at runtime."` - BasicAuth *bool `help:"Enable/Disable basic authentication for applications."` - DeployJob *deployJob `embed:"" prefix:"deploy-job-"` + format.Writer `kong:"-"` + Size *string `help:"Size of the app."` + Port *int32 `help:"Port the app is listening on."` + Replicas *int32 `help:"Amount of replicas of the running app."` + Env map[string]string `help:"Environment variables which are passed to the app at runtime."` + BasicAuth *bool `help:"Enable/Disable basic authentication for applications."` + DeployJob *deployJob `embed:"" prefix:"deploy-job-"` } func (cmd *configCmd) newUpdater( diff --git a/update/update.go b/update/update.go index 058c05b6..149cf183 100644 --- a/update/update.go +++ b/update/update.go @@ -27,8 +27,8 @@ type Cmd struct { } type resourceCmd struct { - format.Writer - Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to update."` + format.Writer `kong:"-"` + Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to update."` } type updater struct { From 283eccd673fbcaf7ad4ac32ea276029d68e94a42 Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 16:53:48 +0100 Subject: [PATCH 07/19] refactor: use consistent naming --- auth/login.go | 58 +++++++++++++++++++++++++------------------------- auth/whoami.go | 22 +++++++++---------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/auth/login.go b/auth/login.go index dba7cd35..039c4df1 100644 --- a/auth/login.go +++ b/auth/login.go @@ -37,13 +37,13 @@ type LoginCmd struct { const ErrNonInteractiveEnvironmentEmptyToken = "a static API token is required in non-interactive environments" -func (l *LoginCmd) Run(ctx context.Context) error { - apiURL, err := url.Parse(l.API.URL) +func (cmd *LoginCmd) Run(ctx context.Context) error { + apiURL, err := url.Parse(cmd.API.URL) if err != nil { return err } - issuerURL, err := url.Parse(l.IssuerURL) + issuerURL, err := url.Parse(cmd.IssuerURL) if err != nil { return err } @@ -58,34 +58,34 @@ func (l *LoginCmd) Run(ctx context.Context) error { return fmt.Errorf("can not identify executable path of %s: %v", util.NctlName, err) } - if l.API.Token != "" { - if l.Organization == "" { + if cmd.API.Token != "" { + if cmd.Organization == "" { return fmt.Errorf("you need to set the --organization parameter explicitly if you use --api-token") } - userInfo, err := api.GetUserInfoFromToken(l.API.Token) + userInfo, err := api.GetUserInfoFromToken(cmd.API.Token) if err != nil { return err } - cfg, err := newAPIConfig(apiURL, issuerURL, command, l.ClientID, useStaticToken(l.API.Token), withOrganization(l.Organization)) + cfg, err := newAPIConfig(apiURL, issuerURL, command, cmd.ClientID, useStaticToken(cmd.API.Token), withOrganization(cmd.Organization)) if err != nil { return err } - return login(l.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(l.Organization)) + return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(cmd.Organization)) } - if l.API.ClientID != "" { - userInfo, err := l.API.UserInfo(ctx) + if cmd.API.ClientID != "" { + userInfo, err := cmd.API.UserInfo(ctx) if err != nil { return err } - if l.Organization == "" && len(userInfo.Orgs) == 0 { + if cmd.Organization == "" && len(userInfo.Orgs) == 0 { return fmt.Errorf("unable to find organization, you need to set the --organization parameter explicitly") } - org := l.Organization + org := cmd.Organization if org == "" { org = userInfo.Orgs[0] } - cfg, err := newAPIConfig(apiURL, issuerURL, command, l.API.ClientID, useClientCredentials(l.API), withOrganization(org)) + cfg, err := newAPIConfig(apiURL, issuerURL, command, cmd.API.ClientID, useClientCredentials(cmd.API), withOrganization(org)) if err != nil { return err } @@ -93,16 +93,16 @@ func (l *LoginCmd) Run(ctx context.Context) error { if userInfo.Project != "" { proj = userInfo.Project } - return login(l.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(proj)) + return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(proj)) } - if !l.ForceInteractiveEnvOverride && !format.IsInteractiveEnvironment(os.Stdout) { + if !cmd.ForceInteractiveEnvOverride && !format.IsInteractiveEnvironment(os.Stdout) { return errors.New(ErrNonInteractiveEnvironmentEmptyToken) } usePKCE := true - token, err := l.tokenGetter().GetTokenString(ctx, l.IssuerURL, l.ClientID, usePKCE) + token, err := cmd.tokenGetter().GetTokenString(ctx, cmd.IssuerURL, cmd.ClientID, usePKCE) if err != nil { return err } @@ -118,21 +118,21 @@ func (l *LoginCmd) Run(ctx context.Context) error { org := userInfo.Orgs[0] if len(userInfo.Orgs) > 1 { - l.Printf("Multiple organizations found for the account %q.\n", userInfo.User) - l.Printf("Defaulting to %q\n", org) - l.printAvailableOrgsString(org, userInfo.Orgs) + cmd.Printf("Multiple organizations found for the account %q.\n", userInfo.User) + cmd.Printf("Defaulting to %q\n", org) + cmd.printAvailableOrgsString(org, userInfo.Orgs) } - cfg, err := newAPIConfig(apiURL, issuerURL, command, l.ClientID, withOrganization(org)) + cfg, err := newAPIConfig(apiURL, issuerURL, command, cmd.ClientID, withOrganization(org)) if err != nil { return err } - return login(l.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) + return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) } -func (l *LoginCmd) printAvailableOrgsString(currentorg string, orgs []string) { - l.Println("\nAvailable Organizations:") +func (cmd *LoginCmd) printAvailableOrgsString(currentorg string, orgs []string) { + cmd.Println("\nAvailable Organizations:") for _, org := range orgs { activeMarker := "" @@ -140,16 +140,16 @@ func (l *LoginCmd) printAvailableOrgsString(currentorg string, orgs []string) { activeMarker = "*" } - l.Printf("%s\t%s\n", activeMarker, org) + cmd.Printf("%s\t%s\n", activeMarker, org) } - l.Printf("\nTo switch the organization use the following command:\n") - l.Printf("$ nctl auth set-org \n") + cmd.Printf("\nTo switch the organization use the following command:\n") + cmd.Printf("$ nctl auth set-org \n") } -func (l *LoginCmd) tokenGetter() api.TokenGetter { - if l.tk != nil { - return l.tk +func (cmd *LoginCmd) tokenGetter() api.TokenGetter { + if cmd.tk != nil { + return cmd.tk } return &api.DefaultTokenGetter{} } diff --git a/auth/whoami.go b/auth/whoami.go index 001f0917..83c90208 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -14,7 +14,7 @@ type WhoAmICmd struct { ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` } -func (s *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { +func (cmd *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { org, err := client.Organization() if err != nil { return err @@ -25,22 +25,22 @@ func (s *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { return err } - s.printUserInfo(userInfo, org) + cmd.printUserInfo(userInfo, org) return nil } -func (s *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org string) { - s.Printf("You are currently logged in with the following account: %q\n", userInfo.User) - s.Printf("Your current organization: %q\n", org) +func (cmd *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org string) { + cmd.Printf("You are currently logged in with the following account: %q\n", userInfo.User) + cmd.Printf("Your current organization: %q\n", org) if len(userInfo.Orgs) > 0 { - s.printAvailableOrgsString(org, userInfo.Orgs) + cmd.printAvailableOrgsString(org, userInfo.Orgs) } } -func (s *WhoAmICmd) printAvailableOrgsString(currentorg string, orgs []string) { - s.Println("\nAvailable Organizations:") +func (cmd *WhoAmICmd) printAvailableOrgsString(currentorg string, orgs []string) { + cmd.Println("\nAvailable Organizations:") for _, org := range orgs { activeMarker := "" @@ -48,9 +48,9 @@ func (s *WhoAmICmd) printAvailableOrgsString(currentorg string, orgs []string) { activeMarker = "*" } - s.Printf("%s\t%s\n", activeMarker, org) + cmd.Printf("%s\t%s\n", activeMarker, org) } - s.Printf("\nTo switch the organization use the following command:\n") - s.Printf("$ nctl auth set-org \n") + cmd.Printf("\nTo switch the organization use the following command:\n") + cmd.Printf("$ nctl auth set-org \n") } From 57cfd9ed34502c7604a09d9226ac90190fde7372 Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 17:11:34 +0100 Subject: [PATCH 08/19] refactor: reuse printAvailableOrgsString --- auth/login.go | 12 ++++++------ auth/set_org.go | 5 ++--- auth/whoami.go | 18 +----------------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/auth/login.go b/auth/login.go index 039c4df1..7da95aa8 100644 --- a/auth/login.go +++ b/auth/login.go @@ -120,7 +120,7 @@ func (cmd *LoginCmd) Run(ctx context.Context) error { if len(userInfo.Orgs) > 1 { cmd.Printf("Multiple organizations found for the account %q.\n", userInfo.User) cmd.Printf("Defaulting to %q\n", org) - cmd.printAvailableOrgsString(org, userInfo.Orgs) + printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs) } cfg, err := newAPIConfig(apiURL, issuerURL, command, cmd.ClientID, withOrganization(org)) @@ -131,8 +131,8 @@ func (cmd *LoginCmd) Run(ctx context.Context) error { return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) } -func (cmd *LoginCmd) printAvailableOrgsString(currentorg string, orgs []string) { - cmd.Println("\nAvailable Organizations:") +func printAvailableOrgsString(w format.Writer, currentorg string, orgs []string) { + w.Println("\nAvailable Organizations:") for _, org := range orgs { activeMarker := "" @@ -140,11 +140,11 @@ func (cmd *LoginCmd) printAvailableOrgsString(currentorg string, orgs []string) activeMarker = "*" } - cmd.Printf("%s\t%s\n", activeMarker, org) + w.Printf("%s\t%s\n", activeMarker, org) } - cmd.Printf("\nTo switch the organization use the following command:\n") - cmd.Printf("$ nctl auth set-org \n") + w.Printf("\nTo switch the organization use the following command:\n") + w.Printf("$ nctl auth set-org \n") } func (cmd *LoginCmd) tokenGetter() api.TokenGetter { diff --git a/auth/set_org.go b/auth/set_org.go index e4b626ee..e1f51f72 100644 --- a/auth/set_org.go +++ b/auth/set_org.go @@ -38,18 +38,17 @@ func (cmd *SetOrgCmd) Run(ctx context.Context, client *api.Client) error { } cmd.Successf("📝", "set active Organization to %s", cmd.Organization) - cmd.Println() // We only warn if the organization is not in the user's token, as RBAC // 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.\n", + "%s is not in list of available Organizations, you might not have access to all resources.", cmd.Organization, ) + printAvailableOrgsString(cmd.Writer, cmd.Organization, userInfo.Orgs) } - cmd.Successf("📝", "set active Organization to %s\n", cmd.Organization) return nil } diff --git a/auth/whoami.go b/auth/whoami.go index 83c90208..42fbdbb2 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -35,22 +35,6 @@ func (cmd *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org string) { cmd.Printf("Your current organization: %q\n", org) if len(userInfo.Orgs) > 0 { - cmd.printAvailableOrgsString(org, userInfo.Orgs) + printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs) } } - -func (cmd *WhoAmICmd) printAvailableOrgsString(currentorg string, orgs []string) { - cmd.Println("\nAvailable Organizations:") - - for _, org := range orgs { - activeMarker := "" - if currentorg == org { - activeMarker = "*" - } - - cmd.Printf("%s\t%s\n", activeMarker, org) - } - - cmd.Printf("\nTo switch the organization use the following command:\n") - cmd.Printf("$ nctl auth set-org \n") -} From f347c485bdc8920ff4b6f7d604fc4c5740147215 Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 5 Feb 2026 17:26:51 +0100 Subject: [PATCH 09/19] refactor: re-add message --- apply/file.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apply/file.go b/apply/file.go index 72055a0d..596f8f2a 100644 --- a/apply/file.go +++ b/apply/file.go @@ -66,7 +66,11 @@ func File(ctx context.Context, w format.Writer, client *api.Client, file *os.Fil if err := client.Create(ctx, obj); err != nil { if errors.IsAlreadyExists(err) && cfg.updateOnExists { - return update(ctx, client, obj) + if err := update(ctx, client, obj); err != nil { + return err + } + w.Successf("🏗", "applied %s", formatObj(obj)) + return nil } return err } From f916dc77d12c3fc8d047c15bd3ee3e4d44b71d62 Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 09:03:14 +0100 Subject: [PATCH 10/19] refactor(api): remove format dependency --- api/client.go | 12 ------------ api/list.go | 37 ++++++++++++++++++------------------- go.mod | 2 +- main.go | 1 - 4 files changed, 19 insertions(+), 33 deletions(-) diff --git a/api/client.go b/api/client.go index 7fe2c33a..14e1e4d3 100644 --- a/api/client.go +++ b/api/client.go @@ -5,7 +5,6 @@ import ( "context" "encoding/base64" "fmt" - "io" "os" "os/user" "path/filepath" @@ -33,8 +32,6 @@ type Client struct { Project string Log *log.Client KubeconfigContext string - - writer format.Writer } type ClientOpt func(c *Client) error @@ -47,7 +44,6 @@ func New(ctx context.Context, apiClusterContext, project string, opts ...ClientO client := &Client{ Project: project, KubeconfigContext: apiClusterContext, - writer: format.NewWriter(os.Stdout), } if err := client.loadConfig(apiClusterContext); err != nil { return nil, err @@ -75,14 +71,6 @@ func New(ctx context.Context, apiClusterContext, project string, opts ...ClientO return client, nil } -// OutputWriter sets the writer for the client. -func OutputWriter(w io.Writer) ClientOpt { - return func(c *Client) error { - c.writer = format.NewWriter(w) - return nil - } -} - // LogClient sets up a log client connected to the provided address. func LogClient(ctx context.Context, address string, insecure bool) ClientOpt { return func(c *Client) error { diff --git a/api/list.go b/api/list.go index 8a3151cb..8f0272cf 100644 --- a/api/list.go +++ b/api/list.go @@ -1,16 +1,16 @@ package api import ( + "cmp" "context" "errors" "fmt" "reflect" "slices" - "sort" "strings" - "sync" management "github.com/ninech/apis/management/v1alpha1" + "golang.org/x/sync/errgroup" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/conversion" runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -135,29 +135,29 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList, } projectsSize := len(projects) - var wg sync.WaitGroup - wg.Add(projectsSize) + + wg := errgroup.Group{} ch := make(chan ProjectItems, projectsSize) for _, proj := range projects { - tempOpts := slices.Clone(opts.clientListOptions) - go func() { - defer wg.Done() - // we ensured the list is a pointer type and that is has an - // 'Items' field which is a slice above, so we don't need to do - // this again here and instead use the reflect functions directly. + wg.Go(func() error { + tempOpts := slices.Clone(opts.clientListOptions) tempList := reflect.New(reflect.TypeOf(list).Elem()).Interface().(runtimeclient.ObjectList) tempList.GetObjectKind().SetGroupVersionKind(list.GetObjectKind().GroupVersionKind()) if err := c.List(ctx, tempList, append(tempOpts, runtimeclient.InNamespace(proj.Name))...); err != nil { - c.writer.Warningf("error when searching in project %s: %s", proj.Name, err) - return + return fmt.Errorf("error when searching in project %s: %w", proj.Name, err) } + tempListItems := reflect.ValueOf(tempList).Elem().FieldByName("Items") ch <- ProjectItems{projectName: proj.Name, items: tempListItems} - }() + + return nil + }) } - wg.Wait() + if err := wg.Wait(); err != nil { + return err + } close(ch) // Collect and sort by project name @@ -166,8 +166,8 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList, collected = append(collected, pi) } - sort.Slice(collected, func(i, j int) bool { - return collected[i].projectName < collected[j].projectName + slices.SortFunc(collected, func(i, j ProjectItems) int { + return cmp.Compare(i.projectName, j.projectName) }) for _, pi := range collected { @@ -176,9 +176,8 @@ func (c *Client) ListObjects(ctx context.Context, list runtimeclient.ObjectList, } } - // if the user did not search for a specific named resource we can already - // quit as this case should not throw an error if no item could be - // found + // If the user did not search for a specific named resource we can already + // quit as this case should not throw an error if no item could be found. if opts.searchForName == "" { return nil } diff --git a/go.mod b/go.mod index b7536b5c..47c8a3da 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( golang.org/x/crypto v0.47.0 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 golang.org/x/oauth2 v0.34.0 + golang.org/x/sync v0.19.0 gotest.tools v2.2.0+incompatible k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 @@ -329,7 +330,6 @@ require ( go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect diff --git a/main.go b/main.go index 65fae474..f3ad74b6 100644 --- a/main.go +++ b/main.go @@ -127,7 +127,6 @@ func main() { nctl.APICluster, nctl.Project, api.LogClient(ctx, nctl.LogAPIAddress, nctl.LogAPIInsecure), - api.OutputWriter(writer), ) if err != nil { fmt.Fprintln(writer, err) From 0891083c839ed11e26acc483558c7733fb1756e9 Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 11:00:50 +0100 Subject: [PATCH 11/19] refactor: make reader configurable --- apply/apply.go | 2 +- auth/cluster.go | 2 +- auth/login.go | 2 +- auth/logout.go | 2 +- auth/set_org.go | 2 +- auth/set_project.go | 2 +- auth/whoami.go | 2 +- copy/copy.go | 6 ++++++ create/create.go | 6 ++++++ create/project_config.go | 2 +- delete/delete.go | 30 +++++++++++++++++++------- delete/project_config.go | 4 +++- edit/edit.go | 5 +++++ get/get.go | 43 ++++++++------------------------------ get/get_test.go | 18 ++++++++++++++++ get/project_config_test.go | 8 ++----- internal/format/reader.go | 22 +++++++++++++++++++ internal/format/writer.go | 6 +++--- logs/logs.go | 6 ++++++ main.go | 8 ++++++- update/project_config.go | 2 +- update/update.go | 6 ++++++ 22 files changed, 124 insertions(+), 62 deletions(-) create mode 100644 internal/format/reader.go diff --git a/apply/apply.go b/apply/apply.go index eb16be48..98d4ae90 100644 --- a/apply/apply.go +++ b/apply/apply.go @@ -9,7 +9,7 @@ import ( ) type Cmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` Filename *os.File `short:"f" completion-predictor:"file"` FromFile fromFile `cmd:"" default:"1" name:"-f " help:"Apply any resource from a yaml or json file."` } diff --git a/auth/cluster.go b/auth/cluster.go index 28d6158d..0be62ab2 100644 --- a/auth/cluster.go +++ b/auth/cluster.go @@ -18,7 +18,7 @@ import ( ) type ClusterCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` Name string `arg:"" help:"Name of the cluster to authenticate with. Also accepts 'name/project' format."` ExecPlugin bool `help:"Automatically run exec plugin after writing the kubeconfig."` } diff --git a/auth/login.go b/auth/login.go index 7da95aa8..c009b9a7 100644 --- a/auth/login.go +++ b/auth/login.go @@ -26,7 +26,7 @@ const ( ) type LoginCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` API API `embed:"" prefix:"api-"` Organization string `help:"Name of your organization to use when providing an API client ID/secret." env:"NCTL_ORGANIZATION"` IssuerURL string `help:"OIDC issuer URL of the API." default:"${issuer_url}" hidden:""` diff --git a/auth/logout.go b/auth/logout.go index 3eb0b3d7..0431faca 100644 --- a/auth/logout.go +++ b/auth/logout.go @@ -22,7 +22,7 @@ import ( ) type LogoutCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` diff --git a/auth/set_org.go b/auth/set_org.go index e1f51f72..d34a132b 100644 --- a/auth/set_org.go +++ b/auth/set_org.go @@ -10,7 +10,7 @@ import ( ) type SetOrgCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` Organization string `arg:"" help:"Name of the organization to login to." default:""` APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` diff --git a/auth/set_project.go b/auth/set_project.go index c786e371..030752b1 100644 --- a/auth/set_project.go +++ b/auth/set_project.go @@ -16,7 +16,7 @@ import ( ) type SetProjectCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` Name string `arg:"" help:"Name of the default project to be used." completion-predictor:"project_name"` } diff --git a/auth/whoami.go b/auth/whoami.go index 42fbdbb2..7ea9a467 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -8,7 +8,7 @@ import ( ) type WhoAmICmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` diff --git a/copy/copy.go b/copy/copy.go index 59067637..c5e22000 100644 --- a/copy/copy.go +++ b/copy/copy.go @@ -2,6 +2,7 @@ package copy import ( + "io" "math/rand" "time" @@ -20,6 +21,11 @@ type resourceCmd struct { TargetProject string `help:"Target project of the new resource. The current project is used if omitted." default:"" completion-predictor:"project_name"` } +// BeforeApply initializes Writer from Kong's bound [io.Writer]. +func (cmd *resourceCmd) BeforeApply(writer io.Writer) error { + return cmd.Writer.BeforeApply(writer) +} + func getName(name string) string { if len(name) != 0 { return name diff --git a/create/create.go b/create/create.go index 5d02a532..136554c7 100644 --- a/create/create.go +++ b/create/create.go @@ -4,6 +4,7 @@ package create import ( "context" "fmt" + "io" "math/rand" "os" "time" @@ -49,6 +50,11 @@ type resourceCmd struct { WaitTimeout time.Duration `default:"30m" help:"Duration to wait for resource getting ready. Only relevant if wait is set."` } +// BeforeApply initializes Writer from Kong's bound [io.Writer]. +func (cmd *resourceCmd) BeforeApply(writer io.Writer) error { + return cmd.Writer.BeforeApply(writer) +} + // resultFunc is the function called on a watch event during creation. It // should return true whenever the wait can be considered done. type resultFunc func(watch.Event) (bool, error) diff --git a/create/project_config.go b/create/project_config.go index 9d10ad38..a290c1de 100644 --- a/create/project_config.go +++ b/create/project_config.go @@ -16,7 +16,7 @@ import ( // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` Size string `help:"Size of the application."` Port *int32 `help:"Port the application is listening on."` Replicas *int32 `help:"Amount of replicas of the running application."` diff --git a/delete/delete.go b/delete/delete.go index 4efc5bfb..d478aea5 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -3,14 +3,16 @@ package delete import ( "context" + "errors" "fmt" + "io" "os" "time" "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/ninech/nctl/api" "github.com/ninech/nctl/internal/format" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) type Cmd struct { @@ -35,12 +37,22 @@ type Cmd struct { type resourceCmd struct { format.Writer `kong:"-"` + format.Reader `kong:"-"` Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to delete."` Force bool `default:"false" help:"Do not ask for confirmation of deletion."` Wait bool `default:"true" help:"Wait until resource is fully deleted."` WaitTimeout time.Duration `default:"5m" help:"Duration to wait for the deletion. Only relevant if wait is set."` } +// BeforeApply initializes Writer and Reader from Kong's bound io.Writer and io.Reader. +// Because Kong wont apply hooks on embedded structs. +func (cmd *resourceCmd) BeforeApply(writer io.Writer, reader io.Reader) error { + return errors.Join( + cmd.Writer.BeforeApply(writer), + cmd.Reader.BeforeApply(reader), + ) +} + // cleanupFunc is called after the resource has been deleted in order to do // any sort of cleanups. type cleanupFunc func(client *api.Client) error @@ -49,11 +61,12 @@ type cleanupFunc func(client *api.Client) error type promptFunc func(kind, name string) string type deleter struct { - format.Writer `kong:"-"` - kind string - mg resource.Managed - cleanup cleanupFunc - prompt promptFunc + format.Writer + format.Reader + kind string + mg resource.Managed + cleanup cleanupFunc + prompt promptFunc } // deleterOption allows to set options for the deletion @@ -66,6 +79,7 @@ func (cmd *resourceCmd) newDeleter( ) *deleter { d := &deleter{ Writer: cmd.Writer, + Reader: cmd.Reader, kind: kind, mg: mg, cleanup: noCleanup, @@ -115,7 +129,7 @@ func (d *deleter) deleteResource( } if !force { - ok, err := d.Confirm(d.prompt(d.kind, d.mg.GetName())) + ok, err := d.Confirm(d.Reader, d.prompt(d.kind, d.mg.GetName())) if err != nil { return err } @@ -158,7 +172,7 @@ func (d *deleter) waitForDeletion(ctx context.Context, client *api.Client) error select { case <-ticker.C: if err := client.Get(ctx, client.Name(d.mg.GetName()), d.mg); err != nil { - if errors.IsNotFound(err) { + if kerrors.IsNotFound(err) { _ = spinner.Stop() return nil } diff --git a/delete/project_config.go b/delete/project_config.go index 65436b06..7cad48ce 100644 --- a/delete/project_config.go +++ b/delete/project_config.go @@ -14,7 +14,8 @@ import ( ) type configCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` + format.Reader `hidden:""` Force bool `default:"false" help:"Do not ask for confirmation of deletion."` Wait bool `default:"true" help:"Wait until Project Configuration is fully deleted."` WaitTimeout time.Duration `default:"10s" help:"Duration to wait for the deletion. Only relevant if wait is set."` @@ -23,6 +24,7 @@ type configCmd struct { func (cmd *configCmd) newDeleter(mg resource.Managed, kind string, opts ...deleterOption) *deleter { d := &deleter{ Writer: cmd.Writer, + Reader: cmd.Reader, kind: kind, mg: mg, cleanup: noCleanup, diff --git a/edit/edit.go b/edit/edit.go index 85cb2ed4..ea33fb65 100644 --- a/edit/edit.go +++ b/edit/edit.go @@ -44,6 +44,11 @@ type resourceCmd struct { Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to edit." required:""` } +// BeforeApply initializes Writer from Kong's bound [io.Writer]. +func (cmd *resourceCmd) BeforeApply(writer io.Writer) error { + return cmd.Writer.BeforeApply(writer) +} + const header = `# Please edit the %s below. # Lines beginning with a '#' will be ignored. If an error occurs while # saving this file will be reopened with the relevant failures. Note diff --git a/get/get.go b/get/get.go index d4c70908..3ca5ef13 100644 --- a/get/get.go +++ b/get/get.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "maps" - "os" "slices" "strings" @@ -70,26 +69,17 @@ const ( jsonOut outputFormat = "json" ) -// AfterApply is called by Kong after parsing to initialize the output. -func (cmd *Cmd) AfterApply() error { - cmd.initOut() - return nil -} - -// NewTestCmd creates a Cmd for testing with the given writer and output format. -// This is a convenience helper that properly initializes the output writer. -func NewTestCmd(w io.Writer, outFormat outputFormat, opts ...func(*Cmd)) *Cmd { - cmd := &Cmd{ - output: output{ - Writer: format.NewWriter(w), - Format: outFormat, - }, +// BeforeApply is called by Kong before parsing to initialize the output. +func (cmd *Cmd) BeforeApply(writer io.Writer) error { + if err := cmd.output.BeforeApply(writer); err != nil { + return err } - cmd.initOut() - for _, opt := range opts { - opt(cmd) + + if cmd.tabWriter == nil { + cmd.tabWriter = tabwriter.NewWriter(writer, 0, 0, 2, ' ', tabwriter.RememberWidths) } - return cmd + + return nil } // WithAllProjects is a test option that sets AllProjects to true. @@ -132,7 +122,6 @@ func (cmd *Cmd) listPrint(ctx context.Context, client *api.Client, lp listPrinte // writeHeader writes the header row, prepending the always shown project func (out *output) writeHeader(headings ...string) { - out.initOut() // don't write header if watch is enabled and RememberedWidths is not empty, // as it means we have already printed the header. if out.Watch && len(out.tabWriter.RememberedWidths()) != 0 { @@ -143,8 +132,6 @@ func (out *output) writeHeader(headings ...string) { // writeTabRow writes a row to w, prepending the passed project func (out *output) writeTabRow(project string, row ...string) { - out.initOut() - if project == "" { // if the project is empty, the content should just be a "tab" // so that the structure will be contained @@ -167,8 +154,6 @@ func (out *output) writeTabRow(project string, row ...string) { } func (out *output) printEmptyMessage(kind, project string) error { - out.initOut() - if out.Format == jsonOut { out.Printf("[]") return nil @@ -186,16 +171,6 @@ func (out *output) printEmptyMessage(kind, project string) error { return nil } -func (out *output) initOut() { - if out.Writer.Writer == nil { - out.Writer = format.NewWriter(os.Stdout) - } - - if out.tabWriter == nil { - out.tabWriter = tabwriter.NewWriter(&out.Writer, 0, 0, 2, ' ', tabwriter.RememberWidths) - } -} - func getConnectionSecretMap(ctx context.Context, client *api.Client, mg resource.Managed) (map[string][]byte, error) { secret, err := client.GetConnectionSecret(ctx, mg) if err != nil { diff --git a/get/get_test.go b/get/get_test.go index c80ec45d..a8f2fd2a 100644 --- a/get/get_test.go +++ b/get/get_test.go @@ -3,17 +3,35 @@ package get import ( "bytes" "context" + "io" "strings" "sync" "testing" "time" infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/require" "sigs.k8s.io/controller-runtime/pkg/client" ) +// NewTestCmd creates a Cmd for testing with the given writer and output format. +// This is a convenience helper that properly initializes the output writer. +func NewTestCmd(w io.Writer, outFormat outputFormat, opts ...func(*Cmd)) *Cmd { + cmd := &Cmd{ + output: output{ + Writer: format.NewWriter(w), + Format: outFormat, + }, + } + _ = cmd.BeforeApply(w) + for _, opt := range opts { + opt(cmd) + } + return cmd +} + func TestListPrint(t *testing.T) { tests := map[string]struct { out outputFormat diff --git a/get/project_config_test.go b/get/project_config_test.go index e72efc97..41e1bb6c 100644 --- a/get/project_config_test.go +++ b/get/project_config_test.go @@ -2,13 +2,11 @@ package get import ( "bytes" - "context" "testing" "time" apps "github.com/ninech/apis/apps/v1alpha1" "github.com/ninech/nctl/api/util" - "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,8 +16,6 @@ import ( ) func TestProjectConfigs(t *testing.T) { - ctx := context.Background() - cases := map[string]struct { get *Cmd project string @@ -135,10 +131,10 @@ func TestProjectConfigs(t *testing.T) { require.NoError(t, err) buf := &bytes.Buffer{} - tc.get.Writer = format.NewWriter(buf) + tc.get.BeforeApply(buf) cmd := configsCmd{} - if err := cmd.Run(ctx, apiClient, tc.get); err != nil { + if err := cmd.Run(t.Context(), apiClient, tc.get); err != nil { t.Fatal(err) } if tc.expectedLineAmountInOutput != nil { diff --git a/internal/format/reader.go b/internal/format/reader.go new file mode 100644 index 00000000..a95c8bfe --- /dev/null +++ b/internal/format/reader.go @@ -0,0 +1,22 @@ +package format + +import "io" + +// Reader is a wrapper around an [io.Reader]. +type Reader struct { + io.Reader +} + +// NewReader returns a new [Reader]. +func NewReader(r io.Reader) Reader { + return Reader{Reader: r} +} + +// BeforeApply ensures that Kong initializes the [Reader]. +func (r *Reader) BeforeApply(reader io.Reader) error { + if r != nil && reader != nil { + r.Reader = reader + } + + return nil +} diff --git a/internal/format/writer.go b/internal/format/writer.go index ea07f727..d0dbe623 100644 --- a/internal/format/writer.go +++ b/internal/format/writer.go @@ -8,7 +8,7 @@ import ( "github.com/theckman/yacspin" ) -// Writer is a wrapper around an io.Writer that provides helper methods for +// Writer is a wrapper around an [io.Writer] that provides helper methods for // printing formatted messages. type Writer struct { io.Writer @@ -75,11 +75,11 @@ func (w *Writer) Println(a ...any) { // Confirm prints a confirm dialog using the supplied message and then waits // until prompt is confirmed or denied. Only y and yes are accepted for // confirmation. -func (w *Writer) Confirm(message string) (bool, error) { +func (w *Writer) Confirm(reader Reader, message string) (bool, error) { var input string w.Printf("%s [y|n]: ", message) - _, err := fmt.Scanln(&input) + _, err := fmt.Fscanln(reader, &input) if err != nil { return false, err } diff --git a/logs/logs.go b/logs/logs.go index 5b2dcc9e..10944dbe 100644 --- a/logs/logs.go +++ b/logs/logs.go @@ -4,6 +4,7 @@ package logs import ( "context" "fmt" + "io" "strings" "time" @@ -35,6 +36,11 @@ type logsCmd struct { out log.Output } +// BeforeApply initializes Writer from Kong's bound [io.Writer]. +func (cmd *logsCmd) BeforeApply(writer io.Writer) error { + return cmd.Writer.BeforeApply(writer) +} + // 30 days, we hardcode this for now as it's not possible to customize this on // deplo.io. We'll need to revisit this if we ever make this configurable. var logRetention = time.Duration(time.Hour * 24 * 30) diff --git a/main.go b/main.go index f3ad74b6..7184b0e7 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,7 @@ var ( date string writer = os.Stdout + reader = os.Stdin ) func main() { @@ -92,6 +93,7 @@ func main() { kongVars, kong.BindTo(ctx, (*context.Context)(nil)), kong.BindTo(writer, (*io.Writer)(nil)), + kong.BindTo(reader, (*io.Reader)(nil)), ) apiClientRequired := !noAPIClientRequired(strings.Join(os.Args[1:], " ")) @@ -120,7 +122,11 @@ func main() { parser.FatalIfErrorf(err) } - binds := []any{ctx, kong.BindTo(writer, (*io.Writer)(nil))} + binds := []any{ + ctx, + kong.BindTo(writer, (*io.Writer)(nil)), + kong.BindTo(reader, (*io.Reader)(nil)), + } if apiClientRequired { client, err := api.New( ctx, diff --git a/update/project_config.go b/update/project_config.go index 1faa4112..b0b5869c 100644 --- a/update/project_config.go +++ b/update/project_config.go @@ -16,7 +16,7 @@ import ( // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { - format.Writer `kong:"-"` + format.Writer `hidden:""` Size *string `help:"Size of the app."` Port *int32 `help:"Port the app is listening on."` Replicas *int32 `help:"Amount of replicas of the running app."` diff --git a/update/update.go b/update/update.go index 149cf183..94926f45 100644 --- a/update/update.go +++ b/update/update.go @@ -3,6 +3,7 @@ package update import ( "context" + "io" "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/ninech/nctl/api" @@ -31,6 +32,11 @@ type resourceCmd struct { Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource to update."` } +// BeforeApply initializes Writer from Kong's bound [io.Writer]. +func (cmd *resourceCmd) BeforeApply(writer io.Writer) error { + return cmd.Writer.BeforeApply(writer) +} + type updater struct { format.Writer mg resource.Managed From c5fd5e4a136aad45d5ef1b0e8b8de6d28ca72b02 Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 11:24:06 +0100 Subject: [PATCH 12/19] test(format): add unit tests --- internal/format/reader_test.go | 87 +++++++++++++ internal/format/writer_test.go | 230 +++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 internal/format/reader_test.go create mode 100644 internal/format/writer_test.go diff --git a/internal/format/reader_test.go b/internal/format/reader_test.go new file mode 100644 index 00000000..97ff1ee4 --- /dev/null +++ b/internal/format/reader_test.go @@ -0,0 +1,87 @@ +package format + +import ( + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewReader(t *testing.T) { + t.Parallel() + is := assert.New(t) + + input := strings.NewReader("test input") + reader := NewReader(input) + + is.Equal(input, reader.Reader) +} + +func TestReader_BeforeApply(t *testing.T) { + t.Parallel() + + input := strings.NewReader("test input") + + tests := []struct { + name string + reader *Reader + input io.Reader + expectReader io.Reader + }{ + { + name: "sets reader from input", + reader: &Reader{}, + input: input, + expectReader: input, + }, + { + name: "nil receiver does not panic", + reader: nil, + input: input, + }, + { + name: "nil input", + reader: &Reader{}, + input: nil, + expectReader: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + err := tt.reader.BeforeApply(tt.input) + require.NoError(t, err) + + if tt.reader != nil { + is.Equal(tt.expectReader, tt.reader.Reader) + } + }) + } +} + +func TestReader_Read(t *testing.T) { + t.Parallel() + is := assert.New(t) + + input := strings.NewReader("hello") + reader := NewReader(input) + + buf := make([]byte, 5) + n, err := reader.Read(buf) + + require.NoError(t, err) + is.Equal(5, n) + is.Equal("hello", string(buf)) +} + +func TestReader_ImplementsIOReader(t *testing.T) { + t.Parallel() + + var _ io.Reader = &Reader{} + var _ io.Reader = Reader{} +} diff --git a/internal/format/writer_test.go b/internal/format/writer_test.go new file mode 100644 index 00000000..9e186e59 --- /dev/null +++ b/internal/format/writer_test.go @@ -0,0 +1,230 @@ +package format + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewWriter(t *testing.T) { + t.Parallel() + is := assert.New(t) + + buf := &bytes.Buffer{} + writer := NewWriter(buf) + + is.Equal(buf, writer.Writer) +} + +func TestWriter_BeforeApply(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + + tests := []struct { + name string + writer *Writer + input io.Writer + expectWriter io.Writer + }{ + { + name: "sets writer from input", + writer: &Writer{}, + input: buf, + expectWriter: buf, + }, + { + name: "nil receiver does not panic", + writer: nil, + input: buf, + }, + { + name: "nil input", + writer: &Writer{}, + input: nil, + expectWriter: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + err := tt.writer.BeforeApply(tt.input) + require.NoError(t, err) + + if tt.writer != nil { + is.Equal(tt.expectWriter, tt.writer.Writer) + } + }) + } +} + +func TestWriter_writer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + writer *Writer + expect io.Writer + }{ + { + name: "nil Writer field returns io.Discard", + writer: &Writer{}, + expect: io.Discard, + }, + { + name: "nil receiver returns io.Discard", + writer: nil, + expect: io.Discard, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + w := tt.writer.writer() + is.Equal(tt.expect, w) + }) + } +} + +func TestWriter_Print(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action func(Writer) + expected string + }{ + { + name: "Printf", + action: func(w Writer) { w.Printf("hello %s", "world") }, + expected: "hello world", + }, + { + name: "Println", + action: func(w Writer) { w.Println("hello", "world") }, + expected: "hello world\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + buf := &bytes.Buffer{} + writer := NewWriter(buf) + tt.action(writer) + + is.Equal(tt.expected, buf.String()) + }) + } +} + +func TestWriter_FormattedOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action func(Writer) + contains []string + }{ + { + name: "Successf", + action: func(w Writer) { w.Successf("🎉", "created %s", "resource") }, + contains: []string{SuccessChar, "created resource", "🎉"}, + }, + { + name: "Success", + action: func(w Writer) { w.Success("🎉", "operation complete") }, + contains: []string{SuccessChar, "operation complete", "🎉"}, + }, + { + name: "Failuref", + action: func(w Writer) { w.Failuref("💥", "failed to %s", "connect") }, + contains: []string{FailureChar, "failed to connect", "💥"}, + }, + { + name: "Warningf", + action: func(w Writer) { w.Warningf("something %s", "happened") }, + contains: []string{"Warning:", "something happened"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + buf := &bytes.Buffer{} + writer := NewWriter(buf) + tt.action(writer) + + for _, s := range tt.contains { + is.Contains(buf.String(), s) + } + }) + } +} + +func TestWriter_ImplementsIOWriter(t *testing.T) { + t.Parallel() + + var _ io.Writer = &Writer{} + var _ io.Writer = Writer{} +} + +func TestConfirm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected bool + expectErr bool + }{ + {"lowercase y", "y\n", true, false}, + {"uppercase Y", "Y\n", true, false}, + {"lowercase yes", "yes\n", true, false}, + {"uppercase YES", "YES\n", true, false}, + {"mixed case Yes", "Yes\n", true, false}, + {"lowercase n", "n\n", false, false}, + {"uppercase N", "N\n", false, false}, + {"lowercase no", "no\n", false, false}, + {"uppercase NO", "NO\n", false, false}, + {"other input", "maybe\n", false, false}, + {"empty input", "", false, true}, + {"whitespace only", " \n", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + output := &bytes.Buffer{} + writer := NewWriter(output) + reader := NewReader(strings.NewReader(tt.input)) + + result, err := writer.Confirm(reader, "Continue?") + + if tt.expectErr { + is.Error(err) + return + } + + require.NoError(t, err) + is.Equal(tt.expected, result) + is.Contains(output.String(), "Continue? [y|n]:") + }) + } +} From fb3d4678b120e9155e7615037518ca45f7cbd7ab Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 11:51:44 +0100 Subject: [PATCH 13/19] test(update): add output tests --- update/apiserviceaccount_test.go | 17 ++++++++++++++++ update/bucketuser_test.go | 30 ++++++++++++++++++++--------- update/cloudvm_test.go | 31 ++++++++++++++++++++++-------- update/mysqldatabase_test.go | 33 ++++++++++++++++++++++++-------- update/postgresdatabase_test.go | 33 ++++++++++++++++++++++++-------- update/project_test.go | 23 ++++++++++++++++++---- update/serviceconnection_test.go | 33 ++++++++++++++++++++++++-------- 7 files changed, 155 insertions(+), 45 deletions(-) diff --git a/update/apiserviceaccount_test.go b/update/apiserviceaccount_test.go index 99aaa765..bf1a532c 100644 --- a/update/apiserviceaccount_test.go +++ b/update/apiserviceaccount_test.go @@ -1,10 +1,13 @@ package update import ( + "bytes" + "strings" "testing" iam "github.com/ninech/apis/iam/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -12,6 +15,8 @@ import ( ) func TestAPIServiceAccount(t *testing.T) { + t.Parallel() + const ( asaName = "some-asa" organization = "org" @@ -40,6 +45,11 @@ func TestAPIServiceAccount(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + tc.cmd.Writer = format.NewWriter(out) + apiClient, err := test.SetupClient( test.WithObjects(tc.orig), test.WithOrganization(organization), @@ -62,6 +72,13 @@ func TestAPIServiceAccount(t *testing.T) { if tc.checkAPIServiceAccount != nil { tc.checkAPIServiceAccount(t, tc.cmd, tc.orig, updated) } + + if !strings.Contains(out.String(), "updated") { + t.Errorf("expected output to contain 'updated', got %q", out.String()) + } + if !strings.Contains(out.String(), asaName) { + t.Errorf("expected output to contain %q, got %q", asaName, out.String()) + } }) } } diff --git a/update/bucketuser_test.go b/update/bucketuser_test.go index b935b998..bc08b685 100644 --- a/update/bucketuser_test.go +++ b/update/bucketuser_test.go @@ -1,44 +1,56 @@ package update import ( - "context" + "bytes" + "strings" "testing" "github.com/google/go-cmp/cmp" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) func TestBucketUser(t *testing.T) { - ctx := context.Background() + t.Parallel() + apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("setup client error, got: %s", err) + } created := bucketUser("user", apiClient.Project, "nine-es34") - if err := apiClient.Create(ctx, created); err != nil { + if err := apiClient.Create(t.Context(), created); err != nil { t.Fatalf("bucketuser create error, got: %s", err) } - if err := apiClient.Get(ctx, api.ObjectName(created), created); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), created); err != nil { t.Fatalf("expected bucketuser to exist, got: %s", err) } - cmd := bucketUserCmd{resourceCmd{Name: created.Name}, ptr.To(true)} + out := &bytes.Buffer{} + cmd := bucketUserCmd{resourceCmd{Writer: format.NewWriter(out), Name: created.Name}, ptr.To(true)} updated := &storage.BucketUser{} - if err := cmd.Run(ctx, apiClient); err != nil { + if err := cmd.Run(t.Context(), apiClient); err != nil { t.Errorf("did not expect err got : %v", err) } - if err := apiClient.Get(ctx, api.ObjectName(created), updated); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), updated); err != nil { t.Fatalf("expected bucketuser to exist, got: %s", err) } if cmp.Equal(updated.Spec.ForProvider.ResetCredentials, created.Spec.ForProvider.ResetCredentials) { t.Fatalf("expected ResetCredentials field to differ, expected= %v, got: %v", updated.Spec.ForProvider.ResetCredentials, created.Spec.ForProvider.ResetCredentials) } + + if !strings.Contains(out.String(), "updated") { + t.Errorf("expected output to contain 'updated', got %q", out.String()) + } + if !strings.Contains(out.String(), created.Name) { + t.Errorf("expected output to contain %q, got %q", created.Name, out.String()) + } } func bucketUser(name, project, location string) *storage.BucketUser { diff --git a/update/cloudvm_test.go b/update/cloudvm_test.go index 6f9a6fdd..a2e50b8f 100644 --- a/update/cloudvm_test.go +++ b/update/cloudvm_test.go @@ -1,20 +1,22 @@ package update import ( - "context" + "bytes" "reflect" + "strings" "testing" infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) func TestCloudVM(t *testing.T) { - ctx := context.Background() + t.Parallel() + tests := []struct { name string create infrastructure.CloudVirtualMachineParameters @@ -53,31 +55,44 @@ func TestCloudVM(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + out := &bytes.Buffer{} + tt.update.Writer = format.NewWriter(out) tt.update.Name = "test-" + t.Name() apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("setup client error, got: %s", err) + } created := test.CloudVirtualMachine(tt.update.Name, apiClient.Project, "nine-es34", tt.create.PowerState) created.Spec.ForProvider = tt.create - if err := apiClient.Create(ctx, created); err != nil { + if err := apiClient.Create(t.Context(), created); err != nil { t.Fatalf("cloudvm create error, got: %s", err) } - if err := apiClient.Get(ctx, api.ObjectName(created), created); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), created); err != nil { t.Fatalf("expected cloudvm to exist, got: %s", err) } updated := &infrastructure.CloudVirtualMachine{ObjectMeta: metav1.ObjectMeta{Name: created.Name, Namespace: created.Namespace}} - if err := tt.update.Run(ctx, apiClient); (err != nil) != tt.wantErr { + if err := tt.update.Run(t.Context(), apiClient); (err != nil) != tt.wantErr { t.Errorf("cloudVMCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } - if err := apiClient.Get(ctx, api.ObjectName(updated), updated); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(updated), updated); err != nil { t.Fatalf("expected cloudvm to exist, got: %s", err) } if !reflect.DeepEqual(updated.Spec.ForProvider, tt.want) { t.Fatalf("expected CloudVirtualMachine.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) } + + if !tt.wantErr { + if !strings.Contains(out.String(), "updated") { + t.Errorf("expected output to contain 'updated', got: %s", out.String()) + } + if !strings.Contains(out.String(), tt.update.Name) { + t.Errorf("expected output to contain %q, got: %s", tt.update.Name, out.String()) + } + } }) } } diff --git a/update/mysqldatabase_test.go b/update/mysqldatabase_test.go index f496efaf..0d9eb3f3 100644 --- a/update/mysqldatabase_test.go +++ b/update/mysqldatabase_test.go @@ -1,18 +1,20 @@ package update import ( - "context" + "bytes" + "strings" "testing" "github.com/google/go-cmp/cmp" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" ) func TestMySQLDatabase(t *testing.T) { - ctx := context.Background() + t.Parallel() + tests := []struct { name string create storage.MySQLDatabaseParameters @@ -31,31 +33,46 @@ func TestMySQLDatabase(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + tt.update.Writer = format.NewWriter(out) tt.update.Name = "test-" + t.Name() apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("setup client error, got: %s", err) + } created := test.MySQLDatabase(tt.update.Name, apiClient.Project, "nine-es34") created.Spec.ForProvider = tt.create - if err := apiClient.Create(ctx, created); err != nil { + if err := apiClient.Create(t.Context(), created); err != nil { t.Fatalf("mysqldatabase create error, got: %s", err) } - if err := apiClient.Get(ctx, api.ObjectName(created), created); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), created); err != nil { t.Fatalf("expected mysqldatabase to exist, got: %s", err) } updated := &storage.MySQLDatabase{} - if err := tt.update.Run(ctx, apiClient); (err != nil) != tt.wantErr { + if err := tt.update.Run(t.Context(), apiClient); (err != nil) != tt.wantErr { t.Errorf("mysqlDatabaseCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } - if err := apiClient.Get(ctx, api.ObjectName(created), updated); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), updated); err != nil { t.Fatalf("expected mysqldatabase to exist, got: %s", err) } if !cmp.Equal(updated.Spec.ForProvider, tt.want) { t.Fatalf("expected mysqlDatabase.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) } + + if !tt.wantErr { + if !strings.Contains(out.String(), "updated") { + t.Fatalf("expected output to contain 'updated', got: %s", out.String()) + } + if !strings.Contains(out.String(), tt.update.Name) { + t.Fatalf("expected output to contain %s, got: %s", tt.update.Name, out.String()) + } + } }) } } diff --git a/update/postgresdatabase_test.go b/update/postgresdatabase_test.go index 23db112e..c5af1732 100644 --- a/update/postgresdatabase_test.go +++ b/update/postgresdatabase_test.go @@ -1,18 +1,20 @@ package update import ( - "context" + "bytes" + "strings" "testing" "github.com/google/go-cmp/cmp" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" ) func TestPostgresDatabase(t *testing.T) { - ctx := context.Background() + t.Parallel() + tests := []struct { name string create storage.PostgresDatabaseParameters @@ -31,31 +33,46 @@ func TestPostgresDatabase(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + tt.update.Writer = format.NewWriter(out) tt.update.Name = "test-" + t.Name() apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("setup client error, got: %s", err) + } created := test.PostgresDatabase(tt.update.Name, apiClient.Project, "nine-es34") created.Spec.ForProvider = tt.create - if err := apiClient.Create(ctx, created); err != nil { + if err := apiClient.Create(t.Context(), created); err != nil { t.Fatalf("postgresdatabase create error, got: %s", err) } - if err := apiClient.Get(ctx, api.ObjectName(created), created); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), created); err != nil { t.Fatalf("expected postgresdatabase to exist, got: %s", err) } updated := &storage.PostgresDatabase{} - if err := tt.update.Run(ctx, apiClient); (err != nil) != tt.wantErr { + if err := tt.update.Run(t.Context(), apiClient); (err != nil) != tt.wantErr { t.Errorf("postgresDatabaseCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } - if err := apiClient.Get(ctx, api.ObjectName(created), updated); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), updated); err != nil { t.Fatalf("expected postgresdatabase to exist, got: %s", err) } if !cmp.Equal(updated.Spec.ForProvider, tt.want) { t.Fatalf("expected postgresDatabase.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) } + + if !tt.wantErr { + if !strings.Contains(out.String(), "updated") { + t.Fatalf("expected output to contain 'updated', got: %s", out.String()) + } + if !strings.Contains(out.String(), tt.update.Name) { + t.Fatalf("expected output to contain %s, got: %s", tt.update.Name, out.String()) + } + } }) } } diff --git a/update/project_test.go b/update/project_test.go index 917fdd17..7d2a5cb8 100644 --- a/update/project_test.go +++ b/update/project_test.go @@ -1,11 +1,13 @@ package update import ( - "context" + "bytes" + "strings" "testing" management "github.com/ninech/apis/management/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -13,6 +15,8 @@ import ( ) func TestProject(t *testing.T) { + t.Parallel() + const ( projectName = "some-project" organization = "org" @@ -44,6 +48,11 @@ func TestProject(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + tc.cmd.Writer = format.NewWriter(out) + apiClient, err := test.SetupClient( test.WithObjects(tc.orig), test.WithOrganization(organization), @@ -54,19 +63,25 @@ func TestProject(t *testing.T) { t.Fatal(err) } - ctx := context.Background() - if err := tc.cmd.Run(ctx, apiClient); err != nil { + if err := tc.cmd.Run(t.Context(), apiClient); err != nil { t.Fatal(err) } updated := &management.Project{} - if err := apiClient.Get(ctx, api.ObjectName(tc.orig), updated); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(tc.orig), updated); err != nil { t.Fatal(err) } if tc.checkProject != nil { tc.checkProject(t, tc.cmd, tc.orig, updated) } + + if !strings.Contains(out.String(), "updated") { + t.Errorf("expected output to contain 'updated', got %q", out.String()) + } + if !strings.Contains(out.String(), projectName) { + t.Errorf("expected output to contain project name '%s', got %q", projectName, out.String()) + } }) } } diff --git a/update/serviceconnection_test.go b/update/serviceconnection_test.go index 2d4b6c96..18d50650 100644 --- a/update/serviceconnection_test.go +++ b/update/serviceconnection_test.go @@ -1,7 +1,8 @@ package update import ( - "context" + "bytes" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -11,13 +12,14 @@ import ( storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/create" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestServiceConnection(t *testing.T) { - ctx := context.Background() + t.Parallel() + tests := []struct { name string update serviceConnectionCmd @@ -128,30 +130,45 @@ func TestServiceConnection(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + tt.update.Writer = format.NewWriter(out) tt.update.Name = "test-" + t.Name() apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("setup client error, got: %s", err) + } created := test.ServiceConnection(tt.update.Name, apiClient.Project) - if err := apiClient.Create(ctx, created); err != nil { + if err := apiClient.Create(t.Context(), created); err != nil { t.Fatalf("serviceconnection create error, got: %s", err) } - if err := apiClient.Get(ctx, api.ObjectName(created), created); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), created); err != nil { t.Fatalf("expected serviceconnection to exist, got: %s", err) } updated := &networking.ServiceConnection{} - if err := tt.update.Run(ctx, apiClient); (err != nil) != tt.wantErr { + if err := tt.update.Run(t.Context(), apiClient); (err != nil) != tt.wantErr { t.Errorf("serviceConnectionCmd.Run() error = %v, wantErr %v", err, tt.wantErr) } - if err := apiClient.Get(ctx, api.ObjectName(created), updated); err != nil { + if err := apiClient.Get(t.Context(), api.ObjectName(created), updated); err != nil { t.Fatalf("expected serviceconnection to exist, got: %s", err) } if !cmp.Equal(updated.Spec.ForProvider, tt.want) { t.Fatalf("expected serviceConnection.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) } + + if !tt.wantErr { + if !strings.Contains(out.String(), "updated") { + t.Fatalf("expected output to contain 'updated', got: %s", out.String()) + } + if !strings.Contains(out.String(), tt.update.Name) { + t.Fatalf("expected output to contain service connection name, got: %s", out.String()) + } + } }) } } From 75a25bf8295ed32b4f750b60e931565cb839d9eb Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 12:25:56 +0100 Subject: [PATCH 14/19] test(delete): add output tests --- delete/apiserviceaccount_test.go | 54 +++++++++++++++++++++++++ delete/application_test.go | 54 +++++++++++++++++-------- delete/bucket_test.go | 33 ++++++++++----- delete/bucketuser_test.go | 33 ++++++++++----- delete/cloudvm_test.go | 35 +++++++++++----- delete/delete.go | 6 +-- delete/delete_test.go | 27 ++++++++----- delete/keyvaluestore_test.go | 35 +++++++++++----- delete/mysql_test.go | 35 +++++++++++----- delete/mysqldatabase_test.go | 35 +++++++++++----- delete/opensearch_test.go | 35 +++++++++++----- delete/postgres_test.go | 35 +++++++++++----- delete/postgresdatabase_test.go | 35 +++++++++++----- delete/project_config_test.go | 33 ++++++++++----- delete/project_test.go | 69 +++++++++++++++++++++++--------- delete/serviceconnection_test.go | 35 +++++++++++----- delete/vcluster_test.go | 68 +++++++++++++++++++++++++++++++ update/project_test.go | 2 +- 18 files changed, 490 insertions(+), 169 deletions(-) create mode 100644 delete/apiserviceaccount_test.go create mode 100644 delete/vcluster_test.go diff --git a/delete/apiserviceaccount_test.go b/delete/apiserviceaccount_test.go new file mode 100644 index 00000000..504b6862 --- /dev/null +++ b/delete/apiserviceaccount_test.go @@ -0,0 +1,54 @@ +package delete + +import ( + "bytes" + "strings" + "testing" + + iam "github.com/ninech/apis/iam/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" + "github.com/ninech/nctl/internal/test" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAPIServiceAccount(t *testing.T) { + t.Parallel() + out := &bytes.Buffer{} + cmd := apiServiceAccountCmd{ + resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), + Name: "test", + Force: true, + Wait: false, + }, + } + + asa := &iam.APIServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: test.DefaultProject, + }, + } + apiClient, err := test.SetupClient(test.WithObjects(asa)) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + + ctx := t.Context() + if err := cmd.Run(ctx, apiClient); err != nil { + t.Fatalf("failed to run apiserviceaccount delete command: %v", err) + } + + if !kerrors.IsNotFound(apiClient.Get(ctx, api.ObjectName(asa), asa)) { + t.Fatal("expected apiserviceaccount to be deleted, but it still exists") + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) + } + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain apiserviceaccount name %q, got %q", cmd.Name, out.String()) + } +} diff --git a/delete/application_test.go b/delete/application_test.go index 969015fc..4afe2d9b 100644 --- a/delete/application_test.go +++ b/delete/application_test.go @@ -1,7 +1,8 @@ package delete import ( - "context" + "bytes" + "strings" "testing" apps "github.com/ninech/apis/apps/v1alpha1" @@ -9,16 +10,16 @@ import ( networking "github.com/ninech/apis/networking/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/util" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) func TestApplication(t *testing.T) { - ctx := context.Background() + t.Parallel() project := "evilcorp" for name, testCase := range map[string]struct { testObjects testObjectList @@ -36,7 +37,7 @@ func TestApplication(t *testing.T) { name: "dev", errorExpected: true, errorCheck: func(err error) bool { - return errors.IsNotFound(err) + return kerrors.IsNotFound(err) }, }, "application-with-git-auth-secret": { @@ -142,11 +143,14 @@ func TestApplication(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { + t.Parallel() + out := &bytes.Buffer{} cmd := applicationCmd{ resourceCmd: resourceCmd{ - Force: true, - Wait: false, - Name: testCase.name, + Writer: format.NewWriter(out), + Force: true, + Wait: false, + Name: testCase.name, }, } @@ -155,26 +159,44 @@ func TestApplication(t *testing.T) { test.WithProjectsFromResources(testCase.testObjects.clientObjects()...), test.WithObjects(testCase.testObjects.clientObjects()...), ) - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() err = cmd.Run(ctx, apiClient) if testCase.errorExpected { - require.Error(t, err) - if testCase.errorCheck != nil { - require.True(t, testCase.errorCheck(err)) + if err == nil { + t.Fatal("expected error but got none") + } + if testCase.errorCheck != nil && !testCase.errorCheck(err) { + t.Fatalf("error check failed for error: %v", err) } - } else { - require.NoError(t, err) + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) } for _, delObj := range testCase.testObjects { err := apiClient.Get(ctx, api.ObjectName(delObj), delObj.Object) if delObj.noDeletion { - require.NoError(t, err) + if err != nil { + t.Errorf("expected resource %s to not be deleted, but got error: %v", delObj.GetName(), err) + } } else { - require.True(t, errors.IsNotFound(err)) + if !kerrors.IsNotFound(err) { + t.Errorf("expected resource %s to be deleted, but it still exists (err: %v)", delObj.GetName(), err) + } } } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) + } + if !strings.Contains(out.String(), testCase.name) { + t.Errorf("expected output to contain application name %q, got %q", testCase.name, out.String()) + } }) } } diff --git a/delete/bucket_test.go b/delete/bucket_test.go index 60d2662d..b6c49556 100644 --- a/delete/bucket_test.go +++ b/delete/bucket_test.go @@ -1,23 +1,26 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestBucket(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := bucketCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -27,20 +30,30 @@ func TestBucket(t *testing.T) { bu := bucket("test", test.DefaultProject, string(meta.LocationNineES34)) apiClient, err := test.SetupClient(test.WithObjects(bu)) - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Get(ctx, api.ObjectName(bu), bu); err != nil { - t.Fatalf("expected bucket to exist, got: %s", err) + t.Fatalf("expected bucket to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run bucket delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(bu), bu) if err == nil { - t.Fatalf("expected bucket to be deleted, but exists") + t.Fatal("expected bucket to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected bucket to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected bucket to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain bucket name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/bucketuser_test.go b/delete/bucketuser_test.go index 7cb24ae5..0ee7c30d 100644 --- a/delete/bucketuser_test.go +++ b/delete/bucketuser_test.go @@ -1,23 +1,26 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestBucketUser(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := bucketUserCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -27,20 +30,30 @@ func TestBucketUser(t *testing.T) { bu := bucketUser("test", test.DefaultProject, "nine-es34") apiClient, err := test.SetupClient(test.WithObjects(bu)) - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Get(ctx, api.ObjectName(bu), bu); err != nil { - t.Fatalf("expected bucketuser to exist, got: %s", err) + t.Fatalf("expected bucketuser to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run bucketuser delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(bu), bu) if err == nil { - t.Fatalf("expected bucketuser to be deleted, but exists") + t.Fatal("expected bucketuser to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected bucketuser to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected bucketuser to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain bucketuser name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/cloudvm_test.go b/delete/cloudvm_test.go index 3c3eee1a..9b043ea9 100644 --- a/delete/cloudvm_test.go +++ b/delete/cloudvm_test.go @@ -1,21 +1,24 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/apis/infrastructure/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestCloudVM(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := cloudVMCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -26,22 +29,32 @@ func TestCloudVM(t *testing.T) { cloudvm := test.CloudVirtualMachine("test", test.DefaultProject, "nine-es34", v1alpha1.VirtualMachinePowerState("on")) apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, cloudvm); err != nil { - t.Fatalf("cloudvm create error, got: %s", err) + t.Fatalf("failed to create cloudvm: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(cloudvm), cloudvm); err != nil { - t.Fatalf("expected cloudvm to exist, got: %s", err) + t.Fatalf("expected cloudvm to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run cloudvm delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(cloudvm), cloudvm) if err == nil { - t.Fatalf("expected cloudvm to be deleted, but exists") + t.Fatal("expected cloudvm to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected cloudvm to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected cloudvm to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain cloudvm name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/delete.go b/delete/delete.go index d478aea5..197030ba 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -148,7 +148,7 @@ func (d *deleter) deleteResource( return err } } else { - d.Successf("🗑", "%s deletion started", d.kind) + d.Successf("🗑", "%s %q deletion started", d.kind, d.mg.GetName()) } return d.cleanup(client) @@ -156,8 +156,8 @@ func (d *deleter) deleteResource( func (d *deleter) waitForDeletion(ctx context.Context, client *api.Client) error { spinner, err := d.Spinner( - format.Progressf("⏳", "%s is being deleted", d.kind), - format.Progressf("🗑", "%s deleted", d.kind), + format.Progressf("⏳", "%s %q is being deleted", d.kind, d.mg.GetName()), + format.Progressf("🗑", "%s %q deleted", d.kind, d.mg.GetName()), ) if err != nil { return err diff --git a/delete/delete_test.go b/delete/delete_test.go index 4b51a604..0ff98c9a 100644 --- a/delete/delete_test.go +++ b/delete/delete_test.go @@ -1,21 +1,20 @@ package delete import ( - "context" + "bytes" + "strings" "testing" - apps "github.com/ninech/apis/apps/v1alpha1" iam "github.com/ninech/apis/iam/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestDeleter(t *testing.T) { - ctx := context.Background() + t.Parallel() asa := &iam.APIServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: "test", @@ -26,16 +25,24 @@ func TestDeleter(t *testing.T) { apiClient, err := test.SetupClient( test.WithObjects(asa), ) - require.NoError(t, err) - cmd := &apiServiceAccountCmd{resourceCmd{Writer: format.NewWriter(t.Output())}} + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + out := &bytes.Buffer{} + cmd := &apiServiceAccountCmd{resourceCmd{Writer: format.NewWriter(out)}} d := cmd.newDeleter(asa, iam.APIServiceAccountKind) + ctx := t.Context() if err := d.deleteResource(ctx, apiClient, 0, false, true); err != nil { - t.Fatalf("error while deleting %s: %s", apps.ApplicationKind, err) + t.Fatalf("failed to delete resource: %v", err) + } + + if !kerrors.IsNotFound(apiClient.Get(ctx, api.ObjectName(asa), asa)) { + t.Fatal("expected resource to be deleted, but it still exists") } - if !errors.IsNotFound(apiClient.Get(ctx, api.ObjectName(asa), asa)) { - t.Fatalf("expected resource to not exist after delete, got %s", err) + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } } diff --git a/delete/keyvaluestore_test.go b/delete/keyvaluestore_test.go index b93033ee..1e94d2dc 100644 --- a/delete/keyvaluestore_test.go +++ b/delete/keyvaluestore_test.go @@ -1,20 +1,23 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestKeyValueStore(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := keyValueStoreCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -25,22 +28,32 @@ func TestKeyValueStore(t *testing.T) { keyValueStore := test.KeyValueStore("test", test.DefaultProject, "nine-es34") apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, keyValueStore); err != nil { - t.Fatalf("keyvaluestore create error, got: %s", err) + t.Fatalf("failed to create keyvaluestore: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(keyValueStore), keyValueStore); err != nil { - t.Fatalf("expected keyvaluestore to exist, got: %s", err) + t.Fatalf("expected keyvaluestore to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run keyvaluestore delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(keyValueStore), keyValueStore) if err == nil { - t.Fatalf("expected keyvaluestore to be deleted, but exists") + t.Fatal("expected keyvaluestore to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected keyvaluestore to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected keyvaluestore to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain keyvaluestore name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/mysql_test.go b/delete/mysql_test.go index 157228ae..79155f54 100644 --- a/delete/mysql_test.go +++ b/delete/mysql_test.go @@ -1,20 +1,23 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestMySQL(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := mySQLCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -24,22 +27,32 @@ func TestMySQL(t *testing.T) { mysql := test.MySQL("test", test.DefaultProject, "nine-es34") apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, mysql); err != nil { - t.Fatalf("mysql create error, got: %s", err) + t.Fatalf("failed to create mysql: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(mysql), mysql); err != nil { - t.Fatalf("expected mysql to exist, got: %s", err) + t.Fatalf("expected mysql to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run mysql delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(mysql), mysql) if err == nil { - t.Fatalf("expected mysql to be deleted, but exists") + t.Fatal("expected mysql to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected mysql to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected mysql to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain mysql name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/mysqldatabase_test.go b/delete/mysqldatabase_test.go index feea188d..6663b4a8 100644 --- a/delete/mysqldatabase_test.go +++ b/delete/mysqldatabase_test.go @@ -1,20 +1,23 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestMySQLDatabase(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := mysqlDatabaseCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -25,22 +28,32 @@ func TestMySQLDatabase(t *testing.T) { mysqlDatabase := test.MySQLDatabase("test", test.DefaultProject, "nine-es34") apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, mysqlDatabase); err != nil { - t.Fatalf("mysqldatabase create error, got: %s", err) + t.Fatalf("failed to create mysqldatabase: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(mysqlDatabase), mysqlDatabase); err != nil { - t.Fatalf("expected mysqldatabase to exist, got: %s", err) + t.Fatalf("expected mysqldatabase to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run mysqldatabase delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(mysqlDatabase), mysqlDatabase) if err == nil { - t.Fatalf("expected mysqldatabase to be deleted, but exists") + t.Fatal("expected mysqldatabase to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected mysqldatabase to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected mysqldatabase to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain mysqldatabase name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/opensearch_test.go b/delete/opensearch_test.go index 933bd774..cf6707a3 100644 --- a/delete/opensearch_test.go +++ b/delete/opensearch_test.go @@ -1,20 +1,23 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestOpenSearch(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := openSearchCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -25,22 +28,32 @@ func TestOpenSearch(t *testing.T) { opensearch := test.OpenSearch("test", test.DefaultProject, "nine-es34") apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, opensearch); err != nil { - t.Fatalf("opensearch create error, got: %s", err) + t.Fatalf("failed to create opensearch: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(opensearch), opensearch); err != nil { - t.Fatalf("expected opensearch to exist, got: %s", err) + t.Fatalf("expected opensearch to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run opensearch delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(opensearch), opensearch) if err == nil { - t.Fatalf("expected opensearch to be deleted, but exists") + t.Fatal("expected opensearch to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected opensearch to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected opensearch to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain opensearch name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/postgres_test.go b/delete/postgres_test.go index 6bfef688..4c8f2317 100644 --- a/delete/postgres_test.go +++ b/delete/postgres_test.go @@ -1,20 +1,23 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestPostgres(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := postgresCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -25,22 +28,32 @@ func TestPostgres(t *testing.T) { postgres := test.Postgres("test", test.DefaultProject, "nine-es34") apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, postgres); err != nil { - t.Fatalf("postgres create error, got: %s", err) + t.Fatalf("failed to create postgres: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(postgres), postgres); err != nil { - t.Fatalf("expected postgres to exist, got: %s", err) + t.Fatalf("expected postgres to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run postgres delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(postgres), postgres) if err == nil { - t.Fatalf("expected postgres to be deleted, but exists") + t.Fatal("expected postgres to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected postgres to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected postgres to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain postgres name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/postgresdatabase_test.go b/delete/postgresdatabase_test.go index 87b22ad3..27b2220d 100644 --- a/delete/postgresdatabase_test.go +++ b/delete/postgresdatabase_test.go @@ -1,20 +1,23 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestPostgresDatabase(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := postgresDatabaseCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -25,22 +28,32 @@ func TestPostgresDatabase(t *testing.T) { postgresDatabase := test.PostgresDatabase("test", test.DefaultProject, "nine-es34") apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, postgresDatabase); err != nil { - t.Fatalf("postgresdatabase create error, got: %s", err) + t.Fatalf("failed to create postgresdatabase: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(postgresDatabase), postgresDatabase); err != nil { - t.Fatalf("expected postgresdatabase to exist, got: %s", err) + t.Fatalf("expected postgresdatabase to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run postgresdatabase delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(postgresDatabase), postgresDatabase) if err == nil { - t.Fatalf("expected postgresdatabase to be deleted, but exists") + t.Fatal("expected postgresdatabase to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected postgresdatabase to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected postgresdatabase to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain postgresdatabase name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/project_config_test.go b/delete/project_config_test.go index b853e601..74f73db4 100644 --- a/delete/project_config_test.go +++ b/delete/project_config_test.go @@ -1,18 +1,20 @@ package delete import ( - "context" + "bytes" + "strings" "testing" apps "github.com/ninech/apis/apps/v1alpha1" "github.com/ninech/nctl/api" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestProjectConfig(t *testing.T) { + t.Parallel() project := "some-project" cfg := &apps.ProjectConfig{ @@ -23,9 +25,11 @@ func TestProjectConfig(t *testing.T) { Spec: apps.ProjectConfigSpec{}, } + out := &bytes.Buffer{} cmd := configCmd{ - Force: true, - Wait: false, + Writer: format.NewWriter(out), + Force: true, + Wait: false, } apiClient, err := test.SetupClient( @@ -34,15 +38,22 @@ func TestProjectConfig(t *testing.T) { test.WithObjects(cfg), ) if err != nil { - t.Fatal(err) + t.Fatalf("failed to setup api client: %v", err) } - ctx := context.Background() + ctx := t.Context() if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run project configuration delete command: %v", err) } - if !errors.IsNotFound(apiClient.Get(ctx, api.ObjectName(cfg), cfg)) { - t.Fatalf("expected project configuration to not exist after delete, got %s", err) + if !kerrors.IsNotFound(apiClient.Get(ctx, api.ObjectName(cfg), cfg)) { + t.Fatal("expected project configuration to be deleted, but it still exists") + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) + } + if !strings.Contains(out.String(), apps.ProjectConfigKind) { + t.Errorf("expected output to contain kind %q, got %q", apps.ProjectConfigKind, out.String()) } } diff --git a/delete/project_test.go b/delete/project_test.go index e3b82486..927189a8 100644 --- a/delete/project_test.go +++ b/delete/project_test.go @@ -1,19 +1,22 @@ package delete import ( - "context" + "bytes" + "errors" "os" + "strings" "testing" management "github.com/ninech/apis/management/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/config" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestProject(t *testing.T) { + t.Parallel() organization := "evilcorp" for name, testCase := range map[string]struct { projects []string @@ -31,16 +34,19 @@ func TestProject(t *testing.T) { name: "dev", errorExpected: true, errorCheck: func(err error) bool { - return errors.IsNotFound(err) + return kerrors.IsNotFound(err) }, }, } { t.Run(name, func(t *testing.T) { + t.Parallel() + out := &bytes.Buffer{} cmd := projectCmd{ resourceCmd: resourceCmd{ - Force: true, - Wait: false, - Name: testCase.name, + Writer: format.NewWriter(out), + Force: true, + Wait: false, + Name: testCase.name, }, } @@ -49,34 +55,51 @@ func TestProject(t *testing.T) { test.WithProjects(testCase.projects...), test.WithKubeconfig(t), ) - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } - ctx := context.Background() + ctx := t.Context() err = cmd.Run(ctx, apiClient) if testCase.errorExpected { - require.Error(t, err) - require.True(t, errorCheck(testCase.errorCheck)(err)) + if err == nil { + t.Fatal("expected error but got none") + } + if !errorCheck(testCase.errorCheck)(err) { + t.Fatalf("error check failed for error: %v", err) + } return - } else { - require.NoError(t, err) + } + if err != nil { + t.Fatalf("unexpected error while running project delete: %v", err) } - require.True(t, errors.IsNotFound( + if !kerrors.IsNotFound( apiClient.Get( ctx, api.NamespacedName(testCase.name, organization), &management.Project{}, ), - )) + ) { + t.Fatal("expected project to be deleted, but it still exists") + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) + } + if !strings.Contains(out.String(), testCase.name) { + t.Errorf("expected output to contain project name %q, got %q", testCase.name, out.String()) + } }) } } func TestProjectsConfigErrors(t *testing.T) { - ctx := context.Background() + t.Parallel() + ctx := t.Context() apiClient, err := test.SetupClient() if err != nil { - t.Fatal(err) + t.Fatalf("failed to setup api client: %v", err) } cmd := projectCmd{ resourceCmd: resourceCmd{ @@ -86,14 +109,20 @@ func TestProjectsConfigErrors(t *testing.T) { }, } // there is no kubeconfig so we expect to fail - require.Error(t, cmd.Run(ctx, apiClient)) + if err := cmd.Run(ctx, apiClient); err == nil { + t.Error("expected error but got none") + } // we create a kubeconfig which does not contain a nctl config // extension kubeconfig, err := test.CreateTestKubeconfig(apiClient, "") - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to create test kubeconfig: %v", err) + } defer os.Remove(kubeconfig) - require.ErrorIs(t, cmd.Run(ctx, apiClient), config.ErrExtensionNotFound) + if err := cmd.Run(ctx, apiClient); !errors.Is(err, config.ErrExtensionNotFound) { + t.Errorf("expected error %v, got %v", config.ErrExtensionNotFound, err) + } } // errorCheck defaults the given errCheck function if it is nil. The returned diff --git a/delete/serviceconnection_test.go b/delete/serviceconnection_test.go index bb43606b..f8196b91 100644 --- a/delete/serviceconnection_test.go +++ b/delete/serviceconnection_test.go @@ -1,20 +1,23 @@ package delete import ( - "context" + "bytes" + "strings" "testing" "time" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/internal/test" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" ) func TestServiceConnection(t *testing.T) { - ctx := context.Background() + t.Parallel() + out := &bytes.Buffer{} cmd := serviceConnectionCmd{ resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), Name: "test", Force: true, Wait: false, @@ -25,22 +28,32 @@ func TestServiceConnection(t *testing.T) { sc := test.ServiceConnection("test", test.DefaultProject) apiClient, err := test.SetupClient() - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + ctx := t.Context() if err := apiClient.Create(ctx, sc); err != nil { - t.Fatalf("serviceconnection create error, got: %s", err) + t.Fatalf("failed to create serviceconnection: %v", err) } if err := apiClient.Get(ctx, api.ObjectName(sc), sc); err != nil { - t.Fatalf("expected serviceconnection to exist, got: %s", err) + t.Fatalf("expected serviceconnection to exist before deletion, got error: %v", err) } if err := cmd.Run(ctx, apiClient); err != nil { - t.Fatal(err) + t.Fatalf("failed to run serviceconnection delete command: %v", err) } err = apiClient.Get(ctx, api.ObjectName(sc), sc) if err == nil { - t.Fatalf("expected serviceconnection to be deleted, but exists") + t.Fatal("expected serviceconnection to be deleted, but it still exists") + } + if !kerrors.IsNotFound(err) { + t.Fatalf("expected serviceconnection to be deleted (NotFound), but got error: %v", err) + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) } - if !errors.IsNotFound(err) { - t.Fatalf("expected serviceconnection to be deleted, got: %s", err.Error()) + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain serviceconnection name %q, got %q", cmd.Name, out.String()) } } diff --git a/delete/vcluster_test.go b/delete/vcluster_test.go new file mode 100644 index 00000000..b1509194 --- /dev/null +++ b/delete/vcluster_test.go @@ -0,0 +1,68 @@ +package delete + +import ( + "bytes" + "os" + "strings" + "testing" + + infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" + "github.com/ninech/nctl/internal/test" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestVCluster(t *testing.T) { + t.Parallel() + out := &bytes.Buffer{} + cmd := vclusterCmd{ + resourceCmd: resourceCmd{ + Writer: format.NewWriter(out), + Name: "test", + Force: true, + Wait: false, + }, + } + + cluster := &infrastructure.KubernetesCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: test.DefaultProject, + }, + Spec: infrastructure.KubernetesClusterSpec{ + ForProvider: infrastructure.KubernetesClusterParameters{ + VCluster: &infrastructure.VClusterSettings{}, + }, + }, + } + + apiClient, err := test.SetupClient(test.WithObjects(cluster)) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + + kubeconfig, err := test.CreateTestKubeconfig(apiClient, "") + if err != nil { + t.Fatalf("failed to create test kubeconfig: %v", err) + } + defer os.Remove(kubeconfig) + apiClient.KubeconfigPath = kubeconfig + + ctx := t.Context() + if err := cmd.Run(ctx, apiClient); err != nil { + t.Fatalf("failed to run vcluster delete command: %v", err) + } + + if !kerrors.IsNotFound(apiClient.Get(ctx, api.ObjectName(cluster), cluster)) { + t.Fatal("expected vcluster to be deleted, but it still exists") + } + + if !strings.Contains(out.String(), "deletion started") { + t.Errorf("expected output to contain 'deletion started', got %q", out.String()) + } + if !strings.Contains(out.String(), cmd.Name) { + t.Errorf("expected output to contain vcluster name %q, got %q", cmd.Name, out.String()) + } +} diff --git a/update/project_test.go b/update/project_test.go index 7d2a5cb8..77fa422d 100644 --- a/update/project_test.go +++ b/update/project_test.go @@ -80,7 +80,7 @@ func TestProject(t *testing.T) { t.Errorf("expected output to contain 'updated', got %q", out.String()) } if !strings.Contains(out.String(), projectName) { - t.Errorf("expected output to contain project name '%s', got %q", projectName, out.String()) + t.Errorf("expected output to contain project name %q, got %q", projectName, out.String()) } }) } From aa1daf41f5826e899ba79d40d6f30ebdae227692 Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 12:43:26 +0100 Subject: [PATCH 15/19] refactor: use dedicated methods --- create/application.go | 3 +-- create/bucketuser.go | 1 - create/cloudvm.go | 2 +- create/mysqldatabase.go | 2 +- create/postgresdatabase.go | 3 +-- create/project.go | 1 - 6 files changed, 4 insertions(+), 8 deletions(-) diff --git a/create/application.go b/create/application.go index 8b291096..1cb9c87b 100644 --- a/create/application.go +++ b/create/application.go @@ -126,7 +126,6 @@ const ( ) func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { - cmd.Printf("Creating new application\n") newApp := cmd.newApplication(client.Project) sshPrivateKey, err := cmd.Git.sshPrivateKey() @@ -166,7 +165,7 @@ func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { // only update the secret if it is managed by nctl in the first place if v, exists := newApp.Annotations[util.ManagedByAnnotation]; exists && v == util.NctlName { - cmd.Printf("updating git auth credentials\n") + cmd.Successf("🔐", "updating git auth credentials") if err := client.Get(ctx, client.Name(secret.Name), secret); err != nil { return err } diff --git a/create/bucketuser.go b/create/bucketuser.go index f1f46fb3..8ef80ae8 100644 --- a/create/bucketuser.go +++ b/create/bucketuser.go @@ -20,7 +20,6 @@ type bucketUserCmd struct { } func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client) error { - cmd.Printf("Creating new bucketuser.\n") bucketuser := cmd.newBucketUser(client.Project) c := cmd.newCreator(client, bucketuser, storage.BucketUserKind) diff --git a/create/cloudvm.go b/create/cloudvm.go index 7c1ca125..a5367031 100644 --- a/create/cloudvm.go +++ b/create/cloudvm.go @@ -66,7 +66,7 @@ func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client) error { return err } - cmd.Printf("\nYour Cloud VM %s is now available, you can now connect with:\n ssh root@%s\n\n", cloudVM.Name, cloudVM.Status.AtProvider.FQDN) + cmd.Successf("🚀", "Your Cloud VM %s is now available, you can now connect with:\n ssh root@%s", cloudVM.Name, cloudVM.Status.AtProvider.FQDN) return nil } diff --git a/create/mysqldatabase.go b/create/mysqldatabase.go index 69ca4168..9108896f 100644 --- a/create/mysqldatabase.go +++ b/create/mysqldatabase.go @@ -50,7 +50,7 @@ func (cmd *mysqlDatabaseCmd) Run(ctx context.Context, client *api.Client) error return err } - cmd.Printf("\n Your MySQLDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get mysqldatabase %s --print-connection-string\n\n", mysqlDatabase.Name, mysqlDatabase.Name) + cmd.Successf("🚀", "Your MySQLDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get mysqldatabase %s --print-connection-string", mysqlDatabase.Name, mysqlDatabase.Name) return nil } diff --git a/create/postgresdatabase.go b/create/postgresdatabase.go index 43886a84..be699f5e 100644 --- a/create/postgresdatabase.go +++ b/create/postgresdatabase.go @@ -49,8 +49,7 @@ func (cmd *postgresDatabaseCmd) Run(ctx context.Context, client *api.Client) err return err } - cmd.Printf( - "\n Your PostgresDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get postgresdatabase %s --print-connection-string\n\n", + cmd.Successf("🚀", "Your PostgresDatabase %s is now available. You can retrieve the database, username and password with:\n\n nctl get postgresdatabase %s --print-connection-string", postgresDatabase.Name, postgresDatabase.Name, ) diff --git a/create/project.go b/create/project.go index 02dee2ea..58a4a2fa 100644 --- a/create/project.go +++ b/create/project.go @@ -21,7 +21,6 @@ func (cmd *projectCmd) Run(ctx context.Context, client *api.Client) error { } p := newProject(cmd.Name, org, cmd.DisplayName) - cmd.Printf("Creating new project %s for organization %s\n", p.Name, org) c := cmd.newCreator(client, p, strings.ToLower(management.ProjectKind)) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() From e47727f068c8c543b9833623970a64bff9b1c105 Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 12:49:02 +0100 Subject: [PATCH 16/19] refactor: add info dedicated method --- auth/login.go | 4 ++-- auth/whoami.go | 4 ++-- create/application.go | 29 ++++++++++------------------- edit/edit.go | 2 +- internal/format/print.go | 11 +++++++++++ internal/format/writer.go | 10 ++++++++++ 6 files changed, 36 insertions(+), 24 deletions(-) diff --git a/auth/login.go b/auth/login.go index c009b9a7..635aca28 100644 --- a/auth/login.go +++ b/auth/login.go @@ -118,8 +118,8 @@ func (cmd *LoginCmd) Run(ctx context.Context) error { org := userInfo.Orgs[0] if len(userInfo.Orgs) > 1 { - cmd.Printf("Multiple organizations found for the account %q.\n", userInfo.User) - cmd.Printf("Defaulting to %q\n", org) + cmd.Infof("", "Multiple organizations found for the account %q.", userInfo.User) + cmd.Infof("", "Defaulting to %q", org) printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs) } diff --git a/auth/whoami.go b/auth/whoami.go index 7ea9a467..7d3d5911 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -31,8 +31,8 @@ func (cmd *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { } func (cmd *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org string) { - cmd.Printf("You are currently logged in with the following account: %q\n", userInfo.User) - cmd.Printf("Your current organization: %q\n", org) + cmd.Infof("👤", "You are currently logged in with the following account: %q", userInfo.User) + cmd.Infof("🏢", "Your current organization: %q", org) if len(userInfo.Orgs) > 0 { printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs) diff --git a/create/application.go b/create/application.go index 1cb9c87b..ef4ed3a3 100644 --- a/create/application.go +++ b/create/application.go @@ -237,8 +237,9 @@ func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { return err } - cmd.Printf( - "\nYour application %q is now available at:\n https://%s\n\n", + cmd.Successf( + "🚀", + "Your application %q is now available at:\n https://%s", newApp.Name, newApp.Status.AtProvider.CNAMETarget, ) @@ -611,21 +612,17 @@ func (cmd *applicationCmd) printUnverifiedHostsMessage(app *apps.Application) { if len(unverifiedHosts) != 0 { dnsDetails := util.GatherDNSDetails([]apps.Application{*app}) - cmd.Printf("You configured the following hosts:\n") + cmd.Infof("🌐", "You configured the following hosts:") for _, name := range unverifiedHosts { cmd.Printf(" %s\n", name) } - cmd.Printf("\nYour DNS details are:\n") + cmd.Infof("📋", "Your DNS details are:") cmd.Printf(" TXT record:\t%s\n", dnsDetails[0].TXTRecord) cmd.Printf(" DNS TARGET:\t%s\n", dnsDetails[0].CNAMETarget) - cmd.Printf("\nTo make your app available on your custom hosts, please use \n"+ - "the DNS details and visit %s\n"+ - "for further instructions.\n", - util.DNSSetupURL, - ) + cmd.Infof("ℹ", "To make your app available on your custom hosts, please use \nthe DNS details and visit %s\nfor further instructions.", util.DNSSetupURL) } } @@ -652,12 +649,8 @@ func printReleaseLogs(ctx context.Context, client *api.Client, release *apps.Rel } func (cmd *applicationCmd) printCredentials(basicAuth *util.BasicAuth) { - cmd.Printf("\nYou can login with the following credentials:\n"+ - " username: %s\n"+ - " password: %s\n", - basicAuth.Username, - basicAuth.Password, - ) + cmd.Infof("🔐", "You can login with the following credentials:") + cmd.Printf(" username: %s\n password: %s\n", basicAuth.Username, basicAuth.Password) } // printErrorDetails prints detailed error information for build and release errors. @@ -668,8 +661,7 @@ func (cmd *applicationCmd) printErrorDetails( ) error { var buildErr buildError if errors.As(err, &buildErr) { - cmd.Printf( - "\nYour build has failed with status %q. Here are the last %v lines of the log:\n\n", + cmd.Infof("❌", "Your build has failed with status %q. Here are the last %v lines of the log:", buildErr.Build().Status.AtProvider.BuildStatus, errorLogLines, ) @@ -678,8 +670,7 @@ func (cmd *applicationCmd) printErrorDetails( var releaseErr releaseError if errors.As(err, &releaseErr) { - cmd.Printf( - "\nYour release has failed with status %q. Here are the last %v lines of the log:\n\n", + cmd.Infof("❌", "Your release has failed with status %q. Here are the last %v lines of the log:", releaseErr.Release().Status.AtProvider.ReleaseStatus, errorLogLines, ) diff --git a/edit/edit.go b/edit/edit.go index ea33fb65..9d52f855 100644 --- a/edit/edit.go +++ b/edit/edit.go @@ -145,7 +145,7 @@ func (cmd *resourceCmd) Run(kong *kong.Context, ctx context.Context, c *api.Clie if modified { cmd.Successf("🏗", "updated %s", formatObj(obj)) } else { - cmd.Printf("no changes made to %s\n", formatObj(obj)) + cmd.Infof("", "no changes made to %s", formatObj(obj)) } return nil } diff --git a/internal/format/print.go b/internal/format/print.go index 535d1f14..efd394fd 100644 --- a/internal/format/print.go +++ b/internal/format/print.go @@ -27,6 +27,7 @@ type OutputFormatType int const ( SuccessChar = "✓" FailureChar = "✗" + InfoChar = "ℹ" spinnerPrefix = " " spinnerFrequency = 100 * time.Millisecond @@ -69,6 +70,16 @@ func failuref(icon, format string, a ...any) string { return fmt.Sprintf(" %s %s %s", FailureChar, fmt.Sprintf(format, a...), icon) } +// infof is a formatted message for providing information. +func infof(icon, format string, a ...any) string { + return fmt.Sprintf(" %s %s %s", InfoChar, fmt.Sprintf(format, a...), icon) +} + +// info returns a message for providing information. +func info(icon, message string) string { + return fmt.Sprintf(" %s %s %s", InfoChar, message, icon) +} + func warningf(msg string, a ...any) string { return fmt.Sprintf(color.YellowString("Warning: ")+msg, a...) } diff --git a/internal/format/writer.go b/internal/format/writer.go index d0dbe623..a1c66771 100644 --- a/internal/format/writer.go +++ b/internal/format/writer.go @@ -62,6 +62,16 @@ func (w *Writer) Failuref(icon string, format string, a ...any) { fmt.Fprint(w.writer(), failuref(icon, format, a...)+"\n") } +// Infof is a formatted message for providing information. +func (w *Writer) Infof(icon string, format string, a ...any) { + fmt.Fprint(w.writer(), infof(icon, format, a...)+"\n") +} + +// Info returns a message for providing information. +func (w *Writer) Info(icon string, message string) { + fmt.Fprint(w.writer(), info(icon, message)+"\n") +} + // Printf formats according to a format specifier and writes to the underlying writer. func (w *Writer) Printf(format string, a ...any) { fmt.Fprintf(w.writer(), format, a...) From 8f83c7bb515674248bc17af8423df7d597d0c1ae Mon Sep 17 00:00:00 2001 From: thde Date: Mon, 9 Feb 2026 12:51:00 +0100 Subject: [PATCH 17/19] docs: note to prefer dedicated methods --- internal/format/writer.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/format/writer.go b/internal/format/writer.go index a1c66771..c8d57178 100644 --- a/internal/format/writer.go +++ b/internal/format/writer.go @@ -73,11 +73,13 @@ func (w *Writer) Info(icon string, message string) { } // Printf formats according to a format specifier and writes to the underlying writer. +// Prefer the dedicated methods if applicable. func (w *Writer) Printf(format string, a ...any) { fmt.Fprintf(w.writer(), format, a...) } // Println prints a formatted message to the underlying writer. +// Prefer the dedicated methods if applicable. func (w *Writer) Println(a ...any) { fmt.Fprintln(w.writer(), a...) } From 28f81a26b1a1b3611d3a1ffb648d3600f5d097dc Mon Sep 17 00:00:00 2001 From: thde Date: Tue, 10 Feb 2026 15:10:53 +0100 Subject: [PATCH 18/19] refactor: align variable name --- get/apiserviceaccount.go | 56 ++++++++++++++++++++-------------------- get/bucketuser.go | 36 +++++++++++++------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/get/apiserviceaccount.go b/get/apiserviceaccount.go index a548ccd6..fd91245e 100644 --- a/get/apiserviceaccount.go +++ b/get/apiserviceaccount.go @@ -20,30 +20,30 @@ type apiServiceAccountsCmd struct { PrintTokenURL bool `help:"Print the oauth2 token URL of the Account. Requires name to be set. Only valid for v2 service accounts." xor:"print"` } -func (asa *apiServiceAccountsCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { - return get.listPrint(ctx, client, asa, api.MatchName(asa.Name)) +func (cmd *apiServiceAccountsCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { + return get.listPrint(ctx, client, cmd, api.MatchName(cmd.Name)) } -func (asa *apiServiceAccountsCmd) list() client.ObjectList { +func (cmd *apiServiceAccountsCmd) list() client.ObjectList { return &iam.APIServiceAccountList{} } -func (asa *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, list client.ObjectList, out *output) error { +func (cmd *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, list client.ObjectList, out *output) error { asaList := list.(*iam.APIServiceAccountList) if len(asaList.Items) == 0 { return out.printEmptyMessage(iam.APIServiceAccountKind, client.Project) } sa := &asaList.Items[0] - if asa.printFlagSet() { - if asa.Name == "" { + if cmd.printFlagSet() { + if cmd.Name == "" { return fmt.Errorf("name needs to be set to print service account information") } - if err := asa.validPrintFlags(sa); err != nil { + if err := cmd.validPrintFlags(sa); err != nil { return err } - if asa.PrintCredentials { - return asa.printCredentials( + if cmd.PrintCredentials { + return cmd.printCredentials( ctx, client, sa, @@ -54,34 +54,34 @@ func (asa *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, key := "" switch sa.Spec.ForProvider.Version { default: - if asa.PrintToken { + if cmd.PrintToken { key = iam.APIServiceAccountTokenKey } - if asa.PrintKubeconfig { + if cmd.PrintKubeconfig { key = iam.APIServiceAccountKubeconfigKey } case iam.APIServiceAccountV2: - if asa.PrintClientID { + if cmd.PrintClientID { key = iam.APIServiceAccountIDKey } - if asa.PrintClientSecret { + if cmd.PrintClientSecret { key = iam.APIServiceAccountSecretKey } - if asa.PrintTokenURL { + if cmd.PrintTokenURL { key = iam.APIServiceAccountTokenURLKey } - if asa.PrintKubeconfig { + if cmd.PrintKubeconfig { key = iam.APIServiceAccountKubeconfigKey } } - return asa.printSecret(ctx, client, sa, key, out) + return cmd.printSecret(ctx, client, sa, key, out) } switch out.Format { case full: - return asa.printAsa(asaList.Items, out, true) + return cmd.printAsa(asaList.Items, out, true) case noHeader: - return asa.printAsa(asaList.Items, out, false) + return cmd.printAsa(asaList.Items, out, false) case yamlOut: return format.PrettyPrintObjects(asaList.GetItems(), format.PrintOpts{Out: &out.Writer}) case jsonOut: @@ -91,7 +91,7 @@ func (asa *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ - PrintSingleItem: asa.Name != "", + PrintSingleItem: cmd.Name != "", }, }) } @@ -99,14 +99,14 @@ func (asa *apiServiceAccountsCmd) print(ctx context.Context, client *api.Client, return nil } -func (asa *apiServiceAccountsCmd) validPrintFlags(sa *iam.APIServiceAccount) error { +func (cmd *apiServiceAccountsCmd) validPrintFlags(sa *iam.APIServiceAccount) error { switch sa.Spec.ForProvider.Version { case iam.APIServiceAccountV2: - if asa.PrintToken { + if cmd.PrintToken { return fmt.Errorf("token printing is not supported for v2 APIServiceAccount") } default: - if asa.PrintClientID || asa.PrintClientSecret || asa.PrintTokenURL { + if cmd.PrintClientID || cmd.PrintClientSecret || cmd.PrintTokenURL { return fmt.Errorf( "client_id/client_secret/token_url printing is not supported for v1 APIServiceAccount", ) @@ -115,13 +115,13 @@ func (asa *apiServiceAccountsCmd) validPrintFlags(sa *iam.APIServiceAccount) err return nil } -func (asa *apiServiceAccountsCmd) printFlagSet() bool { - return asa.PrintToken || asa.PrintKubeconfig || asa.PrintClientID || asa.PrintClientSecret || - asa.PrintTokenURL || - asa.PrintCredentials +func (cmd *apiServiceAccountsCmd) printFlagSet() bool { + return cmd.PrintToken || cmd.PrintKubeconfig || cmd.PrintClientID || cmd.PrintClientSecret || + cmd.PrintTokenURL || + cmd.PrintCredentials } -func (asa *apiServiceAccountsCmd) printAsa( +func (cmd *apiServiceAccountsCmd) printAsa( sas []iam.APIServiceAccount, out *output, header bool, @@ -137,7 +137,7 @@ func (asa *apiServiceAccountsCmd) printAsa( return out.tabWriter.Flush() } -func (asa *apiServiceAccountsCmd) printSecret( +func (cmd *apiServiceAccountsCmd) printSecret( ctx context.Context, client *api.Client, sa *iam.APIServiceAccount, diff --git a/get/bucketuser.go b/get/bucketuser.go index e63e8b57..bbd876aa 100644 --- a/get/bucketuser.go +++ b/get/bucketuser.go @@ -18,15 +18,15 @@ type bucketUserCmd struct { PrintSecretKey bool `help:"Print the secret key of the BucketUser. Requires name to be set." xor:"secret"` } -func (bu *bucketUserCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { - return get.listPrint(ctx, client, bu, api.MatchName(bu.Name)) +func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { + return get.listPrint(ctx, client, cmd, api.MatchName(cmd.Name)) } -func (bu *bucketUserCmd) list() client.ObjectList { +func (cmd *bucketUserCmd) list() client.ObjectList { return &storage.BucketUserList{} } -func (bu *bucketUserCmd) print(ctx context.Context, client *api.Client, list client.ObjectList, out *output) error { +func (cmd *bucketUserCmd) print(ctx context.Context, client *api.Client, list client.ObjectList, out *output) error { bucketUserList := list.(*storage.BucketUserList) if len(bucketUserList.Items) == 0 { @@ -35,31 +35,31 @@ func (bu *bucketUserCmd) print(ctx context.Context, client *api.Client, list cli user := &bucketUserList.Items[0] - if bu.printFlagSet() { - if bu.Name == "" { + if cmd.printFlagSet() { + if cmd.Name == "" { return fmt.Errorf("name needs to be set to print bucket user information") } - if bu.PrintCredentials { - return bu.printCredentials(ctx, client, user, out, nil) + if cmd.PrintCredentials { + return cmd.printCredentials(ctx, client, user, out, nil) } key := "" - if bu.PrintAccessKey { + if cmd.PrintAccessKey { key = storage.BucketUserCredentialAccessKey } - if bu.PrintSecretKey { + if cmd.PrintSecretKey { key = storage.BucketUserCredentialSecretKey } - return bu.printSecret(ctx, client, user, key, out) + return cmd.printSecret(ctx, client, user, key, out) } switch out.Format { case full: - return bu.printBucketUserInstances(bucketUserList.Items, out, true) + return cmd.printBucketUserInstances(bucketUserList.Items, out, true) case noHeader: - return bu.printBucketUserInstances(bucketUserList.Items, out, false) + return cmd.printBucketUserInstances(bucketUserList.Items, out, false) case yamlOut: return format.PrettyPrintObjects( bucketUserList.GetItems(), @@ -72,7 +72,7 @@ func (bu *bucketUserCmd) print(ctx context.Context, client *api.Client, list cli Out: &out.Writer, Format: format.OutputFormatTypeJSON, JSONOpts: format.JSONOutputOptions{ - PrintSingleItem: bu.Name != "", + PrintSingleItem: cmd.Name != "", }, }) } @@ -80,7 +80,7 @@ func (bu *bucketUserCmd) print(ctx context.Context, client *api.Client, list cli return nil } -func (bu *bucketUserCmd) printBucketUserInstances( +func (cmd *bucketUserCmd) printBucketUserInstances( list []storage.BucketUser, out *output, header bool, @@ -96,11 +96,11 @@ func (bu *bucketUserCmd) printBucketUserInstances( return out.tabWriter.Flush() } -func (bu *bucketUserCmd) printFlagSet() bool { - return bu.PrintCredentials || bu.PrintAccessKey || bu.PrintSecretKey +func (cmd *bucketUserCmd) printFlagSet() bool { + return cmd.PrintCredentials || cmd.PrintAccessKey || cmd.PrintSecretKey } -func (bu *bucketUserCmd) printSecret( +func (cmd *bucketUserCmd) printSecret( ctx context.Context, client *api.Client, user *storage.BucketUser, From 469271756654d14074498f40efc4a0eabeb2bb2e Mon Sep 17 00:00:00 2001 From: thde Date: Tue, 10 Feb 2026 15:13:23 +0100 Subject: [PATCH 19/19] refactor: update comment --- get/get_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/get/get_test.go b/get/get_test.go index a8f2fd2a..0ce6a725 100644 --- a/get/get_test.go +++ b/get/get_test.go @@ -16,8 +16,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// NewTestCmd creates a Cmd for testing with the given writer and output format. -// This is a convenience helper that properly initializes the output writer. +// NewTestCmd creates a [Cmd] for testing with the given writer and output format. +// It initializes the format.Writer and calls BeforeApply to ensure the output +// is properly configured. func NewTestCmd(w io.Writer, outFormat outputFormat, opts ...func(*Cmd)) *Cmd { cmd := &Cmd{ output: output{