diff --git a/cmd/dev/deploy.go b/cmd/dev/deploy.go index 000ea321..37970557 100644 --- a/cmd/dev/deploy.go +++ b/cmd/dev/deploy.go @@ -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, @@ -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, diff --git a/cmd/dev/kube_lint.go b/cmd/dev/kube_lint.go index 4833ae16..33710a19 100644 --- a/cmd/dev/kube_lint.go +++ b/cmd/dev/kube_lint.go @@ -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{ diff --git a/cmd/dev/up.go b/cmd/dev/up.go index 52fd350c..30d79828 100644 --- a/cmd/dev/up.go +++ b/cmd/dev/up.go @@ -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", diff --git a/internal/cmd/controller_test.go b/internal/cmd/controller_test.go index 4447f3e5..4922ea98 100644 --- a/internal/cmd/controller_test.go +++ b/internal/cmd/controller_test.go @@ -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", @@ -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) @@ -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) @@ -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) @@ -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 }) @@ -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), }) diff --git a/internal/cmd/testdata/help_controller.golden b/internal/cmd/testdata/help_controller.golden index ed516774..8d7df84e 100644 --- a/internal/cmd/testdata/help_controller.golden +++ b/internal/cmd/testdata/help_controller.golden @@ -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 @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index ead9a7e4..6593a1f4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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 // @@ -60,6 +64,7 @@ package config import ( + "context" "encoding" "fmt" "net/url" @@ -67,8 +72,11 @@ import ( "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" ) @@ -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" @@ -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{ @@ -174,7 +195,7 @@ 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() @@ -182,17 +203,86 @@ func bind(cmd *cobra.Command, cfg any, persistent bool) { 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 @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8f420428..552e1fc0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1015,6 +1015,105 @@ func TestFlagDescriptionContainsEnvVar(t *testing.T) { assert.Assert(t, strings.Contains(flag.Usage, "The name value")) } +// TestFlagAliases covers the comma-separated `flag` tag form: the first +// name is canonical, subsequent names register hidden aliases that bind +// to the same field and emit a deprecation log on use. +func TestFlagAliases(t *testing.T) { + t.Run("canonical flag name parses", func(t *testing.T) { + type cfg struct { + Names []string `flag:"new-name,old-name"` + } + c := &cfg{} + cmd := &cobra.Command{Use: "test", Run: func(cmd *cobra.Command, args []string) {}} + Bind(cmd, c) + cmd.SetArgs([]string{"--new-name=a,b"}) + + err := cmd.Execute() + assert.NilError(t, err) + assert.DeepEqual(t, c.Names, []string{"a", "b"}) + }) + + t.Run("alias flag name parses", func(t *testing.T) { + type cfg struct { + Names []string `flag:"new-name,old-name"` + } + c := &cfg{} + cmd := &cobra.Command{Use: "test", Run: func(cmd *cobra.Command, args []string) {}} + Bind(cmd, c) + cmd.SetArgs([]string{"--old-name=a,b"}) + + err := cmd.Execute() + assert.NilError(t, err) + assert.DeepEqual(t, c.Names, []string{"a", "b"}) + }) + + t.Run("alias flag is hidden in help", func(t *testing.T) { + type cfg struct { + Names []string `flag:"new-name,old-name"` + } + c := &cfg{} + cmd := &cobra.Command{Use: "test"} + Bind(cmd, c) + + canonical := cmd.Flags().Lookup("new-name") + assert.Assert(t, canonical != nil) + assert.Assert(t, !canonical.Hidden, "canonical flag should not be hidden") + + alias := cmd.Flags().Lookup("old-name") + assert.Assert(t, alias != nil) + assert.Assert(t, alias.Hidden, "alias flag should be hidden") + }) + + t.Run("env var alias falls back when canonical unset", func(t *testing.T) { + // Reset deprecation log so this test sees the warning. The key + // matches the `subject` passed to logDeprecation in the env-var + // fallback path (see addPreRun). + deprecationLog.Delete("env SKIPPER_OLD_NAME") + type cfg struct { + Name string `flag:"new-name,old-name"` + } + c := &cfg{} + cmd := &cobra.Command{Use: "test", Run: func(cmd *cobra.Command, args []string) {}} + Bind(cmd, c) + t.Setenv("SKIPPER_OLD_NAME", "from-old-env") + cmd.SetArgs([]string{}) + + err := cmd.Execute() + assert.NilError(t, err) + assert.Equal(t, c.Name, "from-old-env") + }) + + t.Run("canonical env var beats alias env var", func(t *testing.T) { + type cfg struct { + Name string `flag:"new-name2,old-name2"` + } + c := &cfg{} + cmd := &cobra.Command{Use: "test", Run: func(cmd *cobra.Command, args []string) {}} + Bind(cmd, c) + t.Setenv("SKIPPER_NEW_NAME2", "from-new-env") + t.Setenv("SKIPPER_OLD_NAME2", "from-old-env") + cmd.SetArgs([]string{}) + + err := cmd.Execute() + assert.NilError(t, err) + assert.Equal(t, c.Name, "from-new-env") + }) + + t.Run("description mentions canonical and deprecated env vars", func(t *testing.T) { + type cfg struct { + Name string `flag:"new-name3,old-name3" description:"the name"` + } + c := &cfg{} + cmd := &cobra.Command{Use: "test"} + Bind(cmd, c) + + flag := cmd.Flags().Lookup("new-name3") + assert.Assert(t, flag != nil) + assert.Assert(t, strings.Contains(flag.Usage, "SKIPPER_NEW_NAME3")) + assert.Assert(t, strings.Contains(flag.Usage, "deprecated env SKIPPER_OLD_NAME3")) + }) +} + // TestEmptyValues tests handling of empty values. func TestEmptyValues(t *testing.T) { t.Run("empty string", func(t *testing.T) { diff --git a/internal/controller/config.go b/internal/controller/config.go index 57f7f7a8..71f3b694 100644 --- a/internal/controller/config.go +++ b/internal/controller/config.go @@ -27,11 +27,11 @@ type Config struct { HPAInitialReadinessDelay time.Duration `flag:"hpa-initial-readiness-delay" description:"The initial readiness delay for the HPA algorithm." default:"30s"` HPADownscaleStabilization time.Duration `flag:"hpa-downscale-stabilization" description:"The stabilization window for downscaling in the HPA algorithm." default:"90s"` HashRingWaitTime time.Duration `flag:"hash-ring-wait-time" description:"How long to wait for the controller to populate its hash ring." default:"10s"` - FunctionNamespaces []string `flag:"function-namespaces" description:"The namespaces where functions can be invoked." required:"true"` - FunctionAssignPath string `flag:"function-assign-path" description:"The path used to assign a function to a pod." default:"/__skipper/assign"` - FunctionAssignTimeout time.Duration `flag:"function-assign-timeout" description:"The timeout for assigning a function to a pod." default:"30s"` + AssignmentNamespaces []string `flag:"assignment-namespaces,function-namespaces" description:"The namespaces where assignments can be invoked." required:"true"` + AssignPath string `flag:"assign-path,function-assign-path" description:"The path used to assign a pod." default:"/__skipper/assign"` + AssignTimeout time.Duration `flag:"assign-timeout,function-assign-timeout" description:"The timeout for assigning a pod." default:"30s"` MaxConcurrentStaleReplacements int `flag:"max-concurrent-stale-replacements" description:"Maximum number of stale instances that can be replaced concurrently." default:"10"` - SkipForbiddenNamespaces bool `flag:"skip-forbidden-namespaces" description:"Whether to skip function namespaces that the service account does not have access to." default:"false"` + SkipForbiddenNamespaces bool `flag:"skip-forbidden-namespaces" description:"Whether to skip assignment namespaces that the service account does not have access to." default:"false"` WebPort int `flag:"web-port" description:"The port the web UI listens on." default:"8080"` WebTemplateDir string `flag:"web-template-dir" description:"When set, reload templates from this directory on each request (dev mode)."` SingleControllerMode bool `flag:"single-controller-mode" description:"Add only this controller to the hash ring, ignoring controller pod discovery. For local development." default:"false"` diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 82f9d43e..ff212049 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -68,7 +68,7 @@ func New(cfg *Config, newClientFunc NewClientFunc, kubernetes kubernetes.Interfa controllerClients: xsync.NewMap[string, Client](), kubernetes: kubernetes, kubernetesMetrics: kubernetesMetrics, - namespaceListers: make(map[string]namespaceLister, len(cfg.FunctionNamespaces)), + namespaceListers: make(map[string]namespaceLister, len(cfg.AssignmentNamespaces)), podMetrics: xsync.NewMap[string, metricsv1beta1.PodMetrics](), assignmentCache: xsync.NewMap[string, *skipper.Assignment](), portCache: xsync.NewMap[types.UID, string](), @@ -98,7 +98,7 @@ func (ctrl *Controller) Start(ctx context.Context) error { return nil }) - for _, namespace := range ctrl.config.FunctionNamespaces { + for _, namespace := range ctrl.config.AssignmentNamespaces { go timer.Loop(ctx, ctrl.config.ScaleInterval, func(ctx context.Context) error { defer func() { if r := recover(); r != nil { @@ -195,7 +195,7 @@ func (ctrl *Controller) startInformers(ctx context.Context) error { } } - for _, namespace := range ctrl.config.FunctionNamespaces { + for _, namespace := range ctrl.config.AssignmentNamespaces { _, err := ctrl.kubernetes.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{Limit: 1}) if err != nil { if apierrors.IsForbidden(err) && ctrl.config.SkipForbiddenNamespaces { diff --git a/internal/controller/pod.go b/internal/controller/pod.go index 611072d0..d8663c5c 100644 --- a/internal/controller/pod.go +++ b/internal/controller/pod.go @@ -102,8 +102,8 @@ GET_UNASSIGNED_POD: } }() - assignURL := "http://" + net.JoinHostPort(assignedPod.Status.PodIP, port) + ctrl.config.FunctionAssignPath - assignCtx, cancel := context.WithTimeout(ctx, ctrl.config.FunctionAssignTimeout) + assignURL := "http://" + net.JoinHostPort(assignedPod.Status.PodIP, port) + ctrl.config.AssignPath + assignCtx, cancel := context.WithTimeout(ctx, ctrl.config.AssignTimeout) defer cancel() now := time.Now() @@ -150,7 +150,7 @@ GET_UNASSIGNED_POD: assignedPod.Annotations[key.ReadyAt.Label] = readyAtStr go func() { - asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ctrl.config.FunctionAssignTimeout) + asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ctrl.config.AssignTimeout) defer cancel() patches := []byte(`[{ "op": "add", "path": "` + key.ReadyAt.PatchAnnotation + `", "value": "` + readyAtStr + `" }]`) @@ -370,7 +370,7 @@ func (ctrl *Controller) deletePod(ctx context.Context, namespace, name string, o } func (ctrl *Controller) refreshMetrics(ctx context.Context) { - for _, namespace := range ctrl.config.FunctionNamespaces { + for _, namespace := range ctrl.config.AssignmentNamespaces { var continueToken string for { metrics, err := ctrl.kubernetesMetrics. diff --git a/internal/controller/pod_test.go b/internal/controller/pod_test.go index d5037fd1..109e39af 100644 --- a/internal/controller/pod_test.go +++ b/internal/controller/pod_test.go @@ -135,7 +135,7 @@ func TestAssignPod(t *testing.T) { err: context.DeadlineExceeded, setup: func(t *testing.T, state *testState) { state.cfg = testConfig() - state.cfg.FunctionAssignTimeout = time.Millisecond + state.cfg.AssignTimeout = time.Millisecond // create a pod with a slow assign handler slowHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/controller/supervisor.go b/internal/controller/supervisor.go index a0b516fd..27f4bcb4 100644 --- a/internal/controller/supervisor.go +++ b/internal/controller/supervisor.go @@ -524,12 +524,12 @@ func (s *Supervisor) scaleWithoutLock(ctx context.Context, fn *skipper.Assignmen } // cleanupStuckInstances terminates instances that are stuck in the -// assigned state (not ready) for longer than FunctionAssignTimeout*2. +// assigned state (not ready) for longer than AssignTimeout*2. // This is a cheap operation (just deletes) and should be called before // scaling execution to remove broken pods from consideration. func (s *Supervisor) cleanupStuckInstances(ctx context.Context, instances []*skipper.Instance) []*skipper.Instance { return slices.DeleteFunc(instances, func(instance *skipper.Instance) bool { - if !instance.HasReadyAt() && time.Since(instance.GetAssignedAt().AsTime()) > s.ctrl.config.FunctionAssignTimeout*2 { + if !instance.HasReadyAt() && time.Since(instance.GetAssignedAt().AsTime()) > s.ctrl.config.AssignTimeout*2 { ctx := log.With(ctx, skipper.InstanceKey.Slog(instance)) log.Warn(ctx, "terminating instance stuck in assigned state") err := s.ctrl.deletePod(ctx, instance.GetAssignment().GetNamespace(), instance.GetName(), metav1.DeleteOptions{}) diff --git a/internal/controller/supervisor_test.go b/internal/controller/supervisor_test.go index 5a64600f..ae4ae39f 100644 --- a/internal/controller/supervisor_test.go +++ b/internal/controller/supervisor_test.go @@ -2157,7 +2157,7 @@ func TestCleanupStuckInstances(t *testing.T) { // create a pod that was assigned long ago but never became ready pod := fixture.NewAssignedPod(t, state.fn, nil) - stuckTime := time.Now().Add(-state.ctrl.config.FunctionAssignTimeout * 3) // well past the 2x threshold + stuckTime := time.Now().Add(-state.ctrl.config.AssignTimeout * 3) // well past the 2x threshold pod.Annotations[key.AssignedAt.Label] = stuckTime.Format(time.RFC3339) delete(pod.Annotations, key.ReadyAt.Label) // never became ready state.fakeKubernetes.Tracker().Add(pod) @@ -4150,7 +4150,7 @@ func TestSupervisorEvents(t *testing.T) { // create a pod stuck past the threshold pod := fixture.NewAssignedPod(t, state.fn, nil) - stuckTime := time.Now().Add(-state.ctrl.config.FunctionAssignTimeout * 3) + stuckTime := time.Now().Add(-state.ctrl.config.AssignTimeout * 3) pod.Annotations[key.AssignedAt.Label] = stuckTime.Format(time.RFC3339) delete(pod.Annotations, key.ReadyAt.Label) state.fakeKubernetes.Tracker().Add(pod) diff --git a/internal/controller/testutil_test.go b/internal/controller/testutil_test.go index 0e868226..cc12d9e5 100644 --- a/internal/controller/testutil_test.go +++ b/internal/controller/testutil_test.go @@ -11,7 +11,7 @@ func testConfig() *Config { cfg.Namespace = fixture.ControllerNamespace cfg.PodIP = fixture.ControllerIP cfg.PasetoPrivateKey = PasetoPrivateKey{V2AsymmetricSecretKey: fixture.ControllerPasetoSecretKey} - cfg.FunctionNamespaces = []string{fixture.AssignmentNamespace} + cfg.AssignmentNamespaces = []string{fixture.AssignmentNamespace} return cfg } diff --git a/internal/dev/docssite/flagtable.go b/internal/dev/docssite/flagtable.go index fa31a4c4..9eb6f490 100644 --- a/internal/dev/docssite/flagtable.go +++ b/internal/dev/docssite/flagtable.go @@ -86,7 +86,9 @@ func renderFlagRows(structs []any) (string, error) { if flag == "" { continue } - row, err := renderFlagRow(f, flag) + canonical, _, _ := strings.Cut(flag, ",") + canonical = strings.TrimSpace(canonical) + row, err := renderFlagRow(f, canonical) if err != nil { return "", err } diff --git a/internal/dev/docssite/flagtable_test.go b/internal/dev/docssite/flagtable_test.go index 2c6f639e..a231ec99 100644 --- a/internal/dev/docssite/flagtable_test.go +++ b/internal/dev/docssite/flagtable_test.go @@ -31,8 +31,8 @@ func TestRenderFlagTable_ControllerHasExpectedFlags(t *testing.T) { "kubeconfig-qps", "kubeconfig-burst", "paseto-private-key", "heartbeat-timeout", "scale-interval", "hpa-tolerance", "hpa-initial-readiness-delay", "hpa-downscale-stabilization", - "hash-ring-wait-time", "function-namespaces", - "function-assign-path", "function-assign-timeout", + "hash-ring-wait-time", "assignment-namespaces", + "assign-path", "assign-timeout", "max-concurrent-stale-replacements", "skip-forbidden-namespaces", "web-port", "web-template-dir", "single-controller-mode", } @@ -139,7 +139,7 @@ func TestRenderFlagTable_TypeRewrites(t *testing.T) { assert.Equal(t, cells("port")[1], "int") assert.Equal(t, cells("host")[1], "string") assert.Equal(t, cells("shutdown-timeout")[1], "duration") - assert.Equal(t, cells("function-namespaces")[1], "string list") + assert.Equal(t, cells("assignment-namespaces")[1], "string list") assert.Equal(t, cells("paseto-private-key")[1], "string") assert.Equal(t, cells("kubeconfig-qps")[1], "float32") assert.Equal(t, cells("hpa-tolerance")[1], "float64") @@ -343,8 +343,12 @@ title: Configuration Reference if flag == "" { continue } - assert.Assert(t, strings.Contains(out, "--"+flag), - "section %q missing --%s in rendered HTML", b.Section, flag) + // Only the canonical (first) name is rendered in the + // flag table; deprecated aliases are tested separately. + canonical, _, _ := strings.Cut(flag, ",") + canonical = strings.TrimSpace(canonical) + assert.Assert(t, strings.Contains(out, "--"+canonical), + "section %q missing --%s in rendered HTML", b.Section, canonical) } } } diff --git a/internal/dev/krane/krane_test.go b/internal/dev/krane/krane_test.go index 094495f2..ef582351 100644 --- a/internal/dev/krane/krane_test.go +++ b/internal/dev/krane/krane_test.go @@ -18,9 +18,9 @@ import ( // rendering. They mirror cmd/dev/kube_lint.go's kubeLintBaseBindings; // if you add a new required base binding to the template, update both. var kraneCorpusBaseBindings = 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", } // kraneCorpusCases mirrors cmd/dev/kube_lint.go's kubeLintConfigs. diff --git a/internal/dev/krane/testdata/combined-affinity.golden.yaml b/internal/dev/krane/testdata/combined-affinity.golden.yaml index a1de6ade..ccbb69c9 100644 --- a/internal/dev/krane/testdata/combined-affinity.golden.yaml +++ b/internal/dev/krane/testdata/combined-affinity.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/controller-affinity-override.golden.yaml b/internal/dev/krane/testdata/controller-affinity-override.golden.yaml index 3809805d..ee7a5ce8 100644 --- a/internal/dev/krane/testdata/controller-affinity-override.golden.yaml +++ b/internal/dev/krane/testdata/controller-affinity-override.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/full-features.golden.yaml b/internal/dev/krane/testdata/full-features.golden.yaml index ccc277ad..7362d633 100644 --- a/internal/dev/krane/testdata/full-features.golden.yaml +++ b/internal/dev/krane/testdata/full-features.golden.yaml @@ -115,7 +115,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/grpc-protocol.golden.yaml b/internal/dev/krane/testdata/grpc-protocol.golden.yaml index 51064cb5..82cf2c88 100644 --- a/internal/dev/krane/testdata/grpc-protocol.golden.yaml +++ b/internal/dev/krane/testdata/grpc-protocol.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/hpa-enabled.golden.yaml b/internal/dev/krane/testdata/hpa-enabled.golden.yaml index 47fd9981..4155dcc5 100644 --- a/internal/dev/krane/testdata/hpa-enabled.golden.yaml +++ b/internal/dev/krane/testdata/hpa-enabled.golden.yaml @@ -110,7 +110,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/inline-paseto.golden.yaml b/internal/dev/krane/testdata/inline-paseto.golden.yaml index 1451589c..d68df786 100644 --- a/internal/dev/krane/testdata/inline-paseto.golden.yaml +++ b/internal/dev/krane/testdata/inline-paseto.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/minimal.golden.yaml b/internal/dev/krane/testdata/minimal.golden.yaml index 51064cb5..82cf2c88 100644 --- a/internal/dev/krane/testdata/minimal.golden.yaml +++ b/internal/dev/krane/testdata/minimal.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/nodeport.golden.yaml b/internal/dev/krane/testdata/nodeport.golden.yaml index d1a669d3..45178c68 100644 --- a/internal/dev/krane/testdata/nodeport.golden.yaml +++ b/internal/dev/krane/testdata/nodeport.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/router-affinity-override.golden.yaml b/internal/dev/krane/testdata/router-affinity-override.golden.yaml index f82cc76a..120127f9 100644 --- a/internal/dev/krane/testdata/router-affinity-override.golden.yaml +++ b/internal/dev/krane/testdata/router-affinity-override.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/dev/krane/testdata/service-spec-override.golden.yaml b/internal/dev/krane/testdata/service-spec-override.golden.yaml index bd09075c..f22b8d86 100644 --- a/internal/dev/krane/testdata/service-spec-override.golden.yaml +++ b/internal/dev/krane/testdata/service-spec-override.golden.yaml @@ -111,7 +111,7 @@ spec: value: "info" - name: SKIPPER_LOG_FORMAT value: "text" - - name: SKIPPER_FUNCTION_NAMESPACES + - name: SKIPPER_ASSIGNMENT_NAMESPACES value: "default" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "false" diff --git a/internal/web/format.go b/internal/web/format.go index a40b1cff..d3cbc10e 100644 --- a/internal/web/format.go +++ b/internal/web/format.go @@ -92,8 +92,8 @@ func assignmentKey(fn *skipper.Assignment) string { return fn.GetNamespace() + ":" + fn.GetDeployment() + ":" + fn.GetTenant() } -func functionPath(fn *skipper.Assignment) string { - return "/functions/" + url.PathEscape(assignmentKey(fn)) +func assignmentPath(fn *skipper.Assignment) string { + return "/assignments/" + url.PathEscape(assignmentKey(fn)) } func scaleReasonLabel(reason skipper.ScaleReason) string { @@ -196,8 +196,8 @@ func pct(a, b int) int { func activeNav(title string) string { lower := strings.ToLower(title) switch { - case strings.HasPrefix(lower, "function"): - return "/functions" + case strings.HasPrefix(lower, "assignment"), strings.HasPrefix(lower, "function"): + return "/assignments" case strings.HasPrefix(lower, "controller"): return "/controllers" case strings.HasPrefix(lower, "tenant"): @@ -205,7 +205,7 @@ func activeNav(title string) string { case strings.HasPrefix(lower, "router"): return "/routers" case strings.HasPrefix(lower, "instance"): - return "/functions" + return "/assignments" case strings.HasPrefix(lower, "event"): return "/events" case strings.HasPrefix(lower, "deployment"): diff --git a/internal/web/format_test.go b/internal/web/format_test.go index f1861678..bcb850ca 100644 --- a/internal/web/format_test.go +++ b/internal/web/format_test.go @@ -88,7 +88,7 @@ func TestFunctionKey(t *testing.T) { assert.Equal(t, assignmentKey(nil), "") } -func TestFunctionPath(t *testing.T) { +func TestAssignmentPath(t *testing.T) { t.Parallel() fn := skipper.Assignment_builder{ @@ -97,7 +97,7 @@ func TestFunctionPath(t *testing.T) { Tenant: new("tenant-1"), }.Build() - assert.Equal(t, functionPath(fn), "/functions/default:web-app:tenant-1") + assert.Equal(t, assignmentPath(fn), "/assignments/default:web-app:tenant-1") } func TestScaleReasonLabel(t *testing.T) { diff --git a/internal/web/handlers.go b/internal/web/handlers.go index a51b0390..2bbd7f36 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -22,12 +22,12 @@ type dashboardData struct { } type assignmentsData struct { - Title string - State *skipper.ClusterState - Supervisors []*skipper.SupervisorState - FnSearch string - FnSort string - FnSortDir string + Title string + State *skipper.ClusterState + Supervisors []*skipper.SupervisorState + AssignmentSearch string + AssignmentSort string + AssignmentSortDir string } type assignmentData struct { @@ -42,7 +42,7 @@ type assignmentData struct { type controllerRow struct { IP string IsSelf bool - Functions int + Assignments int ReadyInstances int TotalInstances int } @@ -73,9 +73,9 @@ type controllerData struct { } type routerRow struct { - IP string - Functions int - InFlight uint32 + IP string + Assignments int + InFlight uint32 } type routersData struct { @@ -114,7 +114,7 @@ type eventsData struct { type tenantRow struct { Tenant string - Functions int + Assignments int ReadyInstances int TotalInstances int Deployments []string @@ -258,13 +258,13 @@ func (s *Server) handleAssignments(w http.ResponseWriter, r *http.Request) { sups := filterSupervisors(state.GetSupervisors(), search) sups = sortSupervisors(sups, sort, dir) - s.render(w, "functions", &assignmentsData{ - Title: "Functions", - State: state, - Supervisors: sups, - FnSearch: search, - FnSort: sort, - FnSortDir: dir, + s.render(w, "assignments", &assignmentsData{ + Title: "Assignments", + State: state, + Supervisors: sups, + AssignmentSearch: search, + AssignmentSort: sort, + AssignmentSortDir: dir, }) } @@ -274,7 +274,7 @@ func (s *Server) handleAssignment(w http.ResponseWriter, r *http.Request) { sup := findSupervisor(state, key) data := &assignmentData{ - Title: "Function", + Title: "Assignment", Key: key, State: state, Supervisor: sup, @@ -285,7 +285,7 @@ func (s *Server) handleAssignment(w http.ResponseWriter, r *http.Request) { data.StaleInstances = staleInstances(sup) } - s.render(w, "function", data) + s.render(w, "assignment", data) } func (s *Server) handleControllers(w http.ResponseWriter, r *http.Request) { @@ -379,15 +379,22 @@ func (s *Server) handleInstance(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { state := s.state(r.Context()) - fnFilter := r.URL.Query().Get("function") - sevFilter := r.URL.Query().Get("severity") + q := r.URL.Query() + // New `?assignment=` wins over the legacy `?function=` when both + // are present; either alone is honored. The cleanup plan drops the + // legacy alias after the deprecation window. + assignmentFilter := q.Get("assignment") + if assignmentFilter == "" { + assignmentFilter = q.Get("function") + } + sevFilter := q.Get("severity") s.render(w, "events", &eventsData{ Title: "Events", State: state, - AssignmentFilter: fnFilter, + AssignmentFilter: assignmentFilter, SeverityFilter: sevFilter, - FilteredEvents: filterEvents(state.GetEvents(), fnFilter, sevFilter), + FilteredEvents: filterEvents(state.GetEvents(), assignmentFilter, sevFilter), }) } @@ -543,7 +550,7 @@ func buildControllerData(state *skipper.ClusterState) ([]controllerRow, []ringDi rows = append(rows, controllerRow{ IP: ip, IsSelf: ip == state.GetPodIp(), - Functions: counts[ip], + Assignments: counts[ip], ReadyInstances: ready, TotalInstances: total, }) @@ -591,8 +598,8 @@ func buildControllerData(state *skipper.ClusterState) ([]controllerRow, []ringDi func buildRouterRows(state *skipper.ClusterState) []routerRow { type routerStats struct { - functions int - inFlight uint32 + assignments int + inFlight uint32 } m := make(map[string]*routerStats) @@ -604,7 +611,7 @@ func buildRouterRows(state *skipper.ClusterState) []routerRow { stats = &routerStats{} m[ip] = stats } - stats.functions++ + stats.assignments++ if hb.GetHeartbeat() != nil { stats.inFlight += hb.GetHeartbeat().GetInFlightRequests() } @@ -614,9 +621,9 @@ func buildRouterRows(state *skipper.ClusterState) []routerRow { rows := make([]routerRow, 0, len(m)) for ip, stats := range m { rows = append(rows, routerRow{ - IP: ip, - Functions: stats.functions, - InFlight: stats.inFlight, + IP: ip, + Assignments: stats.assignments, + InFlight: stats.inFlight, }) } return rows @@ -698,7 +705,7 @@ func buildTenantData(state *skipper.ClusterState, tenant string) *tenantData { func buildTenantRows(state *skipper.ClusterState) []tenantRow { type tenantStats struct { - functions int + assignments int readyInstances int totalInstances int deployments map[string]struct{} @@ -712,7 +719,7 @@ func buildTenantRows(state *skipper.ClusterState) []tenantRow { stats = &tenantStats{deployments: make(map[string]struct{})} m[tenant] = stats } - stats.functions++ + stats.assignments++ stats.totalInstances += len(sup.GetInstances()) stats.readyInstances += countReady(sup.GetInstances()) stats.deployments[sup.GetAssignment().GetDeployment()] = struct{}{} @@ -727,7 +734,7 @@ func buildTenantRows(state *skipper.ClusterState) []tenantRow { slices.Sort(deploys) rows = append(rows, tenantRow{ Tenant: tenant, - Functions: stats.functions, + Assignments: stats.assignments, ReadyInstances: stats.readyInstances, TotalInstances: stats.totalInstances, Deployments: deploys, @@ -888,8 +895,8 @@ func sortTenantRows(rows []tenantRow, col, dir string) []tenantRow { slices.SortFunc(sorted, func(a, b tenantRow) int { var cmp int switch col { - case "functions": - cmp = a.Functions - b.Functions + case "assignments", "functions": + cmp = a.Assignments - b.Assignments case "instances": cmp = a.ReadyInstances - b.ReadyInstances case "deployments": diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go index ba20392d..7e89c956 100644 --- a/internal/web/handlers_test.go +++ b/internal/web/handlers_test.go @@ -71,9 +71,9 @@ func TestHandlers(t *testing.T) { contains string }{ {name: "dashboard", path: "/", status: 200, contains: "Dashboard"}, - {name: "functions", path: "/functions", status: 200, contains: "Functions"}, - {name: "function detail", path: "/functions/default%3Aweb-app%3Atenant-1", status: 200, contains: "web-app"}, - {name: "function not found", path: "/functions/nonexistent", status: 200, contains: "Function not found"}, + {name: "assignments", path: "/assignments", status: 200, contains: "Assignments"}, + {name: "assignment detail", path: "/assignments/default%3Aweb-app%3Atenant-1", status: 200, contains: "web-app"}, + {name: "assignment not found", path: "/assignments/nonexistent", status: 200, contains: "Assignment not found"}, {name: "controllers", path: "/controllers", status: 200, contains: "Controllers"}, {name: "controller detail", path: "/controllers/10.0.0.1", status: 200, contains: "10.0.0.1"}, {name: "controller not found", path: "/controllers/10.0.0.99", status: 200, contains: "Controller not found"}, @@ -116,7 +116,7 @@ func TestDashboardContent(t *testing.T) { body := w.Body.String() assert.Assert(t, strings.Contains(body, "skipper")) assert.Assert(t, strings.Contains(body, "Tenants")) - assert.Assert(t, strings.Contains(body, "Functions")) + assert.Assert(t, strings.Contains(body, "Assignments")) assert.Assert(t, strings.Contains(body, "Deployments")) assert.Assert(t, strings.Contains(body, "Instances")) assert.Assert(t, strings.Contains(body, "Uptime")) @@ -124,11 +124,11 @@ func TestDashboardContent(t *testing.T) { assert.Assert(t, strings.Contains(body, "data-on-interval")) } -func TestFunctionDetail(t *testing.T) { +func TestAssignmentDetail(t *testing.T) { t.Parallel() srv := testServer() - req := httptest.NewRequest(http.MethodGet, "/functions/default%3Aweb-app%3Atenant-1", nil) + req := httptest.NewRequest(http.MethodGet, "/assignments/default%3Aweb-app%3Atenant-1", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -139,6 +139,35 @@ func TestFunctionDetail(t *testing.T) { assert.Assert(t, strings.Contains(body, "web-app-abc123")) } +// TestLegacyRoutesRedirect documents that pre-Phase-3 URLs still work: +// the router serves a 301 to the equivalent /assignments/* path. +// Datastar clients follow this on the initial handshake. +func TestLegacyRoutesRedirect(t *testing.T) { + t.Parallel() + srv := testServer() + + cases := []struct { + name string + path string + target string + }{ + {name: "functions index", path: "/functions", target: "/assignments"}, + {name: "function detail", path: "/functions/default%3Aweb-app%3Atenant-1", target: "/assignments/default:web-app:tenant-1"}, + {name: "functions with query", path: "/functions?search=web&sort=instances", target: "/assignments?search=web&sort=instances"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + assert.Equal(t, w.Code, http.StatusMovedPermanently) + assert.Equal(t, w.Header().Get("Location"), tc.target) + }) + } +} + func TestInstanceDetail(t *testing.T) { t.Parallel() srv := testServer() @@ -438,7 +467,7 @@ func TestBuildControllerData(t *testing.T) { assert.Equal(t, len(rows), 1) assert.Equal(t, rows[0].IP, "10.0.0.1") assert.Assert(t, rows[0].IsSelf) - assert.Equal(t, rows[0].Functions, 1) + assert.Equal(t, rows[0].Assignments, 1) assert.Equal(t, rows[0].ReadyInstances, 1) assert.Equal(t, rows[0].TotalInstances, 1) @@ -511,7 +540,7 @@ func TestBuildRouterRows(t *testing.T) { rows := buildRouterRows(state) assert.Equal(t, len(rows), 1) assert.Equal(t, rows[0].IP, "10.0.1.1") - assert.Equal(t, rows[0].Functions, 1) + assert.Equal(t, rows[0].Assignments, 1) assert.Equal(t, rows[0].InFlight, uint32(5)) }) } @@ -582,13 +611,13 @@ func TestEmptyState(t *testing.T) { contains string }{ {name: "dashboard", path: "/", status: 200, contains: ""}, - {name: "functions", path: "/functions", status: 200, contains: "No functions"}, + {name: "assignments", path: "/assignments", status: 200, contains: "No assignments"}, {name: "events", path: "/events", status: 200, contains: "No events"}, {name: "controllers", path: "/controllers", status: 200, contains: "No controllers"}, {name: "routers", path: "/routers", status: 200, contains: "No routers"}, {name: "deployments", path: "/deployments", status: 200, contains: ""}, {name: "tenants", path: "/tenants", status: 200, contains: "No tenants"}, - {name: "function not found", path: "/functions/nonexistent", status: 200, contains: "Function not found"}, + {name: "assignment not found", path: "/assignments/nonexistent", status: 200, contains: "Assignment not found"}, } for _, tc := range tests { @@ -622,7 +651,7 @@ func TestBuildTenantRows(t *testing.T) { rows := buildTenantRows(state) assert.Equal(t, len(rows), 1) assert.Equal(t, rows[0].Tenant, "tenant-1") - assert.Equal(t, rows[0].Functions, 1) + assert.Equal(t, rows[0].Assignments, 1) assert.Equal(t, rows[0].ReadyInstances, 1) assert.Equal(t, rows[0].TotalInstances, 1) assert.DeepEqual(t, rows[0].Deployments, []string{"web-app"}) @@ -678,13 +707,13 @@ func TestBuildTenantRows(t *testing.T) { // Should be sorted by tenant name assert.Equal(t, rows[0].Tenant, "tenant-1") - assert.Equal(t, rows[0].Functions, 1) + assert.Equal(t, rows[0].Assignments, 1) assert.Equal(t, rows[0].ReadyInstances, 1) assert.Equal(t, rows[0].TotalInstances, 1) assert.DeepEqual(t, rows[0].Deployments, []string{"web-app"}) assert.Equal(t, rows[1].Tenant, "tenant-2") - assert.Equal(t, rows[1].Functions, 1) + assert.Equal(t, rows[1].Assignments, 1) assert.Equal(t, rows[1].ReadyInstances, 1) assert.Equal(t, rows[1].TotalInstances, 2) assert.DeepEqual(t, rows[1].Deployments, []string{"api-server"}) @@ -907,13 +936,13 @@ func multiSupServer() *Server { }) } -func TestFunctionsQueryParams(t *testing.T) { +func TestAssignmentsQueryParams(t *testing.T) { t.Parallel() srv := multiSupServer() t.Run("search filters supervisors", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/functions?search=web", nil) + req := httptest.NewRequest(http.MethodGet, "/assignments?search=web", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -925,7 +954,7 @@ func TestFunctionsQueryParams(t *testing.T) { t.Run("sort by instances desc", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/functions?sort=instances&dir=desc", nil) + req := httptest.NewRequest(http.MethodGet, "/assignments?sort=instances&dir=desc", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -942,18 +971,18 @@ func TestFunctionsQueryParams(t *testing.T) { t.Run("search nonexistent shows empty", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/functions?search=nonexistent", nil) + req := httptest.NewRequest(http.MethodGet, "/assignments?search=nonexistent", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) body := w.Body.String() assert.Equal(t, w.Code, 200) - assert.Assert(t, strings.Contains(body, "No functions"), "body should show empty state") + assert.Assert(t, strings.Contains(body, "No assignments"), "body should show empty state") }) t.Run("no params uses defaults", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/functions", nil) + req := httptest.NewRequest(http.MethodGet, "/assignments", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -973,7 +1002,7 @@ func TestFunctionsQueryParams(t *testing.T) { t.Run("signal initialization", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/functions?search=web&sort=tenant&dir=desc", nil) + req := httptest.NewRequest(http.MethodGet, "/assignments?search=web&sort=tenant&dir=desc", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -982,19 +1011,19 @@ func TestFunctionsQueryParams(t *testing.T) { // data-signals is rendered as JSON inside an HTML attribute, so the // double-quotes around keys/values arrive as `"` -- the browser // decodes them before Datastar parses the expression. - assert.Assert(t, strings.Contains(body, `"fnSearch":"web"`), "data-signals should contain fnSearch") - assert.Assert(t, strings.Contains(body, `"fnSort":"tenant"`), "data-signals should contain fnSort") - assert.Assert(t, strings.Contains(body, `"fnSortDir":"desc"`), "data-signals should contain fnSortDir") + assert.Assert(t, strings.Contains(body, `"assignmentSearch":"web"`), "data-signals should contain assignmentSearch") + assert.Assert(t, strings.Contains(body, `"assignmentSort":"tenant"`), "data-signals should contain assignmentSort") + assert.Assert(t, strings.Contains(body, `"assignmentSortDir":"desc"`), "data-signals should contain assignmentSortDir") }) t.Run("signal initialization escapes quote and ampersand in user input", func(t *testing.T) { t.Parallel() // A bare apostrophe in `search` would close the inline string - // `'{{.FnSearch}}'` mid-attribute and make Datastar fail to + // `'{{.AssignmentSearch}}'` mid-attribute and make Datastar fail to // parse the entire data-signals expression. JSON-encoding the // signal escapes the apostrophe so the rendered attribute is // parseable after the browser HTML-decodes it. - req := httptest.NewRequest(http.MethodGet, "/functions?search=it%27s+%26+more", nil) + req := httptest.NewRequest(http.MethodGet, "/assignments?search=it%27s+%26+more", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) body := w.Body.String() @@ -1002,7 +1031,7 @@ func TestFunctionsQueryParams(t *testing.T) { // `'` is rendered as the HTML entity `'`; `&` is JSON-escaped // to the 6-character sequence `&` (encoding/json defaults // to HTML-safe output) which the JS parser decodes back to `&`. - assert.Assert(t, strings.Contains(body, ""fnSearch":"it's \\u0026 more""), + assert.Assert(t, strings.Contains(body, ""assignmentSearch":"it's \\u0026 more""), "data-signals must safely encode apostrophe and ampersand, got: %s", body) }) } @@ -1177,9 +1206,9 @@ func TestEventsQueryParams(t *testing.T) { assert.Assert(t, !strings.Contains(body, "timeout web-app"), "body should not contain warn event") }) - t.Run("function filters events", func(t *testing.T) { + t.Run("assignment filters events", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/events?function=web", nil) + req := httptest.NewRequest(http.MethodGet, "/events?assignment=web", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -1190,9 +1219,34 @@ func TestEventsQueryParams(t *testing.T) { assert.Assert(t, !strings.Contains(body, "scaled up api-server"), "body should not contain api-server events") }) + t.Run("legacy function query param still filters", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/events?function=web", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + body := w.Body.String() + assert.Equal(t, w.Code, 200) + assert.Assert(t, strings.Contains(body, "scaled up web-app"), "legacy ?function= should still filter") + assert.Assert(t, !strings.Contains(body, "scaled up api-server"), "non-matching events should be excluded") + }) + + t.Run("assignment beats function when both present", func(t *testing.T) { + t.Parallel() + // When both query params are set, ?assignment= wins. + req := httptest.NewRequest(http.MethodGet, "/events?function=api&assignment=web", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + body := w.Body.String() + assert.Equal(t, w.Code, 200) + assert.Assert(t, strings.Contains(body, "scaled up web-app"), "body should match the assignment filter") + assert.Assert(t, !strings.Contains(body, "scaled up api-server"), "body should not match the legacy function filter") + }) + t.Run("combined filters", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/events?severity=2&function=web", nil) + req := httptest.NewRequest(http.MethodGet, "/events?severity=2&assignment=web", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -1218,14 +1272,14 @@ func TestEventsQueryParams(t *testing.T) { t.Run("signal initialization with severity", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/events?severity=2&function=web", nil) + req := httptest.NewRequest(http.MethodGet, "/events?severity=2&assignment=web", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) body := w.Body.String() assert.Equal(t, w.Code, 200) assert.Assert(t, strings.Contains(body, `"eventSeverity":"2"`), "data-signals should contain eventSeverity") - assert.Assert(t, strings.Contains(body, `"eventFunction":"web"`), "data-signals should contain eventFunction") + assert.Assert(t, strings.Contains(body, `"eventAssignment":"web"`), "data-signals should contain eventAssignment") }) t.Run("signal initialization defaults", func(t *testing.T) { @@ -1237,7 +1291,7 @@ func TestEventsQueryParams(t *testing.T) { body := w.Body.String() assert.Equal(t, w.Code, 200) assert.Assert(t, strings.Contains(body, `"eventSeverity":"all"`), "data-signals should default eventSeverity to 'all'") - assert.Assert(t, strings.Contains(body, `"eventFunction":""`), "data-signals should default eventFunction to empty") + assert.Assert(t, strings.Contains(body, `"eventAssignment":""`), "data-signals should default eventAssignment to empty") }) } @@ -1274,9 +1328,9 @@ func TestSortTenantRows(t *testing.T) { t.Parallel() rows := []tenantRow{ - {Tenant: "charlie", Functions: 3, ReadyInstances: 5}, - {Tenant: "alpha", Functions: 1, ReadyInstances: 2}, - {Tenant: "bravo", Functions: 2, ReadyInstances: 10}, + {Tenant: "charlie", Assignments: 3, ReadyInstances: 5}, + {Tenant: "alpha", Assignments: 1, ReadyInstances: 2}, + {Tenant: "bravo", Assignments: 2, ReadyInstances: 10}, } tests := []struct { diff --git a/internal/web/health.go b/internal/web/health.go index a78dd96c..15b39f5f 100644 --- a/internal/web/health.go +++ b/internal/web/health.go @@ -29,10 +29,10 @@ func computeHealthIssues(state *skipper.ClusterState) []healthIssue { } } if stuckCount > 0 { - issues = append(issues, healthIssue{Color: "red", Label: "Stuck instances", Count: stuckCount, Link: "/functions"}) + issues = append(issues, healthIssue{Color: "red", Label: "Stuck instances", Count: stuckCount, Link: "/assignments"}) } - // Functions waiting for pods: has instances but none ready + // Assignments waiting for pods: has instances but none ready var waitingCount int for _, sup := range state.GetSupervisors() { instances := sup.GetInstances() @@ -50,7 +50,7 @@ func computeHealthIssues(state *skipper.ClusterState) []healthIssue { } } if waitingCount > 0 { - issues = append(issues, healthIssue{Color: "yellow", Label: "Functions waiting for pods", Count: waitingCount, Link: "/functions"}) + issues = append(issues, healthIssue{Color: "yellow", Label: "Assignments waiting for pods", Count: waitingCount, Link: "/assignments"}) } // Stale heartbeats: > 60s old @@ -81,7 +81,7 @@ func computeHealthIssues(state *skipper.ClusterState) []healthIssue { } } if staleInstanceCount > 0 { - issues = append(issues, healthIssue{Color: "yellow", Label: "Stale instances", Count: staleInstanceCount, Link: "/functions"}) + issues = append(issues, healthIssue{Color: "yellow", Label: "Stale instances", Count: staleInstanceCount, Link: "/assignments"}) } return issues diff --git a/internal/web/health_test.go b/internal/web/health_test.go index 7311fc3e..2a4eca23 100644 --- a/internal/web/health_test.go +++ b/internal/web/health_test.go @@ -65,16 +65,16 @@ func TestComputeHealthIssues(t *testing.T) { assert.Equal(t, issues[0].Color, "red") }) - t.Run("functions waiting for pods", func(t *testing.T) { + t.Run("assignments waiting for pods", func(t *testing.T) { t.Parallel() inst := makeInstance("pod-1", 10*time.Second, -1, "rs-1") inst.ClearReadyAt() sup := makeSupervisor([]*skipper.Instance{inst}, nil, "") issues := computeHealthIssues(makeState(sup)) - // Should have "Functions waiting for pods" but not "Stuck" (< 60s) + // Should have "Assignments waiting for pods" but not "Stuck" (< 60s) found := false for _, issue := range issues { - if issue.Label == "Functions waiting for pods" { + if issue.Label == "Assignments waiting for pods" { found = true assert.Equal(t, issue.Color, "yellow") } diff --git a/internal/web/server.go b/internal/web/server.go index a4a5bc1d..76b5143c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -51,8 +51,8 @@ func newFuncMap() template.FuncMap { "timeAgo": timeAgo, "formatTimestamp": formatTimestamp, "durationBetween": durationBetween, - "functionKey": assignmentKey, - "functionPath": functionPath, + "assignmentKey": assignmentKey, + "assignmentPath": assignmentPath, "scaleReasonLabel": scaleReasonLabel, "eventTypeLabel": eventTypeLabel, "eventSeverityBadge": eventSeverityBadge, @@ -89,8 +89,8 @@ func New(state StateProvider, opts ...Option) *Server { mux: http.NewServeMux(), pages: map[string]*template.Template{ "dashboard": parsePage("dashboard"), - "functions": parsePage("functions"), - "function": parsePage("function"), + "assignments": parsePage("assignments"), + "assignment": parsePage("assignment"), "controllers": parsePage("controllers"), "controller": parsePage("controller"), "routers": parsePage("routers"), @@ -110,8 +110,8 @@ func New(state StateProvider, opts ...Option) *Server { // Full-page handlers s.mux.HandleFunc("GET /", s.handleDashboard) - s.mux.HandleFunc("GET /functions", s.handleAssignments) - s.mux.HandleFunc("GET /functions/{key}", s.handleAssignment) + s.mux.HandleFunc("GET /assignments", s.handleAssignments) + s.mux.HandleFunc("GET /assignments/{key}", s.handleAssignment) s.mux.HandleFunc("GET /controllers", s.handleControllers) s.mux.HandleFunc("GET /controllers/{ip}", s.handleController) s.mux.HandleFunc("GET /routers", s.handleRouters) @@ -124,10 +124,16 @@ func New(state StateProvider, opts ...Option) *Server { s.mux.HandleFunc("GET /events", s.handleEvents) s.mux.HandleFunc("GET /config", s.handleConfig) + // Legacy /functions/* routes redirect to /assignments/* with 301. + // Tenants and operators migrate links at their own pace; the cleanup + // plan removes these redirects after a deprecation window. + s.mux.HandleFunc("GET /functions", redirectTo("/assignments")) + s.mux.HandleFunc("GET /functions/{key}", redirectAssignmentKey) + // SSE fragment handlers (Datastar) s.mux.HandleFunc("GET /sse/dashboard", s.sseDashboard) - s.mux.HandleFunc("GET /sse/functions", s.sseFunctions) - s.mux.HandleFunc("GET /sse/function/{key}", s.sseAssignment) + s.mux.HandleFunc("GET /sse/assignments", s.sseAssignments) + s.mux.HandleFunc("GET /sse/assignment/{key}", s.sseAssignment) s.mux.HandleFunc("GET /sse/controllers", s.sseControllers) s.mux.HandleFunc("GET /sse/controller/{ip}", s.sseController) s.mux.HandleFunc("GET /sse/routers", s.sseRouters) @@ -138,6 +144,8 @@ func New(state StateProvider, opts ...Option) *Server { s.mux.HandleFunc("GET /sse/deployments", s.sseDeployments) s.mux.HandleFunc("GET /sse/deployment/{name}", s.sseDeployment) s.mux.HandleFunc("GET /sse/events", s.sseEvents) + s.mux.HandleFunc("GET /sse/functions", redirectTo("/sse/assignments")) + s.mux.HandleFunc("GET /sse/function/{key}", redirectSSEAssignmentKey) // Static assets s.mux.Handle("GET /static/", s.staticHandler()) @@ -177,3 +185,31 @@ func (s *Server) staticHandler() http.Handler { func (s *Server) Handler() http.Handler { return s.mux } + +// redirectTo returns a handler that issues a 301 to the given path, +// preserving the request's RawQuery so query-string state survives. +func redirectTo(target string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + dst := target + if r.URL.RawQuery != "" { + dst += "?" + r.URL.RawQuery + } + http.Redirect(w, r, dst, http.StatusMovedPermanently) + } +} + +func redirectAssignmentKey(w http.ResponseWriter, r *http.Request) { + dst := "/assignments/" + url.PathEscape(r.PathValue("key")) + if r.URL.RawQuery != "" { + dst += "?" + r.URL.RawQuery + } + http.Redirect(w, r, dst, http.StatusMovedPermanently) +} + +func redirectSSEAssignmentKey(w http.ResponseWriter, r *http.Request) { + dst := "/sse/assignment/" + url.PathEscape(r.PathValue("key")) + if r.URL.RawQuery != "" { + dst += "?" + r.URL.RawQuery + } + http.Redirect(w, r, dst, http.StatusMovedPermanently) +} diff --git a/internal/web/sse.go b/internal/web/sse.go index 516c8d80..712ad4d3 100644 --- a/internal/web/sse.go +++ b/internal/web/sse.go @@ -47,27 +47,27 @@ func (s *Server) sseDashboard(w http.ResponseWriter, r *http.Request) { s.patchFragment(sse, "dashboard", "recent-activity", data) } -func (s *Server) sseFunctions(w http.ResponseWriter, r *http.Request) { +func (s *Server) sseAssignments(w http.ResponseWriter, r *http.Request) { state := s.state(r.Context()) var sig struct { - FnSearch string `json:"fnSearch"` - FnSort string `json:"fnSort"` - FnSortDir string `json:"fnSortDir"` + AssignmentSearch string `json:"assignmentSearch"` + AssignmentSort string `json:"assignmentSort"` + AssignmentSortDir string `json:"assignmentSortDir"` } _ = datastar.ReadSignals(r, &sig) - if sig.FnSort == "" { - sig.FnSort = "deployment" + if sig.AssignmentSort == "" { + sig.AssignmentSort = "deployment" } - if sig.FnSortDir == "" { - sig.FnSortDir = "asc" + if sig.AssignmentSortDir == "" { + sig.AssignmentSortDir = "asc" } - sups := filterSupervisors(state.GetSupervisors(), sig.FnSearch) - sups = sortSupervisors(sups, sig.FnSort, sig.FnSortDir) + sups := filterSupervisors(state.GetSupervisors(), sig.AssignmentSearch) + sups = sortSupervisors(sups, sig.AssignmentSort, sig.AssignmentSortDir) sse := datastar.NewSSE(w, r) - s.patchFragment(sse, "functions", "functions-table", &assignmentsData{ + s.patchFragment(sse, "assignments", "assignments-table", &assignmentsData{ State: state, Supervisors: sups, }) @@ -90,7 +90,7 @@ func (s *Server) sseAssignment(w http.ResponseWriter, r *http.Request) { } sse := datastar.NewSSE(w, r) - s.patchFragment(sse, "function", "instances-table", data) + s.patchFragment(sse, "assignment", "instances-table", data) } func (s *Server) sseControllers(w http.ResponseWriter, r *http.Request) { @@ -265,18 +265,18 @@ func (s *Server) sseDeployment(w http.ResponseWriter, r *http.Request) { func (s *Server) sseEvents(w http.ResponseWriter, r *http.Request) { state := s.state(r.Context()) - var fnFilter, sevFilter string + var assignmentFilter, sevFilter string type signals struct { - EventFunction string `json:"eventFunction"` - EventSeverity string `json:"eventSeverity"` + EventAssignment string `json:"eventAssignment"` + EventSeverity string `json:"eventSeverity"` } var sig signals if err := datastar.ReadSignals(r, &sig); err == nil { - fnFilter = sig.EventFunction + assignmentFilter = sig.EventAssignment sevFilter = sig.EventSeverity } - filtered := filterEvents(state.GetEvents(), fnFilter, sevFilter) + filtered := filterEvents(state.GetEvents(), assignmentFilter, sevFilter) sse := datastar.NewSSE(w, r) s.patchFragment(sse, "events", "events-table", filtered) diff --git a/internal/web/sse_test.go b/internal/web/sse_test.go index 04bfe707..cd29040d 100644 --- a/internal/web/sse_test.go +++ b/internal/web/sse_test.go @@ -22,8 +22,8 @@ func TestSSEEndpoints(t *testing.T) { path string }{ {name: "dashboard", path: "/sse/dashboard"}, - {name: "functions", path: "/sse/functions"}, - {name: "function", path: "/sse/function/default%3Aweb-app%3Atenant-1"}, + {name: "assignments", path: "/sse/assignments"}, + {name: "assignment", path: "/sse/assignment/default%3Aweb-app%3Atenant-1"}, {name: "controllers", path: "/sse/controllers"}, {name: "controller", path: "/sse/controller/10.0.0.1"}, {name: "routers", path: "/sse/routers"}, @@ -49,6 +49,38 @@ func TestSSEEndpoints(t *testing.T) { } } +// TestSSELegacyRoutesRedirect ensures the legacy /sse/functions and +// /sse/function/{key} paths return 301 to their /sse/assignments and +// /sse/assignment/{key} counterparts. Datastar's EventSource follows the +// 30x on the initial handshake, so existing browser sessions keep +// streaming through the rollover. +func TestSSELegacyRoutesRedirect(t *testing.T) { + t.Parallel() + srv := New(func(ctx context.Context) *skipper.ClusterState { + return testState() + }) + + cases := []struct { + name string + path string + target string + }{ + {name: "sse functions", path: "/sse/functions", target: "/sse/assignments"}, + {name: "sse function key", path: "/sse/function/default%3Aweb-app%3Atenant-1", target: "/sse/assignment/default:web-app:tenant-1"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + assert.Equal(t, w.Code, http.StatusMovedPermanently) + assert.Equal(t, w.Header().Get("Location"), tc.target) + }) + } +} + func TestSSEDashboardContent(t *testing.T) { t.Parallel() srv := New(func(ctx context.Context) *skipper.ClusterState { @@ -64,13 +96,13 @@ func TestSSEDashboardContent(t *testing.T) { assert.Assert(t, len(body) > 0, "SSE response should not be empty") } -func TestSSEFunctionNotFound(t *testing.T) { +func TestSSEAssignmentNotFound(t *testing.T) { t.Parallel() srv := New(func(ctx context.Context) *skipper.ClusterState { return testState() }) - req := httptest.NewRequest(http.MethodGet, "/sse/function/nonexistent", nil) + req := httptest.NewRequest(http.MethodGet, "/sse/assignment/nonexistent", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -110,21 +142,21 @@ func TestSSEDashboardFragments(t *testing.T) { "should contain supervisors-table selector") } -func TestSSEFunctionsFragments(t *testing.T) { +func TestSSEAssignmentsFragments(t *testing.T) { t.Parallel() srv := New(func(ctx context.Context) *skipper.ClusterState { return testState() }) - req := httptest.NewRequest(http.MethodGet, "/sse/functions", nil) + req := httptest.NewRequest(http.MethodGet, "/sse/assignments", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) body := w.Body.String() assert.Assert(t, strings.Contains(body, "datastar-patch-elements"), "should contain datastar event type") - assert.Assert(t, strings.Contains(body, "functions-table"), - "should contain functions-table selector") + assert.Assert(t, strings.Contains(body, "assignments-table"), + "should contain assignments-table selector") } func TestSSEPatchEventsUseInnerMode(t *testing.T) { @@ -138,8 +170,8 @@ func TestSSEPatchEventsUseInnerMode(t *testing.T) { path string }{ {name: "dashboard", path: "/sse/dashboard"}, - {name: "functions", path: "/sse/functions"}, - {name: "function", path: "/sse/function/default%3Aweb-app%3Atenant-1"}, + {name: "assignments", path: "/sse/assignments"}, + {name: "assignment", path: "/sse/assignment/default%3Aweb-app%3Atenant-1"}, {name: "controllers", path: "/sse/controllers"}, {name: "controller", path: "/sse/controller/10.0.0.1"}, {name: "routers", path: "/sse/routers"}, diff --git a/internal/web/templates/function.html b/internal/web/templates/assignment.html similarity index 93% rename from internal/web/templates/function.html rename to internal/web/templates/assignment.html index ab5b2358..5a8633d4 100644 --- a/internal/web/templates/function.html +++ b/internal/web/templates/assignment.html @@ -1,16 +1,16 @@ {{define "content"}} {{if not .Supervisor}} -
Function not found: {{.Key}}
+
Assignment not found: {{.Key}}
{{else}} {{$fn := .Supervisor.GetAssignment}} {{$scale := $fn.GetScale}} {{$sup := .Supervisor}} -
+
-
+
diff --git a/internal/web/templates/assignments.html b/internal/web/templates/assignments.html new file mode 100644 index 00000000..bcfb4a07 --- /dev/null +++ b/internal/web/templates/assignments.html @@ -0,0 +1,71 @@ +{{define "content"}} +
+ + +
{{template "assignments-table" .}}
+
+{{end}} {{define "assignments-table"}} {{if not .Supervisors}} +
No assignments
+{{else}} + + + + + + + + + + + + + + + {{range .Supervisors}} {{$fn := .GetAssignment}} {{$scale := $fn.GetScale}} {{$totalInflight := 0}} + + + + + + + + + + + {{end}} + +
+ Deployment + + Namespace + + Tenant + + Instances + ScaleRoutersIn-FlightResponsible
{{$fn.GetDeployment}}{{$fn.GetNamespace}}{{$fn.GetTenant}}{{len .GetInstances}}{{if $scale}}{{$scale.GetMinInstances}}–{{$scale.GetMaxInstances}}{{else}}—{{end}}{{len .GetRouterHeartbeats}}— + {{.GetResponsibleControllerIp}} +
+{{end}} {{end}} diff --git a/internal/web/templates/controller.html b/internal/web/templates/controller.html index c40a88a2..c56739c4 100644 --- a/internal/web/templates/controller.html +++ b/internal/web/templates/controller.html @@ -16,7 +16,7 @@

