Skip to content

Commit 8dc91e3

Browse files
committed
fix: cobra could throw error that will end up in json parser
Signed-off-by: Timur Tuktamyshev <[email protected]>
1 parent fd2a647 commit 8dc91e3

File tree

4 files changed

+205
-24
lines changed

4 files changed

+205
-24
lines changed

internal/controller/controller.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ func (c *HookController) ListHooksMeta() []pkg.HookMetadata {
7878
return hooksmetas
7979
}
8080

81-
var ErrHookIndexIsNotExists = errors.New("hook index is not exists")
81+
// TODO: fix typo, didn't fix now to not brake public API
82+
var ErrHookIndexIsNotExists = errors.New("hook index does not exist")
8283

8384
func (c *HookController) RunHook(ctx context.Context, idx int) error {
8485
hooks := c.registry.Hooks()

pkg/app/root.go

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,42 +8,53 @@ import (
88

99
"github.com/spf13/cobra"
1010

11+
"github.com/deckhouse/deckhouse/pkg/log"
12+
1113
"github.com/deckhouse/module-sdk/internal/controller"
1214
)
1315

14-
func newCMD(controller *controller.HookController) *cmd {
16+
func newCMD(controller *controller.HookController, logger *log.Logger) *cmd {
1517
return &cmd{
1618
controller: controller,
19+
logger: logger,
1720
}
1821
}
1922

2023
type cmd struct {
2124
controller *controller.HookController
25+
logger *log.Logger
2226
}
2327

2428
// Execute adds all child commands to the root command and sets flags appropriately.
2529
// This is called by main.main(). It only needs to happen once to the rootCmd.
2630
func (c *cmd) Execute() {
27-
rootCmd := c.rootCmd()
28-
rootCmd.AddCommand(c.hooksCmd())
29-
rootCmd.CompletionOptions.DisableDefaultCmd = true
31+
rootCmd := c.buildCommand()
3032

3133
err := rootCmd.Execute()
3234
if err != nil {
35+
c.logger.Error("failed to execute root command", "error", err)
3336
os.Exit(1)
3437
}
3538
}
3639

37-
// rootCmd represents the base command when called without any subcommands
38-
func (c *cmd) rootCmd() *cobra.Command {
39-
return &cobra.Command{
40+
// buildCommand creates the complete command structure with all subcommands
41+
func (c *cmd) buildCommand() *cobra.Command {
42+
rootCmd := &cobra.Command{
4043
Use: filepath.Base(os.Args[0]),
4144
Short: "Binary with module hooks inside",
4245
Long: `Inside this binary contains list of
4346
precompiled hooks to use with corresponding module.`,
4447
}
48+
rootCmd.AddCommand(c.hooksCmd())
49+
rootCmd.CompletionOptions.DisableDefaultCmd = true
50+
// SilenceUsage and SilenceErrors prevent cobra from outputting
51+
// "Error: ..." and "Usage: ..." which would break JSON output parsing
52+
rootCmd.SilenceUsage = true
53+
rootCmd.SilenceErrors = true
54+
return rootCmd
4555
}
4656

57+
// NOTE: all of the RunE errors and usage errors are silenced by SilenceUsage and SilenceErrors, to not cause "Invalid character 'E'" error
4758
func (c *cmd) hooksCmd() *cobra.Command {
4859
hooksCmd := &cobra.Command{
4960
Use: "hooks",
@@ -67,77 +78,91 @@ func (c *cmd) hooksCmd() *cobra.Command {
6778
},
6879
})
6980

70-
hooksCmd.AddCommand(&cobra.Command{
81+
configCmd := &cobra.Command{
7182
Use: "config",
7283
Short: "Print hooks configs",
7384
Long: `Print list of hooks configs in json format`,
7485
RunE: func(_ *cobra.Command, _ []string) error {
7586
err := c.controller.PrintHookConfigs()
7687
if err != nil {
88+
c.logger.Error("can not print configs", "error", err)
7789
return fmt.Errorf("can not print configs: %w", err)
7890
}
7991

8092
return nil
8193
},
82-
})
94+
}
95+
hooksCmd.AddCommand(configCmd)
8396

84-
hooksCmd.AddCommand(&cobra.Command{
97+
dumpCmd := &cobra.Command{
8598
Use: "dump",
8699
Short: "Dump hooks configs",
87100
Long: `Dump list of hooks configs in config.json file`,
88101
Hidden: true,
89102
RunE: func(_ *cobra.Command, _ []string) error {
90103
err := c.controller.WriteHookConfigsInFile()
91104
if err != nil {
105+
c.logger.Error("can not write configs to file", "error", err)
92106
return fmt.Errorf("can not write configs to file: %w", err)
93107
}
94108

95109
fmt.Println("dump successfully")
96110

97111
return nil
98112
},
99-
})
113+
}
114+
hooksCmd.AddCommand(dumpCmd)
100115

101-
hooksCmd.AddCommand(&cobra.Command{
116+
runCmd := &cobra.Command{
102117
Use: "run",
103118
Short: "Running hook",
104119
Long: `Run hook from binary registry`,
105120
Hidden: true,
106-
Args: cobra.ExactArgs(1),
121+
Args: func(cmd *cobra.Command, args []string) error {
122+
if len(args) != 1 {
123+
c.logger.Error("invalid number of arguments", "expected", 1, "received", len(args))
124+
125+
return fmt.Errorf("invalid number of arguments: expected 1, received %d", len(args))
126+
}
127+
return nil
128+
},
107129
RunE: func(cmd *cobra.Command, args []string) error {
108130
ctx := cmd.Context()
109131

110132
idxRaw := args[0]
111133
idx, err := strconv.Atoi(idxRaw)
112134
if err != nil {
113-
return fmt.Errorf("argument '%s' is not integer", idxRaw)
135+
c.logger.Error("invalid argument", "argument", idxRaw, "error", err)
136+
137+
return fmt.Errorf("invalid argument: %w", err)
114138
}
115139

116140
err = c.controller.RunHook(ctx, idx)
117141
if err != nil {
118-
return fmt.Errorf("run hook error: %w", err)
142+
c.logger.Warn("hook shutdown", "error", err)
143+
return fmt.Errorf("hook shutdown: %w", err)
119144
}
120145

121146
return nil
122147
},
123-
})
148+
}
149+
hooksCmd.AddCommand(runCmd)
124150

125-
hooksCmd.AddCommand(&cobra.Command{
151+
readyCmd := &cobra.Command{
126152
Use: "ready",
127153
Short: "Check readiness",
128154
Long: `Run readiness hook for module`,
129155
Hidden: true,
130-
RunE: func(cmd *cobra.Command, _ []string) error {
156+
Run: func(cmd *cobra.Command, _ []string) {
131157
ctx := cmd.Context()
132158

133159
err := c.controller.RunReadiness(ctx)
134160
if err != nil {
135-
return fmt.Errorf("run readiness hook error: %w", err)
161+
c.logger.Warn("readiness hook shutdown", "error", err)
136162
}
137-
138-
return nil
139163
},
140-
})
164+
}
165+
hooksCmd.AddCommand(readyCmd)
141166

142167
return hooksCmd
143168
}

pkg/app/root_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package app
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/deckhouse/deckhouse/pkg/log"
12+
13+
"github.com/deckhouse/module-sdk/internal/controller"
14+
)
15+
16+
func Test_HooksRun_NoErrorOrUsageOutput(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
args []string
20+
description string
21+
}{
22+
{
23+
name: "missing argument",
24+
args: []string{"hooks", "run"},
25+
description: "should not output Error or Usage when argument is missing",
26+
},
27+
{
28+
name: "invalid argument format",
29+
args: []string{"hooks", "run", "not-a-number"},
30+
description: "should not output Error or Usage when argument is not a number",
31+
},
32+
{
33+
name: "invalid hook index",
34+
args: []string{"hooks", "run", "999"},
35+
description: "should not output Error or Usage when hook index is invalid",
36+
},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
// Capture stdout and stderr
42+
var stdout, stderr bytes.Buffer
43+
44+
// Create a mock controller
45+
cfg := &controller.Config{
46+
ModuleName: "test-module",
47+
HookConfig: &controller.HookConfig{
48+
BindingContextPath: "in/binding_context.json",
49+
ValuesPath: "in/values_path.json",
50+
ConfigValuesPath: "in/config_values_path.json",
51+
HookConfigPath: "out/hook_config.json",
52+
MetricsPath: "out/metrics.json",
53+
KubernetesPath: "out/kubernetes.json",
54+
ValuesJSONPath: "out/values.json",
55+
ConfigValuesJSONPath: "out/config_values.json",
56+
CreateFilesByYourself: true,
57+
},
58+
LogLevel: log.LevelFatal,
59+
}
60+
61+
// Create logger that writes to buffer instead of stderr
62+
logBuf := bytes.NewBuffer(nil)
63+
logger := log.Default()
64+
logger.SetOutput(logBuf)
65+
66+
hookController := controller.NewHookController(cfg, logger)
67+
68+
// Create command structure with our test logger
69+
cmd := newCMD(hookController, logger)
70+
71+
// Build complete command structure
72+
rootCmd := cmd.buildCommand()
73+
rootCmd.SetOut(&stdout)
74+
rootCmd.SetErr(&stderr)
75+
76+
// Set up test arguments
77+
rootCmd.SetArgs(tt.args)
78+
79+
// Execute command
80+
_ = rootCmd.Execute()
81+
82+
// Combine outputs (stdout, stderr, and log output)
83+
output := stdout.String() + stderr.String() + logBuf.String()
84+
// Check that Error: (from cobra) is not in output
85+
// We check for "Error:" with capital E, which is how cobra outputs errors
86+
// JSON logs may contain "error" field, but not "Error:" prefix from cobra
87+
assert.NotContains(t, output, "Error:", tt.description+": should not contain 'Error:' from cobra")
88+
89+
// Check that Usage: (from cobra) is not in output
90+
// We check for "Usage:" with capital U, which is how cobra outputs usage
91+
assert.NotContains(t, output, "Usage:", tt.description+": should not contain 'Usage:' from cobra")
92+
})
93+
}
94+
}
95+
96+
func Test_HooksConfig_NoErrorOrUsageOutput(t *testing.T) {
97+
// Capture stdout and stderr
98+
var stdout, stderr bytes.Buffer
99+
100+
// Create a mock controller
101+
cfg := &controller.Config{
102+
ModuleName: "test-module",
103+
HookConfig: &controller.HookConfig{
104+
BindingContextPath: "in/binding_context.json",
105+
ValuesPath: "in/values_path.json",
106+
ConfigValuesPath: "in/config_values_path.json",
107+
HookConfigPath: "out/hook_config.json",
108+
MetricsPath: "out/metrics.json",
109+
KubernetesPath: "out/kubernetes.json",
110+
ValuesJSONPath: "out/values.json",
111+
ConfigValuesJSONPath: "out/config_values.json",
112+
CreateFilesByYourself: true,
113+
},
114+
LogLevel: log.LevelFatal,
115+
}
116+
117+
// Create logger that writes to buffer instead of stderr
118+
logBuf := bytes.Buffer{}
119+
logger := log.Default()
120+
logger.SetOutput(&logBuf)
121+
122+
hookController := controller.NewHookController(cfg, logger)
123+
124+
// Create command structure with our test logger
125+
cmd := newCMD(hookController, logger)
126+
127+
// Build complete command structure
128+
rootCmd := cmd.buildCommand()
129+
rootCmd.SetOut(&stdout)
130+
rootCmd.SetErr(&stderr)
131+
132+
// Set up test arguments
133+
rootCmd.SetArgs([]string{"hooks", "config"})
134+
135+
// Execute command
136+
err := rootCmd.Execute()
137+
138+
// Combine outputs (stdout, stderr, and log output)
139+
output := stdout.String() + stderr.String() + logBuf.String()
140+
141+
// Check that Error: (from cobra) is not in output
142+
// We check for "Error:" with capital E, which is how cobra outputs errors
143+
// JSON logs may contain "error" field, but not "Error:" prefix from cobra
144+
assert.NotContains(t, output, "Error:", "config command should not contain 'Error:' from cobra")
145+
146+
// Check that Usage: (from cobra) is not in output
147+
// We check for "Usage:" with capital U, which is how cobra outputs usage
148+
assert.NotContains(t, output, "Usage:", "config command should not contain 'Usage:' from cobra")
149+
150+
// Check that output is valid JSON (if there are hooks and no error)
151+
// Only check stdout for JSON, not stderr or logs
152+
if err == nil && len(stdout.String()) > 0 {
153+
require.True(t, strings.HasPrefix(strings.TrimSpace(stdout.String()), "{"), "config command should output valid JSON")
154+
}
155+
}

pkg/app/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func Run(opts ...RunConfigOption) {
3636

3737
controller := controller.NewHookController(remapConfigToControllerConfig(cfg), logger.Named("hook-controller").With("module", cfg.ModuleName))
3838

39-
c := newCMD(controller)
39+
c := newCMD(controller, logger)
4040

4141
c.Execute()
4242
}

0 commit comments

Comments
 (0)