Skip to content

Commit 8aa196c

Browse files
qdrant-cloud-botQdrant Claw
andauthored
feat: add --version flag to cluster update command (#69)
* feat: add --version flag to cluster update command Allow upgrading the Qdrant version of a cluster via `cluster update --version`. Includes a confirmation prompt warning about the rolling restart, shell completion for available versions, and updated help text. Made-with: Cursor * refactor: use single confirmation prompt for all restart-triggering changes Consolidate the version upgrade and database config prompts into one unified prompt. When both --version and db config flags are passed together, the user now sees a single confirmation listing all changes instead of two separate prompts. Made-with: Cursor * test: add tests for version upgrade prompt diffs Cover version-only, db-config-only, and combined version+db-config scenarios to verify a single unified prompt is shown with correct diffs. Also tests --force applying both changes and fallback to config version when state version is empty. Made-with: Cursor * style: fix struct field alignment in update tests Made-with: Cursor --------- Co-authored-by: Qdrant Claw <qdrant-claw@qdrant.com>
1 parent 0f41a4c commit 8aa196c

2 files changed

Lines changed: 244 additions & 39 deletions

File tree

internal/cmd/cluster/update.go

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --label env-
3737
# Restrict access to specific IPs
3838
qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --allowed-ip 10.0.0.0/8
3939
40+
# Upgrade the Qdrant version
41+
qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --version v1.17.0
42+
4043
# Change replication factor (triggers rolling restart)
4144
qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --replication-factor 3 --force`,
4245
BaseCobraCommand: func() *cobra.Command {
@@ -45,8 +48,10 @@ qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --replication-factor
4548
Short: "Update an existing cluster",
4649
Long: `Updates the configuration of a cluster.
4750
48-
Use this command to modify cluster settings such as labels, database defaults,
49-
IP restrictions, restart mode, and rebalance strategy.
51+
Use this command to modify cluster settings such as the Qdrant version, labels,
52+
database defaults, IP restrictions, restart mode, and rebalance strategy.
53+
54+
Version upgrades (--version) will trigger a rolling restart of the cluster.
5055
5156
Database configuration changes (--replication-factor, --write-consistency-factor,
5257
--async-scorer, --optimizer-cpu-budget) will trigger a rolling restart of the
@@ -63,6 +68,7 @@ Allowed IPs are merged with existing IPs by default. Specify an IP CIDR to add
6368
it, or append '-' (e.g. '10.0.0.0/8-') to remove one.`,
6469
Args: util.ExactArgs(1, "a cluster ID"),
6570
}
71+
cmd.Flags().String("version", "", `Qdrant version to upgrade to (e.g. "v1.17.0" or "latest")`)
6672
cmd.Flags().StringArray("label", nil, "Label to set ('key=value') or remove ('key-'); merges with existing labels")
6773
cmd.Flags().Uint32("replication-factor", 0, "Default replication factor for new collections")
6874
cmd.Flags().Int32("write-consistency-factor", 0, "Default write consistency factor for new collections")
@@ -121,8 +127,14 @@ it, or append '-' (e.g. '10.0.0.0/8-') to remove one.`,
121127
}
122128
cfg := updated.Configuration
123129

124-
// --- Database configuration flags (trigger rolling restart) ---
130+
// --- Apply version upgrade ---
131+
versionChanged := cmd.Flags().Changed("version")
132+
if versionChanged {
133+
newVersion, _ := cmd.Flags().GetString("version")
134+
cfg.Version = &newVersion
135+
}
125136

137+
// --- Apply database configuration flags ---
126138
dbChanged := slices.ContainsFunc(dbConfigFlags, func(f string) bool {
127139
return cmd.Flags().Changed(f)
128140
})
@@ -163,10 +175,12 @@ it, or append '-' (e.g. '10.0.0.0/8-') to remove one.`,
163175
dbCfg.Storage.Performance.OptimizerCpuBudget = &v
164176
}
165177
}
178+
}
166179

167-
// Confirmation prompt for rolling restart
180+
// --- Single confirmation prompt for all restart-triggering changes ---
181+
if versionChanged || dbChanged {
168182
force, _ := cmd.Flags().GetBool("force")
169-
prompt := updateDBConfigPrompt(cluster, updated, cmd)
183+
prompt := updateRestartPrompt(cluster, updated, cmd, versionChanged, dbChanged)
170184
if !util.ConfirmAction(force, cmd.ErrOrStderr(), prompt) {
171185
fmt.Fprintln(cmd.OutOrStdout(), "Aborted.")
172186
return nil, nil
@@ -220,57 +234,66 @@ it, or append '-' (e.g. '10.0.0.0/8-') to remove one.`,
220234
ValidArgsFunction: completion.ClusterIDCompletion(s),
221235
}.CobraCommand(s)
222236

