Skip to content

Commit 2d9a2c0

Browse files
committed
pkg: show colors in bash terminal
1 parent aae6687 commit 2d9a2c0

2 files changed

Lines changed: 178 additions & 54 deletions

File tree

cmd/cli/root.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"cloudstackctl/pkg/cloudstack"
5+
"cloudstackctl/pkg/handlers"
56
"os"
67

78
"github.com/spf13/cobra"
@@ -34,11 +35,25 @@ func init() {
3435
rootCmd.PersistentFlags().String("cloudstack-secret-key", "", "CloudStack secret key (overrides CLOUDSTACK_SECRET_KEY)")
3536
rootCmd.PersistentFlags().StringP("cloudstack-config", "c", "", "path to CloudStack config file")
3637
rootCmd.PersistentFlags().BoolVarP(&standalone, "standalone", "s", false, "Run in standalone mode (no DB/controller; use CloudStack APIs directly)")
38+
rootCmd.PersistentFlags().Bool("no-color", false, "Disable color output")
39+
rootCmd.PersistentFlags().String("color", "auto", "Color output mode: auto, always, never")
3740

3841
// Configure cloudstack package from CLI flags before running commands
3942
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
4043
if cfg, _ := cmd.Flags().GetString("cloudstack-config"); cfg != "" {
4144
cloudstack.SetConfigFile(cfg)
4245
}
46+
noColor, _ := cmd.Flags().GetBool("no-color")
47+
colorFlag, _ := cmd.Flags().GetString("color")
48+
switch {
49+
case noColor:
50+
handlers.SetColorMode("never")
51+
case colorFlag == "always":
52+
handlers.SetColorMode("always")
53+
case colorFlag == "never":
54+
handlers.SetColorMode("never")
55+
default:
56+
handlers.SetColorMode("auto")
57+
}
4358
}
4459
}

pkg/handlers/print.go

Lines changed: 163 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,128 @@ import (
1111
v1 "cloudstackctl/apis/v1"
1212

1313
cs "github.com/apache/cloudstack-go/v2/cloudstack"
14+
"golang.org/x/term"
1415
)
1516

