Skip to content
Closed
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
46 changes: 46 additions & 0 deletions cmd/rootCmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ func bindFlags(cmd *cobra.Command, v *viper.Viper) {
configName = strings.ReplaceAll(f.Name, "-", "")
}

// Bind the flag to its environment variables. In addition to the legacy
// concatenated form (e.g. MPF_SUBSCRIPTIONID), also accept a snake_case form
// (e.g. MPF_SUBSCRIPTION_ID) so environment variables are easier to read.
bindEnvVars(v, configName)

// Apply the viper config value to the flag when the flag is not set and viper has a value
if !f.Changed && v.IsSet(configName) {
val := v.Get(configName)
Expand All @@ -169,6 +174,47 @@ func bindFlags(cmd *cobra.Command, v *viper.Viper) {
})
}

// bindEnvVars binds a viper config key to its supported environment variables. Both the
// legacy concatenated form (e.g. MPF_SUBSCRIPTIONID) and a snake_case form
// (e.g. MPF_SUBSCRIPTION_ID) are accepted so that existing setups keep working while
// newer, more readable variable names are also supported.
func bindEnvVars(v *viper.Viper, configName string) {
legacyEnv := envPrefix + "_" + strings.ToUpper(strings.ReplaceAll(configName, "-", ""))
snakeEnv := envPrefix + "_" + camelCaseToSnakeUpper(configName)

var err error
if snakeEnv != legacyEnv {
err = v.BindEnv(configName, legacyEnv, snakeEnv)
} else {
err = v.BindEnv(configName, legacyEnv)
}
if err != nil {
log.Errorf("Error binding environment variables for %s: %v\n", configName, err)
}
}

// camelCaseToSnakeUpper converts a camelCase identifier to an UPPER_SNAKE_CASE string.
// For example, "subscriptionID" becomes "SUBSCRIPTION_ID" and "spClientID" becomes "SP_CLIENT_ID".
func camelCaseToSnakeUpper(s string) string {
var b strings.Builder
runes := []rune(strings.ReplaceAll(s, "-", "_"))
for i, r := range runes {
if i > 0 && r >= 'A' && r <= 'Z' {
prev := runes[i-1]
prevIsLowerOrDigit := (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9')
nextIsLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z'
// Insert an underscore at a lowercase->uppercase boundary (e.g. "subscriptionID" -> "subscription_ID")
// or at an acronym->word boundary (e.g. "APIVersion" -> "API_Version"), but not between
// consecutive acronym letters (e.g. the "ID" in "subscriptionID" stays together).
if prevIsLowerOrDigit || (nextIsLower && prev != '_') {
b.WriteRune('_')
}
}
b.WriteRune(r)
}
return strings.ToUpper(b.String())
}

func setLogLevel() {
if flgVerbose {
log.SetLevel(log.InfoLevel)
Expand Down
98 changes: 98 additions & 0 deletions cmd/rootCmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import (
"path/filepath"
"reflect"
"testing"

"github.com/spf13/viper"
)

func TestParseInitialPermissions(t *testing.T) {
Expand Down Expand Up @@ -141,3 +143,99 @@ func TestParseInitialPermissions(t *testing.T) {
})
}
}

func TestCamelCaseToSnakeUpper(t *testing.T) {
tests := []struct {
in string
want string
}{
{"subscriptionID", "SUBSCRIPTION_ID"},
{"tenantID", "TENANT_ID"},
{"spClientID", "SP_CLIENT_ID"},
{"spObjectID", "SP_OBJECT_ID"},
{"spClientSecret", "SP_CLIENT_SECRET"},
{"initialPermissions", "INITIAL_PERMISSIONS"},
{"showDetailedOutput", "SHOW_DETAILED_OUTPUT"},
{"jsonOutput", "JSON_OUTPUT"},
{"verbose", "VERBOSE"},
{"debug", "DEBUG"},
}

for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := camelCaseToSnakeUpper(tt.in); got != tt.want {
t.Errorf("camelCaseToSnakeUpper(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

// setOnlyEnv unsets every env var in relatedKeys (restoring the originals when the test
// finishes) and then sets setKey to setVal. This keeps each subtest isolated from any
// MPF_* variables that may already exist in the developer's shell.
func setOnlyEnv(t *testing.T, setKey, setVal string, relatedKeys ...string) {
t.Helper()
for _, k := range relatedKeys {
if orig, ok := os.LookupEnv(k); ok {
key, val := k, orig
t.Cleanup(func() { _ = os.Setenv(key, val) })
} else {
key := k
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
_ = os.Unsetenv(k)
}
t.Setenv(setKey, setVal)
}

func TestBindEnvVars(t *testing.T) {
tests := []struct {
name string
configName string
envKey string
envValue string
relatedKeys []string
}{
{
name: "legacy concatenated subscription id",
configName: "subscriptionID",
envKey: "MPF_SUBSCRIPTIONID",
envValue: "sub-legacy",
relatedKeys: []string{"MPF_SUBSCRIPTIONID", "MPF_SUBSCRIPTION_ID"},
},
{
name: "snake_case subscription id",
configName: "subscriptionID",
envKey: "MPF_SUBSCRIPTION_ID",
envValue: "sub-snake",
relatedKeys: []string{"MPF_SUBSCRIPTIONID", "MPF_SUBSCRIPTION_ID"},
},
{
name: "legacy concatenated client secret",
configName: "spClientSecret",
envKey: "MPF_SPCLIENTSECRET",
envValue: "secret-legacy",
relatedKeys: []string{"MPF_SPCLIENTSECRET", "MPF_SP_CLIENT_SECRET"},
},
{
name: "snake_case client secret",
configName: "spClientSecret",
envKey: "MPF_SP_CLIENT_SECRET",
envValue: "secret-snake",
relatedKeys: []string{"MPF_SPCLIENTSECRET", "MPF_SP_CLIENT_SECRET"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setOnlyEnv(t, tt.envKey, tt.envValue, tt.relatedKeys...)

v := viper.New()
bindEnvVars(v, tt.configName)

if got := v.GetString(tt.configName); got != tt.envValue {
t.Errorf("v.GetString(%q) = %q, want %q", tt.configName, got, tt.envValue)
}
})
}
}
2 changes: 2 additions & 0 deletions docs/commandline-flags-and-env-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

**Note**: Environment variables can be set using bash/shell syntax (e.g., `export MPF_SUBSCRIPTIONID=value`) on Linux/macOS, or using PowerShell syntax (e.g., `$env:MPF_SUBSCRIPTIONID = "value"`) on Windows.

**Note**: Each environment variable can also be provided in `snake_case` form for readability. For every variable listed below, MPF additionally accepts a variant where the flag name is split on word boundaries with underscores. For example, `MPF_SUBSCRIPTIONID` can also be supplied as `MPF_SUBSCRIPTION_ID`, and `MPF_SPCLIENTSECRET` as `MPF_SP_CLIENT_SECRET`. The original concatenated form keeps working, so existing configurations are unaffected. If both forms are set, the concatenated form takes precedence.

## Global Flags (Common to all providers)

| Flag | Environment Variable | Required / Optional | Description |
Expand Down
Loading