237+
_ = cmd.RegisterFlagCompletionFunc("version", versionCompletion(s))
223238
_ = cmd.RegisterFlagCompletionFunc("restart-mode", restartModeCompletion())
224239
_ = cmd.RegisterFlagCompletionFunc("rebalance-strategy", rebalanceStrategyCompletion())
225240
return cmd
226241
}
227242

228-
// updateDBConfigPrompt builds the confirmation message shown when database
229-
// configuration flags are changed, warning about the rolling restart.
230-
// It compares old (before mutation) and updated (after mutation) cluster objects
231-
// to display a diff of each changed field.
232-
func updateDBConfigPrompt(old, updated *clusterv1.Cluster, cmd *cobra.Command) string {
243+
// updateRestartPrompt builds a single confirmation message for all changes that
244+
// trigger a rolling restart (version upgrade and/or database configuration).
245+
func updateRestartPrompt(old, updated *clusterv1.Cluster, cmd *cobra.Command, versionChanged, dbChanged bool) string {
233246
var lines []string
234247
lines = append(lines, fmt.Sprintf("Updating cluster %s (%s) will change:", old.GetId(), old.GetName()))
235248

236-
oldCol := old.GetConfiguration().GetDatabaseConfiguration().GetCollection()
237-
newCol := updated.GetConfiguration().GetDatabaseConfiguration().GetCollection()
238-
oldPerf := old.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance()
239-
newPerf := updated.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance()
249+
if versionChanged {
250+
oldVersion := old.GetState().GetVersion()
251+
if oldVersion == "" {
252+
oldVersion = old.GetConfiguration().GetVersion()
253+
}
254+
lines = append(lines, fmt.Sprintf(" Version: %s", output.DiffValue(oldVersion, updated.GetConfiguration().GetVersion())))
255+
}
256+
257+
if dbChanged {
258+
oldCol := old.GetConfiguration().GetDatabaseConfiguration().GetCollection()
259+
newCol := updated.GetConfiguration().GetDatabaseConfiguration().GetCollection()
260+
oldPerf := old.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance()
261+
newPerf := updated.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance()
240262

241-
notSet := "(not set)"
263+
notSet := "(not set)"
242264

243-
if cmd.Flags().Changed("replication-factor") {
244-
var oldRF *uint32
245-
if oldCol != nil {
246-
oldRF = oldCol.ReplicationFactor
265+
if cmd.Flags().Changed("replication-factor") {
266+
var oldRF *uint32
267+
if oldCol != nil {
268+
oldRF = oldCol.ReplicationFactor
269+
}
270+
lines = append(lines, fmt.Sprintf(" Replication factor: %s", output.DiffValue(output.OptionalValue(oldRF, notSet), fmt.Sprintf("%d", newCol.GetReplicationFactor()))))
247271
}
248-
lines = append(lines, fmt.Sprintf(" Replication factor: %s", output.DiffValue(output.OptionalValue(oldRF, notSet), fmt.Sprintf("%d", newCol.GetReplicationFactor()))))
249-
}
250-
if cmd.Flags().Changed("write-consistency-factor") {
251-
var oldWCF *int32
252-
if oldCol != nil {
253-
oldWCF = oldCol.WriteConsistencyFactor
272+
if cmd.Flags().Changed("write-consistency-factor") {
273+
var oldWCF *int32
274+
if oldCol != nil {
275+
oldWCF = oldCol.WriteConsistencyFactor
276+
}
277+
lines = append(lines, fmt.Sprintf(" Write consistency factor: %s", output.DiffValue(output.OptionalValue(oldWCF, notSet), fmt.Sprintf("%d", newCol.GetWriteConsistencyFactor()))))
254278
}
255-
lines = append(lines, fmt.Sprintf(" Write consistency factor: %s", output.DiffValue(output.OptionalValue(oldWCF, notSet), fmt.Sprintf("%d", newCol.GetWriteConsistencyFactor()))))
256-
}
257-
if cmd.Flags().Changed("async-scorer") {
258-
var oldAS *bool
259-
if oldPerf != nil {
260-
oldAS = oldPerf.AsyncScorer
279+
if cmd.Flags().Changed("async-scorer") {
280+
var oldAS *bool
281+
if oldPerf != nil {
282+
oldAS = oldPerf.AsyncScorer
283+
}
284+
lines = append(lines, fmt.Sprintf(" Async scorer: %s", output.DiffValue(output.OptionalValue(oldAS, notSet), boolToYesNo(newPerf.GetAsyncScorer()))))
261285
}
262-
lines = append(lines, fmt.Sprintf(" Async scorer: %s", output.DiffValue(output.OptionalValue(oldAS, notSet), boolToYesNo(newPerf.GetAsyncScorer()))))
263-
}
264-
if cmd.Flags().Changed("optimizer-cpu-budget") {
265-
var oldBudget *int32
266-
if oldPerf != nil {
267-
oldBudget = oldPerf.OptimizerCpuBudget
286+
if cmd.Flags().Changed("optimizer-cpu-budget") {
287+
var oldBudget *int32
288+
if oldPerf != nil {
289+
oldBudget = oldPerf.OptimizerCpuBudget
290+
}
291+
lines = append(lines, fmt.Sprintf(" Optimizer CPU budget: %s", output.DiffValue(output.OptionalValue(oldBudget, notSet), fmt.Sprintf("%d", newPerf.GetOptimizerCpuBudget()))))
268292
}
269-
lines = append(lines, fmt.Sprintf(" Optimizer CPU budget: %s", output.DiffValue(output.OptionalValue(oldBudget, notSet), fmt.Sprintf("%d", newPerf.GetOptimizerCpuBudget()))))
270293
}
271294

272295
lines = append(lines, "")
273-
lines = append(lines, "WARNING: Database configuration changes will result in a rolling restart of your cluster.")
296+
lines = append(lines, "WARNING: These changes will result in a rolling restart of your cluster.")
274297
lines = append(lines, "Proceed?")
275298
return strings.Join(lines, "\n")
276299
}

internal/cmd/cluster/update_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,188 @@ func TestUpdateCluster_PreservesExistingConfig(t *testing.T) {
473473
assert.Equal(t, clusterv1.ClusterConfigurationRestartPolicy_CLUSTER_CONFIGURATION_RESTART_POLICY_ROLLING, cfg.GetRestartPolicy())
474474
}
475475

476+
func TestUpdateCluster_VersionUpgrade(t *testing.T) {
477+
env := testutil.NewTestEnv(t)
478+
setupUpdateHandlers(env)
479+
480+
stdout, _, err := testutil.Exec(t, env,
481+
"cluster", "update", "cluster-abc",
482+
"--version", "v1.17.0",
483+
"--force",
484+
)
485+
require.NoError(t, err)
486+
assert.Contains(t, stdout, "updated successfully")
487+
488+
req, ok := env.Server.UpdateClusterCalls.Last()
489+
require.True(t, ok)
490+
assert.Equal(t, "v1.17.0", req.GetCluster().GetConfiguration().GetVersion())
491+
}
492+
493+
func TestUpdateCluster_VersionPromptShowsDiff(t *testing.T) {
494+
env := testutil.NewTestEnv(t)
495+
496+
env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {
497+
return &clusterv1.GetClusterResponse{
498+
Cluster: &clusterv1.Cluster{
499+
Id: req.GetClusterId(),
500+
Name: "my-cluster",
501+
Configuration: &clusterv1.ClusterConfiguration{},
502+
State: &clusterv1.ClusterState{
503+
Version: "v1.16.2",
504+
},
505+
},
506+
}, nil
507+
})
508+
env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) {
509+
return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil
510+
})
511+
512+
stdout, stderr, err := testutil.Exec(t, env,
513+
"cluster", "update", "cluster-abc",
514+
"--version", "v1.17.0",
515+
)
516+
require.NoError(t, err)
517+
assert.Contains(t, stdout, "Aborted.")
518+
assert.Contains(t, stderr, "v1.16.2 => v1.17.0")
519+
assert.Contains(t, stderr, "rolling restart")
520+
assert.Equal(t, 0, env.Server.UpdateClusterCalls.Count())
521+
}
522+
523+
func TestUpdateCluster_DBConfigPromptShowsDiff(t *testing.T) {
524+
env := testutil.NewTestEnv(t)
525+
526+
rf := uint32(1)
527+
env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {
528+
return &clusterv1.GetClusterResponse{
529+
Cluster: &clusterv1.Cluster{
530+
Id: req.GetClusterId(),
531+
Name: "my-cluster",
532+
Configuration: &clusterv1.ClusterConfiguration{
533+
DatabaseConfiguration: &clusterv1.DatabaseConfiguration{
534+
Collection: &clusterv1.DatabaseConfigurationCollection{
535+
ReplicationFactor: &rf,
536+
},
537+
},
538+
},
539+
},
540+
}, nil
541+
})
542+
env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) {
543+
return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil
544+
})
545+
546+
stdout, stderr, err := testutil.Exec(t, env,
547+
"cluster", "update", "cluster-abc",
548+
"--replication-factor", "3",
549+
)
550+
require.NoError(t, err)
551+
assert.Contains(t, stdout, "Aborted.")
552+
assert.Contains(t, stderr, "1 => 3")
553+
assert.Contains(t, stderr, "rolling restart")
554+
assert.NotContains(t, stderr, "Version:")
555+
assert.Equal(t, 0, env.Server.UpdateClusterCalls.Count())
556+
}
557+
558+
func TestUpdateCluster_VersionAndDBConfigShowSinglePrompt(t *testing.T) {
559+
env := testutil.NewTestEnv(t)
560+
561+
rf := uint32(1)
562+
env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {
563+
return &clusterv1.GetClusterResponse{
564+
Cluster: &clusterv1.Cluster{
565+
Id: req.GetClusterId(),
566+
Name: "my-cluster",
567+
Configuration: &clusterv1.ClusterConfiguration{
568+
DatabaseConfiguration: &clusterv1.DatabaseConfiguration{
569+
Collection: &clusterv1.DatabaseConfigurationCollection{
570+
ReplicationFactor: &rf,
571+
},
572+
},
573+
},
574+
State: &clusterv1.ClusterState{
575+
Version: "v1.16.2",
576+
},
577+
},
578+
}, nil
579+
})
580+
env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) {
581+
return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil
582+
})
583+
584+
stdout, stderr, err := testutil.Exec(t, env,
585+
"cluster", "update", "cluster-abc",
586+
"--version", "v1.17.0",
587+
"--replication-factor", "3",
588+
)
589+
require.NoError(t, err)
590+
assert.Contains(t, stdout, "Aborted.")
591+
assert.Contains(t, stderr, "v1.16.2 => v1.17.0")
592+
assert.Contains(t, stderr, "1 => 3")
593+
assert.Contains(t, stderr, "rolling restart")
594+
assert.Equal(t, 0, env.Server.UpdateClusterCalls.Count())
595+
}
596+
597+
func TestUpdateCluster_VersionAndDBConfigForceAppliesBoth(t *testing.T) {
598+
env := testutil.NewTestEnv(t)
599+
600+
env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {
601+
return &clusterv1.GetClusterResponse{
602+
Cluster: &clusterv1.Cluster{
603+
Id: req.GetClusterId(),
604+
Name: "my-cluster",
605+
Configuration: &clusterv1.ClusterConfiguration{},
606+
State: &clusterv1.ClusterState{
607+
Version: "v1.16.2",
608+
},
609+
},
610+
}, nil
611+
})
612+
env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) {
613+
return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil
614+
})
615+
616+
stdout, _, err := testutil.Exec(t, env,
617+
"cluster", "update", "cluster-abc",
618+
"--version", "v1.17.0",
619+
"--replication-factor", "3",
620+
"--force",
621+
)
622+
require.NoError(t, err)
623+
assert.Contains(t, stdout, "updated successfully")
624+
625+
req, ok := env.Server.UpdateClusterCalls.Last()
626+
require.True(t, ok)
627+
assert.Equal(t, "v1.17.0", req.GetCluster().GetConfiguration().GetVersion())
628+
assert.Equal(t, uint32(3), req.GetCluster().GetConfiguration().GetDatabaseConfiguration().GetCollection().GetReplicationFactor())
629+
}
630+
631+
func TestUpdateCluster_VersionFallsBackToConfigVersion(t *testing.T) {
632+
env := testutil.NewTestEnv(t)
633+
634+
v := "v1.15.0"
635+
env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {
636+
return &clusterv1.GetClusterResponse{
637+
Cluster: &clusterv1.Cluster{
638+
Id: req.GetClusterId(),
639+
Name: "my-cluster",
640+
Configuration: &clusterv1.ClusterConfiguration{
641+
Version: &v,
642+
},
643+
},
644+
}, nil
645+
})
646+
env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) {
647+
return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil
648+
})
649+
650+
_, stderr, err := testutil.Exec(t, env,
651+
"cluster", "update", "cluster-abc",
652+
"--version", "v1.17.0",
653+
)
654+
require.NoError(t, err)
655+
assert.Contains(t, stderr, "v1.15.0 => v1.17.0")
656+
}
657+
476658
// setupUpdateHandlers configures the standard Get/Update handlers for update tests.
477659
func setupUpdateHandlers(env *testutil.TestEnv) {
478660
env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {

0 commit comments

Comments
 (0)