Merge pull request #31034 from Mazafard/feat/color-output-and-test-fixes

Feat: Add color output functionality and tests for release statuses
pull/31107/head
George Jenkins 1 month ago committed by GitHub
commit 18b7d45999
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,67 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package output
import (
"github.com/fatih/color"
release "helm.sh/helm/v4/pkg/release/v1"
)
// ColorizeStatus returns a colorized version of the status string based on the status value
func ColorizeStatus(status release.Status, noColor bool) string {
// Disable color if requested
if noColor {
return status.String()
}
switch status {
case release.StatusDeployed:
return color.GreenString(status.String())
case release.StatusFailed:
return color.RedString(status.String())
case release.StatusPendingInstall, release.StatusPendingUpgrade, release.StatusPendingRollback, release.StatusUninstalling:
return color.YellowString(status.String())
case release.StatusUnknown:
return color.RedString(status.String())
default:
// For uninstalled, superseded, and any other status
return status.String()
}
}
// ColorizeHeader returns a colorized version of a header string
func ColorizeHeader(header string, noColor bool) string {
// Disable color if requested
if noColor {
return header
}
// Use bold for headers
return color.New(color.Bold).Sprint(header)
}
// ColorizeNamespace returns a colorized version of a namespace string
func ColorizeNamespace(namespace string, noColor bool) string {
// Disable color if requested
if noColor {
return namespace
}
// Use cyan for namespaces
return color.CyanString(namespace)
}

@ -0,0 +1,191 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package output
import (
"strings"
"testing"
release "helm.sh/helm/v4/pkg/release/v1"
)
func TestColorizeStatus(t *testing.T) {
tests := []struct {
name string
status release.Status
noColor bool
envNoColor string
wantColor bool // whether we expect color codes in output
}{
{
name: "deployed status with color",
status: release.StatusDeployed,
noColor: false,
envNoColor: "",
wantColor: true,
},
{
name: "deployed status without color flag",
status: release.StatusDeployed,
noColor: true,
envNoColor: "",
wantColor: false,
},
{
name: "deployed status with NO_COLOR env",
status: release.StatusDeployed,
noColor: false,
envNoColor: "1",
wantColor: false,
},
{
name: "failed status with color",
status: release.StatusFailed,
noColor: false,
envNoColor: "",
wantColor: true,
},
{
name: "pending install status with color",
status: release.StatusPendingInstall,
noColor: false,
envNoColor: "",
wantColor: true,
},
{
name: "unknown status with color",
status: release.StatusUnknown,
noColor: false,
envNoColor: "",
wantColor: true,
},
{
name: "superseded status with color",
status: release.StatusSuperseded,
noColor: false,
envNoColor: "",
wantColor: false, // superseded doesn't get colored
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("NO_COLOR", tt.envNoColor)
result := ColorizeStatus(tt.status, tt.noColor)
// Check if result contains ANSI escape codes
hasColor := strings.Contains(result, "\033[")
// In test environment, term.IsTerminal will be false, so we won't get color
// unless we're testing the logic without terminal detection
if hasColor && !tt.wantColor {
t.Errorf("ColorizeStatus() returned color when none expected: %q", result)
}
// Always check the status text is present
if !strings.Contains(result, tt.status.String()) {
t.Errorf("ColorizeStatus() = %q, want to contain %q", result, tt.status.String())
}
})
}
}
func TestColorizeHeader(t *testing.T) {
tests := []struct {
name string
header string
noColor bool
envNoColor string
}{
{
name: "header with color",
header: "NAME",
noColor: false,
envNoColor: "",
},
{
name: "header without color flag",
header: "NAME",
noColor: true,
envNoColor: "",
},
{
name: "header with NO_COLOR env",
header: "NAME",
noColor: false,
envNoColor: "1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("NO_COLOR", tt.envNoColor)
result := ColorizeHeader(tt.header, tt.noColor)
// Always check the header text is present
if !strings.Contains(result, tt.header) {
t.Errorf("ColorizeHeader() = %q, want to contain %q", result, tt.header)
}
})
}
}
func TestColorizeNamespace(t *testing.T) {
tests := []struct {
name string
namespace string
noColor bool
envNoColor string
}{
{
name: "namespace with color",
namespace: "default",
noColor: false,
envNoColor: "",
},
{
name: "namespace without color flag",
namespace: "default",
noColor: true,
envNoColor: "",
},
{
name: "namespace with NO_COLOR env",
namespace: "default",
noColor: false,
envNoColor: "1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("NO_COLOR", tt.envNoColor)
result := ColorizeNamespace(tt.namespace, tt.noColor)
// Always check the namespace text is present
if !strings.Contains(result, tt.namespace) {
t.Errorf("ColorizeNamespace() = %q, want to contain %q", result, tt.namespace)
}
})
}
}

