Skip to content
Open
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
134 changes: 105 additions & 29 deletions tools/fxconfig/internal/cli/v1/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package v1

import (
"fmt"
"reflect"
"slices"
"strings"

Expand Down Expand Up @@ -46,10 +47,7 @@ Examples:
RunE: func(_ *cobra.Command, _ []string) error {
switch format {
case "env":
env, err := toEnv("FXCONFIG", ctx.Config)
if err != nil {
return err
}
env := structToEnv("FXCONFIG", ctx.Config)
slices.Sort(env)
ctx.Printer.Print(strings.Join(env, "\n") + "\n")
case "yaml":
Expand All @@ -70,42 +68,120 @@ Examples:
return cmd
}

func toEnv(prefix string, cfg any) ([]string, error) {
// we use yaml marshaling as a shortcut to get a map representation of the config
// that respects all yaml tags and omitempty.
out, err := yaml.Marshal(cfg)
if err != nil {
return nil, err
// structToEnv converts a struct to a flat list of KEY=value environment variable
// strings by walking all exported fields via reflection. Field names are derived
// from mapstructure tags. Unlike the previous YAML round-trip approach, this
// preserves zero-value fields (false, 0s, empty strings) so the full effective
// configuration is always visible.
func structToEnv(prefix string, cfg any) []string {
v := reflect.ValueOf(cfg)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
v = v.Elem()
}

var m map[string]any
if err := yaml.Unmarshal(out, &m); err != nil {
return nil, err
if v.Kind() != reflect.Struct {
return nil
}

return flatten(prefix, m), nil
return flattenStruct(prefix, v)
}

func flatten(prefix string, m map[string]any) []string {
// flattenStruct recursively walks the struct fields and builds env var entries.
func flattenStruct(prefix string, v reflect.Value) []string {
var result []string
for k, v := range m {
key := strings.ToUpper(strings.ReplaceAll(k, "-", "_"))
if prefix != "" {
key = prefix + "_" + key
t := v.Type()

for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}

tag := field.Tag.Get("mapstructure")
if tag == "-" {
continue
}

switch val := v.(type) {
case map[string]any:
result = append(result, flatten(key, val)...)
case []any:
strVals := make([]string, 0, len(val))
for _, item := range val {
strVals = append(strVals, fmt.Sprintf("%v", item))
name, squash := parseMapstructureTag(tag)
if name == "" {
name = field.Name
}

fieldVal := v.Field(i)

// squash means the embedded struct's fields are promoted into the parent
if squash {
inner := reflect.Indirect(fieldVal)
if inner.IsValid() && inner.Kind() == reflect.Struct {
result = append(result, flattenStruct(prefix, inner)...)
}
result = append(result, fmt.Sprintf("%s=%s", key, strings.Join(strVals, ",")))
default:
result = append(result, fmt.Sprintf("%s=%v", key, val))
continue
}

key := envKey(prefix, name)
result = append(result, flattenValue(key, fieldVal)...)
}

return result
}

// flattenValue converts a single reflected value into env var entries.
func flattenValue(key string, v reflect.Value) []string {
// dereference pointers — nil pointers are skipped
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
v = v.Elem()
}

switch v.Kind() {
case reflect.Struct:
// types like time.Duration implement Stringer; print them as values
if stringer, ok := v.Interface().(fmt.Stringer); ok {
return []string{fmt.Sprintf("%s=%s", key, stringer.String())}
}
return flattenStruct(key, v)

case reflect.Slice:
items := make([]string, v.Len())
for i := 0; i < v.Len(); i++ {
items[i] = fmt.Sprintf("%v", v.Index(i).Interface())
}
return []string{fmt.Sprintf("%s=%s", key, strings.Join(items, ","))}

default:
return []string{fmt.Sprintf("%s=%v", key, v.Interface())}
}
}

// envKey builds an environment variable key from a prefix and field name.
func envKey(prefix, name string) string {
key := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
if prefix != "" {
return prefix + "_" + key
}
return key
}

// parseMapstructureTag extracts the field name and squash flag from a
// mapstructure struct tag. Examples:
//
// "address" → ("address", false)
// ",squash" → ("", true)
// "" → ("", false)
func parseMapstructureTag(tag string) (name string, squash bool) {
parts := strings.Split(tag, ",")
if len(parts) > 0 {
name = parts[0]
}
for _, p := range parts[1:] {
if p == "squash" {
squash = true
}
}
return name, squash
}
62 changes: 62 additions & 0 deletions tools/fxconfig/internal/cli/v1/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,65 @@ func TestInfoCommand_InvalidFormat(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "invalid --format: json (want yaml|env)")
}

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

boolPtr := func(b bool) *bool { return &b }

var outBuf bytes.Buffer
ctx := &CLIContext{
Config: &config.Config{
Logging: config.LoggingConfig{
Level: "",
Format: "",
},
TLS: config.TLSConfig{
Enabled: boolPtr(false),
ClientKeyPath: "",
},
Orderer: config.OrdererConfig{
EndpointServiceConfig: config.EndpointServiceConfig{
Address: "",
ConnectionTimeout: 0,
},
Channel: "",
},
},
Printer: cliio.NewCLIPrinter(&outBuf, &outBuf, cliio.FormatTable),
}

cmd := NewInfoCommand(ctx)
err := cmd.Flags().Set("format", "env")
require.NoError(t, err)

err = cmd.RunE(cmd, nil)
require.NoError(t, err)

output := outBuf.String()

// These zero-value fields were previously dropped by the YAML omitempty round-trip.
// The reflection-based approach must preserve them all.
require.Contains(t, output, "FXCONFIG_LOGGING_LEVEL=")
require.Contains(t, output, "FXCONFIG_LOGGING_FORMAT=")
require.Contains(t, output, "FXCONFIG_TLS_ENABLED=false")
require.Contains(t, output, "FXCONFIG_TLS_CLIENTKEY=")
require.Contains(t, output, "FXCONFIG_ORDERER_ADDRESS=")
require.Contains(t, output, "FXCONFIG_ORDERER_CONNECTIONTIMEOUT=0s")
require.Contains(t, output, "FXCONFIG_ORDERER_CHANNEL=")
}

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

result := structToEnv("FXCONFIG", (*config.Config)(nil))
require.Empty(t, result)
}

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

result := structToEnv("FXCONFIG", "not a struct")
require.Empty(t, result)
}