Controller {{.IP}} {{if .IsSelf}}self
{{.IP}}

-
Functions
+
Assignments
{{len .Supervisors}}
@@ -30,9 +30,9 @@

Controller {{.IP}} {{if .IsSelf}}self

-

Functions

+

Assignments

{{if not .Supervisors}} -
No functions assigned
+
No assignments
{{else}}
@@ -48,7 +48,7 @@

Functions

{{range .Supervisors}} {{$fn := .GetAssignment}} - + diff --git a/internal/web/templates/controllers.html b/internal/web/templates/controllers.html index 619380b5..a6906a34 100644 --- a/internal/web/templates/controllers.html +++ b/internal/web/templates/controllers.html @@ -36,7 +36,7 @@

- + @@ -45,7 +45,7 @@

- + {{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 8767359d..57139978 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -57,7 +57,7 @@

Recent Activity

{{.TotalTenants}}
-
Functions
+
Assignments
{{len .State.GetSupervisors}}
@@ -145,7 +145,7 @@

Recent Activity

{{range .State.GetSupervisors}} {{$fn := .GetAssignment}} {{$ready := 0}}{{$total := len .GetInstances}} - + @@ -169,7 +169,7 @@

Recent Activity

- + @@ -179,7 +179,7 @@

Recent Activity

{{range .RecentEvents}} - + diff --git a/internal/web/templates/deployment.html b/internal/web/templates/deployment.html index 0c0e687c..98f80e01 100644 --- a/internal/web/templates/deployment.html +++ b/internal/web/templates/deployment.html @@ -32,12 +32,12 @@

{{.Name}}

-

Functions

+

Assignments

{{$fn.GetDeployment}}{{$fn.GetDeployment}} {{$fn.GetNamespace}} {{$fn.GetTenant}} {{len .GetInstances}}
IP SelfFunctionsAssignments Instances
{{.IP}} {{if .IsSelf}}self{{end}}{{.Functions}}{{.Assignments}} {{.ReadyInstances}}/{{.TotalInstances}}
{{$fn.GetDeployment}}{{$fn.GetDeployment}} {{$fn.GetNamespace}} {{$fn.GetTenant}} {{$total}}
TimestampFunctionAssignment Type Detail Severity
{{formatTimestamp .GetTimestamp}}{{if .GetAssignment}}{{functionKey .GetAssignment}}{{else}}—{{end}}{{if .GetAssignment}}{{assignmentKey .GetAssignment}}{{else}}—{{end}} {{eventTypeLabel .GetType}} {{.GetMessage}} {{eventSeverityBadge .GetSeverity}}
- + @@ -48,7 +48,7 @@

Functions

{{range .Supervisors}} {{$fn := .GetAssignment}} {{$scale := $fn.GetScale}} - + diff --git a/internal/web/templates/events.html b/internal/web/templates/events.html index 00284295..d96b9b9e 100644 --- a/internal/web/templates/events.html +++ b/internal/web/templates/events.html @@ -1,7 +1,7 @@ {{define "content"}}
- + @@ -50,7 +50,7 @@

Events

{{range .}} - + diff --git a/internal/web/templates/functions.html b/internal/web/templates/functions.html deleted file mode 100644 index 1acd76aa..00000000 --- a/internal/web/templates/functions.html +++ /dev/null @@ -1,71 +0,0 @@ -{{define "content"}} -
- - -
{{template "functions-table" .}}
-
-{{end}} {{define "functions-table"}} {{if not .Supervisors}} -
No functions
-{{else}} -
FunctionAssignment Namespace Tenant Instances
{{$fn.GetDeployment}}{{$fn.GetDeployment}} {{$fn.GetNamespace}} {{$fn.GetTenant}} {{len .GetInstances}}
TimestampFunctionAssignment Type Detail Severity
{{formatTimestamp .GetTimestamp}}{{if .GetAssignment}}{{functionKey .GetAssignment}}{{else}}—{{end}}{{if .GetAssignment}}{{assignmentKey .GetAssignment}}{{else}}—{{end}} {{eventTypeLabel .GetType}} {{.GetMessage}} {{eventSeverityBadge .GetSeverity}}
- - - - - - - - - - - - - - {{range .Supervisors}} {{$fn := .GetAssignment}} {{$scale := $fn.GetScale}} {{$totalInflight := 0}} - - - - - - - - - - - {{end}} - -
- Deployment - - Namespace - - Tenant - - Instances - ScaleRoutersIn-FlightResponsible
{{$fn.GetDeployment}}{{$fn.GetNamespace}}{{$fn.GetTenant}}{{len .GetInstances}}{{if $scale}}{{$scale.GetMinInstances}}–{{$scale.GetMaxInstances}}{{else}}—{{end}}{{len .GetRouterHeartbeats}}— - {{.GetResponsibleControllerIp}} -
-{{end}} {{end}} diff --git a/internal/web/templates/instance.html b/internal/web/templates/instance.html index 1f505b7c..b8cc010b 100644 --- a/internal/web/templates/instance.html +++ b/internal/web/templates/instance.html @@ -4,7 +4,7 @@
-
Functions
+
Assignments
{{len .Entries}}
@@ -26,7 +26,7 @@

Router {{.IP}}

-

Functions

+

Assignments

@@ -41,7 +41,7 @@

Functions

{{range .Entries}} {{$fn := .Supervisor.GetAssignment}} - + diff --git a/internal/web/templates/routers.html b/internal/web/templates/routers.html index f2b0c516..58a6c679 100644 --- a/internal/web/templates/routers.html +++ b/internal/web/templates/routers.html @@ -12,7 +12,7 @@

Routers

- + @@ -20,7 +20,7 @@

Routers

{{range .}} - + {{end}} diff --git a/internal/web/templates/tenant.html b/internal/web/templates/tenant.html index 71996f31..103c82ec 100644 --- a/internal/web/templates/tenant.html +++ b/internal/web/templates/tenant.html @@ -16,7 +16,7 @@

{{.Tenant}}

{{.Tenant}}
-
Functions
+
Assignments
{{len .Supervisors}}
@@ -58,7 +58,7 @@

Deployment Breakdown

{{end}} -

Functions

+

Assignments

{{$fn.GetDeployment}}{{$fn.GetDeployment}} {{$fn.GetNamespace}} {{$fn.GetTenant}} {{if .Heartbeat.GetHeartbeat}}{{.Heartbeat.GetHeartbeat.GetInFlightRequests}}{{else}}0{{end}}
IPFunctionsAssignments Total In-Flight
{{.IP}}{{.Functions}}{{.Assignments}} {{.InFlight}}
@@ -73,7 +73,7 @@

Functions

{{range .Supervisors}} {{$fn := .GetAssignment}} {{$scale := $fn.GetScale}} - + diff --git a/internal/web/templates/tenants.html b/internal/web/templates/tenants.html index 13cdfee0..e962368f 100644 --- a/internal/web/templates/tenants.html +++ b/internal/web/templates/tenants.html @@ -29,9 +29,9 @@

Tenants

- + diff --git a/internal/web/url_state_test.go b/internal/web/url_state_test.go index b5d3905f..d8765b15 100644 --- a/internal/web/url_state_test.go +++ b/internal/web/url_state_test.go @@ -91,16 +91,16 @@ func waitForReady(url string, timeout time.Duration) error { return fmt.Errorf("readiness probe %q did not respond OK within %s", url, timeout) } -func TestFunctionsPagePrefillsSearchFromURL(t *testing.T) { +func TestAssignmentsPagePrefillsSearchFromURL(t *testing.T) { ensureServer(t) ctx, cancel := browserContext(t) defer cancel() var inputValue, tableHTML string err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"/functions?search=web"), - chromedp.WaitVisible(`input[placeholder="Search functions…"]`, chromedp.ByQuery), - chromedp.Value(`input[placeholder="Search functions…"]`, &inputValue, chromedp.ByQuery), + chromedp.Navigate(baseURL+"/assignments?search=web"), + chromedp.WaitVisible(`input[placeholder="Search assignments…"]`, chromedp.ByQuery), + chromedp.Value(`input[placeholder="Search assignments…"]`, &inputValue, chromedp.ByQuery), chromedp.OuterHTML(`table tbody`, &tableHTML, chromedp.ByQuery), ) assert.NilError(t, err) @@ -119,8 +119,8 @@ func TestSyncParamsKeyupUpdatesURL(t *testing.T) { var url string err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"/functions"), - chromedp.WaitVisible(`input[placeholder="Search functions…"]`, chromedp.ByQuery), + chromedp.Navigate(baseURL+"/assignments"), + chromedp.WaitVisible(`input[placeholder="Search assignments…"]`, chromedp.ByQuery), chromedp.Evaluate(`window.syncParams({ search: 'api', sort: 'deployment', dir: 'asc' })`, nil), chromedp.Location(&url), ) @@ -135,8 +135,8 @@ func TestSyncParamsSortClickUpdatesURL(t *testing.T) { var url string err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"/functions"), - chromedp.WaitVisible(`input[placeholder="Search functions…"]`, chromedp.ByQuery), + chromedp.Navigate(baseURL+"/assignments"), + chromedp.WaitVisible(`input[placeholder="Search assignments…"]`, chromedp.ByQuery), chromedp.Evaluate(`window.syncParams({ search: '', sort: 'namespace', dir: 'asc' })`, nil), chromedp.Location(&url), ) @@ -153,12 +153,12 @@ func TestURLStatePreservedAcrossRefresh(t *testing.T) { var beforeValue, afterValue, urlAfter string err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"/functions?search=worker&sort=tenant&dir=desc"), - chromedp.WaitVisible(`input[placeholder="Search functions…"]`, chromedp.ByQuery), - chromedp.Value(`input[placeholder="Search functions…"]`, &beforeValue, chromedp.ByQuery), + chromedp.Navigate(baseURL+"/assignments?search=worker&sort=tenant&dir=desc"), + chromedp.WaitVisible(`input[placeholder="Search assignments…"]`, chromedp.ByQuery), + chromedp.Value(`input[placeholder="Search assignments…"]`, &beforeValue, chromedp.ByQuery), chromedp.Reload(), - chromedp.WaitVisible(`input[placeholder="Search functions…"]`, chromedp.ByQuery), - chromedp.Value(`input[placeholder="Search functions…"]`, &afterValue, chromedp.ByQuery), + chromedp.WaitVisible(`input[placeholder="Search assignments…"]`, chromedp.ByQuery), + chromedp.Value(`input[placeholder="Search assignments…"]`, &afterValue, chromedp.ByQuery), chromedp.Location(&urlAfter), ) assert.NilError(t, err) @@ -182,16 +182,16 @@ func TestTenantsPageSearchFromURL(t *testing.T) { assert.Equal(t, inputValue, "tenant-1") } -func TestEventsPageFunctionFilterFromURL(t *testing.T) { +func TestEventsPageAssignmentFilterFromURL(t *testing.T) { ensureServer(t) ctx, cancel := browserContext(t) defer cancel() var inputValue string err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"/events?function=web"), - chromedp.WaitVisible(`input[placeholder="Filter by function…"]`, chromedp.ByQuery), - chromedp.Value(`input[placeholder="Filter by function…"]`, &inputValue, chromedp.ByQuery), + chromedp.Navigate(baseURL+"/events?assignment=web"), + chromedp.WaitVisible(`input[placeholder="Filter by assignment…"]`, chromedp.ByQuery), + chromedp.Value(`input[placeholder="Filter by assignment…"]`, &inputValue, chromedp.ByQuery), ) assert.NilError(t, err) assert.Equal(t, inputValue, "web") diff --git a/template.yaml.erb b/template.yaml.erb index a1d99ff5..68150821 100644 --- a/template.yaml.erb +++ b/template.yaml.erb @@ -254,8 +254,8 @@ spec: value: "<%= controller_log_level %>" - name: SKIPPER_LOG_FORMAT value: "<%= controller_log_format %>" - - name: SKIPPER_FUNCTION_NAMESPACES - value: "<%= function_namespaces.join(',') %>" + - name: SKIPPER_ASSIGNMENT_NAMESPACES + value: "<%= (defined?(assignment_namespaces) ? assignment_namespaces : function_namespaces).join(',') %>" - name: SKIPPER_SKIP_FORBIDDEN_NAMESPACES value: "<%= skip_forbidden_namespaces %>" - name: SKIPPER_SHUTDOWN_TIMEOUT
{{$fn.GetDeployment}}{{$fn.GetDeployment}} {{$fn.GetNamespace}} {{len .GetInstances}} {{if $scale}}{{$scale.GetMinInstances}}–{{$scale.GetMaxInstances}}{{else}}—{{end}} - Functions + Assignments Tenants {{range .}}
{{.Tenant}}{{.Functions}}{{.Assignments}} {{.ReadyInstances}}/{{.TotalInstances}} {{range $i, $d := .Deployments}}{{if $i}}, {{end}}{{$d}}{{end}}