@ -89,6 +89,8 @@ type EnvSettings struct {
BurstLimit int
// QPS is queries per second which may be used to avoid throttling.
QPS float32
// ColorMode controls colorized output (never, auto, always)
ColorMode string
}
func New() *EnvSettings {
@ -109,6 +111,7 @@ func New() *EnvSettings {
RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")),
BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit),
QPS: envFloat32Or("HELM_QPS", defaultQPS),
ColorMode: envColorMode(),
}
env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG"))
@ -160,6 +163,8 @@ func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) {
fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes")
fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit")
fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting")
fs.StringVar(&s.ColorMode, "color", s.ColorMode, "use colored output (never, auto, always)")
fs.StringVar(&s.ColorMode, "colour", s.ColorMode, "use colored output (never, auto, always)")
}
func envOr(name, def string) string {
@ -213,6 +218,23 @@ func envCSV(name string) (ls []string) {
return
}
func envColorMode() string {
// Check NO_COLOR environment variable first (standard)
if v, ok := os.LookupEnv("NO_COLOR"); ok && v != "" {
return "never"
}
// Check HELM_COLOR environment variable
if v, ok := os.LookupEnv("HELM_COLOR"); ok {
v = strings.ToLower(v)
switch v {
case "never", "auto", "always":
return v
}
}
// Default to auto
return "auto"
}
func (s *EnvSettings) EnvVars() map[string]string {
envvars := map[string]string{
"HELM_BIN": os.Args[0],
@ -265,3 +287,8 @@ func (s *EnvSettings) SetNamespace(namespace string) {
func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter {
return s.config
}
// ShouldDisableColor returns true if color output should be disabled
func (s *EnvSettings) ShouldDisableColor() bool {
return s.ColorMode == "never"
}

@ -63,6 +63,7 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
debug: true,
showMetadata: true,
hideNotes: false,
noColor: settings.ShouldDisableColor(),
})
},
}

@ -168,6 +168,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
debug: settings.Debug,
showMetadata: false,
hideNotes: client.HideNotes,
noColor: settings.ShouldDisableColor(),
})
},
}
@ -239,13 +240,13 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options
client.Version = ">0.0.0-0"
}
name, chart, err := client.NameAndChart(args)
name, chartRef, err := client.NameAndChart(args)
if err != nil {
return nil, err
}
client.ReleaseName = name
cp, err := client.LocateChart(chart, settings)
cp, err := client.LocateChart(chartRef, settings)
if err != nil {
return nil, err
}

