Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
152 changes: 152 additions & 0 deletions integration-tests/db_ssh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
110 changes: 110 additions & 0 deletions integration-tests/mount_ssh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package tests

import (
"os"
"os/exec"
"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"
)

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 <<'OUT'\n" + strings.ReplaceAll(`Filesystem 1-blocks Used Available Capacity Mounted on
/dev/root 8589934592 4294967296 4294967296 50% /
/dev/sdb 1073741824 268435456 805306368 25% APP_DIR/var/log
/dev/sdb 1073741824 268435456 805306368 25% APP_DIR/web/uploads
tmpfs 536870912 0 536870912 0% APP_DIR/tmp
`, "APP_DIR", appDir) + "OUT\n",
"du": `for path; do :; done; printf '%d\t%s\n' $((${#path} * 1024)) "$path"`,
}, nil)
p.serveSSH(t, mockssh.ExecHandler(appDir, append(env, "PLATFORM_APP_DIR="+appDir)))
f := p.f

output, stdErr, err := f.RunCombinedOutput("mount:size", "-p", layoutProjectID, "-e", "main", "--bytes",
"--format", "csv")
require.NoError(t, err)
assert.Contains(t, stdErr, "Checking disk usage for all mounts")
assert.Equal(t, `Mount(s),Size(s),Disk,Used,Available,% Used
"var/log
web/uploads","7168
11264",1073741824,268435456,805306368,25%
tmp,3072,536870912,0,536870912,0%
`, output)

output = f.Run("mount:size", "-p", layoutProjectID, "-e", "main", "--columns", "mounts,sizes,max")
assert.Contains(t, output, "| var/log | 7 KiB | 1.0 GiB |\n| web/uploads | 11 KiB | |\n")
assert.Contains(t, output, "| tmp | 3 KiB | 512.0 MiB |\n")
}

func TestMountUpload(t *testing.T) {
t.Parallel()
if _, err := exec.LookPath("rsync"); err != nil {
t.Skip("rsync is not installed")
}
remoteDir := t.TempDir()
writeFiles(t, remoteDir, map[string]string{"web/uploads/old.txt": "old"})

p := setUpProject(t, sshEnv(mountsDeployment()))
p.serveSSH(t, mockssh.ExecHandler(remoteDir, os.Environ()))
f := p.f

source := t.TempDir()
writeFiles(t, source, map[string]string{"a.txt": "a", "sub/b.txt": "b", "skip.log": "skip"})

_, stdErr, err := f.RunCombinedOutput("mount:upload", "-p", layoutProjectID, "-e", "main", "-m", "web/uploads",
"--source", source, "--exclude", "*.log", "--yes")
require.NoError(t, err)
assert.Contains(t, stdErr, "to the remote mount web/uploads")
uploads := filepath.Join(remoteDir, "web", "uploads")
assert.Equal(t, "a", readFile(t, filepath.Join(uploads, "a.txt")))
assert.Equal(t, "b", readFile(t, filepath.Join(uploads, "sub", "b.txt")))
assert.NoFileExists(t, filepath.Join(uploads, "skip.log"))
assert.FileExists(t, filepath.Join(uploads, "old.txt"))

f.Run("mount:upload", "-p", layoutProjectID, "-e", "main", "-m", "web/uploads", "--source", source,
"--delete", "--yes")
assert.NoFileExists(t, filepath.Join(uploads, "old.txt"))
assert.FileExists(t, filepath.Join(uploads, "skip.log"))

// Download it back, excluding a file and deleting extraneous local files.
target := t.TempDir()
writeFiles(t, target, map[string]string{"extra.txt": "extra"})
f.Run("mount:download", "-p", layoutProjectID, "-e", "main", "-m", "web/uploads", "--target", target,
"--exclude", "a.txt", "--delete", "--yes")
assert.Equal(t, "b", readFile(t, filepath.Join(target, "sub", "b.txt")))
assert.NoFileExists(t, filepath.Join(target, "a.txt"))
assert.NoFileExists(t, filepath.Join(target, "extra.txt"))
}

// writeFiles writes files, by relative path, in a directory.
func writeFiles(t *testing.T, dir string, files map[string]string) {
t.Helper()
for name, content := range files {
path := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
}
60 changes: 58 additions & 2 deletions integration-tests/project_setup_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package tests

import (
"encoding/base64"
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -87,16 +90,69 @@ 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)
names := slices.Collect(maps.Keys(d.WebApps))
for name := range d.Workers {
names = append(names, name)
}
for _, name := range names {
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.
Expand Down
Loading
Loading