diff --git a/cmd/upctl/main.go b/cmd/upctl/main.go index 666c2cd87..757d9de8a 100644 --- a/cmd/upctl/main.go +++ b/cmd/upctl/main.go @@ -7,7 +7,6 @@ import ( ) func main() { - if err := core.BootstrapCLI(os.Args); err != nil { - os.Exit(1) - } + exitCode := core.Execute() + os.Exit(exitCode) } diff --git a/internal/clierrors/command_failed.go b/internal/clierrors/command_failed.go new file mode 100644 index 000000000..5d2e510ca --- /dev/null +++ b/internal/clierrors/command_failed.go @@ -0,0 +1,11 @@ +package clierrors + +import "fmt" + +type CommandFailedError struct { + FailedCount int +} + +func (err CommandFailedError) Error() string { + return fmt.Sprintf("Command execution failed for %d resource(s)", err.FailedCount) +} diff --git a/internal/commands/all/all.go b/internal/commands/all/all.go index 27f02ba34..d76a8bc79 100644 --- a/internal/commands/all/all.go +++ b/internal/commands/all/all.go @@ -11,9 +11,6 @@ import ( "github.com/UpCloudLtd/upcloud-cli/internal/commands/root" "github.com/UpCloudLtd/upcloud-cli/internal/commands/router" "github.com/UpCloudLtd/upcloud-cli/internal/commands/server" - serverfirewall "github.com/UpCloudLtd/upcloud-cli/internal/commands/server/firewall" - "github.com/UpCloudLtd/upcloud-cli/internal/commands/server/networkinterface" - serverstorage "github.com/UpCloudLtd/upcloud-cli/internal/commands/server/storage" "github.com/UpCloudLtd/upcloud-cli/internal/commands/storage" "github.com/UpCloudLtd/upcloud-cli/internal/commands/zone" "github.com/UpCloudLtd/upcloud-cli/internal/config" @@ -21,38 +18,9 @@ import ( "github.com/spf13/cobra" ) -// BuildCommands is the main function that sets up the commands provided by upctl. +// BuildCommands is the main function that sets up the top-level commands provided by upctl. func BuildCommands(rootCmd *cobra.Command, conf *config.Config) { - // Servers - serverCommand := commands.BuildCommand(server.BaseServerCommand(), rootCmd, conf) - commands.BuildCommand(server.ListCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.PlanListCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.ShowCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.StartCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.RestartCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.StopCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.CreateCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.ModifyCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.LoadCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.EjectCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(server.DeleteCommand(), serverCommand.Cobra(), conf) - - // Server Network Interfaces - networkInterfaceCommand := commands.BuildCommand(networkinterface.BaseNetworkInterfaceCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(networkinterface.CreateCommand(), networkInterfaceCommand.Cobra(), conf) - commands.BuildCommand(networkinterface.ModifyCommand(), networkInterfaceCommand.Cobra(), conf) - commands.BuildCommand(networkinterface.DeleteCommand(), networkInterfaceCommand.Cobra(), conf) - - // Server storage operations - serverStorageCommand := commands.BuildCommand(serverstorage.BaseServerStorageCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(serverstorage.AttachCommand(), serverStorageCommand.Cobra(), conf) - commands.BuildCommand(serverstorage.DetachCommand(), serverStorageCommand.Cobra(), conf) - - // Server firewall operations - serverFirewallCommand := commands.BuildCommand(serverfirewall.BaseServerFirewallCommand(), serverCommand.Cobra(), conf) - commands.BuildCommand(serverfirewall.CreateCommand(), serverFirewallCommand.Cobra(), conf) - commands.BuildCommand(serverfirewall.DeleteCommand(), serverFirewallCommand.Cobra(), conf) - commands.BuildCommand(serverfirewall.ShowCommand(), serverFirewallCommand.Cobra(), conf) + commands.BuildCommand(server.BaseServerCommand(), rootCmd, conf) // Storages storageCommand := commands.BuildCommand(storage.BaseStorageCommand(), rootCmd, conf) diff --git a/internal/commands/command.go b/internal/commands/command.go index cba6f78a8..23ae095f3 100644 --- a/internal/commands/command.go +++ b/internal/commands/command.go @@ -27,6 +27,7 @@ func New(name, usage string, examples ...string) *BaseCommand { // Command is the base command type for all commands. type Command interface { InitCommand() + BuildSubCommands(*config.Config) CobraCommand } @@ -115,6 +116,8 @@ func BuildCommand(child Command, parent *cobra.Command, config *config.Config) C return commandRunE(child, service, config, args) } + child.BuildSubCommands(config) + return child } @@ -139,12 +142,15 @@ func (s *BaseCommand) AddFlags(flags *pflag.FlagSet) { }) } -// InitCommand can be overridden to handle flag registration. -// A hook to handle flag registration. +// InitCommand is a hook for handling flag registration and other initialization action. // The config values are not available during this hook. Register a cobra hook to use them. You can set defaults though. func (s *BaseCommand) InitCommand() { } +// BuildSubCommands is a hook for handling possible sub-commands. +func (s *BaseCommand) BuildSubCommands(*config.Config) { +} + // Cobra returns the underlying *cobra.Command func (s *BaseCommand) Cobra() *cobra.Command { return s.cobra diff --git a/internal/commands/runcommand.go b/internal/commands/runcommand.go index 66b56c5d0..b9f6af7ca 100644 --- a/internal/commands/runcommand.go +++ b/internal/commands/runcommand.go @@ -18,6 +18,9 @@ var ( ) func commandRunE(command Command, service internal.AllServices, config *config.Config, args []string) error { + // Cobra validations were successful + command.Cobra().SilenceUsage = true + cmdLogger := logger.With("command", command.Cobra().CommandPath()) executor := NewExecutor(config, service, cmdLogger) defer executor.Close() diff --git a/internal/commands/runcommand_test.go b/internal/commands/runcommand_test.go index ac89c5ec0..d09d74216 100644 --- a/internal/commands/runcommand_test.go +++ b/internal/commands/runcommand_test.go @@ -28,6 +28,10 @@ func (m *mockNone) InitCommand() { panic("implement me") } +func (m *mockNone) BuildSubCommands(*config.Config) { + panic("implement me") +} + func (m *mockNone) Cobra() *cobra.Command { return m.Command } @@ -46,6 +50,10 @@ func (m *mockSingle) InitCommand() { panic("implement me") } +func (m *mockSingle) BuildSubCommands(*config.Config) { + panic("implement me") +} + func (m *mockSingle) Cobra() *cobra.Command { return m.Command } @@ -68,6 +76,10 @@ func (m *mockMulti) InitCommand() { panic("implement me") } +func (m *mockMulti) BuildSubCommands(*config.Config) { + panic("implement me") +} + func (m *mockMulti) Cobra() *cobra.Command { return m.Command } @@ -106,6 +118,10 @@ func (m *mockMultiResolver) InitCommand() { panic("implement me") } +func (m *mockMultiResolver) BuildSubCommands(*config.Config) { + panic("implement me") +} + func (m *mockMultiResolver) Cobra() *cobra.Command { return m.Command } diff --git a/internal/commands/server/firewall/server_firewall.go b/internal/commands/server/firewall/server_firewall.go index e5cf5187c..8d6908182 100644 --- a/internal/commands/server/firewall/server_firewall.go +++ b/internal/commands/server/firewall/server_firewall.go @@ -2,6 +2,7 @@ package serverfirewall import ( "github.com/UpCloudLtd/upcloud-cli/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/internal/config" ) // BaseServerFirewallCommand is the root command for all 'server firewall' commands @@ -16,3 +17,9 @@ func BaseServerFirewallCommand() commands.Command { type serverFirewallCommand struct { *commands.BaseCommand } + +func (s *serverFirewallCommand) BuildSubCommands(cfg *config.Config) { + commands.BuildCommand(CreateCommand(), s.Cobra(), cfg) + commands.BuildCommand(DeleteCommand(), s.Cobra(), cfg) + commands.BuildCommand(ShowCommand(), s.Cobra(), cfg) +} diff --git a/internal/commands/server/networkinterface/network_interface.go b/internal/commands/server/networkinterface/network_interface.go index 3b305d0e2..0b9e7c306 100644 --- a/internal/commands/server/networkinterface/network_interface.go +++ b/internal/commands/server/networkinterface/network_interface.go @@ -3,6 +3,7 @@ package networkinterface import ( "github.com/UpCloudLtd/upcloud-cli/internal/commands" "github.com/UpCloudLtd/upcloud-cli/internal/commands/ipaddress" + "github.com/UpCloudLtd/upcloud-cli/internal/config" "github.com/UpCloudLtd/upcloud-go-api/v4/upcloud/request" ) @@ -17,6 +18,12 @@ type networkInterfaceCommand struct { *commands.BaseCommand } +func (n *networkInterfaceCommand) BuildSubCommands(cfg *config.Config) { + commands.BuildCommand(CreateCommand(), n.Cobra(), cfg) + commands.BuildCommand(ModifyCommand(), n.Cobra(), cfg) + commands.BuildCommand(DeleteCommand(), n.Cobra(), cfg) +} + func mapIPAddressesToRequest(ipStrings []string) ([]request.CreateNetworkInterfaceIPAddress, error) { var ipAddresses []request.CreateNetworkInterfaceIPAddress for _, ipAddrStr := range ipStrings { diff --git a/internal/commands/server/server.go b/internal/commands/server/server.go index b1cecd6df..079e6cd95 100644 --- a/internal/commands/server/server.go +++ b/internal/commands/server/server.go @@ -5,6 +5,10 @@ import ( "time" "github.com/UpCloudLtd/upcloud-cli/internal/commands" + serverfirewall "github.com/UpCloudLtd/upcloud-cli/internal/commands/server/firewall" + "github.com/UpCloudLtd/upcloud-cli/internal/commands/server/networkinterface" + serverstorage "github.com/UpCloudLtd/upcloud-cli/internal/commands/server/storage" + "github.com/UpCloudLtd/upcloud-cli/internal/config" "github.com/UpCloudLtd/upcloud-cli/internal/ui" "github.com/UpCloudLtd/upcloud-go-api/v4/upcloud/request" "github.com/UpCloudLtd/upcloud-go-api/v4/upcloud/service" @@ -31,6 +35,24 @@ type serverCommand struct { *commands.BaseCommand } +func (s *serverCommand) BuildSubCommands(cfg *config.Config) { + commands.BuildCommand(ListCommand(), s.Cobra(), cfg) + commands.BuildCommand(PlanListCommand(), s.Cobra(), cfg) + commands.BuildCommand(ShowCommand(), s.Cobra(), cfg) + commands.BuildCommand(StartCommand(), s.Cobra(), cfg) + commands.BuildCommand(RestartCommand(), s.Cobra(), cfg) + commands.BuildCommand(StopCommand(), s.Cobra(), cfg) + commands.BuildCommand(CreateCommand(), s.Cobra(), cfg) + commands.BuildCommand(ModifyCommand(), s.Cobra(), cfg) + commands.BuildCommand(LoadCommand(), s.Cobra(), cfg) + commands.BuildCommand(EjectCommand(), s.Cobra(), cfg) + commands.BuildCommand(DeleteCommand(), s.Cobra(), cfg) + + commands.BuildCommand(networkinterface.BaseNetworkInterfaceCommand(), s.Cobra(), cfg) + commands.BuildCommand(serverstorage.BaseServerStorageCommand(), s.Cobra(), cfg) + commands.BuildCommand(serverfirewall.BaseServerFirewallCommand(), s.Cobra(), cfg) +} + // waitForServerState waits for server to reach given state and updates given logline with wait progress. Finally, logline is updated with given msg and either done state or timeout warning. func waitForServerState(uuid, state string, service service.Server, logline *ui.LogEntry, msg string) { logline.SetMessage(fmt.Sprintf("Waiting for server %s to be in %s state: polling", uuid, state)) diff --git a/internal/commands/server/storage/server_storage.go b/internal/commands/server/storage/server_storage.go index 8bacf0399..7e8422a3d 100644 --- a/internal/commands/server/storage/server_storage.go +++ b/internal/commands/server/storage/server_storage.go @@ -2,6 +2,7 @@ package serverstorage import ( "github.com/UpCloudLtd/upcloud-cli/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/internal/config" ) const ( @@ -16,3 +17,8 @@ func BaseServerStorageCommand() commands.Command { type serverStorageCommand struct { *commands.BaseCommand } + +func (s *serverStorageCommand) BuildSubCommands(cfg *config.Config) { + commands.BuildCommand(AttachCommand(), s.Cobra(), cfg) + commands.BuildCommand(DetachCommand(), s.Cobra(), cfg) +} diff --git a/internal/core/core.go b/internal/core/core.go index 6cdbba984..68704f831 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -1,9 +1,11 @@ package core import ( + "errors" "fmt" "os" + "github.com/UpCloudLtd/upcloud-cli/internal/clierrors" "github.com/UpCloudLtd/upcloud-cli/internal/commands" "github.com/UpCloudLtd/upcloud-cli/internal/commands/all" "github.com/UpCloudLtd/upcloud-cli/internal/config" @@ -124,8 +126,29 @@ func BuildCLI() cobra.Command { return rootCmd } -// BootstrapCLI is the CLI entrypoint -func BootstrapCLI(args []string) error { +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// Execute is the application entrypoint. It returns the exit code that should be forwarded to the shell. +// +// Exit codes: +// 0-99: Number of failed executions +// 100-: Other, non-execution related, errors, e.g., flag validation failed +func Execute() int { rootCmd := BuildCLI() - return rootCmd.Execute() + err := rootCmd.Execute() + + if err != nil { + var commandFailed *clierrors.CommandFailedError + if errors.As(err, &commandFailed) { + return min(commandFailed.FailedCount, 99) + } + return 100 + } + + return 0 } diff --git a/internal/core/core_test.go b/internal/core/core_test.go index 2e979969d..99c96a062 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -132,3 +132,30 @@ func TestInputValidation(t *testing.T) { }) } } + +func TestExecute(t *testing.T) { + realArgs := os.Args + defer func() { os.Args = realArgs }() + + for _, test := range []struct { + name string + args []string + expected int + }{ + { + name: "Successful command (upctl version)", + args: []string{"upctl", "version"}, + expected: 0, + }, + { + name: "Failing command (upctl server create)", + args: []string{"upctl", "server", "create"}, + expected: 100, + }, + } { + t.Run(test.name, func(t *testing.T) { + os.Args = test.args + assert.Equal(t, test.expected, Execute()) + }) + } +} diff --git a/internal/output/render.go b/internal/output/render.go index bbeba638c..1bc50255b 100644 --- a/internal/output/render.go +++ b/internal/output/render.go @@ -7,6 +7,7 @@ import ( "io" "strings" + "github.com/UpCloudLtd/upcloud-cli/internal/clierrors" "github.com/UpCloudLtd/upcloud-cli/internal/config" ) @@ -74,5 +75,19 @@ func Render(writer io.Writer, cfg *config.Config, commandOutputs ...Output) (err if _, err := writer.Write(output); err != nil { return err } + + // Count failed outputs + var failedCount = 0 + for _, commandOutput := range commandOutputs { + if _, ok := commandOutput.(Error); ok { + failedCount++ + } + } + + if failedCount > 0 { + return &clierrors.CommandFailedError{ + FailedCount: failedCount, + } + } return nil }