diff --git a/cmd/apikey/apikey_remove.go b/cmd/apikey/apikey_remove.go index afd0ea51..34cc79d2 100644 --- a/cmd/apikey/apikey_remove.go +++ b/cmd/apikey/apikey_remove.go @@ -58,7 +58,7 @@ var apikeyRemoveCmd = &cobra.Command{ os.Exit(1) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, diff --git a/cmd/apikey/apikey_save.go b/cmd/apikey/apikey_save.go index 08c3b8a1..2ffce744 100644 --- a/cmd/apikey/apikey_save.go +++ b/cmd/apikey/apikey_save.go @@ -60,6 +60,11 @@ var apikeySaveCmd = &cobra.Command{ } if len(args) == 0 && !loadAPIKeyFromEnv { + if common.Quiet { + utility.Error("name and API key are required when --quiet is set (interactive prompts are disabled); run 'civo apikey save NAME APIKEY' or use --load-from-env") + os.Exit(1) + } + reader := bufio.NewReader(os.Stdin) fmt.Printf("Enter a nice name for this account/API Key: ") @@ -140,7 +145,9 @@ var apikeySaveCmd = &cobra.Command{ case "custom": ow.WriteCustomOutput(common.OutputFields) default: - fmt.Printf("Saved the API Key %s\n", utility.Green(name)) + if !common.Quiet { + fmt.Printf("Saved the API Key %s\n", utility.Green(name)) + } } }, diff --git a/cmd/database/database_backup_delete.go b/cmd/database/database_backup_delete.go index f9010040..042e5a84 100644 --- a/cmd/database/database_backup_delete.go +++ b/cmd/database/database_backup_delete.go @@ -113,7 +113,7 @@ var dbBackupDeleteCmd = &cobra.Command{ pluralize.Has(len(backupList))) } } else { - fmt.Println("Operation aborted") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/database/database_create.go b/cmd/database/database_create.go index a0cd5aff..b7f21309 100644 --- a/cmd/database/database_create.go +++ b/cmd/database/database_create.go @@ -176,8 +176,7 @@ var dbCreateCmd = &cobra.Command{ startTime := utility.StartTime() stillCreating := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = fmt.Sprintf("Create a database called %s ", db.Name) s.Start() @@ -205,10 +204,12 @@ var dbCreateCmd = &cobra.Command{ case "custom": ow.WriteCustomOutput(common.OutputFields) default: - if executionTime != "" { - fmt.Printf("Database (%s) type %s version %s with ID %s and size %s has been created in %s\nTo get fetch the database credentials use the command:\n\ncivo database credentials %s\n", utility.Green(db.Name), strings.ToLower(db.Software), db.SoftwareVersion, db.ID, db.Size, executionTime, db.Name) - } else { - fmt.Printf("Database (%s) type %s version %s with ID %s and size %s has been created\nTo get fetch the database credentials use the command:\n\ncivo database credentials %s\n", utility.Green(db.Name), strings.ToLower(db.Software), db.SoftwareVersion, db.ID, db.Size, db.Name) + if !common.Quiet { + if executionTime != "" { + fmt.Printf("Database (%s) type %s version %s with ID %s and size %s has been created in %s\nTo get fetch the database credentials use the command:\n\ncivo database credentials %s\n", utility.Green(db.Name), strings.ToLower(db.Software), db.SoftwareVersion, db.ID, db.Size, executionTime, db.Name) + } else { + fmt.Printf("Database (%s) type %s version %s with ID %s and size %s has been created\nTo get fetch the database credentials use the command:\n\ncivo database credentials %s\n", utility.Green(db.Name), strings.ToLower(db.Software), db.SoftwareVersion, db.ID, db.Size, db.Name) + } } } }, diff --git a/cmd/database/database_credential.go b/cmd/database/database_credential.go index a5c7676a..4cb34d42 100644 --- a/cmd/database/database_credential.go +++ b/cmd/database/database_credential.go @@ -46,10 +46,11 @@ var dbCredentialCmd = &cobra.Command{ // Add check for database status if db.Status == "Pending" { - fmt.Printf("The DB %s is currently being provisioned, please wait...\n", utility.Green(db.Name)) + if !common.Quiet { + fmt.Printf("The DB %s is currently being provisioned, please wait...\n", utility.Green(db.Name)) + } - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = fmt.Sprintf("Waiting for database (%s)... ", db.Name) s.Start() diff --git a/cmd/database/database_delete.go b/cmd/database/database_delete.go index eaad6568..2eaf5df6 100644 --- a/cmd/database/database_delete.go +++ b/cmd/database/database_delete.go @@ -102,7 +102,7 @@ var dbDeleteCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/domain/domain_record_remove.go b/cmd/domain/domain_record_remove.go index 22c346f0..6fea30dc 100644 --- a/cmd/domain/domain_record_remove.go +++ b/cmd/domain/domain_record_remove.go @@ -105,7 +105,7 @@ var domainRecordRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, diff --git a/cmd/domain/domain_remove.go b/cmd/domain/domain_remove.go index c64e9fc7..df9b022f 100644 --- a/cmd/domain/domain_remove.go +++ b/cmd/domain/domain_remove.go @@ -93,7 +93,7 @@ var domainRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/firewall/firewall_remove.go b/cmd/firewall/firewall_remove.go index 9cca91f5..b3f180b8 100644 --- a/cmd/firewall/firewall_remove.go +++ b/cmd/firewall/firewall_remove.go @@ -97,7 +97,7 @@ var firewallRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/firewall/firewall_rule_remove.go b/cmd/firewall/firewall_rule_remove.go index cf6a2311..85820c68 100644 --- a/cmd/firewall/firewall_rule_remove.go +++ b/cmd/firewall/firewall_rule_remove.go @@ -109,7 +109,7 @@ var firewallRuleRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, diff --git a/cmd/instance/instance_create.go b/cmd/instance/instance_create.go index 13e56c7a..f231152e 100644 --- a/cmd/instance/instance_create.go +++ b/cmd/instance/instance_create.go @@ -300,8 +300,7 @@ If you wish to use a custom format, the available fields are: if wait { stillCreating := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = fmt.Sprintf("Creating instance (%s)... ", resp.Hostname) s.Start() @@ -331,10 +330,12 @@ If you wish to use a custom format, the available fields are: } if common.OutputFormat == common.OutputFormatHuman { - if executionTime != "" { - fmt.Printf("The instance %s %s has been created in %s\n", utility.Green(instance.Hostname), publicIP, executionTime) - } else { - fmt.Printf("The instance %s has been created\n", utility.Green(instance.Hostname)) + if !common.Quiet { + if executionTime != "" { + fmt.Printf("The instance %s %s has been created in %s\n", utility.Green(instance.Hostname), publicIP, executionTime) + } else { + fmt.Printf("The instance %s has been created\n", utility.Green(instance.Hostname)) + } } } else { ow := utility.NewOutputWriter() diff --git a/cmd/instance/instance_remove.go b/cmd/instance/instance_remove.go index 688a1bf0..34619512 100644 --- a/cmd/instance/instance_remove.go +++ b/cmd/instance/instance_remove.go @@ -104,7 +104,7 @@ If you wish to use a custom format, the available fields are: ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/instance/instance_stop.go b/cmd/instance/instance_stop.go index a33bcbd9..00f4f3f6 100644 --- a/cmd/instance/instance_stop.go +++ b/cmd/instance/instance_stop.go @@ -50,8 +50,7 @@ If you wish to use a custom format, the available fields are: if waitStop { stillStopping := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = "Stopping instance... " s.Start() @@ -71,7 +70,9 @@ If you wish to use a custom format, the available fields are: } if common.OutputFormat == common.OutputFormatHuman { - fmt.Printf("The instance %s (%s) is being stopped\n", utility.Green(instance.Hostname), instance.ID) + if !common.Quiet { + fmt.Printf("The instance %s (%s) is being stopped\n", utility.Green(instance.Hostname), instance.ID) + } } else { ow := utility.NewOutputWriter() ow.StartLine() diff --git a/cmd/ip/ip_delete.go b/cmd/ip/ip_delete.go index ef1675cf..2526a5d4 100644 --- a/cmd/ip/ip_delete.go +++ b/cmd/ip/ip_delete.go @@ -66,7 +66,7 @@ Please make sure to delete your domains aren't pointed to it before deleting it. fmt.Printf("IP called %s with ID %s was deleted\n", utility.Green(ip.Name), utility.Green(ip.ID)) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/kubernetes/kubernetes.go b/cmd/kubernetes/kubernetes.go index 1da9d0de..48c880e7 100644 --- a/cmd/kubernetes/kubernetes.go +++ b/cmd/kubernetes/kubernetes.go @@ -104,6 +104,7 @@ func init() { kubernetesCreateCmd.Flags().StringVarP(&cniPlugin, "cni-plugin", "p", "flannel", "optional, possible options: flannel,cilium.") kubernetesCreateCmd.Flags().StringVarP(&clusterType, "cluster-type", "", "k3s", "optional, possible options: k3s,talos.") kubernetesCreateCmd.Flags().StringVar(&volumeType, "volume-type", "", "optional, volume-type name as returned by 'civo volumetypes ls'") + kubernetesCreateCmd.Flags().BoolVar(&logsCollectorEnabled, "logs-collector-enabled", true, "optional, disabling this will prevent you from seeing logs in the Civo platform dashboard, but it will save node resources. Disabled by default on small and xsmall standard nodes") kubernetesRenameCmd.Flags().StringVarP(&kubernetesNewName, "name", "n", "", "the new name for the cluster.") diff --git a/cmd/kubernetes/kubernetes_config.go b/cmd/kubernetes/kubernetes_config.go index feca5750..7660ee42 100644 --- a/cmd/kubernetes/kubernetes_config.go +++ b/cmd/kubernetes/kubernetes_config.go @@ -93,7 +93,7 @@ If you wish to use a custom format, the available fields are: os.Exit(1) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") os.Exit(1) } } else { diff --git a/cmd/kubernetes/kubernetes_create.go b/cmd/kubernetes/kubernetes_create.go index 53d3402f..cbe7c7b4 100644 --- a/cmd/kubernetes/kubernetes_create.go +++ b/cmd/kubernetes/kubernetes_create.go @@ -212,7 +212,7 @@ var kubernetesCreateCmd = &cobra.Command{ os.Exit(1) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") os.Exit(1) } } else { @@ -233,8 +233,7 @@ var kubernetesCreateCmd = &cobra.Command{ startTime := utility.StartTime() stillCreating := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = fmt.Sprintf("Creating a %d node %s cluster of %s instances called %s... ", kubernetesCluster.NumTargetNode, clusterType, kubernetesCluster.TargetNodeSize, kubernetesCluster.Name) s.Start() @@ -278,10 +277,12 @@ var kubernetesCreateCmd = &cobra.Command{ case "custom": ow.WriteCustomOutput(common.OutputFields) default: - if executionTime != "" { - fmt.Printf("The cluster %s (%s) has been created in %s\n", utility.Green(kubernetesCluster.Name), kubernetesCluster.ID, executionTime) - } else { - fmt.Printf("The cluster %s (%s) has been created\n", utility.Green(kubernetesCluster.Name), kubernetesCluster.ID) + if !common.Quiet { + if executionTime != "" { + fmt.Printf("The cluster %s (%s) has been created in %s\n", utility.Green(kubernetesCluster.Name), kubernetesCluster.ID, executionTime) + } else { + fmt.Printf("The cluster %s (%s) has been created\n", utility.Green(kubernetesCluster.Name), kubernetesCluster.ID) + } } } diff --git a/cmd/kubernetes/kubernetes_nodepool_delete.go b/cmd/kubernetes/kubernetes_nodepool_delete.go index af2c42df..39b4102e 100644 --- a/cmd/kubernetes/kubernetes_nodepool_delete.go +++ b/cmd/kubernetes/kubernetes_nodepool_delete.go @@ -108,7 +108,7 @@ var kubernetesNodePoolDeleteCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/kubernetes/kubernetes_nodepool_instance_delete.go b/cmd/kubernetes/kubernetes_nodepool_instance_delete.go index a3a62b86..6cd457bb 100644 --- a/cmd/kubernetes/kubernetes_nodepool_instance_delete.go +++ b/cmd/kubernetes/kubernetes_nodepool_instance_delete.go @@ -78,7 +78,7 @@ var kubernetesNodePoolInstanceDeleteCmd = &cobra.Command{ fmt.Printf("Instance %s has been deleted from node pool %s in cluster %s\n", instanceID, nodePoolID, kubernetesFindCluster.Name) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/kubernetes/kubernetes_remove.go b/cmd/kubernetes/kubernetes_remove.go index ffef9929..d4156620 100644 --- a/cmd/kubernetes/kubernetes_remove.go +++ b/cmd/kubernetes/kubernetes_remove.go @@ -128,7 +128,7 @@ var kubernetesRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/network/network_remove.go b/cmd/network/network_remove.go index be2e1e18..65df5d0e 100644 --- a/cmd/network/network_remove.go +++ b/cmd/network/network_remove.go @@ -113,7 +113,7 @@ var networkRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, diff --git a/cmd/objectstore/objectstore_create.go b/cmd/objectstore/objectstore_create.go index da9fc86e..396c4bbc 100644 --- a/cmd/objectstore/objectstore_create.go +++ b/cmd/objectstore/objectstore_create.go @@ -64,13 +64,22 @@ var objectStoreCreateCmd = &cobra.Command{ utility.Error("The minimum size to create an object store is 500 GB. Please provide a valid size.") os.Exit(1) } else if bucketSize%500 != 0 { - utility.YellowConfirm("The size to create an object store must be a multiple of 500. Would you like to create an %s of %d GB instead? (y/n) ? ", utility.Green("object store"), bucketSize+(500-bucketSize%500)) - _, err := utility.UserAccepts(os.Stdin) - if err != nil { - utility.Error("Unable to parse the input: %s", err) - os.Exit(1) + roundedSize := bucketSize + (500 - bucketSize%500) + if common.Quiet { + if !common.DefaultYes { + utility.Error("The size to create an object store must be a multiple of 500 GB; re-run with --size %d, or combine --quiet with --yes to round up automatically", roundedSize) + os.Exit(1) + } + bucketSize = roundedSize + } else { + utility.YellowConfirm("The size to create an object store must be a multiple of 500. Would you like to create an %s of %d GB instead? (y/n) ? ", utility.Green("object store"), roundedSize) + _, err := utility.UserAccepts(os.Stdin) + if err != nil { + utility.Error("Unable to parse the input: %s", err) + os.Exit(1) + } + bucketSize = roundedSize } - bucketSize = bucketSize + (500 - bucketSize%500) } var credential *civogo.ObjectStoreCredential @@ -107,8 +116,7 @@ var objectStoreCreateCmd = &cobra.Command{ if waitOS { startTime := utility.StartTime() stillCreating := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = fmt.Sprintf("Creating an Object Store with maxSize %d, called %s... ", store.MaxSize, store.Name) s.Start() @@ -143,13 +151,15 @@ var objectStoreCreateCmd = &cobra.Command{ case "custom": ow.WriteCustomOutput(common.OutputFields) default: - if waitOS { - fmt.Printf("Created Object Store %s in %s in %s\n", utility.Green(objectStore.Name), utility.Green(client.Region), executionTime) - fmt.Printf("Created default admin credentials, access key is %s, this will be deleted if the Object Store is deleted. ", utility.Green(objectStore.OwnerInfo.AccessKeyID)) - fmt.Printf("To access the secret key run: civo objectstore credential secret --access-key=%s\n", utility.Green(objectStore.OwnerInfo.AccessKeyID)) - } else { - fmt.Printf("Creating Object Store %s in %s\n", utility.Green(objectStore.Name), utility.Green(client.Region)) - fmt.Printf("To check the status of the Object Store run: civo objectstore show %s\n", utility.Green(objectStore.Name)) + if !common.Quiet { + if waitOS { + fmt.Printf("Created Object Store %s in %s in %s\n", utility.Green(objectStore.Name), utility.Green(client.Region), executionTime) + fmt.Printf("Created default admin credentials, access key is %s, this will be deleted if the Object Store is deleted. ", utility.Green(objectStore.OwnerInfo.AccessKeyID)) + fmt.Printf("To access the secret key run: civo objectstore credential secret --access-key=%s\n", utility.Green(objectStore.OwnerInfo.AccessKeyID)) + } else { + fmt.Printf("Creating Object Store %s in %s\n", utility.Green(objectStore.Name), utility.Green(client.Region)) + fmt.Printf("To check the status of the Object Store run: civo objectstore show %s\n", utility.Green(objectStore.Name)) + } } } }, diff --git a/cmd/objectstore/objectstore_credential_create.go b/cmd/objectstore/objectstore_credential_create.go index 807d8843..569db9c7 100644 --- a/cmd/objectstore/objectstore_credential_create.go +++ b/cmd/objectstore/objectstore_credential_create.go @@ -57,8 +57,7 @@ var objectStoreCredentialCreateCmd = &cobra.Command{ if waitOS { startTime := utility.StartTime() stillCreating := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = fmt.Sprintf("Creating an Object Store Credential with maxSize %d, called %s... ", credential.MaxSizeGB, credential.Name) s.Start() @@ -92,10 +91,12 @@ var objectStoreCredentialCreateCmd = &cobra.Command{ case "custom": ow.WriteCustomOutput(common.OutputFields) default: - if waitOS { - fmt.Printf("Created Object Store Credential %s in %s in %s\n", utility.Green(objectStoreCred.Name), utility.Green(client.Region), executionTime) - } else { - fmt.Printf("Creating Object Store Credential %s in %s\n", utility.Green(objectStoreCred.Name), utility.Green(client.Region)) + if !common.Quiet { + if waitOS { + fmt.Printf("Created Object Store Credential %s in %s in %s\n", utility.Green(objectStoreCred.Name), utility.Green(client.Region), executionTime) + } else { + fmt.Printf("Creating Object Store Credential %s in %s\n", utility.Green(objectStoreCred.Name), utility.Green(client.Region)) + } } } }, diff --git a/cmd/objectstore/objectstore_credential_delete.go b/cmd/objectstore/objectstore_credential_delete.go index abde307a..817e5d2a 100644 --- a/cmd/objectstore/objectstore_credential_delete.go +++ b/cmd/objectstore/objectstore_credential_delete.go @@ -102,7 +102,7 @@ var objectStoreCredentialDeleteCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/objectstore/objectstore_delete.go b/cmd/objectstore/objectstore_delete.go index b74ae0ec..894c18a9 100644 --- a/cmd/objectstore/objectstore_delete.go +++ b/cmd/objectstore/objectstore_delete.go @@ -103,7 +103,7 @@ var objectStoreDeleteCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/root.go b/cmd/root.go index 63e4a216..f84ecf24 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -142,6 +142,7 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&common.DefaultYes, "yes", "y", false, "Automatic yes to prompts; assume \"yes\" as answer to all prompts and run non-interactively") rootCmd.PersistentFlags().StringVarP(&common.RegionSet, "region", "", "", "Choose the region to connect to, if you use this option it will use it over the default region") rootCmd.PersistentFlags().BoolVarP(&common.PrettySet, "pretty", "", false, "Print pretty the json output") + rootCmd.PersistentFlags().BoolVarP(&common.Quiet, "quiet", "q", false, "Suppress interactive prompts, progress indicators and non-error output; useful for scripting. Prompts that would otherwise require confirmation will fail the command instead - combine with --yes to auto-confirm them") rootCmd.Flags().BoolVarP(&version, "version", "v", false, "Print the version of the CLI") rootCmd.AddCommand(apikey.APIKeyCmd) diff --git a/cmd/sshkey/ssh_key_remove.go b/cmd/sshkey/ssh_key_remove.go index b2f1bf6c..3ed9adc1 100644 --- a/cmd/sshkey/ssh_key_remove.go +++ b/cmd/sshkey/ssh_key_remove.go @@ -86,7 +86,7 @@ var sshKeyRemoveCmd = &cobra.Command{ fmt.Printf("The ssh %s (%s) has been deleted\n", pluralize.Pluralize(len(sshList), "key"), utility.Green(strings.Join(sshKeyNameList, ", "))) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/teams/teams_delete.go b/cmd/teams/teams_delete.go index 3b60362d..9d4620f0 100644 --- a/cmd/teams/teams_delete.go +++ b/cmd/teams/teams_delete.go @@ -85,7 +85,7 @@ var teamsDeleteCmd = &cobra.Command{ fmt.Printf("The team %s(%s) has been deleted\n", pluralize.Pluralize(len(teamList), ""), utility.Green(strings.Join(teamNameList, ", "))) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/volume/volume_attach.go b/cmd/volume/volume_attach.go index 4f6f78d1..b2e9440d 100644 --- a/cmd/volume/volume_attach.go +++ b/cmd/volume/volume_attach.go @@ -80,8 +80,7 @@ var volumeAttachCmd = &cobra.Command{ if waitVolumeAttach { stillAttaching := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = "Attaching volume to the instance... " s.Start() @@ -113,7 +112,9 @@ var volumeAttachCmd = &cobra.Command{ case "custom": ow.WriteCustomOutput(common.OutputFields) default: - fmt.Printf("The volume called %s with ID %s was attached to the instance %s\n", utility.Green(volume.Name), utility.Green(volume.ID), utility.Green(instance.Hostname)) + if !common.Quiet { + fmt.Printf("The volume called %s with ID %s was attached to the instance %s\n", utility.Green(volume.Name), utility.Green(volume.ID), utility.Green(instance.Hostname)) + } } }, } diff --git a/cmd/volume/volume_detach.go b/cmd/volume/volume_detach.go index 1ad956b3..4db90107 100644 --- a/cmd/volume/volume_detach.go +++ b/cmd/volume/volume_detach.go @@ -53,8 +53,7 @@ var volumeDetachCmd = &cobra.Command{ if waitVolumeDetach { stillDetaching := true - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Writer = os.Stderr + s := utility.NewSpinner(spinner.CharSets[9], 100*time.Millisecond) s.Prefix = "Detaching the volume... " s.Start() @@ -81,7 +80,9 @@ var volumeDetachCmd = &cobra.Command{ case "custom": ow.WriteCustomOutput(common.OutputFields) default: - fmt.Printf("The volume called %s with ID %s was detached\n", utility.Green(volume.Name), utility.Green(volume.ID)) + if !common.Quiet { + fmt.Printf("The volume called %s with ID %s was detached\n", utility.Green(volume.Name), utility.Green(volume.ID)) + } } }, } diff --git a/cmd/volume/volume_remove.go b/cmd/volume/volume_remove.go index 9f861e91..ef6e4f68 100644 --- a/cmd/volume/volume_remove.go +++ b/cmd/volume/volume_remove.go @@ -104,7 +104,7 @@ var volumeRemoveCmd = &cobra.Command{ pluralize.Has(len(volumeNameList))) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/vpc/vpc_firewall_remove.go b/cmd/vpc/vpc_firewall_remove.go index 92c4a3aa..22e00eb8 100644 --- a/cmd/vpc/vpc_firewall_remove.go +++ b/cmd/vpc/vpc_firewall_remove.go @@ -93,7 +93,7 @@ var vpcFirewallRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/vpc/vpc_firewall_rule_remove.go b/cmd/vpc/vpc_firewall_rule_remove.go index c0f1a9e7..782ea4c6 100644 --- a/cmd/vpc/vpc_firewall_rule_remove.go +++ b/cmd/vpc/vpc_firewall_rule_remove.go @@ -105,7 +105,7 @@ var vpcFirewallRuleRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/vpc/vpc_ip_remove.go b/cmd/vpc/vpc_ip_remove.go index 11d84e74..3cb00428 100644 --- a/cmd/vpc/vpc_ip_remove.go +++ b/cmd/vpc/vpc_ip_remove.go @@ -62,7 +62,7 @@ var vpcIPRemoveCmd = &cobra.Command{ fmt.Printf("VPC IP called %s with ID %s was deleted\n", utility.Green(ip.Name), utility.Green(ip.ID)) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/vpc/vpc_loadbalancer_remove.go b/cmd/vpc/vpc_loadbalancer_remove.go index 0abad703..8352b300 100644 --- a/cmd/vpc/vpc_loadbalancer_remove.go +++ b/cmd/vpc/vpc_loadbalancer_remove.go @@ -93,7 +93,7 @@ var vpcLoadBalancerRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/vpc/vpc_network_remove.go b/cmd/vpc/vpc_network_remove.go index 4970ed29..a7572010 100644 --- a/cmd/vpc/vpc_network_remove.go +++ b/cmd/vpc/vpc_network_remove.go @@ -103,7 +103,7 @@ var vpcNetworkRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/cmd/vpc/vpc_subnet_remove.go b/cmd/vpc/vpc_subnet_remove.go index e379c0b7..abfaf4c1 100644 --- a/cmd/vpc/vpc_subnet_remove.go +++ b/cmd/vpc/vpc_subnet_remove.go @@ -101,7 +101,7 @@ var vpcSubnetRemoveCmd = &cobra.Command{ ) } } else { - fmt.Println("Operation aborted.") + utility.Error("Operation aborted.") } }, } diff --git a/common/common.go b/common/common.go index e07fa035..0392273d 100644 --- a/common/common.go +++ b/common/common.go @@ -17,6 +17,10 @@ var ( RegionSet string // DefaultYes : automatic yes to prompts; assume \"yes\" as answer to all prompts and run non-interactively DefaultYes bool + // Quiet suppresses interactive prompts, progress indicators, and non-error + // output. Interactive prompts fail instead of blocking when no confirmed + // default (--yes) is available. Set via the global --quiet/-q flag. + Quiet bool // PrettySet : Prints the json output in pretty format PrettySet bool // VersionCli is set from outside using ldflags diff --git a/utility/color_util.go b/utility/color_util.go index 0f6fc3dd..07c905cb 100644 --- a/utility/color_util.go +++ b/utility/color_util.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/civo/cli/common" "github.com/gookit/color" ) @@ -44,28 +45,44 @@ func Red(value string) string { } // Error is the function to handler all error in the Cli +// Errors are always shown, even in --quiet mode, so failures are never silently swallowed. func Error(msg string, args ...interface{}) { fmt.Fprintf(os.Stderr, "%s: %s\n", color.Red.Sprintf("Error"), fmt.Sprintf(msg, args...)) } // Info is the function to handler all info messages in the Cli +// Suppressed in --quiet mode. func Info(msg string, args ...interface{}) { + if common.Quiet { + return + } fmt.Fprintf(os.Stderr, "%s: %s\n", color.Blue.Sprintf("Info"), fmt.Sprintf(msg, args...)) } // Warning is the function to handler all warnings in the Cli +// Suppressed in --quiet mode. func Warning(msg string, args ...interface{}) { + if common.Quiet { + return + } fmt.Fprintf(os.Stderr, "%s: %s\n", color.Yellow.Sprintf("Warning"), fmt.Sprintf(msg, args...)) } // YellowConfirm is the function to handler all delete confirm +// Suppressed in --quiet mode (confirmation prompts are skipped entirely when quiet; see AskForConfirm). func YellowConfirm(msg string, args ...interface{}) { + if common.Quiet { + return + } fmt.Fprintf(os.Stderr, "%s: %s", color.Warn.Sprintf("Warning"), fmt.Sprintf(msg, args...)) } // RedConfirm is the function to handler the new version of the Cli +// Suppressed in --quiet mode. func RedConfirm(msg string, args ...interface{}) { - + if common.Quiet { + return + } fmt.Fprintf(os.Stderr, "%s: %s", color.Red.Sprintf("IMPORTANT"), fmt.Sprintf(msg, args...)) } diff --git a/utility/color_util_test.go b/utility/color_util_test.go new file mode 100644 index 00000000..b891f18a --- /dev/null +++ b/utility/color_util_test.go @@ -0,0 +1,73 @@ +package utility + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/civo/cli/common" +) + +// captureStderr redirects os.Stderr for the duration of fn and returns what was written to it. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stderr = w + + fn() + + w.Close() + os.Stderr = originalStderr + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestInfoWarning_SuppressedWhenQuiet(t *testing.T) { + originalQuiet := common.Quiet + defer func() { common.Quiet = originalQuiet }() + + common.Quiet = true + out := captureStderr(t, func() { + Info("some info") + Warning("some warning") + YellowConfirm("some confirm banner") + RedConfirm("some important banner") + }) + if out != "" { + t.Fatalf("expected no output for Info/Warning/YellowConfirm/RedConfirm when --quiet is set, got %q", out) + } +} + +func TestInfoWarning_ShownWhenNotQuiet(t *testing.T) { + originalQuiet := common.Quiet + defer func() { common.Quiet = originalQuiet }() + + common.Quiet = false + out := captureStderr(t, func() { + Info("some info") + }) + if out == "" { + t.Fatal("expected Info to print when --quiet is not set") + } +} + +func TestError_NeverSuppressedEvenWhenQuiet(t *testing.T) { + originalQuiet := common.Quiet + defer func() { common.Quiet = originalQuiet }() + + common.Quiet = true + out := captureStderr(t, func() { + Error("something failed") + }) + if out == "" { + t.Fatal("expected Error to always print, even when --quiet is set") + } +} diff --git a/utility/confirmation.go b/utility/confirmation.go index 0c9d40a0..dcc2a9e7 100644 --- a/utility/confirmation.go +++ b/utility/confirmation.go @@ -6,6 +6,8 @@ import ( "io" "os" "strings" + + "github.com/civo/cli/common" ) // retrieveUserInput is a function that can retrieve user input in form of string. By default, @@ -30,7 +32,17 @@ func readUserInput(in io.Reader, message string) (string, error) { } // AskForConfirm parses and verifies user input for confirmation. +// +// In --quiet mode, this never blocks waiting for interactive input: since +// quiet mode has no confirmed default to fall back on here (callers only +// reach this function when they haven't already been told "yes" via +// --yes/ignoringConfirmed), it fails immediately instead. To proceed +// non-interactively, combine --quiet with --yes at the call site. func AskForConfirm(message string) error { + if common.Quiet { + return fmt.Errorf("confirmation required to %s, but --quiet was set without --yes; re-run with --yes to confirm automatically, or without --quiet to be prompted", message) + } + answer, err := retrieveUserInput(message) if err != nil { Error("Unable to parse users input: %s", err) diff --git a/utility/confirmation_test.go b/utility/confirmation_test.go new file mode 100644 index 00000000..d71cbbca --- /dev/null +++ b/utility/confirmation_test.go @@ -0,0 +1,90 @@ +package utility + +import ( + "testing" + + "github.com/civo/cli/common" +) + +func TestAskForConfirm_QuietModeFailsWithoutPrompting(t *testing.T) { + originalQuiet := common.Quiet + originalRetrieveUserInput := retrieveUserInput + defer func() { + common.Quiet = originalQuiet + retrieveUserInput = originalRetrieveUserInput + }() + + common.Quiet = true + + promptWasCalled := false + retrieveUserInput = func(message string) (string, error) { + promptWasCalled = true + return "yes", nil + } + + err := AskForConfirm("delete the thing") + if err == nil { + t.Fatal("expected AskForConfirm to fail in --quiet mode without --yes, got nil error") + } + if promptWasCalled { + t.Fatal("AskForConfirm must not read interactive input at all when --quiet is set, but it did") + } +} + +func TestAskForConfirm_NonQuietModeStillPrompts(t *testing.T) { + originalQuiet := common.Quiet + originalRetrieveUserInput := retrieveUserInput + defer func() { + common.Quiet = originalQuiet + retrieveUserInput = originalRetrieveUserInput + }() + + common.Quiet = false + + tests := []struct { + name string + answer string + wantError bool + }{ + {name: "yes answer confirms", answer: "yes", wantError: false}, + {name: "y answer confirms", answer: "y", wantError: false}, + {name: "no answer does not confirm", answer: "no", wantError: true}, + {name: "empty answer does not confirm", answer: "", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + promptWasCalled := false + retrieveUserInput = func(message string) (string, error) { + promptWasCalled = true + return tt.answer, nil + } + + err := AskForConfirm("delete the thing") + if !promptWasCalled { + t.Fatal("expected AskForConfirm to prompt for input when --quiet is not set") + } + if (err != nil) != tt.wantError { + t.Fatalf("AskForConfirm(%q) error = %v, wantError %v", tt.answer, err, tt.wantError) + } + }) + } +} + +func TestUserConfirmedDeletion_QuietModeRespectsYesFlag(t *testing.T) { + originalQuiet := common.Quiet + defer func() { common.Quiet = originalQuiet }() + common.Quiet = true + + // ignoringConfirmed=true simulates --yes being passed: should succeed + // without ever touching stdin/AskForConfirm, matching "combine with + // --yes to override" from the issue's acceptance criteria. + if !UserConfirmedDeletion("instance", true, "my-instance") { + t.Fatal("expected deletion to be confirmed automatically when --yes is set, even with --quiet") + } + + // ignoringConfirmed=false (no --yes) with --quiet must fail, not block. + if UserConfirmedDeletion("instance", false, "my-instance") { + t.Fatal("expected deletion to be denied when --quiet is set without --yes") + } +} diff --git a/utility/kubernetes.go b/utility/kubernetes.go index 2378ec2c..aa818c9b 100644 --- a/utility/kubernetes.go +++ b/utility/kubernetes.go @@ -326,3 +326,56 @@ func SizeType(size string) string { return "Instance" } } + +// IsStandardSmallOrXSmallKubeSize returns true when the given node size is a +// "standard" tier (the "g4s" family) small or xsmall Kubernetes node, e.g. +// "g4s.kube.small" or "g4s.kube.xsmall". This works dynamically off the size +// name so it doesn't need updating if new standard node sizes are added - +// only small/xsmall standard nodes qualify, matching the resource-saving +// default requested for those specific sizes. +func IsStandardSmallOrXSmallKubeSize(size string) bool { + if !strings.HasPrefix(size, "g4s.") { + return false + } + + parts := strings.Split(size, ".") + if len(parts) == 0 { + return false + } + + switch parts[len(parts)-1] { + case "small", "xsmall": + return true + default: + return false + } +} + +// LogsCollectorDisabledMessage is shown to the user when the logs collector +// is automatically disabled on a standard small/xsmall node, so they know why +// and how to override it. +const LogsCollectorDisabledMessage = "Logs collector not installed by default to save resource on small and xsmall standard nodes. If you wish to enable logs collector create the cluster with LogsCollectorEnable set to true" + +// ResolveLogsCollectorEnabled works out the value that should be sent to the +// API for a cluster's LogsCollectorEnabled setting, and an informational +// message to display to the user (empty if there's nothing to say). +// +// - If the user explicitly passed --logs-collector-enabled, their choice is +// always respected, regardless of node size. +// - Otherwise, the logs collector is disabled by default on standard small +// and xsmall nodes to save node resources. +// - Otherwise, nil is returned so the API's own default applies (currently +// enabled). +func ResolveLogsCollectorEnabled(size string, explicitlySet bool, value bool) (enabled *bool, message string) { + if explicitlySet { + v := value + return &v, "" + } + + if IsStandardSmallOrXSmallKubeSize(size) { + v := false + return &v, LogsCollectorDisabledMessage + } + + return nil, "" +} diff --git a/utility/kubernetes_test.go b/utility/kubernetes_test.go index a7939ef6..20037ec1 100644 --- a/utility/kubernetes_test.go +++ b/utility/kubernetes_test.go @@ -119,3 +119,109 @@ func TestIsAppCompatibleWithClusterType(t *testing.T) { }) } } + +func TestIsStandardSmallOrXSmallKubeSize(t *testing.T) { + tests := []struct { + name string + size string + want bool + }{ + {name: "standard xsmall", size: "g4s.kube.xsmall", want: true}, + {name: "standard small", size: "g4s.kube.small", want: true}, + {name: "standard medium is not small/xsmall", size: "g4s.kube.medium", want: false}, + {name: "standard large is not small/xsmall", size: "g4s.kube.large", want: false}, + {name: "performance small is not standard tier", size: "g4p.kube.small", want: false}, + {name: "ram-optimized small is not standard tier", size: "g4m.kube.small", want: false}, + {name: "cpu-optimized small is not standard tier", size: "g4c.kube.small", want: false}, + {name: "legacy non-kube instance size", size: "g3.xsmall", want: false}, + {name: "empty size", size: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsStandardSmallOrXSmallKubeSize(tt.size); got != tt.want { + t.Errorf("IsStandardSmallOrXSmallKubeSize(%q) = %v, want %v", tt.size, got, tt.want) + } + }) + } +} + +func TestResolveLogsCollectorEnabled(t *testing.T) { + tests := []struct { + name string + size string + explicitlySet bool + value bool + wantEnabled *bool + wantMessage string + }{ + { + name: "explicit true is respected even on xsmall", + size: "g4s.kube.xsmall", + explicitlySet: true, + value: true, + wantEnabled: boolPtr(true), + wantMessage: "", + }, + { + name: "explicit false is respected even on a large node", + size: "g4s.kube.large", + explicitlySet: true, + value: false, + wantEnabled: boolPtr(false), + wantMessage: "", + }, + { + name: "auto-disabled on standard small when not explicit", + size: "g4s.kube.small", + explicitlySet: false, + value: true, + wantEnabled: boolPtr(false), + wantMessage: LogsCollectorDisabledMessage, + }, + { + name: "auto-disabled on standard xsmall when not explicit", + size: "g4s.kube.xsmall", + explicitlySet: false, + value: true, + wantEnabled: boolPtr(false), + wantMessage: LogsCollectorDisabledMessage, + }, + { + name: "left to API default on standard medium when not explicit", + size: "g4s.kube.medium", + explicitlySet: false, + value: true, + wantEnabled: nil, + wantMessage: "", + }, + { + name: "performance-tier small is not auto-disabled", + size: "g4p.kube.small", + explicitlySet: false, + value: true, + wantEnabled: nil, + wantMessage: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotEnabled, gotMessage := ResolveLogsCollectorEnabled(tt.size, tt.explicitlySet, tt.value) + + if (gotEnabled == nil) != (tt.wantEnabled == nil) { + t.Fatalf("ResolveLogsCollectorEnabled(%q, %v, %v) enabled = %v, want %v", tt.size, tt.explicitlySet, tt.value, gotEnabled, tt.wantEnabled) + } + if gotEnabled != nil && *gotEnabled != *tt.wantEnabled { + t.Errorf("ResolveLogsCollectorEnabled(%q, %v, %v) enabled = %v, want %v", tt.size, tt.explicitlySet, tt.value, *gotEnabled, *tt.wantEnabled) + } + if gotMessage != tt.wantMessage { + t.Errorf("ResolveLogsCollectorEnabled(%q, %v, %v) message = %q, want %q", tt.size, tt.explicitlySet, tt.value, gotMessage, tt.wantMessage) + } + }) + } +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/utility/spinner.go b/utility/spinner.go new file mode 100644 index 00000000..0a6e1da6 --- /dev/null +++ b/utility/spinner.go @@ -0,0 +1,26 @@ +package utility + +import ( + "io" + "os" + "time" + + "github.com/briandowns/spinner" + "github.com/civo/cli/common" +) + +// NewSpinner creates a progress spinner for long-running/--wait style +// commands. In --quiet mode, the spinner is created and can still be +// Start()/Stop()'d as normal by the caller, but its output is discarded so +// no progress indicator is printed - this keeps callers simple (no branching +// needed at each call site) while satisfying --quiet's "no progress +// indicators" requirement. +func NewSpinner(charSet []string, refreshRate time.Duration) *spinner.Spinner { + s := spinner.New(charSet, refreshRate) + if common.Quiet { + s.Writer = io.Discard + } else { + s.Writer = os.Stderr + } + return s +} diff --git a/utility/spinner_test.go b/utility/spinner_test.go new file mode 100644 index 00000000..2839a6c4 --- /dev/null +++ b/utility/spinner_test.go @@ -0,0 +1,32 @@ +package utility + +import ( + "io" + "os" + "testing" + "time" + + "github.com/briandowns/spinner" + "github.com/civo/cli/common" +) + +func TestNewSpinner_WriterSelection(t *testing.T) { + originalQuiet := common.Quiet + defer func() { common.Quiet = originalQuiet }() + + t.Run("quiet mode discards spinner output", func(t *testing.T) { + common.Quiet = true + s := NewSpinner(spinner.CharSets[9], 100*time.Millisecond) + if s.Writer != io.Discard { + t.Fatal("expected spinner Writer to be io.Discard when --quiet is set") + } + }) + + t.Run("non-quiet mode writes to stderr as before", func(t *testing.T) { + common.Quiet = false + s := NewSpinner(spinner.CharSets[9], 100*time.Millisecond) + if s.Writer != os.Stderr { + t.Fatal("expected spinner Writer to be os.Stderr when --quiet is not set") + } + }) +}