From 4b9952a35675428783034258a5128c653ce203dd Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:44:00 +0200 Subject: [PATCH 1/2] feat: add autoscale settings to datumctl compute --- internal/cmd/compute/deploy/deploy.go | 69 +++++++- .../cmd/compute/deploy/scale_settings_test.go | 136 ++++++++++++++ internal/cmd/compute/scale/scale.go | 39 ++-- internal/cmd/compute/util/scalesettings.go | 150 ++++++++++++++++ .../cmd/compute/util/scalesettings_test.go | 167 ++++++++++++++++++ .../cmd/compute/workloads/describe_test.go | 85 +++++++++ internal/cmd/compute/workloads/workloads.go | 19 +- 7 files changed, 643 insertions(+), 22 deletions(-) create mode 100644 internal/cmd/compute/deploy/scale_settings_test.go create mode 100644 internal/cmd/compute/util/scalesettings.go create mode 100644 internal/cmd/compute/util/scalesettings_test.go create mode 100644 internal/cmd/compute/workloads/describe_test.go diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 6d08af3e..69590880 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -56,6 +56,9 @@ type options struct { locationSelector string cities []string min int32 + max int32 + cpuPercent int32 + memoryPercent int32 httpPort int32 noHTTP bool port int32 @@ -134,7 +137,7 @@ HTTP service as it is; --no-http removes it and stops serving.`, cmd.Flags().StringSliceVar(&opts.locations, "location", nil, "One or more locations to deploy to (e.g. us-east-1,eu-west-1)") cmd.Flags().StringVar(&opts.locationSelector, "location-selector", "", "Select every location whose topology matches a label selector (e.g. 'topology.datum.net/city-code=DFW' or 'topology.datum.net/region in (us-east-1,eu-west-1)')") cmd.Flags().StringSliceVar(&opts.cities, "city", nil, "Deploy to every location in these cities (e.g. DFW,IAD); shorthand for a --location-selector on topology.datum.net/city-code") - cmd.Flags().Int32Var(&opts.min, "min", 1, "Minimum number of instances per location") + util.AddScaleFlags(cmd, &opts.min, &opts.max, &opts.cpuPercent, &opts.memoryPercent, 1) cmd.Flags().Int32Var(&opts.httpPort, "http-port", 0, "Port the container serves HTTP on; publishes the workload on a Datum-managed HTTPS URL") cmd.Flags().BoolVar(&opts.noHTTP, "no-http", false, "Remove the workload's HTTP service, and with it its URL") cmd.Flags().StringVarP(&opts.file, "file", "f", "", "Path to a workload manifest file") @@ -260,6 +263,38 @@ func resolveLocationSelector(opts *options) (*metav1.LabelSelector, error) { return nil, nil } +// resolveScaleSettings picks the baseline scale settings for this deploy's +// flags to merge onto, and returns the merged, validated result. +// +// The baseline is the one existing placement's settings, under any name, so +// an unrelated flag change doesn't reset previously configured autoscaling. +// A multi-placement workload (only possible via a manifest deploy) has no +// single baseline to preserve; it warns via out before dropping autoscaling +// rather than doing so silently. +func resolveScaleSettings( + cmd *cobra.Command, out io.Writer, opts *options, existingPlacements []computev1alpha.WorkloadPlacement, +) (computev1alpha.HorizontalScaleSettings, error) { + current := computev1alpha.HorizontalScaleSettings{MinReplicas: 1} + + switch len(existingPlacements) { + case 0: + // Fresh workload; the default above stands. + case 1: + current = existingPlacements[0].ScaleSettings + default: + for _, p := range existingPlacements { + if p.ScaleSettings.MaxReplicas != nil { + fmt.Fprintln(out, planNote(fmt.Sprintf( + "replacing %d placements with one; placement %q's autoscaling settings will be dropped", + len(existingPlacements), p.Name))) + break + } + } + } + + return util.MergeScaleSettings(cmd, current, opts.min, opts.max, opts.cpuPercent, opts.memoryPercent) +} + func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) error { project := util.ProjectFromCmd(cmd) if project == "" { @@ -342,15 +377,19 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err for _, name := range locations { locationRefs = append(locationRefs, locationsv1alpha1.LocationReference{Name: name}) } - // All locations go into one "default" placement. + + // All locations go into one "default" placement, replacing whatever + // placements the workload had before. + scaleSettings, err := resolveScaleSettings(cmd, out, opts, workload.Spec.Placements) + if err != nil { + return err + } + placement := computev1alpha.WorkloadPlacement{ Name: "default", Locations: locationRefs, LocationSelector: locationSelector, - ScaleSettings: computev1alpha.HorizontalScaleSettings{ - MinReplicas: opts.min, - InstanceManagementPolicy: computev1alpha.OrderedReadyInstanceManagementPolicyType, - }, + ScaleSettings: scaleSettings, } workload.Spec = computev1alpha.WorkloadSpec{ @@ -376,7 +415,7 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err } fmt.Fprintln(out, planLine(`Placement "default"`, - fmt.Sprintf("%s, min=%d", describePlacementLocations(placement), opts.min))) + fmt.Sprintf("%s, %s", describePlacementLocations(placement), util.FormatScaleSettings(scaleSettings)))) removedURL := planHTTPService(ctx, out, c, workloadName, httpPort, opts, creating) @@ -842,6 +881,14 @@ func manifestDiff(existing, desired computev1alpha.Workload) []string { lines = append(lines, fmt.Sprintf(" placement %q min replicas: %d → %d", name, op.ScaleSettings.MinReplicas, np.ScaleSettings.MinReplicas)) } + if maxReplicasStr(op.ScaleSettings.MaxReplicas) != maxReplicasStr(np.ScaleSettings.MaxReplicas) { + lines = append(lines, fmt.Sprintf(" placement %q max replicas: %s → %s", + name, maxReplicasStr(op.ScaleSettings.MaxReplicas), maxReplicasStr(np.ScaleSettings.MaxReplicas))) + } + if len(op.ScaleSettings.Metrics) != len(np.ScaleSettings.Metrics) { + lines = append(lines, fmt.Sprintf(" placement %q autoscaling metrics: %d → %d", + name, len(op.ScaleSettings.Metrics), len(np.ScaleSettings.Metrics))) + } if before, after := describePlacementLocations(op), describePlacementLocations(np); before != after { lines = append(lines, fmt.Sprintf(" placement %q: %s → %s", name, before, after)) } @@ -858,6 +905,14 @@ func manifestDiff(existing, desired computev1alpha.Workload) []string { return lines } +// maxReplicasStr renders a placement's MaxReplicas for diff output. +func maxReplicasStr(max *int32) string { + if max == nil { + return "none" + } + return fmt.Sprintf("%d", *max) +} + // describePlacementLocations says where a placement runs the way the CLI // prints it: the locations it names, or the selector it resolves through. func describePlacementLocations(p computev1alpha.WorkloadPlacement) string { diff --git a/internal/cmd/compute/deploy/scale_settings_test.go b/internal/cmd/compute/deploy/scale_settings_test.go new file mode 100644 index 00000000..3e06cfaa --- /dev/null +++ b/internal/cmd/compute/deploy/scale_settings_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package deploy + +import ( + "bytes" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +func placementWithScale(name string, s computev1alpha.HorizontalScaleSettings) computev1alpha.WorkloadPlacement { + return computev1alpha.WorkloadPlacement{Name: name, ScaleSettings: s} +} + +// TestResolveScaleSettings pins the merge baseline: the single existing +// placement's settings (any name), a fresh default with none, and a warning +// — not a silent drop — when several placements collapse into one. +func TestResolveScaleSettings(t *testing.T) { + for _, tc := range []struct { + name string + args []string + existing []computev1alpha.WorkloadPlacement + wantErr string + wantMin int32 + wantMax *int32 + wantNote string // substring expected in the printed plan output + wantNoNote bool + }{ + { + name: "no existing placements defaults to min=1", + args: nil, + existing: nil, + wantMin: 1, + }, + { + name: "a single placement under any name is the baseline", + args: nil, + existing: []computev1alpha.WorkloadPlacement{placementWithScale("us", computev1alpha.HorizontalScaleSettings{MinReplicas: 3})}, + wantMin: 3, + }, + { + name: "flags override the single placement's baseline", + args: []string{"--min=5"}, + existing: []computev1alpha.WorkloadPlacement{ + placementWithScale("default", computev1alpha.HorizontalScaleSettings{MinReplicas: 3}), + }, + wantMin: 5, + }, + { + name: "autoscaling on the single placement survives an unrelated flag change", + args: []string{"--min=2"}, + existing: []computev1alpha.WorkloadPlacement{ + placementWithScale("default", computev1alpha.HorizontalScaleSettings{ + MinReplicas: 1, + MaxReplicas: new(int32(10)), + Metrics: []computev1alpha.MetricSpec{ + {Resource: &computev1alpha.ResourceMetricSource{Name: corev1.ResourceCPU, Target: computev1alpha.MetricTarget{AverageUtilization: new(int32(70))}}}, + }, + }), + }, + wantMin: 2, + wantMax: new(int32(10)), + }, + { + name: "multiple placements warn before their autoscaling is dropped", + args: nil, + existing: []computev1alpha.WorkloadPlacement{ + placementWithScale("us", computev1alpha.HorizontalScaleSettings{MinReplicas: 2}), + placementWithScale("eu", computev1alpha.HorizontalScaleSettings{MinReplicas: 2, MaxReplicas: new(int32(8))}), + }, + wantMin: 1, // falls back to the zero-value default, not either placement's + wantNote: `replacing 2 placements with one; placement "eu"'s autoscaling settings will be dropped`, + }, + { + name: "multiple placements with no autoscaling print no warning", + args: nil, + existing: []computev1alpha.WorkloadPlacement{ + placementWithScale("us", computev1alpha.HorizontalScaleSettings{MinReplicas: 2}), + placementWithScale("eu", computev1alpha.HorizontalScaleSettings{MinReplicas: 2}), + }, + wantMin: 1, + wantNoNote: true, + }, + { + name: "max without a metric is rejected", + args: []string{"--max=10"}, + existing: nil, + wantErr: "requires at least one of --cpu-percent or --memory-percent", + }, + } { + t.Run(tc.name, func(t *testing.T) { + cmd, opts := command() + if err := cmd.Flags().Parse(append([]string{testWorkload, imageFlag}, tc.args...)); err != nil { + t.Fatalf("parsing flags: %v", err) + } + + var out bytes.Buffer + got, err := resolveScaleSettings(cmd, &out, opts, tc.existing) + + if tc.wantErr != "" { + if err == nil { + t.Fatalf("want an error containing %q, got settings %+v", tc.wantErr, got) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %q, want it to contain %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got.MinReplicas != tc.wantMin { + t.Errorf("MinReplicas = %d, want %d", got.MinReplicas, tc.wantMin) + } + if (got.MaxReplicas == nil) != (tc.wantMax == nil) { + t.Fatalf("MaxReplicas = %v, want %v", got.MaxReplicas, tc.wantMax) + } + if got.MaxReplicas != nil && *got.MaxReplicas != *tc.wantMax { + t.Errorf("MaxReplicas = %d, want %d", *got.MaxReplicas, *tc.wantMax) + } + + printed := out.String() + if tc.wantNote != "" && !strings.Contains(printed, tc.wantNote) { + t.Errorf("output = %q, want it to contain %q", printed, tc.wantNote) + } + if tc.wantNoNote && printed != "" { + t.Errorf("output = %q, want no warning printed", printed) + } + }) + } +} diff --git a/internal/cmd/compute/scale/scale.go b/internal/cmd/compute/scale/scale.go index a7e2db74..92bf3e1a 100644 --- a/internal/cmd/compute/scale/scale.go +++ b/internal/cmd/compute/scale/scale.go @@ -13,27 +13,32 @@ import ( ) func Command() *cobra.Command { - var min int32 + var min, max, cpuPercent, memoryPercent int32 cmd := &cobra.Command{ - Use: "scale ", - Short: "Adjust the minimum replica count for a workload", - Args: cobra.ExactArgs(1), - Example: ` datumctl compute scale api --min=4`, + Use: "scale ", + Short: "Adjust replica counts or autoscaling settings for a workload", + Args: cobra.ExactArgs(1), + Example: ` datumctl compute scale api --min=4 + datumctl compute scale api --max=10 --cpu-percent=70 + datumctl compute scale api --max=0 # disable autoscaling`, RunE: func(cmd *cobra.Command, args []string) error { - return runScale(cmd, args, min) + return runScale(cmd, args, min, max, cpuPercent, memoryPercent) }, ValidArgsFunction: util.CompleteWorkloadNames, } - cmd.Flags().Int32Var(&min, "min", 0, "Minimum number of instances per location") - _ = cmd.MarkFlagRequired("min") + util.AddScaleFlags(cmd, &min, &max, &cpuPercent, &memoryPercent, 0) return cmd } -func runScale(cmd *cobra.Command, args []string, min int32) error { - if min <= 0 { +func runScale(cmd *cobra.Command, args []string, min, max, cpuPercent, memoryPercent int32) error { + flags := cmd.Flags() + if !flags.Changed("min") && !flags.Changed("max") && !flags.Changed("cpu-percent") && !flags.Changed("memory-percent") { + return fmt.Errorf("at least one of --min, --max, --cpu-percent, or --memory-percent must be set") + } + if flags.Changed("min") && min <= 0 { return fmt.Errorf("min replicas must be at least 1") } @@ -61,16 +66,24 @@ func runScale(cmd *cobra.Command, args []string, min int32) error { } for i := range workload.Spec.Placements { - workload.Spec.Placements[i].ScaleSettings.MinReplicas = min + placement := &workload.Spec.Placements[i] + + merged, err := util.MergeScaleSettings(cmd, placement.ScaleSettings, min, max, cpuPercent, memoryPercent) + if err != nil { + return fmt.Errorf("placement %q: %w", placement.Name, err) + } + + placement.ScaleSettings = merged } if err := c.Update(ctx, &workload); err != nil { return fmt.Errorf("updating workload: %w", err) } + first := workload.Spec.Placements[0].ScaleSettings fmt.Fprintf(cmd.OutOrStdout(), - "Scaled workload %q — min replicas set to %d across %d placement(s).\nRun 'datumctl compute rollout %s' to watch progress.\n", - workloadName, min, len(workload.Spec.Placements), workloadName, + "Scaled workload %q — %s across %d placement(s).\nRun 'datumctl compute rollout %s' to watch progress.\n", + workloadName, util.FormatScaleSettings(first), len(workload.Spec.Placements), workloadName, ) return nil diff --git a/internal/cmd/compute/util/scalesettings.go b/internal/cmd/compute/util/scalesettings.go new file mode 100644 index 00000000..4b229928 --- /dev/null +++ b/internal/cmd/compute/util/scalesettings.go @@ -0,0 +1,150 @@ +package util + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +// AddScaleFlags registers the --min/--max/--cpu-percent/--memory-percent flags +// shared by "deploy" and "scale" on cmd, backed by the given variables. +func AddScaleFlags(cmd *cobra.Command, min, max, cpuPercent, memoryPercent *int32, minDefault int32) { + cmd.Flags().Int32Var(min, "min", minDefault, "Minimum number of instances per location") + cmd.Flags().Int32Var(max, "max", 0, "Maximum number of instances to autoscale up to (0 disables autoscaling)") + cmd.Flags().Int32Var(cpuPercent, "cpu-percent", 0, "Target average CPU utilization percentage to autoscale on (0 removes the metric)") + cmd.Flags().Int32Var(memoryPercent, "memory-percent", 0, "Target average memory utilization percentage to autoscale on (0 removes the metric)") +} + +// MergeScaleSettings applies the scale-related flags that were explicitly set +// on cmd onto current, leaving any untouched fields as-is, and returns the +// resulting settings once validated. +func MergeScaleSettings( + cmd *cobra.Command, + current computev1alpha.HorizontalScaleSettings, + min, max, cpuPercent, memoryPercent int32, +) (computev1alpha.HorizontalScaleSettings, error) { + flags := cmd.Flags() + settings := *current.DeepCopy() + + if flags.Changed("min") { + settings.MinReplicas = min + } + + if flags.Changed("max") { + if max == 0 { + settings.MaxReplicas = nil + settings.Metrics = nil + } else { + settings.MaxReplicas = &max + } + } + + if flags.Changed("cpu-percent") { + setResourceMetricPercent(&settings, corev1.ResourceCPU, cpuPercent) + } + + if flags.Changed("memory-percent") { + setResourceMetricPercent(&settings, corev1.ResourceMemory, memoryPercent) + } + + if settings.InstanceManagementPolicy == "" { + settings.InstanceManagementPolicy = computev1alpha.OrderedReadyInstanceManagementPolicyType + } + + if err := validateScaleSettings(settings); err != nil { + return settings, err + } + + return settings, nil +} + +// FormatScaleSettings renders a HorizontalScaleSettings for CLI output, e.g. +// "min=2, max=10 (cpu@70%)", or "min=2" when no max is set. +func FormatScaleSettings(s computev1alpha.HorizontalScaleSettings) string { + out := fmt.Sprintf("min=%d", s.MinReplicas) + if s.MaxReplicas == nil { + return out + } + out += fmt.Sprintf(", max=%d", *s.MaxReplicas) + if ann := MetricsAnnotation(s.Metrics); ann != "" { + out += " " + ann + } else { + out += " (autoscaling disabled)" + } + return out +} + +// MetricsAnnotation renders a placement's autoscaling metrics as a +// parenthesized, human-readable list, e.g. "(cpu@70%, memory@80%)", or "" +// when there are none. +func MetricsAnnotation(metrics []computev1alpha.MetricSpec) string { + var parts []string + for _, m := range metrics { + if m.Resource == nil { + continue + } + target := m.Resource.Target + switch { + case target.AverageUtilization != nil: + parts = append(parts, fmt.Sprintf("%s@%d%%", m.Resource.Name, *target.AverageUtilization)) + case target.AverageValue != nil: + parts = append(parts, fmt.Sprintf("%s@%s avg", m.Resource.Name, target.AverageValue.String())) + case target.Value != nil: + parts = append(parts, fmt.Sprintf("%s@%s", m.Resource.Name, target.Value.String())) + } + } + if len(parts) == 0 { + return "" + } + return "(" + strings.Join(parts, ", ") + ")" +} + +// setResourceMetricPercent sets, replaces, or (when percent is 0) removes the +// resource metric with the given name in settings.Metrics. +func setResourceMetricPercent(settings *computev1alpha.HorizontalScaleSettings, name corev1.ResourceName, percent int32) { + filtered := settings.Metrics[:0] + for _, m := range settings.Metrics { + if m.Resource == nil || m.Resource.Name != name { + filtered = append(filtered, m) + } + } + settings.Metrics = filtered + + if percent == 0 { + return + } + + settings.Metrics = append(settings.Metrics, computev1alpha.MetricSpec{ + Resource: &computev1alpha.ResourceMetricSource{ + Name: name, + Target: computev1alpha.MetricTarget{ + AverageUtilization: &percent, + }, + }, + }) +} + +// validateScaleSettings enforces the invariants the HPA controller relies on: +// autoscaling is enabled iff both a max replica count and at least one metric +// are set, and the max must not be below the min. +func validateScaleSettings(settings computev1alpha.HorizontalScaleSettings) error { + hasMax := settings.MaxReplicas != nil + hasMetrics := len(settings.Metrics) > 0 + + if hasMax != hasMetrics { + if hasMax { + return fmt.Errorf("--max requires at least one of --cpu-percent or --memory-percent to enable autoscaling") + } + return fmt.Errorf("--cpu-percent/--memory-percent require --max to enable autoscaling") + } + + if hasMax && *settings.MaxReplicas < settings.MinReplicas { + return fmt.Errorf("--max (%d) must be >= --min (%d)", *settings.MaxReplicas, settings.MinReplicas) + } + + return nil +} diff --git a/internal/cmd/compute/util/scalesettings_test.go b/internal/cmd/compute/util/scalesettings_test.go new file mode 100644 index 00000000..940a8555 --- /dev/null +++ b/internal/cmd/compute/util/scalesettings_test.go @@ -0,0 +1,167 @@ +package util + +import ( + "testing" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +const ( + maxTenFlag = "--max=10" + cpu70PercentFlag = "--cpu-percent=70" +) + +func resourceMetric(name corev1.ResourceName, percent int32) computev1alpha.MetricSpec { + return computev1alpha.MetricSpec{ + Resource: &computev1alpha.ResourceMetricSource{ + Name: name, + Target: computev1alpha.MetricTarget{AverageUtilization: new(percent)}, + }, + } +} + +func TestMergeScaleSettings(t *testing.T) { + tests := []struct { + name string + current computev1alpha.HorizontalScaleSettings + args []string + wantErr bool + wantMin int32 + wantMax *int32 + wantMetrics map[corev1.ResourceName]int32 + }{ + { + name: "min only leaves autoscaling untouched", + current: computev1alpha.HorizontalScaleSettings{MinReplicas: 1}, + args: []string{"--min=4"}, + wantMin: 4, + wantMax: nil, + }, + { + name: "max without a metric is rejected", + current: computev1alpha.HorizontalScaleSettings{MinReplicas: 1}, + args: []string{maxTenFlag}, + wantErr: true, + }, + { + name: "metric without max is rejected", + current: computev1alpha.HorizontalScaleSettings{MinReplicas: 1}, + args: []string{cpu70PercentFlag}, + wantErr: true, + }, + { + name: "max and cpu-percent together enable autoscaling", + current: computev1alpha.HorizontalScaleSettings{MinReplicas: 1}, + args: []string{maxTenFlag, cpu70PercentFlag}, + wantMin: 1, + wantMax: new(int32(10)), + wantMetrics: map[corev1.ResourceName]int32{corev1.ResourceCPU: 70}, + }, + { + name: "setting max alone succeeds when a metric already exists", + current: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 1, + MaxReplicas: new(int32(5)), + Metrics: []computev1alpha.MetricSpec{resourceMetric(corev1.ResourceCPU, 70)}, + }, + args: []string{maxTenFlag}, + wantMin: 1, + wantMax: new(int32(10)), + wantMetrics: map[corev1.ResourceName]int32{corev1.ResourceCPU: 70}, + }, + { + name: "setting a metric alone succeeds when max already exists", + current: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 1, + MaxReplicas: new(int32(5)), + }, + args: []string{cpu70PercentFlag}, + wantMin: 1, + wantMax: new(int32(5)), + wantMetrics: map[corev1.ResourceName]int32{corev1.ResourceCPU: 70}, + }, + { + name: "max=0 disables autoscaling", + current: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 1, + MaxReplicas: new(int32(5)), + Metrics: []computev1alpha.MetricSpec{resourceMetric(corev1.ResourceCPU, 70)}, + }, + args: []string{"--max=0"}, + wantMin: 1, + wantMax: nil, + wantMetrics: map[corev1.ResourceName]int32{}, + }, + { + name: "cpu-percent=0 removes only the cpu metric", + current: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 1, + MaxReplicas: new(int32(5)), + Metrics: []computev1alpha.MetricSpec{ + resourceMetric(corev1.ResourceCPU, 70), + resourceMetric(corev1.ResourceMemory, 80), + }, + }, + args: []string{"--cpu-percent=0"}, + wantMin: 1, + wantMax: new(int32(5)), + wantMetrics: map[corev1.ResourceName]int32{corev1.ResourceMemory: 80}, + }, + { + name: "max below min is rejected", + current: computev1alpha.HorizontalScaleSettings{MinReplicas: 5}, + args: []string{"--max=3", cpu70PercentFlag}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var min, max, cpuPercent, memoryPercent int32 + cmd := &cobra.Command{} + AddScaleFlags(cmd, &min, &max, &cpuPercent, &memoryPercent, 1) + if err := cmd.ParseFlags(tt.args); err != nil { + t.Fatalf("parsing flags %v: %v", tt.args, err) + } + + got, err := MergeScaleSettings(cmd, tt.current, min, max, cpuPercent, memoryPercent) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got.MinReplicas != tt.wantMin { + t.Errorf("MinReplicas = %d, want %d", got.MinReplicas, tt.wantMin) + } + if (got.MaxReplicas == nil) != (tt.wantMax == nil) { + t.Fatalf("MaxReplicas = %v, want %v", got.MaxReplicas, tt.wantMax) + } + if got.MaxReplicas != nil && *got.MaxReplicas != *tt.wantMax { + t.Errorf("MaxReplicas = %d, want %d", *got.MaxReplicas, *tt.wantMax) + } + + gotMetrics := map[corev1.ResourceName]int32{} + for _, m := range got.Metrics { + gotMetrics[m.Resource.Name] = *m.Resource.Target.AverageUtilization + } + if tt.wantMetrics != nil { + if len(gotMetrics) != len(tt.wantMetrics) { + t.Fatalf("Metrics = %+v, want %+v", gotMetrics, tt.wantMetrics) + } + for name, percent := range tt.wantMetrics { + if gotMetrics[name] != percent { + t.Errorf("Metrics[%s] = %d, want %d", name, gotMetrics[name], percent) + } + } + } + }) + } +} diff --git a/internal/cmd/compute/workloads/describe_test.go b/internal/cmd/compute/workloads/describe_test.go new file mode 100644 index 00000000..a2ae6a2a --- /dev/null +++ b/internal/cmd/compute/workloads/describe_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package workloads + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +func TestAutoscaleAnnotation(t *testing.T) { + tests := []struct { + name string + s computev1alpha.HorizontalScaleSettings + want string + }{ + { + name: "no max, no annotation", + s: computev1alpha.HorizontalScaleSettings{MinReplicas: 2}, + want: "", + }, + { + name: "max with no metric is flagged as disabled", + s: computev1alpha.HorizontalScaleSettings{MinReplicas: 2, MaxReplicas: new(int32(10))}, + want: " (autoscaling disabled)", + }, + { + name: "single cpu utilization metric", + s: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 2, + MaxReplicas: new(int32(10)), + Metrics: []computev1alpha.MetricSpec{ + {Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: computev1alpha.MetricTarget{AverageUtilization: new(int32(70))}, + }}, + }, + }, + want: " (cpu@70%)", + }, + { + name: "multiple metrics joined in order", + s: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 2, + MaxReplicas: new(int32(10)), + Metrics: []computev1alpha.MetricSpec{ + {Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: computev1alpha.MetricTarget{AverageUtilization: new(int32(70))}, + }}, + {Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: computev1alpha.MetricTarget{AverageUtilization: new(int32(80))}, + }}, + }, + }, + want: " (cpu@70%, memory@80%)", + }, + { + name: "average value target", + s: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 2, + MaxReplicas: new(int32(10)), + Metrics: []computev1alpha.MetricSpec{ + {Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: computev1alpha.MetricTarget{AverageValue: new(resource.MustParse("500Mi"))}, + }}, + }, + }, + want: " (memory@500Mi avg)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := autoscaleAnnotation(tt.s); got != tt.want { + t.Errorf("autoscaleAnnotation() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/compute/workloads/workloads.go b/internal/cmd/compute/workloads/workloads.go index a813624f..7ff21684 100644 --- a/internal/cmd/compute/workloads/workloads.go +++ b/internal/cmd/compute/workloads/workloads.go @@ -593,8 +593,8 @@ func runDescribe(cmd *cobra.Command, args []string) error { if p.ScaleSettings.MaxReplicas != nil { maxStr = fmt.Sprintf("%d", *p.ScaleSettings.MaxReplicas) } - fmt.Fprintf(out, " %-10s %-34s scale: %d..%s\n", - p.Name, placementLocationsSummary(p), p.ScaleSettings.MinReplicas, maxStr) + fmt.Fprintf(out, " %-10s %-34s scale: %d..%s%s\n", + p.Name, placementLocationsSummary(p), p.ScaleSettings.MinReplicas, maxStr, autoscaleAnnotation(p.ScaleSettings)) // Per-location lines from deployments. for _, d := range deplsByPlacement[p.Name] { @@ -674,6 +674,21 @@ func placementLocationsSummary(p computev1alpha.WorkloadPlacement) string { return "locations: " + strings.Join(names, ", ") } +// autoscaleAnnotation renders a placement's autoscaling metrics for the +// "scale: min..max" line, e.g. " (cpu@70%, memory@80%)". The HPA controller +// only acts when a metric is set too, so a max with none is flagged rather +// than shown as if autoscaling were active. +func autoscaleAnnotation(s computev1alpha.HorizontalScaleSettings) string { + if s.MaxReplicas == nil { + return "" + } + ann := util.MetricsAnnotation(s.Metrics) + if ann == "" { + return " (autoscaling disabled)" + } + return " " + ann +} + // degradedAnnotation returns a short annotation for a per-location line when the // deployment is not fully ready. It reads the blocking reason+message from the // deployment's own Available condition, which the server rolls up from the From 9ed875b7ff35b4beaa01ebdf5c139f1db6f6353c Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:46:52 +0200 Subject: [PATCH 2/2] fix: ValidCityCodes -> ValidLocations --- internal/validation/workload_validation_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/validation/workload_validation_test.go b/internal/validation/workload_validation_test.go index 0a446843..0169c41e 100644 --- a/internal/validation/workload_validation_test.go +++ b/internal/validation/workload_validation_test.go @@ -1135,7 +1135,7 @@ func TestValidateWorkloadUpdate_UnchangedImage(t *testing.T) { opts := WorkloadValidationOptions{ Client: fakeClient, Context: context.Background(), - ValidCityCodes: []string{testCityCodeDFW}, + ValidLocations: []string{testCityCodeDFW}, } t.Run("finalizer-style update leaving the image untouched is not rejected", func(t *testing.T) {