Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions cli/internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import (
"github.com/spf13/viper"
)

// formatText and formatJSON are the shared values for the --log-format and
// --output enums.
const (
formatText = "text"
formatJSON = "json"
)

// RuntimeConfig is the resolved persistent-flag payload. Each field is the
// trimmed, validated value of the corresponding root flag, with the viper
// precedence (flag > env > default) already applied.
Expand All @@ -32,14 +39,18 @@ type RuntimeConfig struct {

// LogFormat is text or json.
LogFormat string

// OutputFormat is the data-plane format: text or json. It is the sole
// machine-output toggle (the per-command --json flags are removed).
OutputFormat string
}

// initializeConfig wires viper to the root persistent flags. It is called
// from PersistentPreRunE so flag-vs-environment precedence is established
// once per execution before any RunE.
func initializeConfig(cmd *cobra.Command, vp *viper.Viper) error {
vp.SetDefault("log-level", "info")
vp.SetDefault("log-format", "text")
vp.SetDefault("log-format", formatText)
vp.SetEnvPrefix("YACD")
// Environment variable names cannot contain "-" or ".", so the replacer
// canonicalises both to "_". Flag and viper-key names already use "-".
Expand All @@ -62,6 +73,9 @@ func initializeConfig(cmd *cobra.Command, vp *viper.Viper) error {
if err := bindFlag(vp, "log-format", rootFlags.Lookup("log-format")); err != nil {
return err
}
if err := bindFlag(vp, "output", rootFlags.Lookup("output")); err != nil {
return err
}
if err := vp.BindPFlags(cmd.Flags()); err != nil {
return fmt.Errorf("bind command flags: %w", err)
}
Expand Down Expand Up @@ -89,17 +103,21 @@ func bindFlag(vp *viper.Viper, key string, flag *pflag.Flag) error {
// directly.
func loadRuntimeConfig(vp *viper.Viper) (RuntimeConfig, error) {
config := RuntimeConfig{
Kubeconfig: strings.TrimSpace(vp.GetString("kubeconfig")),
KubeContext: strings.TrimSpace(vp.GetString("kube-context")),
Namespace: strings.TrimSpace(vp.GetString("namespace")),
LogLevel: strings.TrimSpace(vp.GetString("log-level")),
LogFormat: strings.TrimSpace(vp.GetString("log-format")),
Kubeconfig: strings.TrimSpace(vp.GetString("kubeconfig")),
KubeContext: strings.TrimSpace(vp.GetString("kube-context")),
Namespace: strings.TrimSpace(vp.GetString("namespace")),
LogLevel: strings.TrimSpace(vp.GetString("log-level")),
LogFormat: strings.TrimSpace(vp.GetString("log-format")),
OutputFormat: strings.TrimSpace(vp.GetString("output")),
}
if config.LogLevel == "" {
config.LogLevel = "info"
}
if config.LogFormat == "" {
config.LogFormat = "text"
config.LogFormat = formatText
}
if config.OutputFormat == "" {
config.OutputFormat = formatText
}

switch config.LogLevel {
Expand All @@ -108,14 +126,26 @@ func loadRuntimeConfig(vp *viper.Viper) (RuntimeConfig, error) {
return RuntimeConfig{}, fmt.Errorf("unsupported log level %q", config.LogLevel)
}
switch config.LogFormat {
case "text", "json":
case formatText, formatJSON:
default:
return RuntimeConfig{}, fmt.Errorf("unsupported log format %q", config.LogFormat)
}
switch config.OutputFormat {
case formatText, formatJSON:
default:
return RuntimeConfig{}, fmt.Errorf("unsupported output format %q", config.OutputFormat)
}

return config, nil
}

// outputJSON reports whether the resolved --output format is json. The format
// is validated once in PersistentPreRunE, so this read is safe; it is the sole
// machine-output toggle now that the per-command --json flags are gone.
func (commandContext *commandContext) outputJSON() bool {
return strings.EqualFold(strings.TrimSpace(commandContext.viper.GetString("output")), formatJSON)
}

// raise lifts the base log level by verbose steps along the ordering
// error < warn < info < debug. It never lowers the level and caps at debug; a
// base outside the known set (already rejected by loadRuntimeConfig) is
Expand Down Expand Up @@ -164,7 +194,7 @@ func newLogger(config RuntimeConfig, out io.Writer, quiet bool) *slog.Logger {
}

handlerOptions := &slog.HandlerOptions{Level: level}
if config.LogFormat == "json" {
if config.LogFormat == formatJSON {
return slog.New(slog.NewJSONHandler(out, handlerOptions))
}

Expand Down
3 changes: 1 addition & 2 deletions cli/internal/cli/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func newInfoCommand(commandContext *commandContext) *cobra.Command {
if faucetWallet, walletErr := walletstore.NewStore(kubeClient, namespace, name).Faucet(cmd.Context()); walletErr == nil {
info.Wallet = &walletOutput{Address: faucetWallet.Address, KeySecretName: faucetWallet.SecretName}
}
if commandContext.viper.GetBool("json") {
if commandContext.outputJSON() {
encoded, err := json.MarshalIndent(info, "", " ")
if err != nil {
return fmt.Errorf("marshal info JSON: %w", err)
Expand All @@ -59,7 +59,6 @@ func newInfoCommand(commandContext *commandContext) *cobra.Command {
return printInfo(commandContext.out, info)
},
}
cmd.Flags().Bool("json", false, "Print machine-readable JSON")

return cmd
}
Expand Down
6 changes: 3 additions & 3 deletions cli/internal/cli/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestInfoReadsGlobalKubeEnvironment(t *testing.T) {
Viper: viper.New(),
KubeClientFactory: factory,
})
root.SetArgs([]string{"info", "devnet", "--json"})
root.SetArgs([]string{"info", "devnet", "-o", "json"})

require.NoError(t, root.ExecuteContext(context.Background()))
assert.Equal(t, "dev-context", capturedConfig.Context)
Expand Down Expand Up @@ -75,7 +75,7 @@ func TestInfoDefaultsNamespaceToName(t *testing.T) {
Viper: viper.New(),
KubeClientFactory: kubeClientFactory(client),
})
root.SetArgs([]string{"info", "devnet", "--json"})
root.SetArgs([]string{"info", "devnet", "-o", "json"})

require.NoError(t, root.ExecuteContext(context.Background()))
assert.Contains(t, stdout.String(), `"namespace": "devnet"`)
Expand All @@ -101,7 +101,7 @@ func TestInfoOmitsWalletWhenFaucetWalletAbsent(t *testing.T) {
Viper: viper.New(),
KubeClientFactory: kubeClientFactory(client),
})
root.SetArgs([]string{"info", "devnet", "--json"})
root.SetArgs([]string{"info", "devnet", "-o", "json"})

require.NoError(t, root.ExecuteContext(context.Background()))
assert.NotContains(t, stdout.String(), `"wallet"`)
Expand Down
4 changes: 1 addition & 3 deletions cli/internal/cli/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func newListCommand(commandContext *commandContext) *cobra.Command {
if err != nil {
return err
}
jsonOutput := commandContext.viper.GetBool("json")
jsonOutput := commandContext.outputJSON()

kubeClient, _, err := commandContext.resolveKubeClient(runtimeConfig)
if err != nil {
Expand Down Expand Up @@ -62,8 +62,6 @@ func newListCommand(commandContext *commandContext) *cobra.Command {
},
}

cmd.Flags().Bool("json", false, "Print machine-readable JSON")

return cmd
}

Expand Down
2 changes: 1 addition & 1 deletion cli/internal/cli/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func TestListJSONOutputShape(t *testing.T) {
Viper: viper.New(),
KubeClientFactory: kubeClientFactory(client),
})
root.SetArgs([]string{"list", "-n", "team-a", "--json"})
root.SetArgs([]string{"list", "-n", "team-a", "-o", "json"})

require.NoError(t, root.ExecuteContext(context.Background()))

Expand Down
75 changes: 75 additions & 0 deletions cli/internal/cli/output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package cli

import (
"bytes"
"context"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestJSONFlagIsRemoved(t *testing.T) {
t.Parallel()

var stdout, stderr bytes.Buffer
root := NewRootCommand(Options{Out: &stdout, Err: &stderr, Build: testBuildInfo()})
root.SetArgs([]string{"list", "--json"})

err := root.ExecuteContext(context.Background())
require.Error(t, err, "--json must no longer be a registered flag")
assert.Contains(t, err.Error(), "unknown flag")
}

func TestLoadRuntimeConfigRejectsUnknownOutput(t *testing.T) {
t.Parallel()

vp := viper.New()
vp.Set("output", "xml")

_, err := loadRuntimeConfig(vp)
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported output format")
}

func TestLoadRuntimeConfigOutputDefaultsToText(t *testing.T) {
t.Parallel()

config, err := loadRuntimeConfig(viper.New())
require.NoError(t, err)
assert.Equal(t, "text", config.OutputFormat)
}

func TestOutputJSONHelper(t *testing.T) {
t.Parallel()

cases := map[string]bool{"json": true, "JSON": true, "text": false, "": false}
for value, want := range cases {
t.Run(value, func(t *testing.T) {
t.Parallel()
vp := viper.New()
if value != "" {
vp.Set("output", value)
}
cc := &commandContext{viper: vp}
assert.Equal(t, want, cc.outputJSON())
})
}
}

// TestOutputEnvIsReadAndValidated proves YACD_OUTPUT flows through the env
// binding: a bogus value is rejected in PersistentPreRunE before the command
// runs, which can only happen if the env var is read for the output format.
func TestOutputEnvIsReadAndValidated(t *testing.T) {
t.Setenv("YACD_OUTPUT", "bogus")

var stdout, stderr bytes.Buffer
root := NewRootCommand(Options{Out: &stdout, Err: &stderr, Build: testBuildInfo()})
root.SetArgs([]string{"version"})

err := root.ExecuteContext(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported output format")
assert.Empty(t, stdout.String(), "the command must not run when output is invalid")
}
3 changes: 2 additions & 1 deletion cli/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func NewRootCommand(options Options) *cobra.Command {
root.PersistentFlags().String("context", "", "Kubeconfig context to use")
root.PersistentFlags().StringP("namespace", "n", "", "Kubernetes namespace")
root.PersistentFlags().String("log-level", "info", "Log level: debug, info, warn, error")
root.PersistentFlags().String("log-format", "text", "Log format: text, json")
root.PersistentFlags().String("log-format", formatText, "Log format: text, json")
// Session/TTY decisions. These are flag-only (no env tier by design) and are
// read directly off cmd.Flags() wherever they are consumed, so no ambient
// YACD_* can drive them or leak into a child environment.
Expand All @@ -145,6 +145,7 @@ func NewRootCommand(options Options) *cobra.Command {
root.PersistentFlags().Bool("non-interactive", false, "Never prompt; fail instead of waiting for input")
root.PersistentFlags().String("color", "auto", "Color output: auto, always, never")
root.PersistentFlags().Bool("no-color", false, "Disable color output")
root.PersistentFlags().StringP("output", "o", formatText, "Output format: text, json")

// The network verbs resolve their target through the shared resolver, so
// each is wrapped to clear a stale managed-cluster record when it targeted
Expand Down
9 changes: 3 additions & 6 deletions cli/internal/cli/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,13 @@ func newWalletListCommand(commandContext *commandContext) *cobra.Command {
return err
}

if commandContext.viper.GetBool("json") {
if commandContext.outputJSON() {
return writeJSON(commandContext.out, newWalletListItems(wallets))
}

return printWalletList(commandContext.out, wallets)
},
}
cmd.Flags().Bool("json", false, "Print machine-readable JSON")

return cmd
}
Expand All @@ -152,7 +151,7 @@ func newWalletAddCommand(commandContext *commandContext) *cobra.Command {
topup := strings.TrimSpace(commandContext.viper.GetString("topup"))
await := commandContext.viper.GetBool("await")
awaitTimeout := commandContext.viper.GetDuration("await-timeout")
jsonOutput := commandContext.viper.GetBool("json")
jsonOutput := commandContext.outputJSON()

lovelace, topupRequested, err := parseOptionalLovelace(topup)
if err != nil {
Expand Down Expand Up @@ -215,7 +214,6 @@ func newWalletAddCommand(commandContext *commandContext) *cobra.Command {
cmd.Flags().String("topup", "", "Fund the new wallet with this many lovelace from the faucet")
cmd.Flags().Bool("await", false, "Wait for the funding transaction to confirm on-chain (requires --topup)")
cmd.Flags().Duration("await-timeout", 2*time.Minute, "Maximum time to wait for --await confirmation")
cmd.Flags().Bool("json", false, "Print machine-readable JSON")
addChainOverrideFlags(cmd)

return cmd
Expand All @@ -238,7 +236,7 @@ func newWalletTopUpCommand(commandContext *commandContext) *cobra.Command {
source := strings.TrimSpace(commandContext.viper.GetString("from"))
await := commandContext.viper.GetBool("await")
awaitTimeout := commandContext.viper.GetDuration("await-timeout")
jsonOutput := commandContext.viper.GetBool("json")
jsonOutput := commandContext.outputJSON()
if err := requireAwaitTimeout(await, awaitTimeout); err != nil {
return err
}
Expand Down Expand Up @@ -277,7 +275,6 @@ func newWalletTopUpCommand(commandContext *commandContext) *cobra.Command {
cmd.Flags().String("from", "", "Source wallet name to fund from (default: the faucet wallet)")
cmd.Flags().Bool("await", false, "Wait for the funding transaction to confirm on-chain")
cmd.Flags().Duration("await-timeout", 2*time.Minute, "Maximum time to wait for --await confirmation")
cmd.Flags().Bool("json", false, "Print machine-readable JSON")
addChainOverrideFlags(cmd)

return cmd
Expand Down
2 changes: 1 addition & 1 deletion test/chainsaw/manager-smoke/chainsaw-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ spec:
content: |
set -eu
export KUBECTL_KUBERC="${KUBECTL_KUBERC:-false}"
info_json="$(go run ./cli/cmd/yacd --namespace yacd-smoke info phase4-smoke --json)"
info_json="$(go run ./cli/cmd/yacd --namespace yacd-smoke info phase4-smoke -o json)"
printf '%s\n' "$info_json"
INFO_JSON="$info_json" python3 - <<'PY'
import json
Expand Down
Loading