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
4 changes: 2 additions & 2 deletions cmd/dev/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func newDeployCmd() *cobra.Command {
"controller_annotations": map[string]any{"skipper/image-id": controllerImageID},
"router_annotations": map[string]any{"skipper/image-id": routerImageID},
"namespace": "skipper-development",
"function_namespaces": []string{"skipper-development-fixtures"},
"assignment_namespaces": []string{"skipper-development-fixtures"},
"unsafe_controller_paseto_private_key": string(pasetoPrivate),
"router_node_port": 31020,
"controller_node_port": 31021,
Expand All @@ -156,7 +156,7 @@ func newDeployCmd() *cobra.Command {
"controller_annotations": map[string]any{"skipper/image-id": controllerImageID},
"router_annotations": map[string]any{"skipper/image-id": routerImageID},
"namespace": "skipper-test",
"function_namespaces": []string{"skipper-test-fixtures"},
"assignment_namespaces": []string{"skipper-test-fixtures"},
"unsafe_controller_paseto_private_key": string(pasetoPrivate),
"router_node_port": 31030,
"controller_node_port": 31031,
Expand Down
6 changes: 3 additions & 3 deletions cmd/dev/kube_lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ type bindingConfig struct {
}

var kubeLintBaseBindings = map[string]any{
"namespace": "lint",
"function_namespaces": []string{"default"},
"image_tag": "v1.0.0",
"namespace": "lint",
"assignment_namespaces": []string{"default"},
"image_tag": "v1.0.0",
}

var kubeLintConfigs = []bindingConfig{
Expand Down
2 changes: 1 addition & 1 deletion cmd/dev/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func controllerSpawn(root string) process.SpawnFunc {
"SKIPPER_NAMESPACE=skipper-development",
"SKIPPER_POD_IP=127.0.0.1",
"SKIPPER_PASETO_PRIVATE_KEY="+string(pasetoKey),
"SKIPPER_FUNCTION_NAMESPACES=skipper-development-fixtures,skipper-test-fixtures",
"SKIPPER_ASSIGNMENT_NAMESPACES=skipper-development-fixtures,skipper-test-fixtures",
"SKIPPER_WEB_TEMPLATE_DIR="+filepath.Join(root, "internal", "web"),
"SKIPPER_SINGLE_CONTROLLER_MODE=true",
"SKIPPER_HOST=127.0.0.1",
Expand Down
25 changes: 20 additions & 5 deletions internal/cmd/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ func TestControllerCommandConfigValidation(t *testing.T) {
}{
{
name: "invalid max-concurrent-stale-replacements fails validation",
args: []string{
"--namespace=test",
"--pod-ip=10.0.0.1",
"--paseto-private-key=" + testPasetoPrivateKeyPEM,
"--assignment-namespaces=default",
"--max-concurrent-stale-replacements=0",
},
wantErr: "max concurrent stale replacements must be at least 1",
},
{
// Verifies the legacy --function-namespaces alias still
// resolves to AssignmentNamespaces. Combined with the
// validation failure path, this confirms the binder threaded
// the value into the same field.
name: "legacy --function-namespaces alias still binds",
args: []string{
"--namespace=test",
"--pod-ip=10.0.0.1",
Expand Down Expand Up @@ -194,7 +209,7 @@ func TestControllerKubeConfigLoadFailure(t *testing.T) {
"--namespace=test",
"--pod-ip=10.0.0.1",
"--paseto-private-key=" + testPasetoPrivateKeyPEM,
"--function-namespaces=default",
"--assignment-namespaces=default",
})

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand Down Expand Up @@ -222,7 +237,7 @@ func TestControllerK8sClientCreationFailure(t *testing.T) {
"--namespace=test",
"--pod-ip=10.0.0.1",
"--paseto-private-key=" + testPasetoPrivateKeyPEM,
"--function-namespaces=default",
"--assignment-namespaces=default",
})

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand Down Expand Up @@ -253,7 +268,7 @@ func TestControllerMetricsClientCreationFailure(t *testing.T) {
"--namespace=test",
"--pod-ip=10.0.0.1",
"--paseto-private-key=" + testPasetoPrivateKeyPEM,
"--function-namespaces=default",
"--assignment-namespaces=default",
})

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand Down Expand Up @@ -299,7 +314,7 @@ func TestControllerListenerFailure(t *testing.T) {
"--namespace=test",
"--pod-ip=10.0.0.1",
"--paseto-private-key=" + testPasetoPrivateKeyPEM,
"--function-namespaces=default",
"--assignment-namespaces=default",
"--host=127.0.0.1",
"--port=" + itoa(port), // Same port that's already bound
})
Expand Down Expand Up @@ -365,7 +380,7 @@ func TestControllerHealthCheck(t *testing.T) {
"--namespace=test",
"--pod-ip=10.0.0.1",
"--paseto-private-key=" + testPasetoPrivateKeyPEM,
"--function-namespaces=default",
"--assignment-namespaces=default",
"--host=127.0.0.1",
"--port=" + strconv.Itoa(port),
})
Expand Down
8 changes: 4 additions & 4 deletions internal/cmd/testdata/help_controller.golden
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ Usage:
controller [flags]

Flags:
--function-assign-path string The path used to assign a function to a pod. (env SKIPPER_FUNCTION_ASSIGN_PATH) (default "/__skipper/assign")
--function-assign-timeout duration The timeout for assigning a function to a pod. (env SKIPPER_FUNCTION_ASSIGN_TIMEOUT) (default 30s)
--function-namespaces strings The namespaces where functions can be invoked. (env SKIPPER_FUNCTION_NAMESPACES)
--assign-path string The path used to assign a pod. (env SKIPPER_ASSIGN_PATH, deprecated env SKIPPER_FUNCTION_ASSIGN_PATH) (default "/__skipper/assign")
--assign-timeout duration The timeout for assigning a pod. (env SKIPPER_ASSIGN_TIMEOUT, deprecated env SKIPPER_FUNCTION_ASSIGN_TIMEOUT) (default 30s)
--assignment-namespaces strings The namespaces where assignments can be invoked. (env SKIPPER_ASSIGNMENT_NAMESPACES, deprecated env SKIPPER_FUNCTION_NAMESPACES)
--hash-ring-wait-time duration How long to wait for the controller to populate its hash ring. (env SKIPPER_HASH_RING_WAIT_TIME) (default 10s)
--heartbeat-timeout duration How long to wait before scaling a function to 0 if it has not sent a heartbeat. (env SKIPPER_HEARTBEAT_TIMEOUT) (default 1m30s)
-h, --help help for controller
Expand All @@ -33,7 +33,7 @@ Flags:
--scale-interval duration How often to scale functions. (env SKIPPER_SCALE_INTERVAL) (default 15s)
--shutdown-timeout duration The timeout for shutting down the controller. (env SKIPPER_SHUTDOWN_TIMEOUT) (default 5s)
--single-controller-mode Add only this controller to the hash ring, ignoring controller pod discovery. For local development. (env SKIPPER_SINGLE_CONTROLLER_MODE)
--skip-forbidden-namespaces Whether to skip function namespaces that the service account does not have access to. (env SKIPPER_SKIP_FORBIDDEN_NAMESPACES)
--skip-forbidden-namespaces Whether to skip assignment namespaces that the service account does not have access to. (env SKIPPER_SKIP_FORBIDDEN_NAMESPACES)
--telemetry Whether to enable OpenTelemetry. (env SKIPPER_TELEMETRY)
--telemetry-metric Whether to enable metrics if telemetry is enabled. (env SKIPPER_TELEMETRY_METRIC) (default true)
--telemetry-metric-otlp Whether to send metrics to the OTLP endpoint. (env SKIPPER_TELEMETRY_METRIC_OTLP)
Expand Down
132 changes: 119 additions & 13 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
//
// Configuration structs use the following tags to control flag behavior:
//
// - flag: Flag name (required to register the field as a flag)
// - flag: Flag name (required to register the field as a flag). May be a
// comma-separated list; the first name is canonical and the rest are
// deprecated aliases that emit a one-shot log on use.
// - description: Help text shown in --help output
// - default: Default value, parsed from string representation
// - required: If "true", command fails when flag is not provided
Expand All @@ -17,12 +19,14 @@
// # Environment Variables
//
// Each flag automatically falls back to an environment variable when not
// provided on the command line. The variable name is derived from the flag
// provided on the command line. The variable name is derived from each flag
// name with a SKIPPER_ prefix:
//
// --my-flag → SKIPPER_MY_FLAG
//
// Flags take precedence over environment variables.
// Aliased flags also register an env-var fallback for each alias, with a
// one-shot deprecation log on use. Flags take precedence over environment
// variables.
//
// # Supported Types
//
Expand Down Expand Up @@ -60,15 +64,19 @@
package config

import (
"context"
"encoding"
"fmt"
"net/url"
"os"
"reflect"
"strconv"
"strings"
"sync"
"time"

"github.com/gadget-inc/skipper/internal/key"
"github.com/gadget-inc/skipper/internal/log"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
Expand Down Expand Up @@ -150,11 +158,14 @@ func bind(cmd *cobra.Command, cfg any, persistent bool) {
field := t.Field(i)
fieldValue := v.Field(i)

flagName := field.Tag.Get("flag")
if flagName == "" {
flagTag := field.Tag.Get("flag")
if flagTag == "" {
continue // skip fields without flag tag
}

names := splitFlagNames(flagTag)
canonical, aliases := names[0], names[1:]

description := field.Tag.Get("description")
required := field.Tag.Get("required") == "true"
sensitive := field.Tag.Get("sensitive") == "true"
Expand All @@ -163,9 +174,19 @@ func bind(cmd *cobra.Command, cfg any, persistent bool) {
separator = ","
}

// Build env var name from flag name
envVarName := "SKIPPER_" + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_"))
description += " (env " + envVarName + ")"
// Build env var name from canonical flag name; aliases also bind
// to env vars derived from their own names, with a deprecation
// log on use.
envVarName := envVarFromFlag(canonical)
envSuffix := " (env " + envVarName + ")"
if len(aliases) > 0 {
aliasEnvs := make([]string, len(aliases))
for i, a := range aliases {
aliasEnvs[i] = envVarFromFlag(a)
}
envSuffix = " (env " + envVarName + ", deprecated env " + strings.Join(aliasEnvs, ", ") + ")"
}
description += envSuffix

// Create the value wrapper
fv := &flagValue{
Expand All @@ -174,25 +195,94 @@ func bind(cmd *cobra.Command, cfg any, persistent bool) {
separator: separator,
}

// Register the flag
// Register the canonical flag and any deprecated aliases.
var flags *pflag.FlagSet
if persistent {
flags = cmd.PersistentFlags()
} else {
flags = cmd.Flags()
}

flag := flags.VarPF(fv, flagName, "", description)
flag := flags.VarPF(fv, canonical, "", description)
if fv.IsBoolFlag() {
flag.NoOptDefVal = "true"
}

for _, alias := range aliases {
aliasFlag := flags.VarPF(&aliasFlagValue{inner: fv, canonical: canonical, alias: alias}, alias, "",
"DEPRECATED: use --"+canonical+" instead.")
if fv.IsBoolFlag() {
aliasFlag.NoOptDefVal = "true"
}
aliasFlag.Hidden = true
}

// Add PreRunE hook for env var fallback and required validation
addPreRun(cmd, persistent, flagName, envVarName, required, fv)
addPreRun(cmd, persistent, canonical, envVarName, aliases, required, fv)
}
}

// splitFlagNames parses a flag tag into [canonical, aliases...]. Whitespace
// around each comma-separated name is trimmed.
func splitFlagNames(tag string) []string {
parts := strings.Split(tag, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

// envVarFromFlag returns the SKIPPER_-prefixed env-var name corresponding to
// a flag name (e.g. --my-flag → SKIPPER_MY_FLAG).
func envVarFromFlag(flagName string) string {
return "SKIPPER_" + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_"))
}

// deprecationLog emits a one-shot warning on stderr (and through slog if a
// logger is configured) the first time a deprecated flag or env var is used.
// Returning to a logger keeps the message visible even when the controller is
// not configured to write to stderr.
var deprecationLog sync.Map

// logDeprecation warns on first use of a deprecated identifier. `subject` is
// a human-readable phrase that names what was used (e.g. "flag --function-foo",
// "env SKIPPER_FUNCTION_FOO") and is also the dedup key, so each unique
// identifier warns at most once. `replacement` is the canonical form to
// suggest in the warning.
func logDeprecation(subject, replacement string) {
if _, loaded := deprecationLog.LoadOrStore(subject, struct{}{}); loaded {
return
}
msg := subject + " is deprecated; use " + replacement + " instead"
log.Warn(context.Background(), msg, key.Reason.Slog("deprecated"))
}

// aliasFlagValue is a pflag.Value adapter that forwards Set calls to a
// canonical flagValue and emits a one-shot deprecation log on first use.
type aliasFlagValue struct {
inner *flagValue
canonical string
alias string
}

var _ pflag.Value = (*aliasFlagValue)(nil)

func (a *aliasFlagValue) Set(s string) error {
logDeprecation("flag --"+a.alias, "--"+a.canonical)
return a.inner.Set(s)
}

func (a *aliasFlagValue) String() string { return a.inner.String() }
func (a *aliasFlagValue) Type() string { return a.inner.Type() }
func (a *aliasFlagValue) IsBoolFlag() bool {
return a.inner.IsBoolFlag()
}

func addPreRun(cmd *cobra.Command, persistent bool, flagName, envVarName string, required bool, fv *flagValue) {
func addPreRun(cmd *cobra.Command, persistent bool, flagName, envVarName string, aliases []string, required bool, fv *flagValue) {
var nextPreRunE func(cmd *cobra.Command, args []string) error
if persistent {
nextPreRunE = cmd.PersistentPreRunE
Expand All @@ -202,13 +292,29 @@ func addPreRun(cmd *cobra.Command, persistent bool, flagName, envVarName string,

preRunE := func(cmd *cobra.Command, args []string) error {
if !fv.wasProvided {
// Check environment variable
// Check the canonical environment variable first.
if envValue, ok := os.LookupEnv(envVarName); ok {
if err := fv.Set(envValue); err != nil {
return fmt.Errorf("error parsing environment variable %s: %w", envVarName, err)
}
}
}
if !fv.wasProvided {
// Fall back to alias env vars in order. Each alias env var
// emits a one-shot deprecation log on first use.
for _, alias := range aliases {
aliasEnv := envVarFromFlag(alias)
envValue, ok := os.LookupEnv(aliasEnv)
if !ok {
continue
}
logDeprecation("env "+aliasEnv, envVarName)
if err := fv.Set(envValue); err != nil {
return fmt.Errorf("error parsing environment variable %s: %w", aliasEnv, err)
}
break
}
}

if !fv.wasProvided && required {
return fmt.Errorf("flag --%s is required", flagName)
Expand Down
Loading
Loading