@ -26,6 +26,7 @@ import (
"github.com/gosuri/uitable"
"github.com/spf13/cobra"
coloroutput "helm.sh/helm/v4/internal/cli/output"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cli/output"
"helm.sh/helm/v4/pkg/cmd/require"
@ -106,7 +107,7 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
}
return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders))
return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders, settings.ShouldDisableColor()))
},
}
@ -146,9 +147,10 @@ type releaseElement struct {
type releaseListWriter struct {
releases []releaseElement
noHeaders bool
noColor bool
}
func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool) *releaseListWriter {
func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool, noColor bool) *releaseListWriter {
// Initialize the array so no results returns an empty array instead of null
elements := make([]releaseElement, 0, len(releases))
for _, r := range releases {
@ -173,26 +175,58 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead
elements = append(elements, element)
}
return &releaseListWriter{elements, noHeaders}
return &releaseListWriter{elements, noHeaders, noColor}
}
func (r *releaseListWriter) WriteTable(out io.Writer) error {
func (w *releaseListWriter) WriteTable(out io.Writer) error {
table := uitable.New()
if !r.noHeaders {
table.AddRow("NAME", "NAMESPACE", "REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION")
if !w.noHeaders {
table.AddRow(
coloroutput.ColorizeHeader("NAME", w.noColor),
coloroutput.ColorizeHeader("NAMESPACE", w.noColor),
coloroutput.ColorizeHeader("REVISION", w.noColor),
coloroutput.ColorizeHeader("UPDATED", w.noColor),
coloroutput.ColorizeHeader("STATUS", w.noColor),
coloroutput.ColorizeHeader("CHART", w.noColor),
coloroutput.ColorizeHeader("APP VERSION", w.noColor),
)
}
for _, r := range w.releases {
// Parse the status string back to a release.Status to use color
var status release.Status
switch r.Status {
case "deployed":
status = release.StatusDeployed
case "failed":
status = release.StatusFailed
case "pending-install":
status = release.StatusPendingInstall
case "pending-upgrade":
status = release.StatusPendingUpgrade
case "pending-rollback":
status = release.StatusPendingRollback
case "uninstalling":
status = release.StatusUninstalling
case "uninstalled":
status = release.StatusUninstalled
case "superseded":
status = release.StatusSuperseded
case "unknown":
status = release.StatusUnknown
default:
status = release.Status(r.Status)
}
for _, r := range r.releases {
table.AddRow(r.Name, r.Namespace, r.Revision, r.Updated, r.Status, r.Chart, r.AppVersion)
table.AddRow(r.Name, coloroutput.ColorizeNamespace(r.Namespace, w.noColor), r.Revision, r.Updated, coloroutput.ColorizeStatus(status, w.noColor), r.Chart, r.AppVersion)
}
return output.EncodeTable(out, table)
}
func (r *releaseListWriter) WriteJSON(out io.Writer) error {
return output.EncodeJSON(out, r.releases)
func (w *releaseListWriter) WriteJSON(out io.Writer) error {
return output.EncodeJSON(out, w.releases)
}
func (r *releaseListWriter) WriteYAML(out io.Writer) error {
return output.EncodeYAML(out, r.releases)
func (w *releaseListWriter) WriteYAML(out io.Writer) error {
return output.EncodeYAML(out, w.releases)
}
// Returns all releases from 'releases', except those with names matching 'ignoredReleases'

@ -78,6 +78,7 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
debug: settings.Debug,
showMetadata: false,
hideNotes: client.HideNotes,
noColor: settings.ShouldDisableColor(),
}); err != nil {
return err
}

@ -26,6 +26,7 @@ import (
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"sigs.k8s.io/yaml"
@ -80,6 +81,8 @@ Environment variables:
| $HELM_KUBETLS_SERVER_NAME | set the server name used to validate the Kubernetes API server certificate |
| $HELM_BURST_LIMIT | set the default burst limit in the case the server contains many CRDs (default 100, -1 to disable) |
| $HELM_QPS | set the Queries Per Second in cases where a high number of calls exceed the option for higher burst values |
| $HELM_COLOR | set color output mode. Allowed values: never, always, auto (default: never) |
| $NO_COLOR | set to any non-empty value to disable all colored output (overrides $HELM_COLOR) |
Helm stores cache, configuration, and data based on the following configuration order:
@ -129,6 +132,20 @@ func SetupLogging(debug bool) {
slog.SetDefault(logger)
}
// configureColorOutput configures the color output based on the ColorMode setting
func configureColorOutput(settings *cli.EnvSettings) {
switch settings.ColorMode {
case "never":
color.NoColor = true
case "always":
color.NoColor = false
case "auto":
// Let fatih/color handle automatic detection
// It will check if output is a terminal and NO_COLOR env var
// We don't need to do anything here
}
}
func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "helm",
@ -161,6 +178,27 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg
logSetup(settings.Debug)
// Validate color mode setting
switch settings.ColorMode {
case "never", "auto", "always":
// Valid color mode
default:
return nil, fmt.Errorf("invalid color mode %q: must be one of: never, auto, always", settings.ColorMode)
}
// Configure color output based on ColorMode setting
configureColorOutput(settings)
// Setup shell completion for the color flag
_ = cmd.RegisterFlagCompletionFunc("color", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"never", "auto", "always"}, cobra.ShellCompDirectiveNoFileComp
})
// Setup shell completion for the colour flag
_ = cmd.RegisterFlagCompletionFunc("colour", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"never", "auto", "always"}, cobra.ShellCompDirectiveNoFileComp
})
// Setup shell completion for the namespace flag
err := cmd.RegisterFlagCompletionFunc("namespace", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
if client, err := actionConfig.KubernetesClientSet(); err == nil {

@ -28,6 +28,7 @@ import (
"k8s.io/kubectl/pkg/cmd/get"
coloroutput "helm.sh/helm/v4/internal/cli/output"
"helm.sh/helm/v4/pkg/action"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/cli/output"
@ -84,6 +85,7 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
debug: false,
showMetadata: false,
hideNotes: false,
noColor: settings.ShouldDisableColor(),
})
},
}
@ -112,6 +114,7 @@ type statusPrinter struct {
debug bool
showMetadata bool
hideNotes bool
noColor bool
}
func (s statusPrinter) WriteJSON(out io.Writer) error {
@ -130,8 +133,8 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
if !s.release.Info.LastDeployed.IsZero() {
_, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", s.release.Info.LastDeployed.Format(time.ANSIC))
}
_, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", s.release.Namespace)
_, _ = fmt.Fprintf(out, "STATUS: %s\n", s.release.Info.Status.String())
_, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", coloroutput.ColorizeNamespace(s.release.Namespace, s.noColor))
_, _ = fmt.Fprintf(out, "STATUS: %s\n", coloroutput.ColorizeStatus(s.release.Info.Status, s.noColor))
_, _ = fmt.Fprintf(out, "REVISION: %d\n", s.release.Version)
if s.showMetadata {
_, _ = fmt.Fprintf(out, "CHART: %s\n", s.release.Chart.Metadata.Name)
@ -218,7 +221,7 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
// Hide notes from output - option in install and upgrades
if !s.hideNotes && len(s.release.Info.Notes) > 0 {
fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes))
_, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes))
}
return nil
}

@ -166,6 +166,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
debug: settings.Debug,
showMetadata: false,
hideNotes: instClient.HideNotes,
noColor: settings.ShouldDisableColor(),
})
} else if err != nil {
return err
@ -257,6 +258,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
debug: settings.Debug,
showMetadata: false,
hideNotes: client.HideNotes,
noColor: settings.ShouldDisableColor(),
})
},
}

Loading…
Cancel
Save