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
8 changes: 6 additions & 2 deletions cmd/agent/container/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ type SetupContainerCmd struct {
}

// NewSetupContainerCmd creates a new command.
func NewSetupContainerCmd(flags *flags.GlobalFlags) *cobra.Command {
func NewSetupContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command {
cmd := &SetupContainerCmd{
GlobalFlags: flags,
GlobalFlags: globalFlags,
}
setupContainerCmd := &cobra.Command{
Use: "setup",
Expand Down Expand Up @@ -96,6 +96,10 @@ func NewSetupContainerCmd(flags *flags.GlobalFlags) *cobra.Command {
setupContainerCmd.Flags().
StringVar(&cmd.DotfilesScript, "dotfiles-script", "", "Dotfiles install script path")
_ = setupContainerCmd.MarkFlagRequired("setup-info")

flags.BindEnv(setupContainerCmd.Flags(), "access-key")
flags.BindEnv(setupContainerCmd.Flags(), "platform-host")

return setupContainerCmd
}

Expand Down
181 changes: 181 additions & 0 deletions cmd/env_flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package cmd

import (
"strings"
"testing"

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

const (
envDevsyHost = "DEVSY_HOST"
envDevsyProject = "DEVSY_PROJECT"
)

// TestOptInEnvFlags_AppliesEnvValueToFlag verifies that for each opt-in flag,
// setting the corresponding DEVSY_* env var pushes the value into the bound
// pflag at registration time, satisfying cobra's required-flag check.
func TestOptInEnvFlags_AppliesEnvValueToFlag(t *testing.T) {
cases := []struct {
cmdPath string
flagName string
envName string
want string
persistent bool
}{
{flagName: "home", envName: "DEVSY_HOME", want: "/tmp/h", persistent: true},
{flagName: "context", envName: "DEVSY_CONTEXT", want: "ctx-a", persistent: true},
{flagName: "provider", envName: "DEVSY_PROVIDER", want: "docker", persistent: true},
{flagName: "debug", envName: "DEVSY_DEBUG", want: "true", persistent: true},
{
cmdPath: "pro list-workspaces",
flagName: "host",
envName: envDevsyHost,
want: "pro.example.com",
},
{cmdPath: "pro list-clusters", flagName: "project", envName: envDevsyProject, want: "demo"},
{
cmdPath: "agent container setup",
flagName: "access-key",
envName: "DEVSY_ACCESS_KEY",
want: "secret",
},
{
cmdPath: "agent container setup",
flagName: "platform-host",
envName: "DEVSY_PLATFORM_HOST",
want: "p.example.com",
},
}

for _, tc := range cases {
t.Run(tc.envName, func(t *testing.T) {
t.Setenv(tc.envName, tc.want)
rootCmd, _ := BuildRoot()
cmd := resolveCommand(t, rootCmd, tc.cmdPath)
fs := cmd.Flags()
if tc.persistent {
fs = cmd.PersistentFlags()
}
f := fs.Lookup(tc.flagName)
require.NotNil(t, f, "flag --%s not found on %q", tc.flagName, tc.cmdPath)
assert.Equal(t, tc.want, f.Value.String())
assert.True(t, f.Changed, "Changed must be true so MarkFlagRequired passes")
assert.Contains(t, f.Usage, tc.envName, "usage should advertise env var")
})
}
}

func TestOptInEnvFlags_NoEnvLeavesDefault(t *testing.T) {
for _, name := range []string{"DEVSY_HOME", "DEVSY_CONTEXT", "DEVSY_PROVIDER", envDevsyHost, envDevsyProject} {
t.Setenv(name, "")
}
rootCmd, _ := BuildRoot()
f := rootCmd.PersistentFlags().Lookup("context")
require.NotNil(t, f)
assert.False(t, f.Changed)
assert.Equal(t, "", f.Value.String())
}

// TestOptInEnvFlags_EmptyEnvIsNoOp guards the "value == \"\"" branch in
// BindEnv: setting DEVSY_HOST="" must NOT mark the flag as Changed (otherwise
// MarkFlagRequired would be satisfied by an empty value).
func TestOptInEnvFlags_EmptyEnvIsNoOp(t *testing.T) {
t.Setenv(envDevsyHost, "")
rootCmd, _ := BuildRoot()
cmd := resolveCommand(t, rootCmd, "pro list-workspaces")
f := cmd.Flags().Lookup("host")
require.NotNil(t, f)
assert.False(t, f.Changed, "empty env value must not mark flag as Changed")
}

// TestOptInEnvFlags_CLIOverridesEnv proves that an explicit CLI flag value
// wins over a value supplied via DEVSY_*. This is the standard precedence
// users expect: flag > env > default.
func TestOptInEnvFlags_CLIOverridesEnv(t *testing.T) {
t.Setenv(envDevsyHost, "env-value")
rootCmd, _ := BuildRoot()
// Replace the leaf command's RunE so Execute() doesn't try to actually
// talk to a pro instance; we only care that flag resolution put cli-value
// in cmd.Host.
leaf := resolveCommand(t, rootCmd, "pro list-workspaces")
var seen string
leaf.RunE = func(c *cobra.Command, _ []string) error {
seen, _ = c.Flags().GetString("host")
return nil
}
rootCmd.SetArgs([]string{"pro", "list-workspaces", "--host", "cli-value"})
require.NoError(t, rootCmd.Execute())
assert.Equal(t, "cli-value", seen, "CLI flag must override env value")
}

// TestOptInEnvFlags_EnvSatisfiesRequired drives cobra's full parse +
// ValidateRequiredFlags pipeline to prove DEVSY_HOST satisfies
// MarkFlagRequired("host") at execution time, not just in static inspection.
func TestOptInEnvFlags_EnvSatisfiesRequired(t *testing.T) {
t.Setenv(envDevsyHost, "from-env.example.com")
rootCmd, _ := BuildRoot()
leaf := resolveCommand(t, rootCmd, "pro list-workspaces")
var seen string
leaf.RunE = func(c *cobra.Command, _ []string) error {
seen, _ = c.Flags().GetString("host")
return nil
}
rootCmd.SetArgs([]string{"pro", "list-workspaces"})
require.NoError(t, rootCmd.Execute(), "required --host must be satisfied by env")
assert.Equal(t, "from-env.example.com", seen)
}

// TestOptInEnvFlags_ProSubcommandSweep asserts that DEVSY_HOST applies to
// every pro subcommand that exposes --host (and similarly for --project).
// This catches the regression where a future addition to cmd/pro/ forgets
// the inline BindEnv call.
func TestOptInEnvFlags_ProSubcommandSweep(t *testing.T) {
for _, env := range []string{envDevsyHost, envDevsyProject} {
t.Setenv(env, "sweep-"+strings.ToLower(env))
}
rootCmd, _ := BuildRoot()
proCmd := resolveCommand(t, rootCmd, "pro")

var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
for _, flagName := range []string{"host", "project"} {
if f := c.Flags().Lookup(flagName); f != nil {
assert.True(t, f.Changed,
"%s: --%s should be Changed from DEVSY_%s",
c.CommandPath(), flagName, strings.ToUpper(flagName),
)
}
}
for _, child := range c.Commands() {
walk(child)
}
}
walk(proCmd)
}

func resolveCommand(t *testing.T, rootCmd *cobra.Command, path string) *cobra.Command {
t.Helper()
if path == "" {
return rootCmd
}
cur := rootCmd
for seg := range strings.SplitSeq(path, " ") {
var next *cobra.Command
for _, c := range cur.Commands() {
use := c.Use
if i := strings.IndexByte(use, ' '); i >= 0 {
use = use[:i]
}
if use == seg {
next = c
break
}
}
require.NotNil(t, next, "segment %q not found under %q", seg, cur.Use)
cur = next
}
return cur
}
6 changes: 3 additions & 3 deletions cmd/flag_aliases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestBuildCmd_ConfigAlias(t *testing.T) {
}

func TestGlobalFlags_LogFormatAlias(t *testing.T) {
rootCmd := BuildRoot()
rootCmd, globalFlags := BuildRoot()
rootCmd.SetArgs([]string{flagLogFormat, formatJSON, "version"})
err := rootCmd.Execute()
require.NoError(t, err)
Expand All @@ -58,14 +58,14 @@ func TestConfigAlias_IsHidden(t *testing.T) {
}

func TestLogFormatAlias_IsHidden(t *testing.T) {
rootCmd := BuildRoot()
rootCmd, _ := BuildRoot()
f := rootCmd.PersistentFlags().Lookup("log-format")
require.NotNil(t, f)
assert.True(t, f.Hidden, flagLogFormat+" alias should be hidden")
}

func TestConfigAlias_E2E(t *testing.T) {
rootCmd := BuildRoot()
rootCmd, _ := BuildRoot()
rootCmd.SetArgs([]string{"up", flagConfig, "/tmp/test.json", "--help"})
err := rootCmd.Execute()
require.NoError(t, err)
Expand Down
53 changes: 53 additions & 0 deletions cmd/flags/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package flags

import (
"os"
"strings"

"github.com/devsy-org/devsy/pkg/config"
"github.com/devsy-org/devsy/pkg/log"
pflag "github.com/spf13/pflag"
)

// EnvName returns the canonical env var name for a flag under the uniform rule:
//
// config.EnvPrefix + uppercase(flagName) with "-" replaced by "_"
//
// Example: "agent-url" -> "DEVSY_AGENT_URL".
func EnvName(flagName string) string {
return config.EnvPrefix + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_"))
}

// BindEnv wires the canonical DEVSY_* env var into a cobra flag. Call it
// immediately after the flag's *Var registration. If the env var is set, the
// value is applied to the flag (Changed=true, satisfying MarkFlagRequired) and
// the usage string advertises the env var.
//
// Failure modes are loud by design:
// - panics if the flag is not registered (programmer bug at startup);
// - log.Fatalf if the env value is rejected by the flag's type (operator bug;
// e.g. DEVSY_DEBUG=garbage on a bool flag). This matches the contract of
// the prior inheritFlagsFromEnvironment loop so misconfigured env vars are
// never silently ignored.
func BindEnv(fs *pflag.FlagSet, flagName string) {
f := fs.Lookup(flagName)
if f == nil {
panic("flags.BindEnv: flag --" + flagName + " is not registered")
}
envName := EnvName(flagName)
// "[$DEVSY_FOO]" — $VAR convention, bracket avoids clashing with
// parentheses inside the existing description.
suffix := " [$" + envName + "]"
desc := strings.TrimRight(f.Usage, " .")
if !strings.HasSuffix(desc, suffix) {
f.Usage = desc + suffix
}
value, ok := os.LookupEnv(envName)
if !ok || value == "" {
return
}
if err := fs.Set(flagName, value); err != nil {
log.Fatalf("invalid value %q for %s (flag --%s): %v", value, envName, flagName, err)
}
f.DefValue = value
}
24 changes: 24 additions & 0 deletions cmd/flags/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package flags

import (
"testing"

"github.com/devsy-org/devsy/pkg/config"
"github.com/stretchr/testify/assert"
)

// TestEnvName_MatchesConfigConstants pins the uniform rule against the
// canonical env-var constants in pkg/config. If a constant ever stops matching
// what EnvName(flagName) produces, this test fires so the two surfaces stay in
// sync.
func TestEnvName_MatchesConfigConstants(t *testing.T) {
cases := map[string]string{
"home": config.EnvHome,
"debug": config.EnvDebug,
"agent-url": config.EnvAgentURL,
"log-level": config.EnvLogLevel,
}
for flagName, want := range cases {
assert.Equal(t, want, EnvName(flagName), "EnvName(%q)", flagName)
}
}
9 changes: 7 additions & 2 deletions cmd/flags/flags.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package flags

import (
"github.com/devsy-org/devsy/pkg/config"
"github.com/devsy-org/devsy/pkg/platform"
flag "github.com/spf13/pflag"
)
Expand All @@ -27,7 +26,7 @@ func SetGlobalFlags(flags *flag.FlagSet) *GlobalFlags {

flags.StringVar(
&globalFlags.DevsyHome,
config.BinaryName+"-home",
"home",
"",
"If defined will override the default devsy home",
)
Expand Down Expand Up @@ -78,5 +77,11 @@ func SetGlobalFlags(flags *flag.FlagSet) *GlobalFlags {
"The data folder where agent data is stored.",
)
_ = flags.MarkHidden("agent-dir")

BindEnv(flags, "home")
BindEnv(flags, "context")
BindEnv(flags, "provider")
BindEnv(flags, "debug")

return globalFlags
}
1 change: 1 addition & 0 deletions cmd/pro/add/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func NewClusterCmd(globalFlags *proflags.GlobalFlags) *cobra.Command {
c.Flags().
StringVar(&cmd.KubeContext, "kube-context", "", "The kube context to use for installation")
c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use")
proflags.BindEnv(c.Flags(), "host")

return c
}
Expand Down
1 change: 1 addition & 0 deletions cmd/pro/check_health.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func NewCheckHealthCmd(globalFlags *flags.GlobalFlags) *cobra.Command {

c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use")
_ = c.MarkFlagRequired("host")
flags.BindEnv(c.Flags(), "host")

return c
}
Expand Down
1 change: 1 addition & 0 deletions cmd/pro/check_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func NewCheckUpdateCmd(globalFlags *flags.GlobalFlags) *cobra.Command {

c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use")
_ = c.MarkFlagRequired("host")
flags.BindEnv(c.Flags(), "host")

return c
}
Expand Down
1 change: 1 addition & 0 deletions cmd/pro/create_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func NewCreateWorkspaceCmd(globalFlags *flags.GlobalFlags) *cobra.Command {

c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use")
_ = c.MarkFlagRequired("host")
flags.BindEnv(c.Flags(), "host")
c.Flags().StringVar(&cmd.Instance, "instance", "", "The workspace instance to create")
_ = c.MarkFlagRequired("instance")

Expand Down
1 change: 1 addition & 0 deletions cmd/pro/daemon/netcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func NewNetcheckCmd(flags *proflags.GlobalFlags) *cobra.Command {

c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use")
_ = c.MarkFlagRequired("host")
proflags.BindEnv(c.Flags(), "host")
_ = c.RegisterFlagCompletionFunc(
"host",
func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
Expand Down
1 change: 1 addition & 0 deletions cmd/pro/daemon/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func NewStartCmd(flags *proflags.GlobalFlags) *cobra.Command {

c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use")
_ = c.MarkFlagRequired("host")
proflags.BindEnv(c.Flags(), "host")
_ = c.RegisterFlagCompletionFunc(
"host",
func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
Expand Down
1 change: 1 addition & 0 deletions cmd/pro/daemon/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func NewStatusCmd(flags *proflags.GlobalFlags) *cobra.Command {

c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use")
_ = c.MarkFlagRequired("host")
proflags.BindEnv(c.Flags(), "host")
_ = c.RegisterFlagCompletionFunc(
"host",
func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
Expand Down
Loading
Loading