diff --git a/api/client.go b/api/client.go index 98923247..14e1e4d3 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/list.go b/api/list.go index 804fb137..8f0272cf 100644 --- a/api/list.go +++ b/api/list.go @@ -1,17 +1,16 @@ package api import ( + "cmp" "context" "errors" "fmt" "reflect" "slices" - "sort" "strings" - "sync" management "github.com/ninech/apis/management/v1alpha1" - "github.com/ninech/nctl/internal/format" + "golang.org/x/sync/errgroup" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/conversion" runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -136,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 { - format.PrintWarningf("error when searching in project %s: %s\n", 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 @@ -167,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 { @@ -177,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/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/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..98d4ae90 100644 --- a/apply/apply.go +++ b/apply/apply.go @@ -1,8 +1,15 @@ +// 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 { - 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 `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/apply/file.go b/apply/file.go index 96438c62..596f8f2a 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,19 +59,23 @@ 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 } 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 } - format.PrintSuccessf("🏗", "created %s", formatObj(obj)) + w.Successf("🏗", "created %s", formatObj(obj)) return nil } @@ -99,7 +102,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..0be62ab2 100644 --- a/auth/cluster.go +++ b/auth/cluster.go @@ -12,12 +12,15 @@ 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 { - 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 `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."` } func (a *ClusterCmd) Run(ctx context.Context, client *api.Client) error { @@ -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..635aca28 100644 --- a/auth/login.go +++ b/auth/login.go @@ -26,6 +26,7 @@ const ( ) type LoginCmd struct { + 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:""` @@ -36,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 } @@ -57,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(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 } @@ -92,16 +93,16 @@ 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(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 } @@ -117,22 +118,38 @@ 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) + cmd.Infof("", "Multiple organizations found for the account %q.", userInfo.User) + cmd.Infof("", "Defaulting to %q", org) + printAvailableOrgsString(cmd.Writer, 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(cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) + return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) } -func (l *LoginCmd) tokenGetter() api.TokenGetter { - if l.tk != nil { - return l.tk +func printAvailableOrgsString(w format.Writer, currentorg string, orgs []string) { + w.Println("\nAvailable Organizations:") + + for _, org := range orgs { + activeMarker := "" + if currentorg == org { + activeMarker = "*" + } + + w.Printf("%s\t%s\n", activeMarker, org) + } + + w.Printf("\nTo switch the organization use the following command:\n") + w.Printf("$ nctl auth set-org \n") +} + +func (cmd *LoginCmd) tokenGetter() api.TokenGetter { + if cmd.tk != nil { + return cmd.tk } return &api.DefaultTokenGetter{} } @@ -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", toOrg) } - format.PrintSuccessf("📋", "added %s to kubeconfig", 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) } - format.PrintSuccess("🚀", loginMessage) + w.Success("🚀", loginMessage) return nil } diff --git a/auth/logout.go b/auth/logout.go index 14c3555a..0431faca 100644 --- a/auth/logout.go +++ b/auth/logout.go @@ -22,10 +22,11 @@ import ( ) type LogoutCmd struct { - 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 `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"` + tk api.TokenGetter } func (l *LogoutCmd) Run(ctx context.Context) error { @@ -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", 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", 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..d34a132b 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 { - 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 `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"` + 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,22 @@ 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) + cmd.Successf("📝", "set active Organization to %s", cmd.Organization) + + // 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.", + cmd.Organization, + ) + printAvailableOrgsString(cmd.Writer, cmd.Organization, userInfo.Orgs) } - fmt.Println(format.SuccessMessagef("📝", "set active Organization to %s", s.Organization)) return nil } diff --git a/auth/set_project.go b/auth/set_project.go index 3a048ae8..030752b1 100644 --- a/auth/set_project.go +++ b/auth/set_project.go @@ -10,12 +10,14 @@ import ( "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/config" "github.com/ninech/nctl/internal/format" - "k8s.io/apimachinery/pkg/api/errors" + + kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" ) type SetProjectCmd struct { - Name string `arg:"" help:"Name of the default project to be used." completion-predictor:"project_name"` + format.Writer `hidden:""` + 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 { @@ -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 !errors.IsNotFound(err) && !errors.IsForbidden(err) { + if err := client.Get( + ctx, + types.NamespacedName{Name: s.Name, Namespace: org}, + &management.Project{}, + ); err != nil { + if !kerrors.IsNotFound(err) && !kerrors.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 %q does not exist in organization %q, checking other organizations...", + s.Name, + 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", 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 } @@ -103,7 +120,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/auth/whoami.go b/auth/whoami.go index 6de832bc..7d3d5911 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -2,18 +2,19 @@ package auth import ( "context" - "fmt" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" ) type WhoAmICmd struct { - 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 `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"` } -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 @@ -24,32 +25,16 @@ func (s *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { return err } - printUserInfo(userInfo, org) + cmd.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 (cmd *WhoAmICmd) printUserInfo(userInfo *api.UserInfo, org string) { + cmd.Infof("👤", "You are currently logged in with the following account: %q", userInfo.User) + cmd.Infof("🏢", "Your current organization: %q", org) if len(userInfo.Orgs) > 0 { - printAvailableOrgsString(org, userInfo.Orgs) + printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs) } } - -func printAvailableOrgsString(currentorg string, orgs []string) { - fmt.Println("\nAvailable Organizations:") - - for _, org := range orgs { - activeMarker := "" - if currentorg == org { - activeMarker = "*" - } - - fmt.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") -} 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..c5e22000 100644 --- a/copy/copy.go +++ b/copy/copy.go @@ -1,10 +1,13 @@ +// Package copy provides commands to copy resources. package copy import ( + "io" "math/rand" "time" "github.com/lucasepe/codename" + "github.com/ninech/nctl/internal/format" ) type Cmd struct { @@ -12,11 +15,17 @@ type Cmd struct { } type resourceCmd struct { + 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"` } +// 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/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..ef4ed3a3 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,30 @@ 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 { + 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 +163,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.Successf("🔐", "updating git auth credentials") if err := client.Get(ctx, client.Name(secret.Name), secret); err != nil { return err } @@ -191,8 +196,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, apps.ApplicationKind) + appWaitCtx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() if err := c.createResource(appWaitCtx); err != nil { @@ -206,7 +211,7 @@ func (app *applicationCmd) Run(ctx context.Context, client *api.Client) error { return err } - if !app.Wait { + if !cmd.Wait { return nil } @@ -218,15 +223,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 +233,17 @@ 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.Successf( + "🚀", + "Your application %q is now available at:\n https://%s", + 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,26 +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.Infof("🌐", "You configured the following hosts:") 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.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) - fmt.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) } } @@ -633,18 +639,45 @@ 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"+ - " username: %s\n"+ - " password: %s\n", - basicAuth.Username, - basicAuth.Password, - ) +func (cmd *applicationCmd) printCredentials(basicAuth *util.BasicAuth) { + 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. +func (cmd *applicationCmd) printErrorDetails( + ctx context.Context, + client *api.Client, + err error, +) error { + var buildErr buildError + if errors.As(err, &buildErr) { + cmd.Infof("❌", "Your build has failed with status %q. Here are the last %v lines of the log:", + buildErr.Build().Status.AtProvider.BuildStatus, + errorLogLines, + ) + return printBuildLogs(ctx, client, buildErr.Build()) + } + + var releaseErr releaseError + if errors.As(err, &releaseErr) { + cmd.Infof("❌", "Your release has failed with status %q. Here are the last %v lines of the log:", + 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 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..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 := 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 16257824..8ef80ae8 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,9 @@ type bucketUserCmd struct { } func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client) error { - fmt.Println("Creating new bucketuser.") bucketuser := cmd.newBucketUser(client.Project) - c := newCreator(client, bucketuser, "bucketuser") + c := cmd.newCreator(client, bucketuser, storage.BucketUserKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() diff --git a/create/cloudvm.go b/create/cloudvm.go index 68dfb3e0..a5367031 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.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 } @@ -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..136554c7 100644 --- a/create/create.go +++ b/create/create.go @@ -1,8 +1,10 @@ +// Package create provides functionality to create resources in the nine.ch API. package create import ( "context" "fmt" + "io" "math/rand" "os" "time" @@ -13,6 +15,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,9 +44,15 @@ type Cmd struct { } type resourceCmd struct { - 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."` +} + +// 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 @@ -51,12 +60,18 @@ type resourceCmd struct { type resultFunc func(watch.Event) (bool, error) type creator struct { + format.Writer + client *api.Client mg resource.Managed kind string + + timeout time.Duration } type waitStage struct { + format.Writer + kind string waitMessage *message doneMessage *message @@ -65,6 +80,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. @@ -84,24 +100,30 @@ var watchBackoff = wait.Backoff{ Jitter: 0.1, } +const remainingTimeUpdateInterval = time.Second + func (m *message) progress() string { if m.disabled { return "" } - return format.ProgressMessage(m.icon, m.text) + return format.Progress(m.icon, m.text) } -func (m *message) printSuccess() { - if m.disabled { - return +// 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)) + } } - - format.PrintSuccess(m.icon, m.text) + return format.Progress(w.waitMessage.icon, 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, 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 { @@ -109,7 +131,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", c.kind, c.mg.GetName(), c.mg.GetNamespace()) return nil } @@ -120,10 +142,11 @@ func (c *creator) wait(ctx context.Context, stages ...waitStage) error { } stage.setDefaults(c) + stage.Writer = c.Writer - spinner, err := format.NewSpinner( - stage.waitMessage.progress(), - stage.waitMessage.progress(), + spinner, err := c.Spinner( + stage.progressWithRemaining(ctx), + stage.progressWithRemaining(ctx), ) if err != nil { return err @@ -134,6 +157,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 { @@ -178,7 +202,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 { @@ -203,6 +230,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(): @@ -218,17 +248,20 @@ 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() - // print out the done message directly - w.doneMessage.printSuccess() + w.Successf(w.doneMessage.icon, "%s (%s)", w.doneMessage.text, elapsed) return nil } + case <-ticker.C: + w.spinner.Message(w.progressWithRemaining(ctx)) case <-ctx.Done(): 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..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 := 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 c1dc8b84..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 } - fmt.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, storage.MySQLKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -62,6 +61,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..9108896f 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,9 @@ 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) 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 +38,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 +50,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.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/opensearch.go b/create/opensearch.go index b9bb6bc8..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 := 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 0f5422ba..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 } - fmt.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, storage.PostgresKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -56,6 +55,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..be699f5e 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,9 @@ 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) postgresDatabase := cmd.newPostgresDatabase(client.Project) - c := newCreator(client, postgresDatabase, "postgresdatabase") + c := cmd.newCreator(client, postgresDatabase, storage.PostgresDatabaseKind) ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) defer cancel() @@ -39,6 +37,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 +49,10 @@ 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.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, + ) return nil } diff --git a/create/project.go b/create/project.go index 735a25f4..58a4a2fa 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,27 @@ 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) + 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..a290c1de 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,16 +16,25 @@ import ( // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { - 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 `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."` + 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( + 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..12468ba0 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", 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", 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/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.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/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.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/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.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/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.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/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 1c04ba8a..197030ba 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -1,15 +1,18 @@ +// Package delete provides functionality to delete resources in the nine.ch API. 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 { @@ -33,10 +36,21 @@ type Cmd struct { } type resourceCmd struct { - 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:"-"` + 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 @@ -47,6 +61,8 @@ type cleanupFunc func(client *api.Client) error type promptFunc func(kind, name string) string type deleter struct { + format.Writer + format.Reader kind string mg resource.Managed cleanup cleanupFunc @@ -56,8 +72,14 @@ 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, + Reader: cmd.Reader, kind: kind, mg: mg, cleanup: noCleanup, @@ -89,10 +111,15 @@ 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(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 +129,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.Reader, 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 +148,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 %q deletion started", d.kind, d.mg.GetName()) } 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 %q is being deleted", d.kind, d.mg.GetName()), + format.Progressf("🗑", "%s %q deleted", d.kind, d.mg.GetName()), ) if err != nil { return err @@ -145,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 } @@ -157,7 +184,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..0ff98c9a 100644 --- a/delete/delete_test.go +++ b/delete/delete_test.go @@ -1,20 +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", @@ -25,15 +25,24 @@ func TestDeleter(t *testing.T) { apiClient, err := test.SetupClient( test.WithObjects(asa), ) - require.NoError(t, err) + if err != nil { + t.Fatalf("failed to setup api client: %v", err) + } + out := &bytes.Buffer{} + cmd := &apiServiceAccountCmd{resourceCmd{Writer: format.NewWriter(out)}} - d := newDeleter(asa, iam.APIServiceAccountKind) + 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/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/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.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/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.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/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.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/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.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/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.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/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.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..7cad48ce 100644 --- a/delete/project_config.go +++ b/delete/project_config.go @@ -5,15 +5,36 @@ 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 { - 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 `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."` +} + +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, + prompt: defaultPrompt, + } + for _, opt := range opts { + opt(d) + } + + return d } func (cmd *configCmd) Run(ctx context.Context, client *api.Client) error { @@ -27,7 +48,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/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.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/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.go b/delete/vcluster.go index 3f7e41d2..4ef46ff9 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", 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/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/edit/edit.go b/edit/edit.go index b765a611..9d52f855 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,7 +40,13 @@ type Cmd struct { } type resourceCmd struct { - 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:""` +} + +// 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. @@ -74,7 +81,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 +143,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.Infof("", "no changes made to %s", formatObj(obj)) } return nil } @@ -162,7 +172,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 +203,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 +238,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/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/get/all.go b/get/all.go index d6dd6e62..c84cf8c7 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", 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..fd91245e 100644 --- a/get/apiserviceaccount.go +++ b/get/apiserviceaccount.go @@ -20,71 +20,78 @@ 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(ctx, client, sa, out, func(key string) bool { return key == iam.APIServiceAccountKubeconfigKey }) + if cmd.PrintCredentials { + return cmd.printCredentials( + ctx, + client, + sa, + out, + func(key string) bool { return key == iam.APIServiceAccountKubeconfigKey }, + ) } 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) + 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{}) + 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 != "", + PrintSingleItem: cmd.Name != "", }, }) } @@ -92,25 +99,33 @@ 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 { - return fmt.Errorf("client_id/client_secret/token_url printing is not supported for v1 APIServiceAccount") + if cmd.PrintClientID || cmd.PrintClientSecret || cmd.PrintTokenURL { + 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 +func (cmd *apiServiceAccountsCmd) printFlagSet() bool { + return cmd.PrintToken || cmd.PrintKubeconfig || cmd.PrintClientID || cmd.PrintClientSecret || + cmd.PrintTokenURL || + cmd.PrintCredentials } -func (asa *apiServiceAccountsCmd) printAsa(sas []iam.APIServiceAccount, out *output, header bool) error { +func (cmd *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 (cmd *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..02c03501 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", 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", 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..bbd876aa 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" ) @@ -17,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 { @@ -34,41 +35,44 @@ 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) + 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(), 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 != "", + PrintSingleItem: cmd.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 (cmd *bucketUserCmd) printBucketUserInstances( + list []storage.BucketUser, + out *output, + header bool, +) error { if header { out.writeHeader("NAME", "LOCATION") } @@ -88,15 +96,21 @@ func (bu *bucketUserCmd) printBucketUserInstances(list []storage.BucketUser, out 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(ctx context.Context, client *api.Client, user *storage.BucketUser, key string) error { +func (cmd *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..07f19b6c 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", 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..3ca5ef13 100644 --- a/get/get.go +++ b/get/get.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "maps" - "os" "slices" "strings" @@ -17,6 +16,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,16 +45,17 @@ type Cmd struct { } type output struct { + 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"` 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 { - 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 @@ -67,11 +69,29 @@ const ( jsonOut outputFormat = "json" ) -func (cmd *Cmd) AfterApply() error { - cmd.initOut() +// 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 + } + + if cmd.tabWriter == nil { + cmd.tabWriter = tabwriter.NewWriter(writer, 0, 0, 2, ' ', tabwriter.RememberWidths) + } + return nil } +// 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 @@ -102,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 { @@ -113,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 @@ -137,33 +154,21 @@ func (out *output) writeTabRow(project string, row ...string) { } 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 - } - - _, err := fmt.Fprintf(out.writer, "no %s found in project %s\n", flect.Pluralize(kind), project) - return err -} - -func (out *output) initOut() { - if out.writer == nil { - out.writer = os.Stdout + out.Printf("no %s found\n", flect.Pluralize(kind)) + return nil } - if out.tabWriter == nil { - out.tabWriter = tabwriter.NewWriter(out.writer, 0, 0, 2, ' ', tabwriter.RememberWidths) - } + out.Printf("no %s found in project %s\n", flect.Pluralize(kind), project) + return nil } func getConnectionSecretMap(ctx context.Context, client *api.Client, mg resource.Managed) (map[string][]byte, error) { @@ -189,21 +194,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 +246,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..0ce6a725 100644 --- a/get/get_test.go +++ b/get/get_test.go @@ -3,17 +3,36 @@ 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. +// 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{ + 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 @@ -77,7 +96,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..41e1bb6c 100644 --- a/get/project_config_test.go +++ b/get/project_config_test.go @@ -2,7 +2,6 @@ package get import ( "bytes" - "context" "testing" "time" @@ -17,8 +16,6 @@ import ( ) func TestProjectConfigs(t *testing.T) { - ctx := context.Background() - cases := map[string]struct { get *Cmd project string @@ -134,10 +131,10 @@ func TestProjectConfigs(t *testing.T) { require.NoError(t, err) buf := &bytes.Buffer{} - tc.get.writer = 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/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/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/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/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/print.go b/internal/format/print.go index e29e799c..efd394fd 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" @@ -24,6 +27,7 @@ type OutputFormatType int const ( SuccessChar = "✓" FailureChar = "✗" + InfoChar = "ℹ" spinnerPrefix = " " spinnerFrequency = 100 * time.Millisecond @@ -39,84 +43,55 @@ 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") +// 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) } -func PrintWarningf(msg string, a ...any) { - fmt.Printf(color.YellowString("Warning: ")+msg, a...) +// info returns a message for providing information. +func info(icon, message string) string { + return fmt.Sprintf(" %s %s %s", InfoChar, message, icon) } -// 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 +244,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 +317,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/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/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.go b/internal/format/writer.go new file mode 100644 index 00000000..c8d57178 --- /dev/null +++ b/internal/format/writer.go @@ -0,0 +1,104 @@ +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...)+"\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)+"\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...)+"\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...)+"\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. +// 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...) +} + +// 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(reader Reader, message string) (bool, error) { + var input string + + w.Printf("%s [y|n]: ", message) + _, err := fmt.Fscanln(reader, &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/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]:") + }) + } +} 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/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/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/logs/logs.go b/logs/logs.go index 2f077f9b..10944dbe 100644 --- a/logs/logs.go +++ b/logs/logs.go @@ -1,8 +1,10 @@ +// Package logs provides commands to retrieve and tail logs from deplo.io resources. package logs import ( "context" "fmt" + "io" "strings" "time" @@ -10,6 +12,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,21 +25,32 @@ type resourceCmd struct { } type logsCmd struct { - 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 +} + +// 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) -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 +60,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 +75,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 +92,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..7184b0e7 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,11 @@ +// Package main is the entry point for nctl. package main import ( "context" "errors" "fmt" + "io" "os" "os/signal" "reflect" @@ -64,6 +66,9 @@ var ( version string commit string date string + + writer = os.Stdout + reader = os.Stdin ) func main() { @@ -73,18 +78,22 @@ 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)), + kong.BindTo(reader, (*io.Reader)(nil)), ) apiClientRequired := !noAPIClientRequired(strings.Join(os.Args[1:], " ")) @@ -105,7 +114,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 +122,21 @@ func main() { parser.FatalIfErrorf(err) } - binds := []any{ctx} + binds := []any{ + ctx, + kong.BindTo(writer, (*io.Writer)(nil)), + kong.BindTo(reader, (*io.Reader)(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), + ) 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/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/application.go b/update/application.go index 69a78309..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 { @@ -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") 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", 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") 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", 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", 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/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.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/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/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..db790a9e 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.") } 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/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..2451726b 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.") } 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.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..b0b5869c 100644 --- a/update/project_config.go +++ b/update/project_config.go @@ -8,18 +8,30 @@ 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 { - 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 `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."` + 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( + 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 { @@ -30,7 +42,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/project_test.go b/update/project_test.go index 917fdd17..77fa422d 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 %q, got %q", projectName, out.String()) + } }) } } 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/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()) + } + } }) } } diff --git a/update/update.go b/update/update.go index 2546aead..94926f45 100644 --- a/update/update.go +++ b/update/update.go @@ -1,7 +1,9 @@ +// Package update contains the commands for updating resources. package update import ( "context" + "io" "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/ninech/nctl/api" @@ -26,10 +28,17 @@ type Cmd struct { } type resourceCmd struct { - 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."` +} + +// 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 client *api.Client kind string @@ -38,8 +47,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 +69,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", u.kind, u.mg.GetName()) return nil }