Skip to content

Commit e9f6fcc

Browse files
authored
Honor persistent flags in proxy commands (#274)
1 parent efa6f19 commit e9f6fcc

5 files changed

Lines changed: 185 additions & 75 deletions

File tree

cmd/aws.go

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import (
1919
)
2020

2121
func newAWSCmd(cfg *env.Env) *cobra.Command {
22+
// DisableFlagParsing means Cobra won't strip lstk's own flags; PreRunE does
23+
// that and stashes the remaining args here for RunE to forward to aws.
24+
var passthrough []string
2225
return &cobra.Command{
2326
Use: "aws [args...]",
2427
Short: "Run AWS CLI commands against LocalStack",
@@ -35,10 +38,21 @@ Examples:
3538
lstk aws sqs list-queues
3639
lstk aws s3 mb s3://my-bucket`,
3740
DisableFlagParsing: true,
38-
PreRunE: initConfig(nil),
39-
RunE: func(cmd *cobra.Command, args []string) error {
40-
args, nonInteractive := stripNonInteractiveFlag(args)
41-
41+
PreRunE: func(cmd *cobra.Command, args []string) error {
42+
var gf globalFlags
43+
passthrough, gf = stripGlobalFlags(args)
44+
if gf.nonInteractive {
45+
cfg.NonInteractive = true
46+
}
47+
if gf.configPath != "" {
48+
// initConfig reads the "config" flag, so feed the value back to it.
49+
if err := cmd.Flags().Set("config", gf.configPath); err != nil {
50+
return err
51+
}
52+
}
53+
return initConfig(nil)(cmd, args)
54+
},
55+
RunE: func(cmd *cobra.Command, _ []string) error {
4256
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
4357
if err != nil {
4458
return err
@@ -87,32 +101,15 @@ Examples:
87101
}
88102

89103
stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr)
90-
if !nonInteractive && terminal.IsTerminal(os.Stderr) {
104+
if !cfg.NonInteractive && terminal.IsTerminal(os.Stderr) {
91105
s := terminal.NewSpinner(os.Stderr, "Loading service...", 4*time.Second)
92106
s.Start()
93107
defer s.Stop()
94108
stdout = &terminal.StopOnWriteWriter{W: os.Stdout, Spinner: s}
95109
stderr = &terminal.StopOnWriteWriter{W: os.Stderr, Spinner: s}
96110
}
97111

98-
return awscli.Exec(cmd.Context(), "http://"+host, profileExists, stdout, stderr, args)
112+
return awscli.Exec(cmd.Context(), "http://"+host, profileExists, stdout, stderr, passthrough)
99113
},
100114
}
101115
}
102-
103-
// stripNonInteractiveFlag pulls lstk's --non-interactive flag out of the AWS CLI
104-
// passthrough args and reports whether it was set. The aws command uses
105-
// DisableFlagParsing, so Cobra never parses the flag here — left in place it would
106-
// be forwarded to the aws binary and rejected as an unknown option.
107-
func stripNonInteractiveFlag(args []string) ([]string, bool) {
108-
out := make([]string, 0, len(args))
109-
nonInteractive := false
110-
for _, a := range args {
111-
if a == "--non-interactive" {
112-
nonInteractive = true
113-
continue
114-
}
115-
out = append(out, a)
116-
}
117-
return out, nonInteractive
118-
}

cmd/aws_test.go

Lines changed: 0 additions & 52 deletions
This file was deleted.

cmd/proxy.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmd
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
)
7+
8+
type globalFlags struct {
9+
nonInteractive bool
10+
configPath string
11+
}
12+
13+
// stripGlobalFlags removes lstk's persistent flags (--non-interactive and
14+
// --config) from a proxy command's arguments, returning the remaining args and
15+
// the extracted values. Proxy commands set DisableFlagParsing, so without this
16+
// these flags would be forwarded to the wrapped binary (which rejects them as
17+
// unknown) and their effect silently lost. Both --flag value and --flag=value
18+
// forms are recognized, in any position.
19+
func stripGlobalFlags(args []string) ([]string, globalFlags) {
20+
out := make([]string, 0, len(args))
21+
var gf globalFlags
22+
for i := 0; i < len(args); i++ {
23+
arg := args[i]
24+
switch {
25+
case arg == "--non-interactive":
26+
gf.nonInteractive = true
27+
case strings.HasPrefix(arg, "--non-interactive="):
28+
// A malformed value still strips the flag (it must never reach the
29+
// wrapped binary) and enables the mode, matching the user's intent.
30+
v, err := strconv.ParseBool(strings.TrimPrefix(arg, "--non-interactive="))
31+
gf.nonInteractive = err != nil || v
32+
case arg == "--config":
33+
if i+1 < len(args) {
34+
gf.configPath = args[i+1]
35+
i++
36+
}
37+
case strings.HasPrefix(arg, "--config="):
38+
gf.configPath = strings.TrimPrefix(arg, "--config=")
39+
default:
40+
out = append(out, arg)
41+
}
42+
}
43+
return out, gf
44+
}

cmd/proxy_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package cmd
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestStripGlobalFlags(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
args []string
12+
wantArgs []string
13+
wantNonInteract bool
14+
wantConfigPath string
15+
}{
16+
{
17+
name: "no global flags",
18+
args: []string{"s3", "ls"},
19+
wantArgs: []string{"s3", "ls"},
20+
},
21+
{
22+
name: "bare non-interactive is stripped",
23+
args: []string{"--non-interactive", "s3", "ls"},
24+
wantArgs: []string{"s3", "ls"},
25+
wantNonInteract: true,
26+
},
27+
{
28+
name: "non-interactive among aws args is stripped",
29+
args: []string{"s3", "ls", "--non-interactive", "--recursive"},
30+
wantArgs: []string{"s3", "ls", "--recursive"},
31+
wantNonInteract: true,
32+
},
33+
{
34+
name: "non-interactive with explicit true value",
35+
args: []string{"--non-interactive=true", "s3", "ls"},
36+
wantArgs: []string{"s3", "ls"},
37+
wantNonInteract: true,
38+
},
39+
{
40+
name: "non-interactive with explicit false value",
41+
args: []string{"--non-interactive=false", "s3", "ls"},
42+
wantArgs: []string{"s3", "ls"},
43+
wantNonInteract: false,
44+
},
45+
{
46+
name: "config with separate value",
47+
args: []string{"--config", "/tmp/c.toml", "s3", "ls"},
48+
wantArgs: []string{"s3", "ls"},
49+
wantConfigPath: "/tmp/c.toml",
50+
},
51+
{
52+
name: "config with equals value",
53+
args: []string{"--config=/tmp/c.toml", "s3", "ls"},
54+
wantArgs: []string{"s3", "ls"},
55+
wantConfigPath: "/tmp/c.toml",
56+
},
57+
{
58+
name: "config among aws args",
59+
args: []string{"s3", "ls", "--config", "/tmp/c.toml"},
60+
wantArgs: []string{"s3", "ls"},
61+
wantConfigPath: "/tmp/c.toml",
62+
},
63+
{
64+
name: "both flags together",
65+
args: []string{"--non-interactive", "--config=/tmp/c.toml", "s3", "ls"},
66+
wantArgs: []string{"s3", "ls"},
67+
wantNonInteract: true,
68+
wantConfigPath: "/tmp/c.toml",
69+
},
70+
{
71+
name: "trailing config without value is dropped",
72+
args: []string{"s3", "ls", "--config"},
73+
wantArgs: []string{"s3", "ls"},
74+
},
75+
{
76+
name: "similarly named flags are left untouched",
77+
args: []string{"s3", "ls", "--non-interactive-mode", "--config-file", "x"},
78+
wantArgs: []string{"s3", "ls", "--non-interactive-mode", "--config-file", "x"},
79+
},
80+
}
81+
82+
for _, tt := range tests {
83+
t.Run(tt.name, func(t *testing.T) {
84+
gotArgs, gf := stripGlobalFlags(tt.args)
85+
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
86+
t.Errorf("args = %v, want %v", gotArgs, tt.wantArgs)
87+
}
88+
if gf.nonInteractive != tt.wantNonInteract {
89+
t.Errorf("nonInteractive = %v, want %v", gf.nonInteractive, tt.wantNonInteract)
90+
}
91+
if gf.configPath != tt.wantConfigPath {
92+
t.Errorf("configPath = %q, want %q", gf.configPath, tt.wantConfigPath)
93+
}
94+
})
95+
}
96+
}