17+
const (
18+
ansiReset = "\033[0m"
19+
ansiBold = "\033[1m"
20+
ansiCyan = "\033[36m"
21+
ansiBlue = "\033[34m"
22+
ansiGreen = "\033[32m"
23+
ansiYellow = "\033[33m"
24+
ansiMagenta = "\033[35m"
25+
ansiRed = "\033[31m"
26+
)
27+
28+
// colorMode controls color output:
29+
//
30+
// "auto" – colors only when stdout is a TTY (default)
31+
// "always" – force colors on
32+
// "never" – force colors off
33+
var colorMode = "auto"
34+
35+
// SetColorMode sets the color output mode. Valid values: "auto", "always", "never".
36+
func SetColorMode(mode string) {
37+
colorMode = mode
38+
}
39+
40+
func shellColorEnabled() bool {
41+
switch colorMode {
42+
case "always":
43+
return true
44+
case "never":
45+
return false
46+
}
47+
// "auto": respect env vars and TTY detection
48+
if os.Getenv("NO_COLOR") != "" {
49+
return false
50+
}
51+
if os.Getenv("TERM") == "" || os.Getenv("TERM") == "dumb" {
52+
return false
53+
}
54+
return term.IsTerminal(int(os.Stdout.Fd()))
55+
}
56+
57+
// tabwriterEsc is the escape byte used by tabwriter to mark invisible sequences.
58+
// Content wrapped in \xff pairs is excluded from column-width calculations.
59+
const tabwriterEsc = "\xff"
60+
61+
func colorize(s, color string) string {
62+
if !shellColorEnabled() {
63+
return s
64+
}
65+
// Wrap ANSI codes in tabwriter escape markers so they are not counted
66+
// as visible characters when computing column widths.
67+
return tabwriterEsc + color + tabwriterEsc + s + tabwriterEsc + ansiReset + tabwriterEsc
68+
}
69+
70+
func newTabWriter() *tabwriter.Writer {
71+
return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.StripEscape)
72+
}
73+
74+
func colorHeader(s string) string {
75+
return colorize(s, ansiBold+ansiBlue)
76+
}
77+
78+
func colorResourceName(kind, s string) string {
79+
switch kind {
80+
case "Volume":
81+
return colorize(s, ansiCyan)
82+
case "Network":
83+
return colorize(s, ansiYellow)
84+
case "VirtualMachine":
85+
return colorize(s, ansiGreen)
86+
case "Template":
87+
return colorize(s, ansiMagenta)
88+
case "SSHKey":
89+
return colorize(s, ansiBlue)
90+
case "SecurityGroup":
91+
return colorize(s, ansiRed)
92+
case "AffinityGroup":
93+
return colorize(s, ansiMagenta)
94+
case "UserData":
95+
return colorize(s, ansiYellow)
96+
case "Project":
97+
return colorize(s, ansiCyan)
98+
case "Component":
99+
return colorize(s, ansiYellow)
100+
case "VMSpec":
101+
return colorize(s, ansiMagenta)
102+
case "Application":
103+
return colorize(s, ansiCyan)
104+
default:
105+
return s
106+
}
107+
}
108+
109+
func colorStatus(s string) string {
110+
switch s {
111+
case "Running", "Healthy", "Implemented", "Setup", "Ready":
112+
return colorize(s, ansiGreen)
113+
case "Allocated", "Starting", "Started", "Creating", "Stopping":
114+
return colorize(s, ansiYellow)
115+
case "Error", "Failed", "Destroyed", "VMNotFound", "Unhealthy":
116+
return colorize(s, ansiRed)
117+
default:
118+
return s
119+
}
120+
}
121+
122+
func colorBool(v bool) string {
123+
if v {
124+
return colorize("true", ansiGreen)
125+
}
126+
return colorize("false", ansiRed)
127+
}
128+
129+
func colorDrift(v bool) string {
130+
if v {
131+
return colorize("true", ansiRed)
132+
}
133+
return colorize("false", ansiGreen)
134+
}
135+
16136
// PrintCloudStackResource renders a short tabular view for common CloudStack
17137
// list response objects. If the kind is not recognized or the provided
18138
// object doesn't match expected SDK types, the function falls back to
@@ -31,70 +151,70 @@ func PrintCloudStackResource(kind string, obj any) error {
31151
}
32152
case "VirtualMachine":
33153
if resp, ok := obj.(*cs.ListVirtualMachinesResponse); ok {
34-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
35-
fmt.Fprintln(w, "VIRTUAL MACHINE\tID\tIP ADDRESS\tPUBLIC IP\tSERVICE OFFERING\tSTATUS")
154+
w := newTabWriter()
155+
fmt.Fprintln(w, colorHeader("VIRTUAL MACHINE")+"\t"+colorHeader("ID")+"\t"+colorHeader("IP ADDRESS")+"\t"+colorHeader("PUBLIC IP")+"\t"+colorHeader("SERVICE OFFERING")+"\t"+colorHeader("STATUS"))
36156
for _, v := range resp.VirtualMachines {
37-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", v.Name, v.Id, v.Ipaddress, v.Publicip, v.Serviceofferingname, v.State)
157+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", colorResourceName("VirtualMachine", v.Name), v.Id, v.Ipaddress, v.Publicip, v.Serviceofferingname, colorStatus(v.State))
38158
}
39159
w.Flush()
40160
return nil
41161
}
42162
case "Template":
43163
if resp, ok := obj.(*cs.ListTemplatesResponse); ok {
44-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
45-
fmt.Fprintln(w, "TEMPLATE\tID\tOS\tFEATURED")
164+
w := newTabWriter()
165+
fmt.Fprintln(w, colorHeader("TEMPLATE")+"\t"+colorHeader("ID")+"\t"+colorHeader("OS")+"\t"+colorHeader("FEATURED"))
46166
for _, t := range resp.Templates {
47-
fmt.Fprintf(w, "%s\t%s\t%s\t%t\n", t.Name, t.Id, t.Ostypename, t.Isfeatured)
167+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", colorResourceName("Template", t.Name), t.Id, t.Ostypename, colorBool(t.Isfeatured))
48168
}
49169
w.Flush()
50170
return nil
51171
}
52172
case "SSHKey":
53173
if resp, ok := obj.(*cs.ListSSHKeyPairsResponse); ok {
54-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
55-
fmt.Fprintln(w, "SSH KEY\tFINGERPRINT")
174+
w := newTabWriter()
175+
fmt.Fprintln(w, colorHeader("SSH KEY")+"\t"+colorHeader("FINGERPRINT"))
56176
for _, k := range resp.SSHKeyPairs {
57-
fmt.Fprintf(w, "%s\t%s\n", k.Name, k.Fingerprint)
177+
fmt.Fprintf(w, "%s\t%s\n", colorResourceName("SSHKey", k.Name), k.Fingerprint)
58178
}
59179
w.Flush()
60180
return nil
61181
}
62182
case "SecurityGroup":
63183
if resp, ok := obj.(*cs.ListSecurityGroupsResponse); ok {
64-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
65-
fmt.Fprintln(w, "SECURITY GROUP\tID\tDESCRIPTION")
184+
w := newTabWriter()
185+
fmt.Fprintln(w, colorHeader("SECURITY GROUP")+"\t"+colorHeader("ID")+"\t"+colorHeader("DESCRIPTION"))
66186
for _, sg := range resp.SecurityGroups {
67-
fmt.Fprintf(w, "%s\t%s\t%s\n", sg.Name, sg.Id, sg.Description)
187+
fmt.Fprintf(w, "%s\t%s\t%s\n", colorResourceName("SecurityGroup", sg.Name), sg.Id, sg.Description)
68188
}
69189
w.Flush()
70190
return nil
71191
}
72192
case "AffinityGroup":
73193
if resp, ok := obj.(*cs.ListAffinityGroupsResponse); ok {
74-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
75-
fmt.Fprintln(w, "AFFINITY GROUP\tID\tDESCRIPTION")
194+
w := newTabWriter()
195+
fmt.Fprintln(w, colorHeader("AFFINITY GROUP")+"\t"+colorHeader("ID")+"\t"+colorHeader("DESCRIPTION"))
76196
for _, a := range resp.AffinityGroups {
77-
fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, a.Id, a.Description)
197+
fmt.Fprintf(w, "%s\t%s\t%s\n", colorResourceName("AffinityGroup", a.Name), a.Id, a.Description)
78198
}
79199
w.Flush()
80200
return nil
81201
}
82202
case "UserData":
83203
if resp, ok := obj.(*cs.ListUserDataResponse); ok {
84-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
85-
fmt.Fprintln(w, "USERDATA\tID\tACCOUNT")
204+
w := newTabWriter()
205+
fmt.Fprintln(w, colorHeader("USERDATA")+"\t"+colorHeader("ID")+"\t"+colorHeader("ACCOUNT"))
86206
for _, u := range resp.UserData {
87-
fmt.Fprintf(w, "%s\t%s\t%s\n", u.Name, u.Id, u.Account)
207+
fmt.Fprintf(w, "%s\t%s\t%s\n", colorResourceName("UserData", u.Name), u.Id, u.Account)
88208
}
89209
w.Flush()
90210
return nil
91211
}
92212
case "Project":
93213
if resp, ok := obj.(*cs.ListProjectsResponse); ok {
94-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
95-
fmt.Fprintln(w, "PROJECT\tID\tSTATE\tDISPLAY TEXT")
214+
w := newTabWriter()
215+
fmt.Fprintln(w, colorHeader("PROJECT")+"\t"+colorHeader("ID")+"\t"+colorHeader("STATE")+"\t"+colorHeader("DISPLAY TEXT"))
96216
for _, p := range resp.Projects {
97-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Name, p.Id, p.State, p.Displaytext)
217+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", colorResourceName("Project", p.Name), p.Id, colorStatus(p.State), p.Displaytext)
98218
}
99219
w.Flush()
100220
return nil
@@ -109,36 +229,25 @@ func PrintCloudStackResource(kind string, obj any) error {
109229

110230
// PrintVolumes prints a table of volumes.
111231
func PrintVolumes(vols []*cs.Volume) {
112-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
113-
fmt.Fprintln(w, "VOLUME\tID\tVIRTUAL MACHINE\tTYPE\tSTATUS")
232+
w := newTabWriter()
233+
fmt.Fprintln(w, colorHeader("VOLUME")+"\t"+colorHeader("ID")+"\t"+colorHeader("VIRTUAL MACHINE")+"\t"+colorHeader("TYPE")+"\t"+colorHeader("STATUS"))
114234
for _, v := range vols {
115-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", v.Name, v.Id, v.Vmname, v.Type, v.State)
235+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", colorResourceName("Volume", v.Name), v.Id, v.Vmname, v.Type, colorStatus(v.State))
116236
}
117237
w.Flush()
118238
}
119239

120240
// PrintNetworks prints a table of networks.
121241
func PrintNetworks(nets []*cs.Network) {
122-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
123-
fmt.Fprintln(w, "NETWORK\tID\tZONE\tVLAN\tDISPLAY TEXT\tTYPE\tSTATE")
124-
client, _ := cloudstack.NewClient()
242+
w := newTabWriter()
243+
fmt.Fprintln(w, colorHeader("NETWORK")+"\t"+colorHeader("ID")+"\t"+colorHeader("VLAN")+"\t"+colorHeader("DISPLAY TEXT")+"\t"+colorHeader("TYPE")+"\t"+colorHeader("STATE"))
125244

126245
for _, n := range nets {
127246
display := n.Displaytext
128247
if display == "" {
129248
display = n.Name
130249
}
131250

132-
zoneName := n.Zoneid
133-
if n.Zoneid != "" && client != nil {
134-
zp := client.Zone.NewListZonesParams()
135-
zp.SetId(n.Zoneid)
136-
zr, zerr := client.Zone.ListZones(zp)
137-
if zerr == nil && zr != nil && len(zr.Zones) > 0 {
138-
zoneName = zr.Zones[0].Name
139-
}
140-
}
141-
142251
vlan := ""
143252
if b, merr := json.Marshal(n); merr == nil {
144253
var m map[string]interface{}
@@ -151,15 +260,15 @@ func PrintNetworks(nets []*cs.Network) {
151260
}
152261
}
153262

154-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.Name, n.Id, zoneName, vlan, display, n.Type, n.State)
263+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", colorResourceName("Network", n.Name), n.Id, vlan, display, n.Type, colorStatus(n.State))
155264
}
156265
w.Flush()
157266
}
158267

