diff --git a/go-tests/go.mod b/go-tests/go.mod index 39405cc7ad..30fbf9c05f 100644 --- a/go-tests/go.mod +++ b/go-tests/go.mod @@ -3,8 +3,9 @@ module github.com/platformsh/legacy-cli/tests go 1.22.9 require ( - github.com/platformsh/cli v0.0.0-20241229194532-b86546247906 + github.com/platformsh/cli v0.0.0-20250115153051-60dcb793eb89 github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.31.0 ) require ( @@ -13,7 +14,6 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.28.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go-tests/go.sum b/go-tests/go.sum index d509303e44..332ea2f4e4 100644 --- a/go-tests/go.sum +++ b/go-tests/go.sum @@ -11,8 +11,8 @@ github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/platformsh/cli v0.0.0-20241229194532-b86546247906 h1:BvQf2yvT7USm80fAJ0C5FXfTfjjK2onrnoVMPz5CT9k= -github.com/platformsh/cli v0.0.0-20241229194532-b86546247906/go.mod h1:jMxyJGLMlkjDq7l9cVWFhsX/8xxFqEOH95rWbDFyO08= +github.com/platformsh/cli v0.0.0-20250115153051-60dcb793eb89 h1:+EN931RRA5tsviOEqw6HW62u6UusZJq7LJKY3EIYrE0= +github.com/platformsh/cli v0.0.0-20250115153051-60dcb793eb89/go.mod h1:b1v98rkg8bScSoo5gK8Fc1qRta1FUU8wElRf+bGvV5Y= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= diff --git a/go-tests/ssh_test.go b/go-tests/ssh_test.go index d2b5a56fa5..2ecac9f63e 100644 --- a/go-tests/ssh_test.go +++ b/go-tests/ssh_test.go @@ -2,6 +2,7 @@ package tests import ( "net/http/httptest" + "os" "os/exec" "strconv" "testing" @@ -21,10 +22,6 @@ func TestSSH(t *testing.T) { if err != nil { t.Fatal(err) } - sshServer.RemoteEnv = []string{ - // TODO use this - "PLATFORM_RELATIONSHIPS=e30K", - } t.Cleanup(func() { if err := sshServer.Stop(); err != nil { t.Error(err) @@ -71,7 +68,8 @@ func TestSSH(t *testing.T) { } f.Run("cc") - assert.Equal(t, sshServer.RemoteDir+"\n", f.Run("ssh", "-p", projectID, "-e", ".", "pwd")) + wd, _ := os.Getwd() + assert.Equal(t, wd+"\n", f.Run("ssh", "-p", projectID, "-e", ".", "pwd")) _, stdErr, err := f.RunCombinedOutput("ssh", "-p", projectID, "-e", "main", "--instance", "2", "pwd") assert.Error(t, err) diff --git a/go-tests/valkey_test.go b/go-tests/valkey_test.go new file mode 100644 index 0000000000..7a4c4f19c0 --- /dev/null +++ b/go-tests/valkey_test.go @@ -0,0 +1,115 @@ +package tests + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + + "github.com/platformsh/cli/pkg/mockapi" + "github.com/platformsh/cli/pkg/mockssh" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValkey(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "my-user-id" + + sshServer, err := mockssh.NewServer(t, authServer.URL+"/ssh/authority") + if err != nil { + t.Fatal(err) + } + + relationships := map[string]any{ + "cache": []map[string]any{{ + "username": nil, + "host": "cache.internal", + "path": nil, + "query": url.Values{}, + "password": nil, + "port": 6379, + "service": "cache", + "scheme": "valkey", + "type": "valkey:8.0", + "public": false, + }}, + } + relationshipsJSON, err := json.Marshal(relationships) + require.NoError(t, err) + + execHandler := mockssh.ExecHandler(t.TempDir(), []string{ + "PLATFORM_RELATIONSHIPS=" + base64.StdEncoding.EncodeToString(relationshipsJSON), + }) + + sshServer.CommandHandler = func(conn ssh.ConnMetadata, command string, io mockssh.CommandIO) int { + if strings.HasPrefix(command, "valkey-cli") { + _, _ = fmt.Fprint(io.StdOut, "Received command: "+command) + return 0 + } + + return execHandler(conn, command, io) + } + t.Cleanup(func() { + if err := sshServer.Stop(); err != nil { + t.Error(err) + } + }) + + projectID := mockapi.ProjectID() + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }, + }) + mainEnv := makeEnv(projectID, "main", "production", "active", nil) + mainEnv.SetCurrentDeployment(&mockapi.Deployment{ + WebApps: map[string]mockapi.App{ + "app": {Name: "app", Type: "golang:1.23", Size: "M", Disk: 2048, Mounts: map[string]mockapi.Mount{}}, + }, + Services: map[string]mockapi.App{}, + Workers: map[string]mockapi.Worker{}, + Routes: mockRoutes(), + Links: mockapi.MakeHALLinks("self=/projects/" + projectID + "/environments/main/deployment/current"), + }) + mainEnv.Links["pf:ssh:app:0"] = mockapi.HALLink{HREF: "ssh://app--0@ssh.cli-tests.example.com"} + apiHandler.SetEnvironments([]*mockapi.Environment{ + mainEnv, + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.extraEnv = []string{ + EnvPrefix + "SSH_OPTIONS=HostName 127.0.0.1\nPort " + strconv.Itoa(sshServer.Port()), + EnvPrefix + "SSH_HOST_KEYS=" + sshServer.HostKeyConfig(), + } + + f.Run("cc") + + assert.Equal(t, "Received command: valkey-cli -h cache.internal -p 6379 ping", + f.Run("valkey", "-p", projectID, "-e", ".", "ping")) + + assert.Equal(t, "Received command: valkey-cli -h cache.internal -p 6379 --scan", + f.Run("valkey", "-p", projectID, "-e", ".", "--", "--scan")) + + assert.Equal(t, "Received command: valkey-cli -h cache.internal -p 6379 --scan --pattern '*-11*'", + f.Run("valkey", "-p", projectID, "-e", ".", "--", "--scan --pattern '*-11*'")) +} diff --git a/src/Application.php b/src/Application.php index 5da57a1172..06e7510309 100644 --- a/src/Application.php +++ b/src/Application.php @@ -233,6 +233,7 @@ protected function getCommands() $commands[] = new Command\Service\MongoDB\MongoShellCommand(); $commands[] = new Command\Service\RedisCliCommand(); $commands[] = new Command\Service\ServiceListCommand(); + $commands[] = new Command\Service\ValkeyCliCommand(); $commands[] = new Command\Session\SessionSwitchCommand(); $commands[] = new Command\Backup\BackupCreateCommand(); $commands[] = new Command\Backup\BackupDeleteCommand(); diff --git a/src/Command/Service/RedisCliCommand.php b/src/Command/Service/RedisCliCommand.php index fd8124c55f..5280bf4d74 100644 --- a/src/Command/Service/RedisCliCommand.php +++ b/src/Command/Service/RedisCliCommand.php @@ -2,70 +2,9 @@ namespace Platformsh\Cli\Command\Service; -use Platformsh\Cli\Command\CommandBase; -use Platformsh\Cli\Model\Host\RemoteHost; -use Platformsh\Cli\Service\Relationships; -use Platformsh\Cli\Service\Ssh; -use Platformsh\Cli\Util\OsUtil; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; - -class RedisCliCommand extends CommandBase +class RedisCliCommand extends ValkeyCliCommand { - protected function configure() - { - $this->setName('service:redis-cli'); - $this->setAliases(['redis']); - $this->setDescription('Access the Redis CLI'); - $this->addArgument('args', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Arguments to add to the Redis command'); - Relationships::configureInput($this->getDefinition()); - Ssh::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); - $this->addExample('Open the redis-cli shell'); - $this->addExample('Ping the Redis server', 'ping'); - $this->addExample('Show Redis status information', 'info'); - $this->addExample('Scan keys', "-- --scan"); - $this->addExample('Scan keys matching a pattern', '-- "--scan --pattern \'*-11*\'"'); - } - - protected function execute(InputInterface $input, OutputInterface $output) - { - if ($this->runningViaMulti && !$input->getArgument('args')) { - throw new \RuntimeException('The redis-cli command cannot run as a shell via multi'); - } - - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $host = $this->selectHost($input, $relationshipsService->hasLocalEnvVar()); - - $service = $relationshipsService->chooseService($host, $input, $output, ['redis']); - if (!$service) { - return 1; - } - - $redisCommand = sprintf( - 'redis-cli -h %s -p %d', - OsUtil::escapePosixShellArg($service['host']), - $service['port'] - ); - if ($args = $input->getArgument('args')) { - if (count($args) === 1) { - $redisCommand .= ' ' . $args[0]; - } else { - $redisCommand .= ' ' . implode(' ', array_map([OsUtil::class, 'escapePosixShellArg'], $args)); - } - } elseif ($this->isTerminal(STDIN) && $host instanceof RemoteHost) { - // Force TTY output when the input is a terminal. - $host->setExtraSshOptions(['RequestTTY yes']); - } - - $this->stdErr->writeln( - sprintf('Connecting to Redis service via relationship %s on %s', $service['_relationship_name'], $host->getLabel()) - ); - - return $host->runCommandDirect($redisCommand); - } + protected $dbName = 'redis'; + protected $dbTitle = 'Redis'; + protected $dbCommand = 'redis-cli'; } diff --git a/src/Command/Service/ValkeyCliCommand.php b/src/Command/Service/ValkeyCliCommand.php new file mode 100644 index 0000000000..1e30208eb9 --- /dev/null +++ b/src/Command/Service/ValkeyCliCommand.php @@ -0,0 +1,76 @@ +setName('service:' . $this->dbCommand); + $this->setAliases([$this->dbName]); + $this->setDescription('Access the ' . $this->dbTitle . ' CLI'); + $this->addArgument('args', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, sprintf('Arguments to add to the %s command', $this->dbCommand)); + Relationships::configureInput($this->getDefinition()); + Ssh::configureInput($this->getDefinition()); + $this->addProjectOption() + ->addEnvironmentOption() + ->addAppOption(); + $this->addExample(sprintf('Open the %s shell', $this->dbCommand)); + $this->addExample(sprintf('Ping the %s server', $this->dbTitle), 'ping'); + $this->addExample(sprintf('Show %s status information', $this->dbTitle), 'info'); + $this->addExample('Scan keys', "-- --scan"); + $this->addExample('Scan keys matching a pattern', '-- "--scan --pattern \'*-11*\'"'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if ($this->runningViaMulti && !$input->getArgument('args')) { + throw new \RuntimeException(sprintf('The %s command cannot run as a shell via multi', $this->dbCommand)); + } + + /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ + $relationshipsService = $this->getService('relationships'); + $host = $this->selectHost($input, $relationshipsService->hasLocalEnvVar()); + + $service = $relationshipsService->chooseService($host, $input, $output, [$this->dbName]); + if (!$service) { + return 1; + } + + $command = sprintf( + '%s -h %s -p %d', + $this->dbCommand, + OsUtil::escapePosixShellArg($service['host']), + $service['port'] + ); + if ($args = $input->getArgument('args')) { + if (count($args) === 1) { + $command .= ' ' . $args[0]; + } else { + $command .= ' ' . implode(' ', array_map([OsUtil::class, 'escapePosixShellArg'], $args)); + } + } elseif ($this->isTerminal(STDIN) && $host instanceof RemoteHost) { + // Force TTY output when the input is a terminal. + $host->setExtraSshOptions(['RequestTTY yes']); + } + + $this->stdErr->writeln( + sprintf('Connecting to %s service via relationship %s on %s', $this->dbTitle, $service['_relationship_name'], $host->getLabel()) + ); + + return $host->runCommandDirect($command); + } +}