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
3 changes: 3 additions & 0 deletions internal/config/command/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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) }
Expand Down
83 changes: 74 additions & 9 deletions internal/config/command/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -57,24 +70,45 @@ 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))
if err != nil {
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 {
Expand All @@ -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,
Expand Down
55 changes: 48 additions & 7 deletions internal/config/command/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
},
Expand All @@ -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)
}
Expand Down
35 changes: 25 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"context"
"encoding/base64"
"fmt"
"slices"
"time"
Expand Down Expand Up @@ -81,6 +82,7 @@ func GetAll(ctx context.Context, teamSlug string) ([]gql.GetAllConfigsTeamConfig
values {
name
value
encoding
}
teamEnvironment {
environment {
Expand Down Expand Up @@ -127,6 +129,7 @@ func Get(ctx context.Context, metadata Metadata) (*gql.GetConfigTeamEnvironmentC
values {
name
value
encoding
}
teamEnvironment {
environment {
Expand Down Expand Up @@ -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)
Expand All @@ -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}) {
Expand All @@ -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}) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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("<binary, %d bytes>", len(raw))
} else {
displayValue = "<binary>"
}
}
data = append(data, []string{v.Name, displayValue})
}
return data
}
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"encoding/base64"
"encoding/json"
"reflect"
"testing"
Expand Down Expand Up @@ -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", "<binary, 4 bytes>"},
},
},
{
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", "<binary, 36 bytes>"},
},
},
}

for _, tt := range tests {
Expand Down
Loading
Loading