159268
// PrintVMsFromController prints VMs returned by the controller query.
160269
func PrintVMsFromController(vms []v1.VirtualMachine) {
161-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
162-
fmt.Fprintln(w, "VIRTUAL MACHINE\tAPPLICATION\tCOMPONENT\tID\tIP ADDRESS\tPUBLIC IP\tSERVICE OFFERING\tSTATUS\tREADY\tDRIFT")
270+
w := newTabWriter()
271+
fmt.Fprintln(w, colorHeader("VIRTUAL MACHINE")+"\t"+colorHeader("APPLICATION")+"\t"+colorHeader("COMPONENT")+"\t"+colorHeader("ID")+"\t"+colorHeader("IP ADDRESS")+"\t"+colorHeader("PUBLIC IP")+"\t"+colorHeader("SERVICE OFFERING")+"\t"+colorHeader("STATUS")+"\t"+colorHeader("READY")+"\t"+colorHeader("DRIFT"))
163272
client, _ := cloudstack.NewClient()
164273
for _, vm := range vms {
165274
id := vm.CloudStackID
@@ -189,26 +298,26 @@ func PrintVMsFromController(vms []v1.VirtualMachine) {
189298
if so == "" && vm.ObservedSpec.ServiceOffering != "" {
190299
so = vm.ObservedSpec.ServiceOffering
191300
}
192-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%t\t%t\n",
193-
vm.Metadata.Name,
301+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
302+
colorResourceName("VirtualMachine", vm.Metadata.Name),
194303
vm.Application,
195304
vm.Component,
196305
id,
197306
ipAddress,
198307
publicIP,
199308
so,
200-
vm.Status.ObservedState,
201-
vm.Status.Ready,
202-
vm.Status.Drift,
309+
colorStatus(vm.Status.ObservedState),
310+
colorBool(vm.Status.Ready),
311+
colorDrift(vm.Status.Drift),
203312
)
204313
}
205314
w.Flush()
206315
}
207316

