diff --git a/internal/cmd/cluster/update.go b/internal/cmd/cluster/update.go index 2216be8..d73ed9f 100644 --- a/internal/cmd/cluster/update.go +++ b/internal/cmd/cluster/update.go @@ -37,6 +37,9 @@ qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --label env- # Restrict access to specific IPs qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --allowed-ip 10.0.0.0/8 +# Upgrade the Qdrant version +qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --version v1.17.0 + # Change replication factor (triggers rolling restart) qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --replication-factor 3 --force`, BaseCobraCommand: func() *cobra.Command { @@ -45,8 +48,10 @@ qcloud cluster update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --replication-factor Short: "Update an existing cluster", Long: `Updates the configuration of a cluster. -Use this command to modify cluster settings such as labels, database defaults, -IP restrictions, restart mode, and rebalance strategy. +Use this command to modify cluster settings such as the Qdrant version, labels, +database defaults, IP restrictions, restart mode, and rebalance strategy. + +Version upgrades (--version) will trigger a rolling restart of the cluster. Database configuration changes (--replication-factor, --write-consistency-factor, --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 it, or append '-' (e.g. '10.0.0.0/8-') to remove one.`, Args: util.ExactArgs(1, "a cluster ID"), } + cmd.Flags().String("version", "", `Qdrant version to upgrade to (e.g. "v1.17.0" or "latest")`) cmd.Flags().StringArray("label", nil, "Label to set ('key=value') or remove ('key-'); merges with existing labels") cmd.Flags().Uint32("replication-factor", 0, "Default replication factor for new collections") 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.`, } cfg := updated.Configuration - // --- Database configuration flags (trigger rolling restart) --- + // --- Apply version upgrade --- + versionChanged := cmd.Flags().Changed("version") + if versionChanged { + newVersion, _ := cmd.Flags().GetString("version") + cfg.Version = &newVersion + } + // --- Apply database configuration flags --- dbChanged := slices.ContainsFunc(dbConfigFlags, func(f string) bool { return cmd.Flags().Changed(f) }) @@ -163,10 +175,12 @@ it, or append '-' (e.g. '10.0.0.0/8-') to remove one.`, dbCfg.Storage.Performance.OptimizerCpuBudget = &v } } + } - // Confirmation prompt for rolling restart + // --- Single confirmation prompt for all restart-triggering changes --- + if versionChanged || dbChanged { force, _ := cmd.Flags().GetBool("force") - prompt := updateDBConfigPrompt(cluster, updated, cmd) + prompt := updateRestartPrompt(cluster, updated, cmd, versionChanged, dbChanged) if !util.ConfirmAction(force, cmd.ErrOrStderr(), prompt) { fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") return nil, nil @@ -220,57 +234,66 @@ it, or append '-' (e.g. '10.0.0.0/8-') to remove one.`, ValidArgsFunction: completion.ClusterIDCompletion(s), }.CobraCommand(s) + _ = cmd.RegisterFlagCompletionFunc("version", versionCompletion(s)) _ = cmd.RegisterFlagCompletionFunc("restart-mode", restartModeCompletion()) _ = cmd.RegisterFlagCompletionFunc("rebalance-strategy", rebalanceStrategyCompletion()) return cmd } -// updateDBConfigPrompt builds the confirmation message shown when database -// configuration flags are changed, warning about the rolling restart. -// It compares old (before mutation) and updated (after mutation) cluster objects -// to display a diff of each changed field. -func updateDBConfigPrompt(old, updated *clusterv1.Cluster, cmd *cobra.Command) string { +// updateRestartPrompt builds a single confirmation message for all changes that +// trigger a rolling restart (version upgrade and/or database configuration). +func updateRestartPrompt(old, updated *clusterv1.Cluster, cmd *cobra.Command, versionChanged, dbChanged bool) string { var lines []string lines = append(lines, fmt.Sprintf("Updating cluster %s (%s) will change:", old.GetId(), old.GetName())) - oldCol := old.GetConfiguration().GetDatabaseConfiguration().GetCollection() - newCol := updated.GetConfiguration().GetDatabaseConfiguration().GetCollection() - oldPerf := old.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance() - newPerf := updated.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance() + if versionChanged { + oldVersion := old.GetState().GetVersion() + if oldVersion == "" { + oldVersion = old.GetConfiguration().GetVersion() + } + lines = append(lines, fmt.Sprintf(" Version: %s", output.DiffValue(oldVersion, updated.GetConfiguration().GetVersion()))) + } + + if dbChanged { + oldCol := old.GetConfiguration().GetDatabaseConfiguration().GetCollection() + newCol := updated.GetConfiguration().GetDatabaseConfiguration().GetCollection() + oldPerf := old.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance() + newPerf := updated.GetConfiguration().GetDatabaseConfiguration().GetStorage().GetPerformance() - notSet := "(not set)" + notSet := "(not set)" - if cmd.Flags().Changed("replication-factor") { - var oldRF *uint32 - if oldCol != nil { - oldRF = oldCol.ReplicationFactor + if cmd.Flags().Changed("replication-factor") { + var oldRF *uint32 + if oldCol != nil { + oldRF = oldCol.ReplicationFactor + } + lines = append(lines, fmt.Sprintf(" Replication factor: %s", output.DiffValue(output.OptionalValue(oldRF, notSet), fmt.Sprintf("%d", newCol.GetReplicationFactor())))) } - lines = append(lines, fmt.Sprintf(" Replication factor: %s", output.DiffValue(output.OptionalValue(oldRF, notSet), fmt.Sprintf("%d", newCol.GetReplicationFactor())))) - } - if cmd.Flags().Changed("write-consistency-factor") { - var oldWCF *int32 - if oldCol != nil { - oldWCF = oldCol.WriteConsistencyFactor + if cmd.Flags().Changed("write-consistency-factor") { + var oldWCF *int32 + if oldCol != nil { + oldWCF = oldCol.WriteConsistencyFactor + } + lines = append(lines, fmt.Sprintf(" Write consistency factor: %s", output.DiffValue(output.OptionalValue(oldWCF, notSet), fmt.Sprintf("%d", newCol.GetWriteConsistencyFactor())))) } - lines = append(lines, fmt.Sprintf(" Write consistency factor: %s", output.DiffValue(output.OptionalValue(oldWCF, notSet), fmt.Sprintf("%d", newCol.GetWriteConsistencyFactor())))) - } - if cmd.Flags().Changed("async-scorer") { - var oldAS *bool - if oldPerf != nil { - oldAS = oldPerf.AsyncScorer + if cmd.Flags().Changed("async-scorer") { + var oldAS *bool + if oldPerf != nil { + oldAS = oldPerf.AsyncScorer + } + lines = append(lines, fmt.Sprintf(" Async scorer: %s", output.DiffValue(output.OptionalValue(oldAS, notSet), boolToYesNo(newPerf.GetAsyncScorer())))) } - lines = append(lines, fmt.Sprintf(" Async scorer: %s", output.DiffValue(output.OptionalValue(oldAS, notSet), boolToYesNo(newPerf.GetAsyncScorer())))) - } - if cmd.Flags().Changed("optimizer-cpu-budget") { - var oldBudget *int32 - if oldPerf != nil { - oldBudget = oldPerf.OptimizerCpuBudget + if cmd.Flags().Changed("optimizer-cpu-budget") { + var oldBudget *int32 + if oldPerf != nil { + oldBudget = oldPerf.OptimizerCpuBudget + } + lines = append(lines, fmt.Sprintf(" Optimizer CPU budget: %s", output.DiffValue(output.OptionalValue(oldBudget, notSet), fmt.Sprintf("%d", newPerf.GetOptimizerCpuBudget())))) } - lines = append(lines, fmt.Sprintf(" Optimizer CPU budget: %s", output.DiffValue(output.OptionalValue(oldBudget, notSet), fmt.Sprintf("%d", newPerf.GetOptimizerCpuBudget())))) } lines = append(lines, "") - lines = append(lines, "WARNING: Database configuration changes will result in a rolling restart of your cluster.") + lines = append(lines, "WARNING: These changes will result in a rolling restart of your cluster.") lines = append(lines, "Proceed?") return strings.Join(lines, "\n") } diff --git a/internal/cmd/cluster/update_test.go b/internal/cmd/cluster/update_test.go index 6b03ab2..c7f52d2 100644 --- a/internal/cmd/cluster/update_test.go +++ b/internal/cmd/cluster/update_test.go @@ -473,6 +473,188 @@ func TestUpdateCluster_PreservesExistingConfig(t *testing.T) { assert.Equal(t, clusterv1.ClusterConfigurationRestartPolicy_CLUSTER_CONFIGURATION_RESTART_POLICY_ROLLING, cfg.GetRestartPolicy()) } +func TestUpdateCluster_VersionUpgrade(t *testing.T) { + env := testutil.NewTestEnv(t) + setupUpdateHandlers(env) + + stdout, _, err := testutil.Exec(t, env, + "cluster", "update", "cluster-abc", + "--version", "v1.17.0", + "--force", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "updated successfully") + + req, ok := env.Server.UpdateClusterCalls.Last() + require.True(t, ok) + assert.Equal(t, "v1.17.0", req.GetCluster().GetConfiguration().GetVersion()) +} + +func TestUpdateCluster_VersionPromptShowsDiff(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) { + return &clusterv1.GetClusterResponse{ + Cluster: &clusterv1.Cluster{ + Id: req.GetClusterId(), + Name: "my-cluster", + Configuration: &clusterv1.ClusterConfiguration{}, + State: &clusterv1.ClusterState{ + Version: "v1.16.2", + }, + }, + }, nil + }) + env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) { + return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil + }) + + stdout, stderr, err := testutil.Exec(t, env, + "cluster", "update", "cluster-abc", + "--version", "v1.17.0", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "Aborted.") + assert.Contains(t, stderr, "v1.16.2 => v1.17.0") + assert.Contains(t, stderr, "rolling restart") + assert.Equal(t, 0, env.Server.UpdateClusterCalls.Count()) +} + +func TestUpdateCluster_DBConfigPromptShowsDiff(t *testing.T) { + env := testutil.NewTestEnv(t) + + rf := uint32(1) + env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) { + return &clusterv1.GetClusterResponse{ + Cluster: &clusterv1.Cluster{ + Id: req.GetClusterId(), + Name: "my-cluster", + Configuration: &clusterv1.ClusterConfiguration{ + DatabaseConfiguration: &clusterv1.DatabaseConfiguration{ + Collection: &clusterv1.DatabaseConfigurationCollection{ + ReplicationFactor: &rf, + }, + }, + }, + }, + }, nil + }) + env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) { + return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil + }) + + stdout, stderr, err := testutil.Exec(t, env, + "cluster", "update", "cluster-abc", + "--replication-factor", "3", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "Aborted.") + assert.Contains(t, stderr, "1 => 3") + assert.Contains(t, stderr, "rolling restart") + assert.NotContains(t, stderr, "Version:") + assert.Equal(t, 0, env.Server.UpdateClusterCalls.Count()) +} + +func TestUpdateCluster_VersionAndDBConfigShowSinglePrompt(t *testing.T) { + env := testutil.NewTestEnv(t) + + rf := uint32(1) + env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) { + return &clusterv1.GetClusterResponse{ + Cluster: &clusterv1.Cluster{ + Id: req.GetClusterId(), + Name: "my-cluster", + Configuration: &clusterv1.ClusterConfiguration{ + DatabaseConfiguration: &clusterv1.DatabaseConfiguration{ + Collection: &clusterv1.DatabaseConfigurationCollection{ + ReplicationFactor: &rf, + }, + }, + }, + State: &clusterv1.ClusterState{ + Version: "v1.16.2", + }, + }, + }, nil + }) + env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) { + return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil + }) + + stdout, stderr, err := testutil.Exec(t, env, + "cluster", "update", "cluster-abc", + "--version", "v1.17.0", + "--replication-factor", "3", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "Aborted.") + assert.Contains(t, stderr, "v1.16.2 => v1.17.0") + assert.Contains(t, stderr, "1 => 3") + assert.Contains(t, stderr, "rolling restart") + assert.Equal(t, 0, env.Server.UpdateClusterCalls.Count()) +} + +func TestUpdateCluster_VersionAndDBConfigForceAppliesBoth(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) { + return &clusterv1.GetClusterResponse{ + Cluster: &clusterv1.Cluster{ + Id: req.GetClusterId(), + Name: "my-cluster", + Configuration: &clusterv1.ClusterConfiguration{}, + State: &clusterv1.ClusterState{ + Version: "v1.16.2", + }, + }, + }, nil + }) + env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) { + return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil + }) + + stdout, _, err := testutil.Exec(t, env, + "cluster", "update", "cluster-abc", + "--version", "v1.17.0", + "--replication-factor", "3", + "--force", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "updated successfully") + + req, ok := env.Server.UpdateClusterCalls.Last() + require.True(t, ok) + assert.Equal(t, "v1.17.0", req.GetCluster().GetConfiguration().GetVersion()) + assert.Equal(t, uint32(3), req.GetCluster().GetConfiguration().GetDatabaseConfiguration().GetCollection().GetReplicationFactor()) +} + +func TestUpdateCluster_VersionFallsBackToConfigVersion(t *testing.T) { + env := testutil.NewTestEnv(t) + + v := "v1.15.0" + env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) { + return &clusterv1.GetClusterResponse{ + Cluster: &clusterv1.Cluster{ + Id: req.GetClusterId(), + Name: "my-cluster", + Configuration: &clusterv1.ClusterConfiguration{ + Version: &v, + }, + }, + }, nil + }) + env.Server.UpdateClusterCalls.Always(func(_ context.Context, req *clusterv1.UpdateClusterRequest) (*clusterv1.UpdateClusterResponse, error) { + return &clusterv1.UpdateClusterResponse{Cluster: req.GetCluster()}, nil + }) + + _, stderr, err := testutil.Exec(t, env, + "cluster", "update", "cluster-abc", + "--version", "v1.17.0", + ) + require.NoError(t, err) + assert.Contains(t, stderr, "v1.15.0 => v1.17.0") +} + // setupUpdateHandlers configures the standard Get/Update handlers for update tests. func setupUpdateHandlers(env *testutil.TestEnv) { env.Server.GetClusterCalls.Always(func(_ context.Context, req *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {