From 91d1c3329d5efbe98911753157f891b2d187f86b Mon Sep 17 00:00:00 2001 From: Matt Spilchen Date: Wed, 3 Jan 2024 08:18:28 -0400 Subject: [PATCH] Sync from server repo (63169351518) --- commands/cluster_command_launcher.go | 2 + commands/cmd_revive_db.go | 6 ++ commands/cmd_sandbox.go | 110 ++++++++++++++++++++++ commands/cmd_unsandbox.go | 109 +++++++++++++++++++++ vclusterops/nma_load_remote_catalog_op.go | 38 +++++--- vclusterops/revive_db.go | 17 +++- vclusterops/sandbox.go | 79 ++++++++++++++++ vclusterops/unsandbox.go | 72 ++++++++++++++ vclusterops/util/util.go | 54 +++++++++++ vclusterops/vcluster_database_options.go | 15 +-- 10 files changed, 484 insertions(+), 18 deletions(-) create mode 100644 commands/cmd_sandbox.go create mode 100644 commands/cmd_unsandbox.go create mode 100644 vclusterops/sandbox.go create mode 100644 vclusterops/unsandbox.go diff --git a/commands/cluster_command_launcher.go b/commands/cluster_command_launcher.go index f5492cf..0464898 100644 --- a/commands/cluster_command_launcher.go +++ b/commands/cluster_command_launcher.go @@ -102,6 +102,8 @@ func constructCmds(_ vlog.Printer) []ClusterCommand { // sc-scope cmds makeCmdAddSubcluster(), makeCmdRemoveSubcluster(), + makeCmdSandboxSubcluster(), + makeCmdUnsandboxSubcluster(), // node-scope cmds makeCmdAddNode(), makeCmdRemoveNode(), diff --git a/commands/cmd_revive_db.go b/commands/cmd_revive_db.go index a86d094..5586b63 100644 --- a/commands/cmd_revive_db.go +++ b/commands/cmd_revive_db.go @@ -47,6 +47,12 @@ func makeCmdReviveDB() *CmdReviveDB { reviveDBOptions.IgnoreClusterLease = newCmd.parser.Bool("ignore-cluster-lease", false, util.GetOptionalFlagMsg("Ignore the check of other clusters running on the same communal storage."+ " The communal storage can be corrupted when two clusters modified it at the same time. Proceed with caution")) + reviveDBOptions.RestorePoint.Archive = newCmd.parser.String("restore-point-archive", "", util.GetOptionalFlagMsg( + "Name of the restore archive to use for bootstrapping")) + reviveDBOptions.RestorePoint.Index = newCmd.parser.Int("restore-point-index", 0, util.GetOptionalFlagMsg( + "The (1-based) index of the restore point in the restore archive to restore from")) + reviveDBOptions.RestorePoint.ID = newCmd.parser.String("restore-point-id", "", util.GetOptionalFlagMsg( + "The identifier of the restore point in the restore archive to restore from")) newCmd.reviveDBOptions = &reviveDBOptions diff --git a/commands/cmd_sandbox.go b/commands/cmd_sandbox.go new file mode 100644 index 0000000..49f758b --- /dev/null +++ b/commands/cmd_sandbox.go @@ -0,0 +1,110 @@ +/* + (c) Copyright [2023] Open Text. + Licensed under the Apache License, Version 2.0 (the "License"); + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package commands + +import ( + "flag" + "fmt" + + "github.com/vertica/vcluster/vclusterops" + "github.com/vertica/vcluster/vclusterops/util" + "github.com/vertica/vcluster/vclusterops/vlog" +) + +/* CmdSandbox + * + * Implements ClusterCommand interface + * + * Parses CLI arguments for sandbox operation. + * Prepares the inputs for the library. + * + */ + +type CmdSandboxSubcluster struct { + CmdBase + sbOptions vclusterops.VSandboxOptions +} + +func (c *CmdSandboxSubcluster) TypeName() string { + return "CmdSandboxSubcluster" +} + +func makeCmdSandboxSubcluster() *CmdSandboxSubcluster { + newCmd := &CmdSandboxSubcluster{} + newCmd.parser = flag.NewFlagSet("sandbox_subcluster", flag.ExitOnError) + newCmd.sbOptions = vclusterops.VSandboxOptionsFactory() + + // required flags + newCmd.sbOptions.DBName = newCmd.parser.String("db-name", "", "The name of the database to run sandbox. May be omitted on k8s.") + newCmd.sbOptions.SCName = newCmd.parser.String("subcluster", "", "The name of the subcluster to be sandboxed") + newCmd.sbOptions.SandboxName = newCmd.parser.String("sandbox", "", "The name of the sandbox") + + // optional flags + newCmd.sbOptions.Password = newCmd.parser.String("password", "", + util.GetOptionalFlagMsg("Database password. Consider using in single quotes to avoid shell substitution.")) + newCmd.hostListStr = newCmd.parser.String("hosts", "", util.GetOptionalFlagMsg("Comma-separated list of hosts to participate in database."+ + " Use it when you do not trust "+vclusterops.ConfigFileName)) + newCmd.ipv6 = newCmd.parser.Bool("ipv6", false, "start database with with IPv6 hosts") + newCmd.sbOptions.HonorUserInput = newCmd.parser.Bool("honor-user-input", false, + util.GetOptionalFlagMsg("Forcefully use the user's input instead of reading the options from "+vclusterops.ConfigFileName)) + newCmd.sbOptions.ConfigDirectory = newCmd.parser.String("config-directory", "", + util.GetOptionalFlagMsg("Directory where "+vclusterops.ConfigFileName+" is located")) + + return newCmd +} + +func (c *CmdSandboxSubcluster) CommandType() string { + return "sandbox_subcluster" +} + +func (c *CmdSandboxSubcluster) Parse(inputArgv []string, logger vlog.Printer) error { + c.argv = inputArgv + // from now on we use the internal copy of argv + return c.parseInternal(logger) +} + +func (c *CmdSandboxSubcluster) parseInternal(logger vlog.Printer) error { + if c.parser == nil { + return fmt.Errorf("unexpected nil for CmdSandboxSubcluster.parser") + } + logger.PrintInfo("Parsing sandboxing command input") + parseError := c.ParseArgv() + if parseError != nil { + return parseError + } + return nil +} + +func (c *CmdSandboxSubcluster) Analyze(logger vlog.Printer) error { + logger.Info("Called method Analyze()") + return nil +} + +func (c *CmdSandboxSubcluster) Run(vcc vclusterops.VClusterCommands) error { + vcc.Log.PrintInfo("Running sandbox subcluster") + vcc.Log.Info("Calling method Run() for command " + c.CommandType()) + + options := c.sbOptions + // get config from vertica_cluster.yaml + config, err := options.GetDBConfig(vcc) + if err != nil { + return err + } + options.Config = config + err = vcc.VSandbox(&options) + vcc.Log.PrintInfo("Completed method Run() for command " + c.CommandType()) + return err +} diff --git a/commands/cmd_unsandbox.go b/commands/cmd_unsandbox.go new file mode 100644 index 0000000..d855843 --- /dev/null +++ b/commands/cmd_unsandbox.go @@ -0,0 +1,109 @@ +/* + (c) Copyright [2023] Open Text. + Licensed under the Apache License, Version 2.0 (the "License"); + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package commands + +import ( + "flag" + "fmt" + + "github.com/vertica/vcluster/vclusterops" + "github.com/vertica/vcluster/vclusterops/util" + "github.com/vertica/vcluster/vclusterops/vlog" +) + +/* CmdUnsandbox + * + * Implements ClusterCommand interface + * + * Parses CLI arguments for Unsandboxing operation. + * Prepares the inputs for the library. + * + */ +type CmdUnsandboxSubcluster struct { + CmdBase + usOptions vclusterops.VUnsandboxOptions +} + +func (c *CmdUnsandboxSubcluster) TypeName() string { + return "CmdUnsandboxSubcluster" +} + +func makeCmdUnsandboxSubcluster() *CmdUnsandboxSubcluster { + newCmd := &CmdUnsandboxSubcluster{} + newCmd.parser = flag.NewFlagSet("unsandbox", flag.ExitOnError) + newCmd.usOptions = vclusterops.VUnsandboxOptionsFactory() + + // required flags + newCmd.usOptions.DBName = newCmd.parser.String("db-name", "", "The name of the database to run unsandboxing. May be omitted on k8s.") + newCmd.usOptions.SCName = newCmd.parser.String("subcluster", "", "The name of the subcluster to be unsandboxed") + + // optional flags + newCmd.usOptions.Password = newCmd.parser.String("password", "", + util.GetOptionalFlagMsg("Database password. Consider using in single quotes to avoid shell substitution.")) + newCmd.hostListStr = newCmd.parser.String("hosts", "", util.GetOptionalFlagMsg("Comma-separated list of hosts to participate in database."+ + " Use it when you do not trust "+vclusterops.ConfigFileName)) + newCmd.ipv6 = newCmd.parser.Bool("ipv6", false, "start database with with IPv6 hosts") + newCmd.usOptions.HonorUserInput = newCmd.parser.Bool("honor-user-input", false, + util.GetOptionalFlagMsg("Forcefully use the user's input instead of reading the options from "+vclusterops.ConfigFileName)) + newCmd.usOptions.ConfigDirectory = newCmd.parser.String("config-directory", "", + util.GetOptionalFlagMsg("Directory where "+vclusterops.ConfigFileName+" is located")) + + return newCmd +} + +func (c *CmdUnsandboxSubcluster) CommandType() string { + return "unsandbox_subcluster" +} + +func (c *CmdUnsandboxSubcluster) Parse(inputArgv []string, logger vlog.Printer) error { + c.argv = inputArgv + // from now on we use the internal copy of argv + return c.parseInternal(logger) +} + +func (c *CmdUnsandboxSubcluster) parseInternal(logger vlog.Printer) error { + if c.parser == nil { + return fmt.Errorf("unexpected nil for CmdUnsandboxSubcluster.parser") + } + logger.PrintInfo("Parsing Unsandboxing command input") + parseError := c.ParseArgv() + if parseError != nil { + return parseError + } + return nil +} + +func (c *CmdUnsandboxSubcluster) Analyze(logger vlog.Printer) error { + logger.Info("Called method Analyze()") + + return nil +} + +func (c *CmdUnsandboxSubcluster) Run(vcc vclusterops.VClusterCommands) error { + vcc.Log.PrintInfo("Running unsandbox subcluster") + vcc.Log.Info("Calling method Run() for command " + c.CommandType()) + + options := c.usOptions + // get config from vertica_cluster.yaml + config, err := options.GetDBConfig(vcc) + if err != nil { + return err + } + options.Config = config + err = vcc.VUnsandbox(&options) + vcc.Log.PrintInfo("Completed method Run() for command " + c.CommandType()) + return err +} diff --git a/vclusterops/nma_load_remote_catalog_op.go b/vclusterops/nma_load_remote_catalog_op.go index a41f0c3..26fc919 100644 --- a/vclusterops/nma_load_remote_catalog_op.go +++ b/vclusterops/nma_load_remote_catalog_op.go @@ -31,23 +31,27 @@ type nmaLoadRemoteCatalogOp struct { vdb *VCoordinationDatabase timeout uint primaryNodeCount uint + restorePoint *RestorePointPolicy } type loadRemoteCatalogRequestData struct { - DBName string `json:"db_name"` - StorageLocations []string `json:"storage_locations"` - CommunalLocation string `json:"communal_location"` - CatalogPath string `json:"catalog_path"` - Host string `json:"host"` - NodeName string `json:"node_name"` - AWSAccessKeyID string `json:"aws_access_key_id,omitempty"` - AWSSecretAccessKey string `json:"aws_secret_access_key,omitempty"` - NodeAddresses map[string][]string `json:"node_addresses"` - Parameters map[string]string `json:"parameters,omitempty"` + DBName string `json:"db_name"` + StorageLocations []string `json:"storage_locations"` + CommunalLocation string `json:"communal_location"` + CatalogPath string `json:"catalog_path"` + Host string `json:"host"` + NodeName string `json:"node_name"` + AWSAccessKeyID string `json:"aws_access_key_id,omitempty"` + AWSSecretAccessKey string `json:"aws_secret_access_key,omitempty"` + NodeAddresses map[string][]string `json:"node_addresses"` + Parameters map[string]string `json:"parameters,omitempty"` + RestorePointArchive string `json:"restore_point_archive,omitempty"` + RestorePointIndex int `json:"restore_point_index,omitempty"` + RestorePointID string `json:"restore_point_id,omitempty"` } func makeNMALoadRemoteCatalogOp(logger vlog.Printer, oldHosts []string, configurationParameters map[string]string, - vdb *VCoordinationDatabase, timeout uint) nmaLoadRemoteCatalogOp { + vdb *VCoordinationDatabase, timeout uint, restorePoint *RestorePointPolicy) nmaLoadRemoteCatalogOp { op := nmaLoadRemoteCatalogOp{} op.name = "NMALoadRemoteCatalogOp" op.logger = logger.WithName(op.name) @@ -56,6 +60,7 @@ func makeNMALoadRemoteCatalogOp(logger vlog.Printer, oldHosts []string, configur op.configurationParameters = configurationParameters op.vdb = vdb op.timeout = timeout + op.restorePoint = restorePoint op.primaryNodeCount = 0 for _, vnode := range vdb.HostNodeMap { @@ -98,6 +103,17 @@ func (op *nmaLoadRemoteCatalogOp) setupRequestBody(execContext *opEngineExecCont requestData.StorageLocations = vNode.StorageLocations requestData.NodeAddresses = nodeAddresses requestData.Parameters = op.configurationParameters + if op.restorePoint != nil { + if op.restorePoint.Archive != nil { + requestData.RestorePointArchive = *op.restorePoint.Archive + } + if op.restorePoint.Index != nil { + requestData.RestorePointIndex = *op.restorePoint.Index + } + if op.restorePoint.ID != nil { + requestData.RestorePointID = *op.restorePoint.ID + } + } dataBytes, err := json.Marshal(requestData) if err != nil { diff --git a/vclusterops/revive_db.go b/vclusterops/revive_db.go index c0e6804..f980fc8 100644 --- a/vclusterops/revive_db.go +++ b/vclusterops/revive_db.go @@ -36,6 +36,17 @@ type VReviveDatabaseOptions struct { DisplayOnly *bool // whether ignore the cluster lease IgnoreClusterLease *bool + // the restore policy + RestorePoint *RestorePointPolicy +} + +type RestorePointPolicy struct { + // Name of the restore archive to use for bootstrapping + Archive *string + // The (1-based) index of the restore point in the restore archive to restore from + Index *int + // The identifier of the restore point in the restore archive to restore from + ID *string } func VReviveDBOptionsFactory() VReviveDatabaseOptions { @@ -56,6 +67,10 @@ func (options *VReviveDatabaseOptions) setDefaultValues() { options.ForceRemoval = new(bool) options.DisplayOnly = new(bool) options.IgnoreClusterLease = new(bool) + options.RestorePoint = new(RestorePointPolicy) + options.RestorePoint.Archive = new(string) + options.RestorePoint.Index = new(int) + options.RestorePoint.ID = new(string) } func (options *VReviveDatabaseOptions) validateRequiredOptions() error { @@ -239,7 +254,7 @@ func (vcc *VClusterCommands) produceReviveDBInstructions(options *VReviveDatabas nmaNetworkProfileOp := makeNMANetworkProfileOp(vcc.Log, options.Hosts) nmaLoadRemoteCatalogOp := makeNMALoadRemoteCatalogOp(vcc.Log, oldHosts, options.ConfigurationParameters, - &newVDB, *options.LoadCatalogTimeout) + &newVDB, *options.LoadCatalogTimeout, options.RestorePoint) instructions = append(instructions, &nmaPrepareDirectoriesOp, diff --git a/vclusterops/sandbox.go b/vclusterops/sandbox.go new file mode 100644 index 0000000..86b9766 --- /dev/null +++ b/vclusterops/sandbox.go @@ -0,0 +1,79 @@ +/* + (c) Copyright [2023] Open Text. + Licensed under the Apache License, Version 2.0 (the "License"); + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package vclusterops + +import ( + "fmt" + + "github.com/vertica/vcluster/vclusterops/vlog" +) + +type VSandboxOptions struct { + DatabaseOptions + SandboxName *string + SCName *string +} + +func VSandboxOptionsFactory() VSandboxOptions { + opt := VSandboxOptions{} + opt.setDefaultValues() + return opt +} + +func (options *VSandboxOptions) setDefaultValues() { + options.DatabaseOptions.setDefaultValues() + options.SCName = new(string) + options.SandboxName = new(string) +} + +func (options *VSandboxOptions) validateRequiredOptions(logger vlog.Printer) error { + err := options.validateBaseOptions("sandbox_subcluster", logger) + if err != nil { + return err + } + + if *options.SCName == "" { + return fmt.Errorf("must specify a subcluster name") + } + + if *options.SandboxName == "" { + return fmt.Errorf("must specify a sandbox name") + } + return nil +} + +func (options *VSandboxOptions) ValidateAnalyzeOptions(vcc *VClusterCommands) error { + if err := options.validateRequiredOptions(vcc.Log); err != nil { + return err + } + + // TODO: More validations : + // validate eon db, db up. + // check if sc is already sandboxed + // Validate sandboxing conditions: execute from an non-sandboxed UP host + return nil +} + +func (vcc *VClusterCommands) VSandbox(options *VSandboxOptions) error { + vcc.Log.V(0).Info("VSandbox method called with options " + fmt.Sprintf("%#v", options)) + // check required options + err := options.ValidateAnalyzeOptions(vcc) + if err != nil { + vcc.Log.Error(err, "validation of sandboxing arguments failed") + return err + } + return nil +} diff --git a/vclusterops/unsandbox.go b/vclusterops/unsandbox.go new file mode 100644 index 0000000..6026cd4 --- /dev/null +++ b/vclusterops/unsandbox.go @@ -0,0 +1,72 @@ +/* + (c) Copyright [2023] Open Text. + Licensed under the Apache License, Version 2.0 (the "License"); + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package vclusterops + +import ( + "fmt" + + "github.com/vertica/vcluster/vclusterops/vlog" +) + +type VUnsandboxOptions struct { + DatabaseOptions + SCName *string +} + +func VUnsandboxOptionsFactory() VUnsandboxOptions { + opt := VUnsandboxOptions{} + opt.setDefaultValues() + return opt +} + +func (options *VUnsandboxOptions) setDefaultValues() { + options.DatabaseOptions.setDefaultValues() + options.SCName = new(string) +} + +func (options *VUnsandboxOptions) validateRequiredOptions(logger vlog.Printer) error { + err := options.validateBaseOptions("unsandbox_subcluster", logger) + if err != nil { + return err + } + + if *options.SCName == "" { + return fmt.Errorf("must specify a subcluster name") + } + return nil +} + +func (options *VUnsandboxOptions) ValidateAnalyzeOptions(vcc *VClusterCommands) error { + if err := options.validateRequiredOptions(vcc.Log); err != nil { + return err + } + + // TODO: More validations here + // check eon db, up + // validate sc info, if sandboxed or not + return nil +} + +func (vcc *VClusterCommands) VUnsandbox(options *VUnsandboxOptions) error { + vcc.Log.V(0).Info("VUnsandbox method called with options " + fmt.Sprintf("%#v", options)) + // check required options + err := options.ValidateAnalyzeOptions(vcc) + if err != nil { + vcc.Log.Error(err, "validation of unsandboxing arguments failed") + return err + } + return nil +} diff --git a/vclusterops/util/util.go b/vclusterops/util/util.go index 183620e..0e768ac 100644 --- a/vclusterops/util/util.go +++ b/vclusterops/util/util.go @@ -37,13 +37,45 @@ import ( "github.com/vertica/vcluster/vclusterops/vlog" ) +type FetchAllEnvVars interface { + SetK8Secrets(port, secretNameSpace, secretName string) + SetK8Certs(rootCAPath, certPath, keyPath string) + TypeName() string +} + const ( keyValueArrayLen = 2 ipv4Str = "IPv4" ipv6Str = "IPv6" AWSAuthKey = "awsauth" + kubernetesPort = "KUBERNETES_PORT" + + // Environment variable names storing name of k8s secret that has NMA cert + secretNameSpaceEnvVar = "NMA_SECRET_NAMESPACE" + secretNameEnvVar = "NMA_SECRET_NAME" + + // Environment variable names for locating the NMA certs located in the file system + nmaRootCAPathEnvVar = "NMA_ROOTCA_PATH" + nmaCertPathEnvVar = "NMA_CERT_PATH" + nmaKeyPathEnvVar = "NMA_KEY_PATH" ) +// NmaSecretLookup retrieves kubernetes secrets. +func NmaSecretLookup(f FetchAllEnvVars) { + k8port, _ := os.LookupEnv(kubernetesPort) + secretNameSpace, _ := os.LookupEnv(secretNameSpaceEnvVar) + secretName, _ := os.LookupEnv(secretNameEnvVar) + f.SetK8Secrets(k8port, secretNameSpace, secretName) +} + +// NmaCertsLookup retrieves kubernetes certs. +func NmaCertsLookup(f FetchAllEnvVars) { + rootCAPath, _ := os.LookupEnv(nmaRootCAPathEnvVar) + certPath, _ := os.LookupEnv(nmaCertPathEnvVar) + keyPath, _ := os.LookupEnv(nmaKeyPathEnvVar) + f.SetK8Certs(rootCAPath, certPath, keyPath) +} + func GetJSONLogErrors(responseContent string, responseObj any, opName string, logger vlog.Printer) error { err := json.Unmarshal([]byte(responseContent), responseObj) if err != nil { @@ -59,6 +91,28 @@ func GetJSONLogErrors(responseContent string, responseObj any, opName string, lo return nil } +func CheckNotEmpty(a string) bool { + return a != "" +} + +func CheckAllEmptyOrNonEmpty(vars ...string) bool { + // Initialize flags for empty and non-empty conditions + allEmpty := true + allNonEmpty := true + + // Check each string variable + for _, v := range vars { + if v != "" { + allEmpty = false + } else { + allNonEmpty = false + } + } + + // Return true if either all are empty or all are non-empty + return allEmpty || allNonEmpty +} + // calculate array diff: m-n func SliceDiff[K comparable](m, n []K) []K { nSet := make(map[K]struct{}, len(n)) diff --git a/vclusterops/vcluster_database_options.go b/vclusterops/vcluster_database_options.go index 634698f..e515d35 100644 --- a/vclusterops/vcluster_database_options.go +++ b/vclusterops/vcluster_database_options.go @@ -89,11 +89,13 @@ const ( ) const ( - commandCreateDB = "create_db" - commandDropDB = "drop_db" - commandStopDB = "stop_db" - commandStartDB = "start_db" - commandAddCluster = "db_add_subcluster" + commandCreateDB = "create_db" + commandDropDB = "drop_db" + commandStopDB = "stop_db" + commandStartDB = "start_db" + commandAddCluster = "db_add_subcluster" + commandSandboxSC = "sandbox_subcluster" + commandUnsandboxSC = "unsandbox_subcluster" ) func (opt *DatabaseOptions) setDefaultValues() { @@ -235,7 +237,8 @@ func (opt *DatabaseOptions) validateCatalogPath() error { func (opt *DatabaseOptions) validateConfigDir(commandName string) error { // validate for the following commands only // TODO: add other commands into the command list - commands := []string{commandCreateDB, commandDropDB, commandStopDB, commandStartDB, commandAddCluster} + commands := []string{commandCreateDB, commandDropDB, commandStopDB, commandStartDB, commandAddCluster, commandSandboxSC, + commandUnsandboxSC} if slices.Contains(commands, commandName) { return nil }