208317
// PrintComponents prints components returned by the controller DB query.
209318
func PrintComponents(comps []v1.Component) {
210-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
211-
fmt.Fprintln(w, "COMPONENT\tAPPLICATION\tREPLICAS\tVM SPEC\tSTATE\tOBSERVED REPLICAS\tLAST CHECKED\tCREATED")
319+
w := newTabWriter()
320+
fmt.Fprintln(w, colorHeader("COMPONENT")+"\t"+colorHeader("APPLICATION")+"\t"+colorHeader("REPLICAS")+"\t"+colorHeader("VM SPEC")+"\t"+colorHeader("STATE")+"\t"+colorHeader("OBSERVED REPLICAS")+"\t"+colorHeader("LAST CHECKED")+"\t"+colorHeader("CREATED"))
212321

213322
for _, c := range comps {
214323
replicas := c.Spec.Replicas
@@ -224,15 +333,15 @@ func PrintComponents(comps []v1.Component) {
224333
if !c.CreatedAt.IsZero() {
225334
created = c.CreatedAt.Format(time.RFC3339)
226335
}
227-
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\n", c.Metadata.Name, appNames, replicas, vmSpec, state, observed, last, created)
336+
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\n", colorResourceName("Component", c.Metadata.Name), appNames, replicas, vmSpec, colorStatus(state), observed, last, created)
228337
}
229338
w.Flush()
230339
}
231340

232341
// PrintVMSpecs prints VirtualMachineSpecResource entries in a compact table.
233342
func PrintVMSpecs(specs []v1.VirtualMachineSpecResource) {
234-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
235-
fmt.Fprintln(w, "VM SPEC\tTEMPLATE\tSERVICE OFFERING\tNETWORKS\tVOLUMES\tCREATED")
343+
w := newTabWriter()
344+
fmt.Fprintln(w, colorHeader("VM SPEC")+"\t"+colorHeader("TEMPLATE")+"\t"+colorHeader("SERVICE OFFERING")+"\t"+colorHeader("NETWORKS")+"\t"+colorHeader("VOLUMES")+"\t"+colorHeader("CREATED"))
236345
for _, s := range specs {
237346
tmpl := s.Spec.Template
238347
so := s.Spec.ServiceOffering
@@ -255,15 +364,15 @@ func PrintVMSpecs(specs []v1.VirtualMachineSpecResource) {
255364
if !s.CreatedAt.IsZero() {
256365
created = s.CreatedAt.Format(time.RFC3339)
257366
}
258-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", s.Metadata.Name, tmpl, so, nets, volCount, created)
367+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", colorResourceName("VMSpec", s.Metadata.Name), tmpl, so, nets, volCount, created)
259368
}
260369
w.Flush()
261370
}
262371

263372
// PrintApplications prints applications returned by the controller DB query.
264373
func PrintApplications(apps []v1.Application) {
265-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
266-
fmt.Fprintln(w, "APPLICATION\tCOMPONENTS\tSTATE\tREADY\tLAST CHECKED\tCREATED")
374+
w := newTabWriter()
375+
fmt.Fprintln(w, colorHeader("APPLICATION")+"\t"+colorHeader("COMPONENTS")+"\t"+colorHeader("STATE")+"\t"+colorHeader("READY")+"\t"+colorHeader("LAST CHECKED")+"\t"+colorHeader("CREATED"))
267376
for _, a := range apps {
268377
compNames := ""
269378
if len(a.Spec.Components) > 0 {
@@ -280,7 +389,7 @@ func PrintApplications(apps []v1.Application) {
280389
if !a.CreatedAt.IsZero() {
281390
created = a.CreatedAt.Format(time.RFC3339)
282391
}
283-
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\t%s\n", a.Metadata.Name, compNames, a.Status.ObservedState, a.Status.Ready, last, created)
392+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", colorResourceName("Application", a.Metadata.Name), compNames, colorStatus(a.Status.ObservedState), colorBool(a.Status.Ready), last, created)
284393
}
285394
w.Flush()
286395
}

0 commit comments

Comments
 (0)