test/integration/aws_cmd_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,31 @@ func TestAWSCommandInjectsEndpointAndArgs(t *testing.T) {
6969
assertCommandTelemetry(t, events, "aws", 0)
7070
}
7171

72+
func TestAWSCommandStripsGlobalFlagsFromPassthrough(t *testing.T) {
73+
requireDocker(t)
74+
cleanup()
75+
t.Cleanup(cleanup)
76+
ctx := testContext(t)
77+
startTestContainer(t, ctx)
78+
79+
fakeDir := writeFakeAWS(t)
80+
homeDir := t.TempDir()
81+
writeAWSProfile(t, homeDir)
82+
83+
// --config must resolve to this file, not be forwarded to the aws binary.
84+
configPath := filepath.Join(t.TempDir(), "config.toml")
85+
require.NoError(t, os.WriteFile(configPath, []byte("# lstk test config\n"), 0600))
86+
87+
e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, homeDir)
88+
89+
stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "--config", configPath, "--non-interactive", "aws", "s3", "ls")
90+
require.NoError(t, err, "lstk aws failed: %s", stderr)
91+
92+
assert.Contains(t, stdout, "ARGS:--profile localstack s3 ls")
93+
assert.NotContains(t, stdout, "--config")
94+
assert.NotContains(t, stdout, "--non-interactive")
95+
}
96+
7297
func TestAWSCommandInjectsCredentials(t *testing.T) {
7398
requireDocker(t)
7499
cleanup()

0 commit comments

Comments
 (0)