Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions internal/cmd/compute/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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{
Expand All @@ -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)

Expand Down Expand Up @@ -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))
}
Expand All @@ -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 {
Expand Down
136 changes: 136 additions & 0 deletions internal/cmd/compute/deploy/scale_settings_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
39 changes: 26 additions & 13 deletions internal/cmd/compute/scale/scale.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,32 @@ import (
)

func Command() *cobra.Command {
var min int32
var min, max, cpuPercent, memoryPercent int32

cmd := &cobra.Command{
Use: "scale <workload-name>",
Short: "Adjust the minimum replica count for a workload",
Args: cobra.ExactArgs(1),
Example: ` datumctl compute scale api --min=4`,
Use: "scale <workload-name>",
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")
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading