From 29f41977a17c077bef5606ccf5ba035e06425a34 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Fri, 20 Mar 2026 13:44:42 +0100 Subject: [PATCH] feat: add binary config value support with --value-from-file and --to-file Add encoding field to ConfigValue/ConfigValueInput in the GraphQL schema and update CLI commands to support binary config values, mirroring the existing secret binary value support. Key changes: - Add encoding: ValueEncoding! to ConfigValue, encoding with default to ConfigValueInput - Add --value-from-file flag to 'config set' with auto binary detection (utf8.Valid) - Add --to-file and --key flags to 'config get' for extracting binary values - Show binary values as '' in table output - Add 1 MiB size validation for values - Mutual exclusivity between --value, --value-from-stdin, --value-from-file Depends on nais/api binary-config-values branch. --- internal/config/command/flag/flag.go | 3 + internal/config/command/get.go | 83 +++++++++++++++++++++++++--- internal/config/command/set.go | 55 +++++++++++++++--- internal/config/config.go | 35 ++++++++---- internal/config/config_test.go | 23 ++++++++ internal/naisapi/gql/generated.go | 25 ++++++++- schema.graphql | 10 +++- 7 files changed, 205 insertions(+), 29 deletions(-) diff --git a/internal/config/command/flag/flag.go b/internal/config/command/flag/flag.go index b8be2dd2..d6199b92 100644 --- a/internal/config/command/flag/flag.go +++ b/internal/config/command/flag/flag.go @@ -93,6 +93,8 @@ type Get struct { *Config Environment GetEnv `name:"environment" short:"e" usage:"Filter by environment."` Output Output `name:"output" short:"o" usage:"Format output (table|json)."` + ToFile string `name:"to-file" usage:"Write a single key's value to a file. Requires --key. Binary values are decoded automatically."` + Key string `name:"key" usage:"Name of the key to extract. Used with --to-file."` } func (g *Get) GetTeam() string { return string(g.Team) } @@ -115,6 +117,7 @@ type Set struct { Key string `name:"key" usage:"Name of the key to set."` Value string `name:"value" usage:"Value to set."` ValueFromStdin bool `name:"value-from-stdin" usage:"Read value from stdin."` + ValueFromFile string `name:"value-from-file" usage:"Read value from file (e.g. keystore.p12, cert.pem). Binary files are automatically Base64-encoded."` } func (s *Set) GetTeam() string { return string(s.Team) } diff --git a/internal/config/command/get.go b/internal/config/command/get.go index aecb95e3..e1e983b1 100644 --- a/internal/config/command/get.go +++ b/internal/config/command/get.go @@ -2,10 +2,13 @@ package command import ( "context" + "encoding/base64" "fmt" + "os" "github.com/nais/cli/internal/config" "github.com/nais/cli/internal/config/command/flag" + "github.com/nais/cli/internal/naisapi/gql" "github.com/nais/cli/internal/validation" "github.com/nais/naistrix" "github.com/nais/naistrix/output" @@ -14,8 +17,9 @@ import ( // Entry represents a key-value pair in a config. type Entry struct { - Key string `json:"key"` - Value string `json:"value"` + Key string `json:"key"` + Value string `json:"value"` + Encoding gql.ValueEncoding `json:"encoding,omitempty"` } type ConfigDetail struct { @@ -44,7 +48,16 @@ func get(parentFlags *flag.Config) *naistrix.Command { return err } } - return validateArgs(args) + if err := validateArgs(args); err != nil { + return err + } + if f.ToFile != "" && f.Key == "" { + return fmt.Errorf("--to-file requires --key to specify which key to extract") + } + if f.Key != "" && f.ToFile == "" { + return fmt.Errorf("--key is only used with --to-file") + } + return nil }, AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { if args.Len() == 0 { @@ -57,10 +70,22 @@ func get(parentFlags *flag.Config) *naistrix.Command { Description: "Get details for a config named my-config in environment dev.", Command: "my-config --environment dev", }, + { + Description: "Extract a binary value (e.g. keystore) to a file.", + Command: "my-config --environment prod --key keystore.p12 --to-file ./keystore.p12", + }, }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + opts := getOptions{ + team: f.Team, + outputFormat: f.Output, + toFile: f.ToFile, + key: f.Key, + } + if providedEnvironment := string(f.Environment); providedEnvironment != "" { - return runGetCommand(ctx, args, out, f.Team, providedEnvironment, f.Output) + opts.environment = providedEnvironment + return runGetCommand(ctx, args, out, opts) } environment, err := resolveConfigEnvironment(ctx, f.Team, args.Get("name"), string(f.Environment)) @@ -68,13 +93,22 @@ func get(parentFlags *flag.Config) *naistrix.Command { return err } - return runGetCommand(ctx, args, out, f.Team, environment, f.Output) + opts.environment = environment + return runGetCommand(ctx, args, out, opts) }, } } -func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter, team, environment string, outputFormat flag.Output) error { - metadata := metadataFromArgs(args, team, environment) +type getOptions struct { + team string + environment string + outputFormat flag.Output + toFile string + key string +} + +func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter, opts getOptions) error { + metadata := metadataFromArgs(args, opts.team, opts.environment) existing, err := config.Get(ctx, metadata) if err != nil { @@ -83,10 +117,41 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. entries := make([]Entry, len(existing.Values)) for i, v := range existing.Values { - entries[i] = Entry{Key: v.Name, Value: v.Value} + entries[i] = Entry{Key: v.Name, Value: v.Value, Encoding: v.Encoding} + } + + // Handle --to-file: extract a single key's value to a file + if opts.toFile != "" { + var found *Entry + for i := range entries { + if entries[i].Key == opts.key { + found = &entries[i] + break + } + } + if found == nil { + return fmt.Errorf("key %q not found in config %q", opts.key, metadata.Name) + } + + var data []byte + if found.Encoding == gql.ValueEncodingBase64 { + data, err = base64.StdEncoding.DecodeString(found.Value) + if err != nil { + return fmt.Errorf("decoding base64 value for key %q: %w", opts.key, err) + } + } else { + data = []byte(found.Value) + } + + if err := os.WriteFile(opts.toFile, data, 0o600); err != nil { + return fmt.Errorf("writing to file %q: %w", opts.toFile, err) + } + + pterm.Success.Printfln("Wrote key %q (%d bytes) to %s", opts.key, len(data), opts.toFile) + return nil } - if outputFormat == "json" { + if opts.outputFormat == "json" { detail := ConfigDetail{ Name: existing.Name, Environment: existing.TeamEnvironment.Environment.Name, diff --git a/internal/config/command/set.go b/internal/config/command/set.go index 92988245..c70cc363 100644 --- a/internal/config/command/set.go +++ b/internal/config/command/set.go @@ -2,13 +2,16 @@ package command import ( "context" + "encoding/base64" "fmt" "io" "os" "strings" + "unicode/utf8" "github.com/nais/cli/internal/config" "github.com/nais/cli/internal/config/command/flag" + "github.com/nais/cli/internal/naisapi/gql" "github.com/nais/cli/internal/validation" "github.com/nais/naistrix" "github.com/pterm/pterm" @@ -35,11 +38,23 @@ func set(parentFlags *flag.Config) *naistrix.Command { if f.Key == "" { return fmt.Errorf("--key is required") } - if f.Value == "" && !f.ValueFromStdin { - return fmt.Errorf("--value or --value-from-stdin is required") + + // Count the number of value sources provided + sources := 0 + if f.Value != "" { + sources++ + } + if f.ValueFromStdin { + sources++ + } + if f.ValueFromFile != "" { + sources++ } - if f.Value != "" && f.ValueFromStdin { - return fmt.Errorf("--value and --value-from-stdin are mutually exclusive") + if sources == 0 { + return fmt.Errorf("--value, --value-from-stdin, or --value-from-file is required") + } + if sources > 1 { + return fmt.Errorf("--value, --value-from-stdin, and --value-from-file are mutually exclusive") } return nil }, @@ -58,20 +73,46 @@ func set(parentFlags *flag.Config) *naistrix.Command { Description: "Read value from stdin (useful for multi-line values).", Command: "my-config --environment dev --key CONFIG_FILE --value-from-stdin < config.yaml", }, + { + Description: "Upload a file as a config value. Binary files are automatically Base64-encoded.", + Command: "my-config --environment prod --key keystore.p12 --value-from-file ./keystore.p12", + }, }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { metadata := metadataFromArgs(args, f.Team, string(f.Environment)) - value := f.Value - if f.ValueFromStdin { + var value string + encoding := gql.ValueEncodingPlainText + + switch { + case f.ValueFromFile != "": + data, err := os.ReadFile(f.ValueFromFile) + if err != nil { + return fmt.Errorf("reading file %q: %w", f.ValueFromFile, err) + } + if utf8.Valid(data) { + value = string(data) + } else { + value = base64.StdEncoding.EncodeToString(data) + encoding = gql.ValueEncodingBase64 + } + case f.ValueFromStdin: data, err := io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("reading from stdin: %w", err) } value = strings.TrimSuffix(string(data), "\n") + default: + value = f.Value + } + + // Check the value size early to give a clear error message. + const maxValueSize = 1 << 20 // 1 MiB + if len(value) > maxValueSize { + return fmt.Errorf("value too large (%d bytes); maximum size is 1 MiB", len(value)) } - updated, err := config.SetValue(ctx, metadata, f.Key, value) + updated, err := config.SetValue(ctx, metadata, f.Key, value, encoding) if err != nil { return fmt.Errorf("setting config value: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 1c281b58..0dbcc1c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "context" + "encoding/base64" "fmt" "slices" "time" @@ -81,6 +82,7 @@ func GetAll(ctx context.Context, teamSlug string) ([]gql.GetAllConfigsTeamConfig values { name value + encoding } teamEnvironment { environment { @@ -127,6 +129,7 @@ func Get(ctx context.Context, metadata Metadata) (*gql.GetConfigTeamEnvironmentC values { name value + encoding } teamEnvironment { environment { @@ -213,7 +216,7 @@ func Delete(ctx context.Context, metadata Metadata) (bool, error) { // SetValue sets a key-value pair in a config. If the key already exists, its value is updated. // If the key does not exist, it is added. -func SetValue(ctx context.Context, metadata Metadata, key, value string) (updated bool, err error) { +func SetValue(ctx context.Context, metadata Metadata, key, value string, encoding gql.ValueEncoding) (updated bool, err error) { existing, err := Get(ctx, metadata) if err != nil { return false, fmt.Errorf("fetching config: %w", err) @@ -224,13 +227,13 @@ func SetValue(ctx context.Context, metadata Metadata, key, value string) (update }) if keyExists { - return true, updateValue(ctx, metadata, key, value) + return true, updateValue(ctx, metadata, key, value, encoding) } - return false, addValue(ctx, metadata, key, value) + return false, addValue(ctx, metadata, key, value, encoding) } -func addValue(ctx context.Context, metadata Metadata, key, value string) error { +func addValue(ctx context.Context, metadata Metadata, key, value string, encoding gql.ValueEncoding) error { _ = `# @genqlient mutation AddConfigValue($name: String!, $environmentName: String!, $teamSlug: Slug!, $value: ConfigValueInput!) { addConfigValue(input: {name: $name, environmentName: $environmentName, teamSlug: $teamSlug, value: $value}) { @@ -248,13 +251,14 @@ func addValue(ctx context.Context, metadata Metadata, key, value string) error { } _, err = gql.AddConfigValue(ctx, client, metadata.Name, metadata.EnvironmentName, metadata.TeamSlug, gql.ConfigValueInput{ - Name: key, - Value: value, + Name: key, + Value: value, + Encoding: encoding, }) return err } -func updateValue(ctx context.Context, metadata Metadata, key, value string) error { +func updateValue(ctx context.Context, metadata Metadata, key, value string, encoding gql.ValueEncoding) error { _ = `# @genqlient mutation UpdateConfigValue($name: String!, $environmentName: String!, $teamSlug: Slug!, $value: ConfigValueInput!) { updateConfigValue(input: {name: $name, environmentName: $environmentName, teamSlug: $teamSlug, value: $value}) { @@ -272,8 +276,9 @@ func updateValue(ctx context.Context, metadata Metadata, key, value string) erro } _, err = gql.UpdateConfigValue(ctx, client, metadata.Name, metadata.EnvironmentName, metadata.TeamSlug, gql.ConfigValueInput{ - Name: key, - Value: value, + Name: key, + Value: value, + Encoding: encoding, }) return err } @@ -320,12 +325,22 @@ func FormatDetails(metadata Metadata, c *gql.GetConfigTeamEnvironmentConfig) [][ } // FormatData formats config values as a key-value table for pterm rendering. +// Binary values (BASE64 encoding) are shown as a placeholder with byte count. func FormatData(values []gql.GetConfigTeamEnvironmentConfigValuesConfigValue) [][]string { data := [][]string{ {"Key", "Value"}, } for _, v := range values { - data = append(data, []string{v.Name, v.Value}) + displayValue := v.Value + if v.Encoding == gql.ValueEncodingBase64 { + raw, err := base64.StdEncoding.DecodeString(v.Value) + if err == nil { + displayValue = fmt.Sprintf("", len(raw)) + } else { + displayValue = "" + } + } + data = append(data, []string{v.Name, displayValue}) } return data } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2f95ba94..1f16ada7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/base64" "encoding/json" "reflect" "testing" @@ -149,6 +150,28 @@ func TestFormatData(t *testing.T) { {"EMPTY_KEY", ""}, }, }, + { + name: "binary value shows placeholder with byte count", + values: []gql.GetConfigTeamEnvironmentConfigValuesConfigValue{ + {Name: "keystore.p12", Value: base64.StdEncoding.EncodeToString([]byte{0x00, 0x01, 0x02, 0xff}), Encoding: gql.ValueEncodingBase64}, + }, + want: [][]string{ + {"Key", "Value"}, + {"keystore.p12", ""}, + }, + }, + { + name: "mixed plain text and binary values", + values: []gql.GetConfigTeamEnvironmentConfigValuesConfigValue{ + {Name: "DATABASE_HOST", Value: "db.example.com", Encoding: gql.ValueEncodingPlainText}, + {Name: "cert.pem", Value: base64.StdEncoding.EncodeToString([]byte("not really binary but marked as such")), Encoding: gql.ValueEncodingBase64}, + }, + want: [][]string{ + {"Key", "Value"}, + {"DATABASE_HOST", "db.example.com"}, + {"cert.pem", ""}, + }, + }, } for _, tt := range tests { diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index dca1b6e0..09ac0ed0 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -516,6 +516,8 @@ var AllApplicationState = []ApplicationState{ type ConfigValueInput struct { Name string `json:"name"` Value string `json:"value"` + // Encoding of the value. Defaults to PLAIN_TEXT. Use BASE64 for binary data (certificates, keystores, etc.). + Encoding ValueEncoding `json:"encoding"` } // GetName returns ConfigValueInput.Name, and is useful for accessing the field via an interface. @@ -524,6 +526,9 @@ func (v *ConfigValueInput) GetName() string { return v.Name } // GetValue returns ConfigValueInput.Value, and is useful for accessing the field via an interface. func (v *ConfigValueInput) GetValue() string { return v.Value } +// GetEncoding returns ConfigValueInput.Encoding, and is useful for accessing the field via an interface. +func (v *ConfigValueInput) GetEncoding() ValueEncoding { return v.Encoding } + // CreateConfigCreateConfigCreateConfigPayload includes the requested fields of the GraphQL type CreateConfigPayload. type CreateConfigCreateConfigCreateConfigPayload struct { // The created config. @@ -1531,8 +1536,10 @@ func (v *GetAllConfigsTeamConfigsConfigConnectionNodesConfigTeamEnvironmentEnvir type GetAllConfigsTeamConfigsConfigConnectionNodesConfigValuesConfigValue struct { // The name of the config value. Name string `json:"name"` - // The config value itself. + // The config value itself. When encoding is BASE64, the value is Base64-encoded binary data. Value string `json:"value"` + // Encoding of the value. PLAIN_TEXT for UTF-8 text, BASE64 for binary data. + Encoding ValueEncoding `json:"encoding"` } // GetName returns GetAllConfigsTeamConfigsConfigConnectionNodesConfigValuesConfigValue.Name, and is useful for accessing the field via an interface. @@ -1545,6 +1552,11 @@ func (v *GetAllConfigsTeamConfigsConfigConnectionNodesConfigValuesConfigValue) G return v.Value } +// GetEncoding returns GetAllConfigsTeamConfigsConfigConnectionNodesConfigValuesConfigValue.Encoding, and is useful for accessing the field via an interface. +func (v *GetAllConfigsTeamConfigsConfigConnectionNodesConfigValuesConfigValue) GetEncoding() ValueEncoding { + return v.Encoding +} + // GetAllConfigsTeamConfigsConfigConnectionNodesConfigWorkloadsWorkloadConnection includes the requested fields of the GraphQL type WorkloadConnection. // The GraphQL type's documentation follows. // @@ -11779,8 +11791,10 @@ func (v *GetConfigTeamEnvironmentConfigTeamEnvironmentEnvironment) GetName() str type GetConfigTeamEnvironmentConfigValuesConfigValue struct { // The name of the config value. Name string `json:"name"` - // The config value itself. + // The config value itself. When encoding is BASE64, the value is Base64-encoded binary data. Value string `json:"value"` + // Encoding of the value. PLAIN_TEXT for UTF-8 text, BASE64 for binary data. + Encoding ValueEncoding `json:"encoding"` } // GetName returns GetConfigTeamEnvironmentConfigValuesConfigValue.Name, and is useful for accessing the field via an interface. @@ -11789,6 +11803,11 @@ func (v *GetConfigTeamEnvironmentConfigValuesConfigValue) GetName() string { ret // GetValue returns GetConfigTeamEnvironmentConfigValuesConfigValue.Value, and is useful for accessing the field via an interface. func (v *GetConfigTeamEnvironmentConfigValuesConfigValue) GetValue() string { return v.Value } +// GetEncoding returns GetConfigTeamEnvironmentConfigValuesConfigValue.Encoding, and is useful for accessing the field via an interface. +func (v *GetConfigTeamEnvironmentConfigValuesConfigValue) GetEncoding() ValueEncoding { + return v.Encoding +} + // GetConfigTeamEnvironmentConfigWorkloadsWorkloadConnection includes the requested fields of the GraphQL type WorkloadConnection. // The GraphQL type's documentation follows. // @@ -30525,6 +30544,7 @@ query GetAllConfigs ($teamSlug: Slug!) { values { name value + encoding } teamEnvironment { environment { @@ -31081,6 +31101,7 @@ query GetConfig ($name: String!, $environmentName: String!, $teamSlug: Slug!) { values { name value + encoding } teamEnvironment { environment { diff --git a/schema.graphql b/schema.graphql index a5a185ec..812b4136 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1831,14 +1831,22 @@ The name of the config value. """ name: String! """ -The config value itself. +The config value itself. When encoding is BASE64, the value is Base64-encoded binary data. """ value: String! +""" +Encoding of the value. PLAIN_TEXT for UTF-8 text, BASE64 for binary data. +""" + encoding: ValueEncoding! } input ConfigValueInput { name: String! value: String! +""" +Encoding of the value. Defaults to PLAIN_TEXT. Use BASE64 for binary data (certificates, keystores, etc.). +""" + encoding: ValueEncoding = PLAIN_TEXT } input ConfigureReconcilerInput {