From e7cd77a7bd8e1f68bb335cc750346a10dc2321c4 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 13:41:43 +0100 Subject: [PATCH 1/6] fix(ssh): list all SSH URLs without selecting an app `ssh --all` lists the SSH URLs of every app, but it selected a container first, so it failed in non-interactive mode in a project with several apps ("Specifying the --app or --worker is required"). Add integration tests for `ssh --pipe`, `--all`, and app and worker selection, with helpers to serve the layout project over the mock SSH server. Co-Authored-By: Claude Opus 5.5 --- integration-tests/project_setup_test.go | 55 ++++++++++++++++++- integration-tests/ssh_test.go | 45 +++++++++++++++ .../Environment/EnvironmentSshCommand.php | 6 +- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/integration-tests/project_setup_test.go b/integration-tests/project_setup_test.go index 41fb4df64..c8184a8c3 100644 --- a/integration-tests/project_setup_test.go +++ b/integration-tests/project_setup_test.go @@ -1,6 +1,7 @@ package tests import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -87,16 +88,66 @@ func setUpGitProject(t *testing.T, envs ...*mockapi.Environment) *gitProject { // Override any global hooks path. runGit(t, p.repo, "config", "core.hooksPath", filepath.Join(p.repo, "hooks")) + p.serveSSH(t, mockssh.ExecHandler(reposDir, nil)) + + return p +} + +// serveSSH starts a mock SSH server, to which the CLI connects for all SSH URLs. +func (p *testProject) serveSSH(t *testing.T, handler mockssh.CommandHandler) *mockssh.Server { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the mock SSH server runs commands with bash") + } sshServer, err := mockssh.NewServer(t, p.authURL+"/ssh/authority") require.NoError(t, err) t.Cleanup(func() { _ = sshServer.Stop() }) - sshServer.CommandHandler = mockssh.ExecHandler(reposDir, nil) + sshServer.CommandHandler = handler p.f.extraEnv = append(p.f.extraEnv, EnvPrefix+"SSH_OPTIONS=HostName 127.0.0.1\nPort "+strconv.Itoa(sshServer.Port()), EnvPrefix+"SSH_HOST_KEYS="+sshServer.HostKeyConfig(), ) + return sshServer +} - return p +// remoteEnv returns the environment for commands run by the mock SSH server, with +// fake executables (shell scripts, by name) ahead of the system PATH, and relationships. +func remoteEnv(t *testing.T, scripts map[string]string, relationships map[string]any) []string { + t.Helper() + binDir := t.TempDir() + for name, script := range scripts { + require.NoError(t, os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/bash\n"+script), 0o755)) + } + env := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + if relationships != nil { + data, err := json.Marshal(relationships) + require.NoError(t, err) + env = append(env, "PLATFORM_RELATIONSHIPS="+base64.StdEncoding.EncodeToString(data)) + } + return env +} + +// sshEnv returns an active "main" environment with a deployment, and an SSH URL for each app and worker. +func sshEnv(d *mockapi.Deployment) *mockapi.Environment { + env := makeEnv(layoutProjectID, "main", "production", "active", nil) + if d.Services == nil { + d.Services = map[string]mockapi.App{} + } + if d.Workers == nil { + d.Workers = map[string]mockapi.Worker{} + } + if d.Routes == nil { + d.Routes = map[string]any{} + } + d.Links = mockapi.MakeHALLinks("self=/projects/" + layoutProjectID + "/environments/main/deployment/current") + env.SetCurrentDeployment(d) + for name := range d.WebApps { + env.Links["pf:ssh:"+name] = mockapi.HALLink{HREF: "ssh://" + layoutProjectID + "-main--" + name + "@ssh.cli-tests.example.com"} + } + for name := range d.Workers { + env.Links["pf:ssh:"+name] = mockapi.HALLink{HREF: "ssh://" + layoutProjectID + "-main--" + name + "@ssh.cli-tests.example.com"} + } + return env } // setHook writes a hook script in the served repository. diff --git a/integration-tests/ssh_test.go b/integration-tests/ssh_test.go index a9c56ac01..01c97f43a 100644 --- a/integration-tests/ssh_test.go +++ b/integration-tests/ssh_test.go @@ -1,13 +1,16 @@ package tests import ( + "fmt" "net/http/httptest" "os" "os/exec" "strconv" + "strings" "testing" "github.com/stretchr/testify/assert" + "golang.org/x/crypto/ssh" "github.com/upsun/cli/pkg/mockapi" "github.com/upsun/cli/pkg/mockssh" @@ -82,3 +85,45 @@ func TestSSH(t *testing.T) { assert.ErrorAs(t, err, &exitErr) assert.Equal(t, 2, exitErr.ExitCode()) } + +func TestSSHAppSelection(t *testing.T) { + t.Parallel() + p := setUpProject(t, sshEnv(&mockapi.Deployment{ + WebApps: map[string]mockapi.App{ + "app": {Name: "app", Type: "golang:1.23", Size: "M", Mounts: map[string]mockapi.Mount{}}, + "admin": {Name: "admin", Type: "php:8.4", Size: "M", Mounts: map[string]mockapi.Mount{}}, + }, + Workers: map[string]mockapi.Worker{ + "app--queue": {App: mockapi.App{Name: "app--queue", Type: "golang:1.23", Size: "S", Mounts: map[string]mockapi.Mount{}}}, + }, + })) + // The server prints the SSH user, which identifies the container. + p.serveSSH(t, func(conn ssh.ConnMetadata, command string, io mockssh.CommandIO) int { + _, _ = fmt.Fprintf(io.StdOut, "%s: %s\n", conn.User(), command) + return 0 + }) + f := p.f + sshUser := layoutProjectID + "-main--" + sshURL := func(name string) string { return sshUser + name + "@ssh.cli-tests.example.com" } + + assert.Equal(t, sshURL("admin"), f.Run("ssh", "-p", layoutProjectID, "-e", "main", "--app", "admin", "--pipe")) + assert.Equal(t, sshURL("app--queue"), + f.Run("ssh", "-p", layoutProjectID, "-e", "main", "--worker", "app--queue", "--pipe")) + assert.Equal(t, sshURL("app--queue"), + f.Run("ssh", "-p", layoutProjectID, "-e", "main", "--app", "app", "--worker", "queue", "--pipe")) + + all := strings.Split(strings.TrimSpace(f.Run("ssh", "-p", layoutProjectID, "-e", "main", "--all")), "\n") + assert.ElementsMatch(t, []string{sshURL("app"), sshURL("admin"), sshURL("app--queue")}, all) + + assert.Equal(t, sshUser+"admin: echo hi\n", f.Run("ssh", "-p", layoutProjectID, "-e", "main", "-A", "admin", "echo hi")) + assert.Equal(t, sshUser+"app--queue: ls -a\n", + f.Run("ssh", "-p", layoutProjectID, "-e", "main", "--worker", "app--queue", "--", "ls", "-a")) + + _, stdErr, err := f.RunCombinedOutput("ssh", "-p", layoutProjectID, "-e", "main", "--app", "missing", "--pipe") + assert.Error(t, err) + assert.Contains(t, stdErr, "missing") + + _, stdErr, err = f.RunCombinedOutput("ssh", "-p", layoutProjectID, "-e", "main", "--pipe") + assert.Error(t, err) + assert.Contains(t, stdErr, "Specifying the --app or --worker is required in non-interactive mode") +} diff --git a/legacy/src/Command/Environment/EnvironmentSshCommand.php b/legacy/src/Command/Environment/EnvironmentSshCommand.php index 10bea35fb..af0147545 100644 --- a/legacy/src/Command/Environment/EnvironmentSshCommand.php +++ b/legacy/src/Command/Environment/EnvironmentSshCommand.php @@ -56,7 +56,11 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { try { - $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + // The --all option lists every app, so it does not need a container. + $selection = $this->selector->getSelection($input, new SelectorConfig( + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + selectRemoteContainer: !Option::bool($input, 'all'), + )); $environment = $selection->getEnvironment(); if (Option::bool($input, 'all')) { From 6f6626f850cfdac777fa5f5090bc20e9aecb5d1a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 13:43:28 +0100 Subject: [PATCH 2/6] test: cover ssh-cert:load and ssh-cert:info Test certificate generation and reuse, --new, --new-key, --refresh-only, the SSH configuration files, and ssh-cert:info --no-refresh with missing, expired and mismatched certificates. The mock auth server's certificate key ID and lifetime are now configurable, as the CLI only reuses a certificate whose key ID is the user ID and which expires in more than 2 minutes. Co-Authored-By: Claude Opus 5.5 --- integration-tests/ssh_cert_test.go | 93 ++++++++++++++++++++++++++++++ pkg/mockapi/auth_server.go | 40 ++++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/integration-tests/ssh_cert_test.go b/integration-tests/ssh_cert_test.go index bcb6ad9ce..7f4a07b27 100644 --- a/integration-tests/ssh_cert_test.go +++ b/integration-tests/ssh_cert_test.go @@ -2,9 +2,15 @@ package tests import ( "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/upsun/cli/pkg/mockapi" ) @@ -42,3 +48,90 @@ func TestSSHCerts(t *testing.T) { }) } } + +func TestSSHCertLoad(t *testing.T) { + t.Parallel() + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "my-user-id" + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + + _, stdErr, err := f.RunCombinedOutput("ssh-cert:info", "--no-refresh") + assertExitCode(t, 1, err) + assert.Contains(t, stdErr, "No valid SSH certificate found.") + + // The CLI treats a certificate as expired 2 minutes early. + authServer.SetSSHCertificate(myUserID, time.Minute) + _, stdErr, err = f.RunCombinedOutput("ssh-cert:load", "--refresh-only") + require.NoError(t, err) + assert.Contains(t, stdErr, "Generating SSH certificate...") + assert.NoFileExists(t, filepath.Join(f.home, ".ssh", "config")) + + _, stdErr, err = f.RunCombinedOutput("ssh-cert:info", "--no-refresh") + assertExitCode(t, 1, err) + assert.Contains(t, stdErr, "No valid SSH certificate found.") + + // A certificate with the wrong key ID is not valid. + authServer.SetSSHCertificate("other-user-id", time.Hour) + f.Run("ssh-cert:load", "--new") + _, _, err = f.RunCombinedOutput("ssh-cert:info", "--no-refresh") + assertExitCode(t, 1, err) + + // Without --no-refresh, an invalid certificate is regenerated. + authServer.SetSSHCertificate(myUserID, time.Hour) + before := time.Now() + output := f.Run("ssh-cert:info") + assert.Contains(t, output, "key_id: "+myUserID+"\n") + assert.Equal(t, 3, authServer.SSHCertificateCount()) + + validBefore, err := strconv.ParseInt(strings.TrimSpace(f.Run("ssh-cert:info", "-P", "valid_before", "--date-fmt", "U")), 10, 64) + require.NoError(t, err) + assert.WithinDuration(t, before.Add(time.Hour), time.Unix(validBefore, 0), time.Minute) + + certFile := strings.TrimSpace(f.Run("ssh-cert:info", "-P", "filename")) + keyFile := strings.TrimSpace(f.Run("ssh-cert:info", "-P", "key_filename")) + assert.Equal(t, keyFile+"-cert.pub", certFile) + assert.Equal(t, 3, authServer.SSHCertificateCount(), "a valid certificate is reused") + + // A valid certificate is reused, and the SSH configuration is written. + _, stdErr, err = f.RunCombinedOutput("ssh-cert:load") + require.NoError(t, err) + assert.Contains(t, stdErr, "A valid SSH certificate exists") + assert.Equal(t, 3, authServer.SSHCertificateCount()) + userConfig := readFile(t, filepath.Join(f.home, ".ssh", "config")) + assert.Contains(t, userConfig, "Host *.cli-tests.example.com\n") + assert.Contains(t, userConfig, "Include "+filepath.Join(f.home, ".platform-test-cli", "ssh", "*.config")) + assert.Contains(t, readFile(t, filepath.Join(f.home, ".platform-test-cli", "ssh", "session.config")), "Include ") + sessionConfig := readFile(t, filepath.Join(filepath.Dir(certFile), "config")) + assert.Contains(t, sessionConfig, "CertificateFile "+certFile) + + // --new refreshes the certificate, keeping the key. + publicKey := readFile(t, keyFile+".pub") + cert := readFile(t, certFile) + _, stdErr, err = f.RunCombinedOutput("ssh-cert:load", "--new") + require.NoError(t, err) + assert.Contains(t, stdErr, "Generating SSH certificate...") + assert.Equal(t, 4, authServer.SSHCertificateCount()) + assert.Equal(t, publicKey, readFile(t, keyFile+".pub")) + assert.NotEqual(t, cert, readFile(t, certFile)) + + // --new-key also generates a new key. + _, stdErr, err = f.RunCombinedOutput("ssh-cert:load", "--new-key") + require.NoError(t, err) + assert.Contains(t, stdErr, "The --new-key option is deprecated") + assert.Equal(t, 5, authServer.SSHCertificateCount()) + assert.NotEqual(t, publicKey, readFile(t, keyFile+".pub")) +} + +func readFile(t *testing.T, filename string) string { + t.Helper() + b, err := os.ReadFile(filename) + require.NoError(t, err) + return string(b) +} diff --git a/pkg/mockapi/auth_server.go b/pkg/mockapi/auth_server.go index c19f50298..123d04c61 100644 --- a/pkg/mockapi/auth_server.go +++ b/pkg/mockapi/auth_server.go @@ -43,6 +43,41 @@ type AuthServer struct { accessCount int reuseDetected bool revokedFamilies map[string]bool + + sshCertMu sync.Mutex + sshCertKeyID string + sshCertLifetime time.Duration + sshCertCount int +} + +// SetSSHCertificate sets the key ID and lifetime of issued SSH certificates (default: "test-key-id" and 1 minute). +// The legacy CLI only reuses a certificate if its key ID is the user ID, and it expires more than 2 minutes later. +func (s *AuthServer) SetSSHCertificate(keyID string, lifetime time.Duration) { + s.sshCertMu.Lock() + defer s.sshCertMu.Unlock() + s.sshCertKeyID = keyID + s.sshCertLifetime = lifetime +} + +// SSHCertificateCount returns the number of SSH certificates issued. +func (s *AuthServer) SSHCertificateCount() int { + s.sshCertMu.Lock() + defer s.sshCertMu.Unlock() + return s.sshCertCount +} + +func (s *AuthServer) nextSSHCertificate() (keyID string, lifetime time.Duration) { + s.sshCertMu.Lock() + defer s.sshCertMu.Unlock() + s.sshCertCount++ + keyID, lifetime = s.sshCertKeyID, s.sshCertLifetime + if keyID == "" { + keyID = "test-key-id" + } + if lifetime == 0 { + lifetime = time.Minute + } + return keyID, lifetime } // SetTokenLifetime sets the lifetime of issued access tokens (default: 1 hour). @@ -315,13 +350,14 @@ func NewAuthServer(t *testing.T) *AuthServer { extensions["permit-port-forwarding"] = "" extensions["permit-pty"] = "" extensions["permit-user-rc"] = "" + keyID, lifetime := srv.nextSSHCertificate() cert := &ssh.Certificate{ Key: key, Serial: 0, CertType: ssh.UserCert, - KeyId: "test-key-id", + KeyId: keyID, ValidAfter: uint64(time.Now().Add(-1 * time.Second).Unix()), - ValidBefore: uint64(time.Now().Add(time.Minute).Unix()), + ValidBefore: uint64(time.Now().Add(lifetime).Unix()), Permissions: ssh.Permissions{ Extensions: extensions, }, From b5bfd4a4effdfa0cfcf64719b9119e85d999d2a4 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 13:49:14 +0100 Subject: [PATCH 3/6] test: cover database, mount and service commands over SSH Run the commands against the mock SSH server, with fake remote clients (shell scripts on the remote PATH) which print their arguments: - db:dump: default and --file names, --gzip, --stdout, --directory, table and schema options, MariaDB and PostgreSQL, and remote failures - db:sql: queries, --raw, --schema, and piped input - db:size: PostgreSQL and MariaDB usage - mount:size: df and du output parsing - mount:upload and mount:download with rsync: --exclude and --delete - service:redis-cli and service:mongo:* commands Co-Authored-By: Claude Opus 5.5 --- integration-tests/db_ssh_test.go | 152 ++++++++++++++++++++++++ integration-tests/mount_ssh_test.go | 110 +++++++++++++++++ integration-tests/project_setup_test.go | 13 +- integration-tests/service_ssh_test.go | 80 +++++++++++++ integration-tests/ssh_cert_test.go | 3 +- integration-tests/ssh_test.go | 7 +- 6 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 integration-tests/db_ssh_test.go create mode 100644 integration-tests/mount_ssh_test.go create mode 100644 integration-tests/service_ssh_test.go diff --git a/integration-tests/db_ssh_test.go b/integration-tests/db_ssh_test.go new file mode 100644 index 000000000..171d5a0f6 --- /dev/null +++ b/integration-tests/db_ssh_test.go @@ -0,0 +1,152 @@ +package tests + +import ( + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" + "github.com/upsun/cli/pkg/mockssh" +) + +// fakeDBClients are remote scripts which print their name and arguments, and any input. +var fakeDBClients = map[string]string{ + "mariadb": `echo "mariadb $*"; cat`, + "mariadb-dump": `echo "mariadb-dump $*"`, + "psql": `echo "psql $*"; cat`, + "pg_dump": `echo "pg_dump $*"`, +} + +func dbRelationships() map[string]any { + return map[string]any{ + "database": []any{map[string]any{ + "service": "db", "rel": "mysql", "scheme": "mysql", "type": "mariadb:11.4", + "host": "database.internal", "port": 3306, "username": "user", "password": "", "path": "main", + }}, + "pg": []any{map[string]any{ + "service": "pg", "rel": "postgresql", "scheme": "pgsql", "type": "postgresql:16", + "host": "pg.internal", "port": 5432, "username": "main", "password": "secret", "path": "main", + }}, + } +} + +// setUpDBProject serves an app with database relationships over SSH, with fake database clients. +func setUpDBProject(t *testing.T, clients map[string]string) *testProject { + t.Helper() + p := setUpProject(t, sshEnv(&mockapi.Deployment{ + WebApps: map[string]mockapi.App{ + "app": {Name: "app", Type: "golang:1.23", Size: "M", Mounts: map[string]mockapi.Mount{}}, + }, + Services: map[string]mockapi.App{ + "db": {Name: "db", Type: "mariadb:11.4", Size: "M", Disk: 1024}, + "pg": {Name: "pg", Type: "postgresql:16", Size: "M", Disk: 2048}, + }, + })) + p.serveSSH(t, mockssh.ExecHandler(t.TempDir(), remoteEnv(t, clients, dbRelationships()))) + return p +} + +func TestDbDump(t *testing.T) { + t.Parallel() + p := setUpDBProject(t, fakeDBClients) + f := p.f + args := []string{"-p", layoutProjectID, "-e", "main"} + mariadbArgs := "--user=user --password= --host=database.internal --port=3306 main" + + _, stdErr, err := f.RunCombinedOutput(append([]string{"db:dump", "-r", "database"}, args...)...) + require.NoError(t, err) + dumpFile := filepath.Join(f.dir, layoutProjectID+"--main-xyz--db--main--dump.sql") + assert.Contains(t, stdErr, "Creating SQL dump file: "+dumpFile) + assert.Equal(t, "mariadb-dump --single-transaction "+mariadbArgs+"\n", readFile(t, dumpFile)) + + // An existing file is overwritten in non-interactive mode. + _, stdErr, err = f.RunCombinedOutput(append([]string{"db:dump", "-r", "database"}, args...)...) + require.NoError(t, err) + assert.Contains(t, stdErr, "File exists: "+dumpFile+". Overwrite? [Y/n] y") + + _, stdErr, err = f.RunCombinedOutput(append([]string{"db:dump", "-r", "database", "--gzip", "-f", "dump.sql.gz", + "--table", "users", "--table", "posts", "--exclude-table", "cache"}, args...)...) + require.NoError(t, err) + assert.Contains(t, stdErr, "Creating gzipped SQL dump file: "+filepath.Join(f.dir, "dump.sql.gz")) + assert.Equal(t, "mariadb-dump --single-transaction "+mariadbArgs+ + " --ignore-table=main.cache --tables users posts\n", gunzipFile(t, filepath.Join(f.dir, "dump.sql.gz"))) + + tableDir := t.TempDir() + f.Run(append([]string{"db:dump", "-r", "database", "--table", "users", "--schema-only", "-d", tableDir}, args...)...) + assert.Equal(t, "mariadb-dump --single-transaction "+mariadbArgs+" --no-data --tables users\n", + readFile(t, filepath.Join(tableDir, layoutProjectID+"--main-xyz--db--main--users--schema--dump.sql"))) + + output := f.Run(append([]string{"db:dump", "-r", "pg", "--stdout", "--schema-only", + "--table", "users", "--exclude-table", "cache", "--charset", "UTF8"}, args...)...) + assert.Equal(t, "pg_dump --no-owner --if-exists --clean --blobs postgresql://main:secret@pg.internal:5432/main"+ + " --schema-only --table=users --exclude-table=cache --encoding=UTF8\n", output) + + // The remote exit code is returned. + failing := setUpDBProject(t, map[string]string{"mariadb-dump": "echo 'dump failed' >&2; exit 3"}) + _, stdErr, err = failing.f.RunCombinedOutput(append([]string{"db:dump", "-r", "database", "--stdout"}, args...)...) + assertExitCode(t, 3, err) + assert.Contains(t, stdErr, "dump failed") +} + +func TestDbSQL(t *testing.T) { + t.Parallel() + p := setUpDBProject(t, fakeDBClients) + f := p.f + args := []string{"-p", layoutProjectID, "-e", "main"} + + assert.Equal(t, "mariadb --no-auto-rehash --user=user --password= --host=database.internal --port=3306 main"+ + " --execute SELECT 1\n", f.Run(append([]string{"db:sql", "-r", "database", "SELECT 1"}, args...)...)) + + assert.Equal(t, "mariadb --no-auto-rehash --user=user --password= --host=database.internal --port=3306 other"+ + " --batch --raw --execute SELECT 2\n", + f.Run(append([]string{"db:sql", "-r", "database", "--schema", "other", "--raw", "SELECT 2"}, args...)...)) + + assert.Equal(t, "psql postgresql://main:secret@pg.internal:5432/main -t -c SELECT 3\n", + f.Run(append([]string{"sql", "-r", "pg", "--raw", "SELECT 3"}, args...)...)) + + // A query can be piped in. + f.stdin = strings.NewReader("SELECT 4;\n") + assert.Equal(t, "psql postgresql://main:secret@pg.internal:5432/main\nSELECT 4;\n", + f.Run(append([]string{"db:sql", "-r", "pg"}, args...)...)) +} + +func TestDbSize(t *testing.T) { + t.Parallel() + // Each query returns 50 MiB. + p := setUpDBProject(t, map[string]string{ + "mariadb": "cat > /dev/null; echo 52428800", + "psql": "cat > /dev/null; echo 52428800", + }) + f := p.f + args := []string{"-p", layoutProjectID, "-e", "main"} + + output, stdErr, err := f.RunCombinedOutput(append([]string{"db:size", "-r", "pg", "--format", "csv"}, args...)...) + require.NoError(t, err) + assert.Contains(t, stdErr, "Checking database service pg...") + assert.Equal(t, "Allocated disk,Estimated usage,% used\n2147483648,52428800,2\n", output) + + output = f.Run(append([]string{"db:size", "-r", "pg", "--no-header"}, args...)...) + assert.Contains(t, output, "| 2.0 GiB | 50.0 MiB | ~ 2% |") + + // The MySQL usage includes InnoDB and other tables separately. + output = f.Run(append([]string{"db:size", "-r", "database", "--format", "csv", "--no-header"}, args...)...) + assert.Equal(t, "1073741824,104857600,10\n", output) +} + +func gunzipFile(t *testing.T, filename string) string { + t.Helper() + b, err := os.ReadFile(filename) + require.NoError(t, err) + r, err := gzip.NewReader(bytes.NewReader(b)) + require.NoError(t, err) + out, err := io.ReadAll(r) + require.NoError(t, err) + return string(out) +} diff --git a/integration-tests/mount_ssh_test.go b/integration-tests/mount_ssh_test.go new file mode 100644 index 000000000..6b1676743 --- /dev/null +++ b/integration-tests/mount_ssh_test.go @@ -0,0 +1,110 @@ +package tests + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" + "github.com/upsun/cli/pkg/mockssh" +) + +func mountsDeployment() *mockapi.Deployment { + return &mockapi.Deployment{ + WebApps: map[string]mockapi.App{ + "app": {Name: "app", Type: "golang:1.23", Size: "M", Mounts: map[string]mockapi.Mount{ + "/var/log": {Source: "storage", SourcePath: "logs"}, + "web/uploads": {Source: "storage", SourcePath: "uploads"}, + "tmp": {Source: "tmp", SourcePath: "tmp"}, + }}, + }, + } +} + +func TestMountSize(t *testing.T) { + t.Parallel() + appDir := t.TempDir() + p := setUpProject(t, sshEnv(mountsDeployment())) + // The df output has two mounts on one disk and one on another. Each du output is the path's length in KiB. + env := remoteEnv(t, map[string]string{ + "df": `cat < Date: Thu, 15 Oct 2026 13:50:38 +0100 Subject: [PATCH 4/6] docs: mention SSH integration test helpers Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 74d3d2964..f6f4225b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,7 @@ make download-php Tests use github.com/stretchr/testify for assertions. Table-driven tests are preferred with a "cases" slice containing simple test case structs. -Integration tests (`integration-tests/`) call `t.Parallel()`, so they must not share state: no `t.Setenv` or unsynchronized package variables, and use `cmdFactory.useConfig` to change the CLI config file. Write output with `t.Log` (e.g. `mockapi.RequestLogger`), not to stdout or stderr: it is then attributed to its test, and shown only on failure or with `-v`. Tests of environment operations use `setUpProject`, and tests of Git commands (push, clone, fetch) use `setUpGitProject`, which also serves a bare repository over the mock SSH server. +Integration tests (`integration-tests/`) call `t.Parallel()`, so they must not share state: no `t.Setenv` or unsynchronized package variables, and use `cmdFactory.useConfig` to change the CLI config file. Write output with `t.Log` (e.g. `mockapi.RequestLogger`), not to stdout or stderr: it is then attributed to its test, and shown only on failure or with `-v`. Tests of environment operations use `setUpProject`, and tests of Git commands (push, clone, fetch) use `setUpGitProject`, which also serves a bare repository over the mock SSH server. Tests of other SSH commands use `serveSSH` with an app from `sshEnv`, and fake remote executables from `remoteEnv`. ### Configuration From c111a207270145ef4abf921b1c11eb7f479d320b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 14:22:25 +0100 Subject: [PATCH 5/6] fix(ssh): report paused and inactive environments with --all Skipping the container selection for `ssh --all` also skipped the deployment lookup that reported paused and inactive environments, so the command printed the SSH URLs and exited successfully. Also assert the app-not-found message in TestSSHAppSelection. Co-Authored-By: Claude Opus 5.5 --- integration-tests/ssh_test.go | 23 ++++++++++++++++++- .../Environment/EnvironmentSshCommand.php | 4 ++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/integration-tests/ssh_test.go b/integration-tests/ssh_test.go index 62a705582..24c03b4e7 100644 --- a/integration-tests/ssh_test.go +++ b/integration-tests/ssh_test.go @@ -124,9 +124,30 @@ func TestSSHAppSelection(t *testing.T) { _, stdErr, err := f.RunCombinedOutput("ssh", "-p", layoutProjectID, "-e", "main", "--app", "missing", "--pipe") assert.Error(t, err) - assert.Contains(t, stdErr, "missing") + assert.Contains(t, stdErr, `App not found: missing`) _, stdErr, err = f.RunCombinedOutput("ssh", "-p", layoutProjectID, "-e", "main", "--pipe") assert.Error(t, err) assert.Contains(t, stdErr, "Specifying the --app or --worker is required in non-interactive mode") } + +func TestSSHInactiveEnvironment(t *testing.T) { + t.Parallel() + // Paused and inactive environments have no current deployment. + statuses := []string{"paused", "inactive"} + envs := make([]*mockapi.Environment, 0, len(statuses)) + for _, status := range statuses { + env := makeEnv(layoutProjectID, status, "development", status, "main") + env.Links["pf:ssh:app"] = mockapi.HALLink{HREF: "ssh://" + status + "--app@ssh.cli-tests.example.com"} + envs = append(envs, env) + } + f := setUpProject(t, envs...).f + + for _, status := range statuses { + for _, opt := range []string{"--pipe", "--all"} { + _, stdErr, err := f.RunCombinedOutput("ssh", "-p", layoutProjectID, "-e", status, opt) + assertExitCode(t, 1, err) + assert.Contains(t, stdErr, "is "+status+", so an SSH connection is not possible", opt) + } + } +} diff --git a/legacy/src/Command/Environment/EnvironmentSshCommand.php b/legacy/src/Command/Environment/EnvironmentSshCommand.php index af0147545..3d7fe3506 100644 --- a/legacy/src/Command/Environment/EnvironmentSshCommand.php +++ b/legacy/src/Command/Environment/EnvironmentSshCommand.php @@ -64,6 +64,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $environment = $selection->getEnvironment(); if (Option::bool($input, 'all')) { + // Without a container selection, check the state here. + if (in_array($environment->status, ['inactive', 'paused'], true)) { + throw new EnvironmentStateException('The environment is ' . $environment->status, $environment); + } $output->writeln(array_values($environment->getSshUrls())); return 0; From 25e3024d396caf8a2c0b6f8f4376f04f1242a6c5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 14:25:54 +0100 Subject: [PATCH 6/6] test: write literal paths in the fake df output Co-Authored-By: Claude Opus 5.5 --- integration-tests/mount_ssh_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/integration-tests/mount_ssh_test.go b/integration-tests/mount_ssh_test.go index 6b1676743..12ac9a815 100644 --- a/integration-tests/mount_ssh_test.go +++ b/integration-tests/mount_ssh_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -31,13 +32,12 @@ func TestMountSize(t *testing.T) { p := setUpProject(t, sshEnv(mountsDeployment())) // The df output has two mounts on one disk and one on another. Each du output is the path's length in KiB. env := remoteEnv(t, map[string]string{ - "df": `cat <