diff --git a/cmd/project/project_clear_cache.go b/cmd/project/project_clear_cache.go index b92adec3..69734bcc 100644 --- a/cmd/project/project_clear_cache.go +++ b/cmd/project/project_clear_cache.go @@ -22,6 +22,27 @@ var projectClearCacheCmd = &cobra.Command{ return err } + // with a selected environment the executor decides how and where + // cache:clear runs (local, docker, ssh, ...) + if environmentName != "" { + // the project root is only needed by local executors, remote + // environments work from anywhere + projectRoot, _ := findClosestShopwareProject() + + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return err + } + + logging.FromContext(cmd.Context()).Infof("Clearing cache on environment %s", environmentName) + + p := cmdExecutor.ConsoleCommand(cmd.Context(), "cache:clear") + p.Cmd.Stdout = cmd.OutOrStdout() + p.Cmd.Stderr = cmd.ErrOrStderr() + + return p.Run() + } + if cfg.AdminApi == nil { logging.FromContext(cmd.Context()).Infof("Clearing cache localy") diff --git a/cmd/project/project_console.go b/cmd/project/project_console.go index 475dd7f5..61c083f8 100644 --- a/cmd/project/project_console.go +++ b/cmd/project/project_console.go @@ -2,8 +2,10 @@ package project import ( "context" + "fmt" "os/exec" "slices" + "strings" "github.com/spf13/cobra" @@ -16,12 +18,63 @@ var ( appCommands = []string{"app:install", "app:update", "app:activate", "app:deactivate"} ) +// stripConsoleFlags extracts shopware-cli's own flags (--env/-e and +// --project-config) from the arguments before the first positional argument +// and applies them. Everything from the first positional argument on — the +// console command name — is passed to bin/console untouched, so Symfony's own +// --env/-e flag keeps working there. A leading "--" ends flag handling early. +func stripConsoleFlags(args []string) []string { + rest := make([]string, 0, len(args)) + + for i := 0; i < len(args); i++ { + arg := args[i] + + if arg == "--" { + return append(rest, args[i+1:]...) + } + + if !strings.HasPrefix(arg, "-") { + return append(rest, args[i:]...) + } + + switch { + case arg == "--env" || arg == "-e": + if i+1 < len(args) { + environmentName = args[i+1] + i++ + } + case strings.HasPrefix(arg, "--env="): + environmentName = strings.TrimPrefix(arg, "--env=") + case strings.HasPrefix(arg, "-e="): + environmentName = strings.TrimPrefix(arg, "-e=") + case arg == "--project-config": + if i+1 < len(args) { + projectConfigPath = args[i+1] + i++ + } + case strings.HasPrefix(arg, "--project-config="): + projectConfigPath = strings.TrimPrefix(arg, "--project-config=") + default: + rest = append(rest, arg) + } + } + + return rest +} + var projectConsoleCmd = &cobra.Command{ - Use: "console", - Short: "Runs the Symfony Console (bin/console) for current project", + Use: "console [flags] -- [console-args]", + Short: "Runs the Symfony Console (bin/console) for current project", + Long: `Runs the Symfony Console (bin/console) for the current project. + +Flags for shopware-cli (--env/-e to pick an environment, --project-config) must +be placed before the console command name; everything after it is passed to +bin/console unchanged, including Symfony's own --env flag.`, Args: cobra.MinimumNArgs(1), DisableFlagParsing: true, ValidArgsFunction: func(cmd *cobra.Command, input []string, _ string) ([]string, cobra.ShellCompDirective) { + input = stripConsoleFlags(input) + projectRoot, err := findClosestShopwareProject() if err != nil { return nil, cobra.ShellCompDirectiveDefault @@ -82,6 +135,11 @@ var projectConsoleCmd = &cobra.Command{ return completions, cobra.ShellCompDirectiveDefault }, RunE: func(cmd *cobra.Command, args []string) error { + args = stripConsoleFlags(args) + if len(args) == 0 { + return fmt.Errorf("no console command given") + } + projectRoot, err := findClosestShopwareProject() if err != nil { return err diff --git a/cmd/project/project_console_test.go b/cmd/project/project_console_test.go new file mode 100644 index 00000000..64af1743 --- /dev/null +++ b/cmd/project/project_console_test.go @@ -0,0 +1,73 @@ +package project + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStripConsoleFlags(t *testing.T) { + cases := []struct { + name string + args []string + expected []string + expectedEnv string + }{ + { + name: "no flags", + args: []string{"cache:clear"}, + expected: []string{"cache:clear"}, + }, + { + name: "env before command", + args: []string{"--env", "production", "cache:clear"}, + expected: []string{"cache:clear"}, + expectedEnv: "production", + }, + { + name: "env equals form", + args: []string{"--env=production", "cache:clear"}, + expected: []string{"cache:clear"}, + expectedEnv: "production", + }, + { + name: "shorthand", + args: []string{"-e", "production", "cache:clear"}, + expected: []string{"cache:clear"}, + expectedEnv: "production", + }, + { + name: "symfony env after command stays untouched", + args: []string{"cache:clear", "--env=prod"}, + expected: []string{"cache:clear", "--env=prod"}, + }, + { + name: "cli env before, symfony env after", + args: []string{"--env", "production", "cache:clear", "--env=prod", "-e", "dev"}, + expected: []string{"cache:clear", "--env=prod", "-e", "dev"}, + expectedEnv: "production", + }, + { + name: "double dash passes everything through", + args: []string{"--", "--env=prod", "cache:clear"}, + expected: []string{"--env=prod", "cache:clear"}, + }, + { + name: "unknown flags before command are forwarded", + args: []string{"-v", "cache:clear"}, + expected: []string{"-v", "cache:clear"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + environmentName = "" + t.Cleanup(func() { environmentName = "" }) + + rest := stripConsoleFlags(tc.args) + + assert.Equal(t, tc.expected, rest) + assert.Equal(t, tc.expectedEnv, environmentName) + }) + } +} diff --git a/cmd/project/project_deploy.go b/cmd/project/project_deploy.go new file mode 100644 index 00000000..abaab009 --- /dev/null +++ b/cmd/project/project_deploy.go @@ -0,0 +1,66 @@ +package project + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/deployment" + "github.com/shopware/shopware-cli/internal/shop" +) + +var projectDeployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy the project to a remote environment", + Long: `Deploys the project to the environment selected with --env. + +The project is uploaded as a new release into /releases, shared +files and directories are linked from /shared and the +/current symlink is switched atomically to the new release. +Use "shopware-cli project deploy rollback" to switch back to a previous release.`, + RunE: func(cmd *cobra.Command, _ []string) error { + skipBuildHooks, _ := cmd.Flags().GetBool("skip-build-hooks") + + deployer, cleanup, err := resolveDeployer(cmd) + if err != nil { + return err + } + defer cleanup() + + return deployer.Deploy(cmd.Context(), deployment.Options{SkipBuildHooks: skipBuildHooks}) + }, +} + +// resolveDeployer creates the Deployer for the selected environment. +func resolveDeployer(cmd *cobra.Command) (deployment.Deployer, func(), error) { + projectRoot, err := findClosestShopwareProject() + if err != nil { + return nil, nil, err + } + + cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, false) + if err != nil { + return nil, nil, err + } + + if environmentName == "" { + return nil, nil, fmt.Errorf("no environment selected, use --env to pick one of the environments defined in %s", projectConfigPath) + } + + envCfg, err := cfg.ResolveEnvironment(environmentName) + if err != nil { + return nil, nil, err + } + + deployer, err := deployment.NewDeployer(projectRoot, envCfg, cfg) + if err != nil { + return nil, nil, err + } + + return deployer, func() { _ = deployer.Close() }, nil +} + +func init() { + projectDeployCmd.Flags().Bool("skip-build-hooks", false, "Skip the local build hooks before the upload") + projectRootCmd.AddCommand(projectDeployCmd) +} diff --git a/cmd/project/project_deploy_releases.go b/cmd/project/project_deploy_releases.go new file mode 100644 index 00000000..1add4fdf --- /dev/null +++ b/cmd/project/project_deploy_releases.go @@ -0,0 +1,57 @@ +package project + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var projectDeployReleasesCmd = &cobra.Command{ + Use: "releases", + Short: "List the releases on the deployment target", + RunE: func(cmd *cobra.Command, _ []string) error { + deployer, cleanup, err := resolveDeployer(cmd) + if err != nil { + return err + } + defer cleanup() + + hostReleases, err := deployer.Releases(cmd.Context()) + if err != nil { + return err + } + + for i, hr := range hostReleases { + if len(hostReleases) > 1 { + if i > 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Host %s:\n", hr.Host) + } + + if len(hr.Releases) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No releases found") + continue + } + + for _, release := range hr.Releases { + marker := "" + if release.Active { + marker = " (active)" + } + if release.Bad { + marker += " (bad)" + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s%s\n", release.Name, marker) + } + } + + return nil + }, +} + +func init() { + projectDeployCmd.AddCommand(projectDeployReleasesCmd) +} diff --git a/cmd/project/project_deploy_rollback.go b/cmd/project/project_deploy_rollback.go new file mode 100644 index 00000000..62a486e8 --- /dev/null +++ b/cmd/project/project_deploy_rollback.go @@ -0,0 +1,33 @@ +package project + +import ( + "github.com/spf13/cobra" +) + +var projectDeployRollbackCmd = &cobra.Command{ + Use: "rollback [release]", + Short: "Roll back to a previous release", + Long: `Switches the current symlink back to a previous release. + +Without an argument the release deployed before the currently active one is used. +The release that was rolled back from is marked as bad and skipped by later rollbacks.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var release string + if len(args) > 0 { + release = args[0] + } + + deployer, cleanup, err := resolveDeployer(cmd) + if err != nil { + return err + } + defer cleanup() + + return deployer.Rollback(cmd.Context(), release) + }, +} + +func init() { + projectDeployCmd.AddCommand(projectDeployRollbackCmd) +} diff --git a/cmd/project/project_dump.go b/cmd/project/project_dump.go index 418532f8..7ffdc5b6 100644 --- a/cmd/project/project_dump.go +++ b/cmd/project/project_dump.go @@ -2,21 +2,16 @@ package project import ( "compress/gzip" - "context" "database/sql" "fmt" "io" - "net" - "net/url" "os" "strings" - "time" "github.com/go-sql-driver/mysql" "github.com/klauspost/compress/zstd" "github.com/spf13/cobra" - "github.com/shopware/shopware-cli/internal/envfile" "github.com/shopware/shopware-cli/internal/mysqldump" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" @@ -49,6 +44,9 @@ var projectDatabaseDumpCmd = &cobra.Command{ if err != nil { return err } + defer func() { + _ = db.Close() + }() dumper := mysqldump.NewMySQLDumper(db) dumper.LockTables = !skipLockTables @@ -135,22 +133,18 @@ var projectDatabaseDumpCmd = &cobra.Command{ } func assembleConnectionURI(cmd *cobra.Command) (*mysql.Config, error) { - cfg := &mysql.Config{ - Loc: time.UTC, - Net: "tcp", - ParseTime: false, - AllowNativePasswords: true, - CheckConnLiveness: true, - User: "root", - Passwd: "root", - Addr: "127.0.0.1:3306", - DBName: "shopware", + // the executor decides where the database lives and how to reach it + // (local, docker, or through the ssh connection of a remote environment) + projectRoot, _ := findClosestShopwareProject() + + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return nil, err } - if projectRoot, err := findClosestShopwareProject(); err == nil { - if err := loadDatabaseURLIntoConnection(cmd.Context(), projectRoot, cfg); err != nil { - return nil, err - } + cfg, err := cmdExecutor.DatabaseConnection(cmd.Context()) + if err != nil { + return nil, err } host, _ := cmd.Flags().GetString("host") @@ -183,50 +177,6 @@ func assembleConnectionURI(cmd *cobra.Command) (*mysql.Config, error) { return cfg, nil } -func loadDatabaseURLIntoConnection(ctx context.Context, projectRoot string, cfg *mysql.Config) error { - if err := envfile.LoadSymfonyEnvFile(projectRoot); err != nil { - return err - } - - databaseUrl := os.Getenv("DATABASE_URL") - - if databaseUrl == "" { - return nil - } - - logging.FromContext(ctx).Info("Using DATABASE_URL env as default connection string. options can override specific parts (--username=foo)") - - parsedUri, err := url.Parse(databaseUrl) - if err != nil { - return fmt.Errorf("could not parse DATABASE_URL: %w", err) - } - - if parsedUri.User != nil { - cfg.User = parsedUri.User.Username() - - if password, ok := parsedUri.User.Password(); ok { - cfg.Passwd = password - } else { - // Reset password if it is not set - cfg.Passwd = "" - } - } - - if parsedUri.Host != "" { - cfg.Addr = parsedUri.Host - - if parsedUri.Port() == "" { - cfg.Addr = net.JoinHostPort(parsedUri.Host, "3306") - } - } - - if parsedUri.Path != "" { - cfg.DBName = strings.Trim(parsedUri.Path, "/") - } - - return nil -} - func init() { projectRootCmd.AddCommand(projectDatabaseDumpCmd) projectDatabaseDumpCmd.Flags().String("host", "", "hostname") diff --git a/internal/deployment/archive.go b/internal/deployment/archive.go new file mode 100644 index 00000000..3d5e9948 --- /dev/null +++ b/internal/deployment/archive.go @@ -0,0 +1,121 @@ +package deployment + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +// defaultExcludes are never uploaded to the deployment target. +var defaultExcludes = []string{ + ".git", + ".github", + ".idea", + ".vscode", + "node_modules", + "var/cache", + "var/log", + ".shopware-project.local.yml", + ".shopware-project.local.yaml", +} + +// writeProjectArchive writes the project directory as a gzipped tarball to w. +// Paths in exclude (and sharedPaths, which live in the shared directory on the +// target) are skipped. All paths are relative to root and use forward slashes. +func writeProjectArchive(w io.Writer, root string, exclude []string) error { + gzWriter := gzip.NewWriter(w) + tarWriter := tar.NewWriter(gzWriter) + + excluded := make([]string, 0, len(defaultExcludes)+len(exclude)) + for _, e := range slices.Concat(defaultExcludes, exclude) { + excluded = append(excluded, filepath.ToSlash(strings.Trim(e, "/"))) + } + + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + + if relPath == "." { + return nil + } + + relPath = filepath.ToSlash(relPath) + + if slices.Contains(excluded, relPath) { + if entry.IsDir() { + return filepath.SkipDir + } + + return nil + } + + info, err := entry.Info() + if err != nil { + return err + } + + var linkTarget string + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err = os.Readlink(path) + if err != nil { + return err + } + } else if !info.Mode().IsRegular() && !info.IsDir() { + // skip sockets, devices, pipes + return nil + } + + header, err := tar.FileInfoHeader(info, linkTarget) + if err != nil { + return err + } + + header.Name = relPath + if info.IsDir() { + header.Name += "/" + } + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + if info.Mode().IsRegular() { + file, err := os.Open(path) + if err != nil { + return err + } + + if _, err := io.Copy(tarWriter, file); err != nil { + _ = file.Close() + return fmt.Errorf("cannot archive %s: %w", relPath, err) + } + + if err := file.Close(); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return err + } + + if err := tarWriter.Close(); err != nil { + return err + } + + return gzWriter.Close() +} diff --git a/internal/deployment/archive_test.go b/internal/deployment/archive_test.go new file mode 100644 index 00000000..1671ffa8 --- /dev/null +++ b/internal/deployment/archive_test.go @@ -0,0 +1,110 @@ +package deployment + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func writeTestFile(t *testing.T, root, name string) { + t.Helper() + + path := filepath.Join(root, name) + assert.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + assert.NoError(t, os.WriteFile(path, []byte(name), 0o644)) +} + +func archiveEntries(t *testing.T, data []byte) []string { + t.Helper() + + gzReader, err := gzip.NewReader(bytes.NewReader(data)) + assert.NoError(t, err) + + tarReader := tar.NewReader(gzReader) + + var entries []string + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + assert.NoError(t, err) + + entries = append(entries, header.Name) + } + + return entries +} + +func TestWriteProjectArchive(t *testing.T) { + root := t.TempDir() + + writeTestFile(t, root, "composer.json") + writeTestFile(t, root, "bin/console") + writeTestFile(t, root, "public/index.php") + writeTestFile(t, root, ".git/HEAD") + writeTestFile(t, root, "node_modules/foo/index.js") + writeTestFile(t, root, "var/cache/prod/app.php") + writeTestFile(t, root, "public/media/image.png") + writeTestFile(t, root, ".env") + writeTestFile(t, root, ".shopware-project.local.yml") + + var buf bytes.Buffer + err := writeProjectArchive(&buf, root, []string{"public/media", ".env"}) + assert.NoError(t, err) + + entries := archiveEntries(t, buf.Bytes()) + + assert.Contains(t, entries, "composer.json") + assert.Contains(t, entries, "bin/console") + assert.Contains(t, entries, "public/index.php") + + // default excludes + assert.NotContains(t, entries, ".git/HEAD") + assert.NotContains(t, entries, ".git/") + assert.NotContains(t, entries, "node_modules/foo/index.js") + assert.NotContains(t, entries, "var/cache/prod/app.php") + assert.NotContains(t, entries, ".shopware-project.local.yml") + + // configured excludes (shared paths) + assert.NotContains(t, entries, "public/media/image.png") + assert.NotContains(t, entries, ".env") +} + +func TestWriteProjectArchivePreservesSymlinks(t *testing.T) { + root := t.TempDir() + + writeTestFile(t, root, "vendor/bin/real-binary") + assert.NoError(t, os.Symlink("real-binary", filepath.Join(root, "vendor", "bin", "linked"))) + + var buf bytes.Buffer + assert.NoError(t, writeProjectArchive(&buf, root, nil)) + + gzReader, err := gzip.NewReader(bytes.NewReader(buf.Bytes())) + assert.NoError(t, err) + + tarReader := tar.NewReader(gzReader) + + found := false + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + assert.NoError(t, err) + + if header.Name == "vendor/bin/linked" { + found = true + assert.Equal(t, byte(tar.TypeSymlink), header.Typeflag) + assert.Equal(t, "real-binary", header.Linkname) + } + } + + assert.True(t, found, "symlink missing in archive") +} diff --git a/internal/deployment/connection.go b/internal/deployment/connection.go new file mode 100644 index 00000000..d91e1afa --- /dev/null +++ b/internal/deployment/connection.go @@ -0,0 +1,17 @@ +package deployment + +import ( + "context" + "io" +) + +// Connection is a shell on the deployment target. It is an interface so the +// release logic can be tested without a real server. +type Connection interface { + // Run executes a command remotely and returns its combined output + Run(ctx context.Context, command string) (string, error) + // Stream executes a command remotely with the given reader attached to stdin + Stream(ctx context.Context, command string, stdin io.Reader) error + // Close terminates the connection + Close() error +} diff --git a/internal/deployment/deployment.go b/internal/deployment/deployment.go new file mode 100644 index 00000000..2205c391 --- /dev/null +++ b/internal/deployment/deployment.go @@ -0,0 +1,58 @@ +// Package deployment provides an abstraction for deploying a Shopware project +// to a remote environment. Deployment methods (SSH, SFTP, PaaS, ...) implement +// the Deployer interface so the CLI can offer the same commands for all of them. +package deployment + +import ( + "context" + "fmt" + + "github.com/shopware/shopware-cli/internal/shop" +) + +// Release describes a single release on the deployment target. +type Release struct { + // Name of the release (sortable, e.g. a timestamp) + Name string + // Active is true when the release is the one currently served + Active bool + // Bad is true when the release was marked as bad by a rollback + Bad bool +} + +// HostReleases are the releases found on a single host of the target. +type HostReleases struct { + // Host the releases were found on + Host string + // Releases on the host, sorted from oldest to newest + Releases []Release +} + +// Options controls a single deployment run. +type Options struct { + // SkipBuildHooks skips the local build hooks before the upload + SkipBuildHooks bool +} + +// Deployer is the common interface of all deployment methods. +type Deployer interface { + // Deploy uploads the project as a new release and switches to it + Deploy(ctx context.Context, opts Options) error + // Rollback switches back to a previous release on all hosts. When release + // is empty, the release deployed before the currently active one is used. + Rollback(ctx context.Context, release string) error + // Releases lists the releases available on each host of the target + Releases(ctx context.Context) ([]HostReleases, error) + // Close releases all resources held by the deployer + Close() error +} + +// NewDeployer creates the Deployer matching the environment type. +func NewDeployer(projectRoot string, env *shop.EnvironmentConfig, cfg *shop.Config) (Deployer, error) { + switch env.Type { + case "ssh": + return newSSHDeployer(projectRoot, env, cfg) + default: + return nil, fmt.Errorf("environment type %q does not support deployments, supported types: ssh", env.Type) + } +} diff --git a/internal/deployment/ssh_connection.go b/internal/deployment/ssh_connection.go new file mode 100644 index 00000000..d834ee66 --- /dev/null +++ b/internal/deployment/ssh_connection.go @@ -0,0 +1,66 @@ +package deployment + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" + + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/sshcmd" + "github.com/shopware/shopware-cli/logging" +) + +// sshConnection runs remote commands through the system ssh client. Together +// with the ControlMaster options from sshcmd all commands of a deployment (and +// remote command execution) share one multiplexed connection per host. +type sshConnection struct { + cfg *shop.EnvironmentSSH +} + +func newSSHConnection(cfg *shop.EnvironmentSSH) (*sshConnection, error) { + if cfg == nil || cfg.Host == "" { + return nil, fmt.Errorf("the environment is missing the ssh.host setting") + } + + if _, err := exec.LookPath("ssh"); err != nil { + return nil, fmt.Errorf("the ssh client was not found in PATH: %w", err) + } + + return &sshConnection{cfg: cfg}, nil +} + +func (c *sshConnection) Run(ctx context.Context, command string) (string, error) { + logging.FromContext(ctx).Debugf("ssh %s: %s", c.cfg.Host, command) + + cmd := sshcmd.Build(ctx, c.cfg, command) + + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("remote command %q failed: %w\n%s", command, err, strings.TrimSpace(string(output))) + } + + return string(output), nil +} + +func (c *sshConnection) Stream(ctx context.Context, command string, stdin io.Reader) error { + logging.FromContext(ctx).Debugf("ssh %s (stream): %s", c.cfg.Host, command) + + cmd := sshcmd.Build(ctx, c.cfg, command) + cmd.Stdin = stdin + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("remote command %q failed: %w\n%s", command, err, strings.TrimSpace(string(output))) + } + + return nil +} + +func (c *sshConnection) Close() error { + // nothing to close: the multiplexed master connection is managed by the + // ssh client itself and lingers (ControlPersist) so follow-up commands + // like "project console" reuse it + return nil +} diff --git a/internal/deployment/ssh_deployer.go b/internal/deployment/ssh_deployer.go new file mode 100644 index 00000000..ed5006ae --- /dev/null +++ b/internal/deployment/ssh_deployer.go @@ -0,0 +1,523 @@ +package deployment + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "slices" + "strings" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/shopware/shopware-cli/internal/shell" + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/logging" +) + +const badReleaseMarker = "BAD_RELEASE" + +var ( + defaultSharedFiles = []string{".env", "install.lock"} + defaultSharedDirs = []string{"files", "public/media", "public/thumbnail", "public/sitemap", "var/log"} +) + +const defaultKeepReleases = 5 + +// deployHost is a single server of the deployment target. +type deployHost struct { + name string + conn Connection +} + +// sshDeployer deploys a project to one or more hosts using a releases/shared +// directory layout and an atomically switched "current" symlink, similar to +// what Deployer (deployer.org) does. +type sshDeployer struct { + projectRoot string + deployPath string + config *shop.EnvironmentDeployment + hosts []deployHost + + // injected for tests + now func() time.Time + runLocal func(ctx context.Context, dir string, command string) error +} + +func newSSHDeployer(projectRoot string, env *shop.EnvironmentConfig, _ *shop.Config) (Deployer, error) { + if env.SSH == nil || env.SSH.Host == "" { + return nil, fmt.Errorf("the environment is missing the ssh.host setting") + } + + if env.SSH.Deployment == nil || env.SSH.Deployment.Path == "" { + return nil, fmt.Errorf("the environment is missing the ssh.deployment.path setting") + } + + var hosts []deployHost + + for _, hostCfg := range env.SSH.AllHosts() { + conn, err := newSSHConnection(&hostCfg) + if err != nil { + for _, host := range hosts { + _ = host.conn.Close() + } + + return nil, fmt.Errorf("host %s: %w", hostCfg.Host, err) + } + + hosts = append(hosts, deployHost{name: hostCfg.Host, conn: conn}) + } + + return &sshDeployer{ + projectRoot: projectRoot, + deployPath: strings.TrimRight(env.SSH.Deployment.Path, "/"), + config: env.SSH.Deployment, + hosts: hosts, + now: time.Now, + runLocal: runLocalCommand, + }, nil +} + +func runLocalCommand(ctx context.Context, dir string, command string) error { + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// logPrefix identifies the host in log output for multi-host targets. +func (d *sshDeployer) logPrefix(host deployHost) string { + if len(d.hosts) == 1 { + return "" + } + + return "[" + host.name + "] " +} + +func (d *sshDeployer) sharedFiles() []string { + if d.config.SharedFiles != nil { + return d.config.SharedFiles + } + + return defaultSharedFiles +} + +func (d *sshDeployer) sharedDirs() []string { + if d.config.SharedDirs != nil { + return d.config.SharedDirs + } + + return defaultSharedDirs +} + +func (d *sshDeployer) keepReleases() int { + if d.config.KeepReleases > 0 { + return d.config.KeepReleases + } + + return defaultKeepReleases +} + +func (d *sshDeployer) releasesPath() string { + return path.Join(d.deployPath, "releases") +} + +func (d *sshDeployer) sharedPath() string { + return path.Join(d.deployPath, "shared") +} + +func (d *sshDeployer) currentPath() string { + return path.Join(d.deployPath, "current") +} + +func (d *sshDeployer) Deploy(ctx context.Context, opts Options) error { + logger := logging.FromContext(ctx) + + if !opts.SkipBuildHooks { + for _, hook := range d.config.Hooks.Build { + logger.Infof("Running local build hook: %s", hook) + + if err := d.runLocal(ctx, d.projectRoot, hook); err != nil { + return fmt.Errorf("local build hook %q failed: %w", hook, err) + } + } + } + + releaseName := d.now().UTC().Format("20060102150405") + releaseDir := path.Join(d.releasesPath(), releaseName) + + // upload to all hosts in parallel, nothing is switched yet so a failing + // host aborts the deployment without affecting the running release + uploads, uploadCtx := errgroup.WithContext(ctx) + + for _, host := range d.hosts { + uploads.Go(func() error { + if err := d.prepareHost(uploadCtx, host, releaseName, releaseDir); err != nil { + return fmt.Errorf("host %s: %w", host.name, err) + } + + return nil + }) + } + + if err := uploads.Wait(); err != nil { + return err + } + + // hooks run host after host so database work (e.g. migrations run by the + // deployment helper) never executes concurrently + for _, host := range d.hosts { + if err := d.runPreSwitchHooks(ctx, host, releaseDir); err != nil { + return fmt.Errorf("host %s: %w", host.name, err) + } + } + + // every host finished its setup, now switch them all + for _, host := range d.hosts { + logger.Infof("%sSwitching %s to release %s", d.logPrefix(host), d.currentPath(), releaseName) + + if err := d.switchCurrent(ctx, host, releaseDir); err != nil { + return fmt.Errorf("host %s: %w", host.name, err) + } + } + + for _, host := range d.hosts { + if err := d.runHooks(ctx, host, d.config.Hooks.PostSwitch, d.currentPath()); err != nil { + return fmt.Errorf("host %s: %w", host.name, err) + } + } + + for _, host := range d.hosts { + if err := d.cleanupReleases(ctx, host); err != nil { + logger.Warnf("%sCleanup of old releases failed: %v", d.logPrefix(host), err) + } + } + + logger.Infof("Deployed release %s", releaseName) + + return nil +} + +// prepareHost creates the release on a single host: directory structure, +// upload and shared symlinks. +func (d *sshDeployer) prepareHost(ctx context.Context, host deployHost, releaseName, releaseDir string) error { + logger := logging.FromContext(ctx) + + logger.Infof("%sPreparing deployment structure in %s", d.logPrefix(host), d.deployPath) + + if _, err := host.conn.Run(ctx, fmt.Sprintf("mkdir -p %s %s", shell.Quote(d.releasesPath()), shell.Quote(d.sharedPath()))); err != nil { + return err + } + + if _, err := host.conn.Run(ctx, fmt.Sprintf("[ ! -e %[1]s ] || [ -L %[1]s ]", shell.Quote(d.currentPath()))); err != nil { + return fmt.Errorf("%s exists but is not a symlink, refusing to deploy into an unmanaged directory", d.currentPath()) + } + + if _, err := host.conn.Run(ctx, fmt.Sprintf("mkdir -p %s", shell.Quote(releaseDir))); err != nil { + return err + } + + logger.Infof("%sUploading project to release %s", d.logPrefix(host), releaseName) + + if err := d.upload(ctx, host, releaseDir); err != nil { + return err + } + + logger.Infof("%sLinking shared files and directories", d.logPrefix(host)) + + return d.linkShared(ctx, host, releaseDir) +} + +func (d *sshDeployer) upload(ctx context.Context, host deployHost, releaseDir string) error { + exclude := slices.Concat(d.config.Exclude, d.sharedDirs(), d.sharedFiles()) + + reader, writer := io.Pipe() + + go func() { + writer.CloseWithError(writeProjectArchive(writer, d.projectRoot, exclude)) + }() + + if err := host.conn.Stream(ctx, fmt.Sprintf("tar -xzpf - -C %s", shell.Quote(releaseDir)), reader); err != nil { + _ = reader.CloseWithError(err) + return fmt.Errorf("upload failed: %w", err) + } + + // var/cache and var/log are excluded from the upload but Shopware expects them + _, err := host.conn.Run(ctx, fmt.Sprintf("mkdir -p %s %s", shell.Quote(path.Join(releaseDir, "var", "cache")), shell.Quote(path.Join(releaseDir, "var", "log")))) + + return err +} + +func (d *sshDeployer) linkShared(ctx context.Context, host deployHost, releaseDir string) error { + for _, dir := range d.sharedDirs() { + dir = strings.Trim(dir, "/") + sharedTarget := path.Join(d.sharedPath(), dir) + releasePath := path.Join(releaseDir, dir) + + // seed the shared directory from the release on first deployment (best + // effort), afterwards drop the uploaded variant and replace it with a + // symlink into shared/ + script := fmt.Sprintf( + "if [ -d %[2]s ] && [ ! -e %[1]s ]; then mkdir -p %[3]s && { mv %[2]s %[1]s || true; }; fi && mkdir -p %[1]s && rm -rf %[2]s && mkdir -p %[4]s && ln -sfn %[1]s %[2]s", + shell.Quote(sharedTarget), + shell.Quote(releasePath), + shell.Quote(path.Dir(sharedTarget)), + shell.Quote(path.Dir(releasePath)), + ) + + if _, err := host.conn.Run(ctx, script); err != nil { + return fmt.Errorf("cannot link shared directory %s: %w", dir, err) + } + } + + for _, file := range d.sharedFiles() { + file = strings.Trim(file, "/") + sharedTarget := path.Join(d.sharedPath(), file) + releasePath := path.Join(releaseDir, file) + + script := fmt.Sprintf( + "mkdir -p %[3]s && if [ -f %[2]s ] && [ ! -e %[1]s ]; then mv %[2]s %[1]s || true; fi && if [ ! -e %[1]s ]; then touch %[1]s; fi && rm -f %[2]s && mkdir -p %[4]s && ln -sfn %[1]s %[2]s", + shell.Quote(sharedTarget), + shell.Quote(releasePath), + shell.Quote(path.Dir(sharedTarget)), + shell.Quote(path.Dir(releasePath)), + ) + + if _, err := host.conn.Run(ctx, script); err != nil { + return fmt.Errorf("cannot link shared file %s: %w", file, err) + } + } + + return nil +} + +func (d *sshDeployer) runPreSwitchHooks(ctx context.Context, host deployHost, releaseDir string) error { + if len(d.config.Hooks.PreSwitch) > 0 { + return d.runHooks(ctx, host, d.config.Hooks.PreSwitch, releaseDir) + } + + output, err := host.conn.Run(ctx, fmt.Sprintf("test -f %s && echo found || true", shell.Quote(path.Join(releaseDir, "vendor", "bin", "shopware-deployment-helper")))) + if err != nil { + return err + } + + if !strings.Contains(output, "found") { + logging.FromContext(ctx).Warnf("%sNo pre_switch hooks configured and vendor/bin/shopware-deployment-helper is missing, skipping application setup. Consider requiring shopware/deployment-helper in your project", d.logPrefix(host)) + return nil + } + + return d.runHooks(ctx, host, []string{"vendor/bin/shopware-deployment-helper run"}, releaseDir) +} + +func (d *sshDeployer) runHooks(ctx context.Context, host deployHost, hooks []string, dir string) error { + for _, hook := range hooks { + logging.FromContext(ctx).Infof("%sRunning remote hook: %s", d.logPrefix(host), hook) + + output, err := host.conn.Run(ctx, fmt.Sprintf("cd %s && { %s; }", shell.Quote(dir), hook)) + if err != nil { + return fmt.Errorf("remote hook %q failed: %w", hook, err) + } + + if trimmed := strings.TrimSpace(output); trimmed != "" { + logging.FromContext(ctx).Infof("%s%s", d.logPrefix(host), trimmed) + } + } + + return nil +} + +func (d *sshDeployer) switchCurrent(ctx context.Context, host deployHost, releaseDir string) error { + tmpLink := d.currentPath() + ".tmp" + + // creating a new symlink and renaming it over the old one is atomic, + // ln -sfn on its own is not + _, err := host.conn.Run(ctx, fmt.Sprintf("ln -sfn %s %s && mv -fT %s %s", shell.Quote(releaseDir), shell.Quote(tmpLink), shell.Quote(tmpLink), shell.Quote(d.currentPath()))) + + return err +} + +func (d *sshDeployer) Releases(ctx context.Context) ([]HostReleases, error) { + result := make([]HostReleases, len(d.hosts)) + + group, groupCtx := errgroup.WithContext(ctx) + + for i, host := range d.hosts { + group.Go(func() error { + releases, err := d.hostReleases(groupCtx, host) + if err != nil { + return fmt.Errorf("host %s: %w", host.name, err) + } + + result[i] = HostReleases{Host: host.name, Releases: releases} + + return nil + }) + } + + if err := group.Wait(); err != nil { + return nil, err + } + + return result, nil +} + +func (d *sshDeployer) hostReleases(ctx context.Context, host deployHost) ([]Release, error) { + output, err := host.conn.Run(ctx, fmt.Sprintf("ls -1 %s 2>/dev/null || true", shell.Quote(d.releasesPath()))) + if err != nil { + return nil, err + } + + activeOutput, err := host.conn.Run(ctx, fmt.Sprintf("readlink %s 2>/dev/null || true", shell.Quote(d.currentPath()))) + if err != nil { + return nil, err + } + + active := path.Base(strings.TrimSpace(activeOutput)) + + badOutput, err := host.conn.Run(ctx, fmt.Sprintf("ls -1 %s/*/%s 2>/dev/null || true", shell.Quote(d.releasesPath()), badReleaseMarker)) + if err != nil { + return nil, err + } + + bad := make(map[string]bool) + for _, line := range strings.Split(strings.TrimSpace(badOutput), "\n") { + if line == "" { + continue + } + + bad[path.Base(path.Dir(line))] = true + } + + var releases []Release + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + name := strings.TrimSpace(line) + if name == "" { + continue + } + + releases = append(releases, Release{ + Name: name, + Active: name == active, + Bad: bad[name], + }) + } + + slices.SortFunc(releases, func(a, b Release) int { + return strings.Compare(a.Name, b.Name) + }) + + return releases, nil +} + +func (d *sshDeployer) Rollback(ctx context.Context, target string) error { + logger := logging.FromContext(ctx) + + hostReleases, err := d.Releases(ctx) + if err != nil { + return err + } + + // the primary host decides which release to roll back to + releases := hostReleases[0].Releases + + activeIdx := slices.IndexFunc(releases, func(r Release) bool { return r.Active }) + if activeIdx == -1 { + return fmt.Errorf("cannot determine the active release, nothing to roll back") + } + + active := releases[activeIdx] + + if target == "" { + for i := activeIdx - 1; i >= 0; i-- { + if releases[i].Bad { + continue + } + + target = releases[i].Name + break + } + + if target == "" { + return fmt.Errorf("no earlier release available to roll back to") + } + } else if target == active.Name { + return fmt.Errorf("release %s is already active", target) + } + + // every host must have the target before any host is switched + for _, hr := range hostReleases { + if !slices.ContainsFunc(hr.Releases, func(r Release) bool { return r.Name == target }) { + return fmt.Errorf("release %s does not exist on host %s", target, hr.Host) + } + } + + logger.Infof("Rolling back from release %s to %s", active.Name, target) + + for _, host := range d.hosts { + if err := d.switchCurrent(ctx, host, path.Join(d.releasesPath(), target)); err != nil { + return fmt.Errorf("host %s: %w", host.name, err) + } + + // mark the release we rolled back from as bad so a later rollback skips it + if _, err := host.conn.Run(ctx, fmt.Sprintf("touch %s", shell.Quote(path.Join(d.releasesPath(), active.Name, badReleaseMarker)))); err != nil { + logger.Warnf("%sCannot mark release %s as bad: %v", d.logPrefix(host), active.Name, err) + } + } + + for _, host := range d.hosts { + if err := d.runHooks(ctx, host, d.config.Hooks.PostSwitch, d.currentPath()); err != nil { + return fmt.Errorf("host %s: %w", host.name, err) + } + } + + logger.Infof("Rolled back to release %s", target) + + return nil +} + +func (d *sshDeployer) cleanupReleases(ctx context.Context, host deployHost) error { + releases, err := d.hostReleases(ctx, host) + if err != nil { + return err + } + + keep := d.keepReleases() + if len(releases) <= keep { + return nil + } + + // releases are sorted ascending, the oldest come first + obsolete := releases[:len(releases)-keep] + + for _, release := range obsolete { + if release.Active { + continue + } + + logging.FromContext(ctx).Infof("%sRemoving old release %s", d.logPrefix(host), release.Name) + + if _, err := host.conn.Run(ctx, fmt.Sprintf("rm -rf %s", shell.Quote(path.Join(d.releasesPath(), release.Name)))); err != nil { + return err + } + } + + return nil +} + +func (d *sshDeployer) Close() error { + var errs []error + + for _, host := range d.hosts { + if err := host.conn.Close(); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/internal/deployment/ssh_deployer_test.go b/internal/deployment/ssh_deployer_test.go new file mode 100644 index 00000000..969580a6 --- /dev/null +++ b/internal/deployment/ssh_deployer_test.go @@ -0,0 +1,448 @@ +package deployment + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/shop" +) + +// fakeConnection records executed commands and answers them with canned responses. +type fakeConnection struct { + commands []string + streamed []string + responses map[string]string + failures map[string]error + closed bool +} + +func (f *fakeConnection) Run(_ context.Context, command string) (string, error) { + f.commands = append(f.commands, command) + + for prefix, err := range f.failures { + if strings.Contains(command, prefix) { + return "", err + } + } + + for prefix, response := range f.responses { + if strings.Contains(command, prefix) { + return response, nil + } + } + + return "", nil +} + +func (f *fakeConnection) Stream(_ context.Context, command string, stdin io.Reader) error { + _, _ = io.Copy(io.Discard, stdin) + f.streamed = append(f.streamed, command) + return nil +} + +func (f *fakeConnection) Close() error { + f.closed = true + return nil +} + +func newTestDeployer(t *testing.T, conn *fakeConnection, cfg *shop.EnvironmentDeployment) *sshDeployer { + t.Helper() + + return newMultiHostTestDeployer(t, []deployHost{{name: "server1", conn: conn}}, cfg) +} + +func newMultiHostTestDeployer(t *testing.T, hosts []deployHost, cfg *shop.EnvironmentDeployment) *sshDeployer { + t.Helper() + + if cfg == nil { + cfg = &shop.EnvironmentDeployment{} + } + + return &sshDeployer{ + projectRoot: t.TempDir(), + deployPath: "/var/www/shop", + config: cfg, + hosts: hosts, + now: func() time.Time { return time.Date(2026, 7, 7, 12, 30, 45, 0, time.UTC) }, + runLocal: func(context.Context, string, string) error { return nil }, + } +} + +func commandsContaining(commands []string, needle string) []string { + var matches []string + for _, command := range commands { + if strings.Contains(command, needle) { + matches = append(matches, command) + } + } + return matches +} + +func TestDeployCreatesReleaseAndSwitchesSymlink(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "20260707123045\n", + "readlink": "/var/www/shop/releases/20260707123045\n", + }, + } + + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Deploy(t.Context(), Options{}) + assert.NoError(t, err) + + // structure setup + assert.Contains(t, conn.commands, "mkdir -p '/var/www/shop/releases' '/var/www/shop/shared'") + assert.Contains(t, conn.commands, "mkdir -p '/var/www/shop/releases/20260707123045'") + + // upload streamed into the release directory + assert.Equal(t, []string{"tar -xzpf - -C '/var/www/shop/releases/20260707123045'"}, conn.streamed) + + // atomic symlink switch + assert.Contains(t, conn.commands, "ln -sfn '/var/www/shop/releases/20260707123045' '/var/www/shop/current.tmp' && mv -fT '/var/www/shop/current.tmp' '/var/www/shop/current'") +} + +func TestDeployLinksDefaultSharedPaths(t *testing.T) { + conn := &fakeConnection{} + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Deploy(t.Context(), Options{}) + assert.NoError(t, err) + + links := commandsContaining(conn.commands, "ln -sfn '/var/www/shop/shared/") + + assert.Len(t, links, len(defaultSharedDirs)+len(defaultSharedFiles)) + assert.NotEmpty(t, commandsContaining(links, "'/var/www/shop/shared/public/media'")) + assert.NotEmpty(t, commandsContaining(links, "'/var/www/shop/shared/.env'")) +} + +func TestDeployRunsConfiguredHooks(t *testing.T) { + var localHooks []string + + conn := &fakeConnection{} + deployer := newTestDeployer(t, conn, &shop.EnvironmentDeployment{ + Hooks: shop.EnvironmentDeploymentHooks{ + Build: []string{"shopware-cli project ci ."}, + PreSwitch: []string{"php bin/console system:setup"}, + PostSwitch: []string{"php bin/console cache:pool:clear cache.http"}, + }, + }) + deployer.runLocal = func(_ context.Context, _ string, command string) error { + localHooks = append(localHooks, command) + return nil + } + + err := deployer.Deploy(t.Context(), Options{}) + assert.NoError(t, err) + + assert.Equal(t, []string{"shopware-cli project ci ."}, localHooks) + assert.Contains(t, conn.commands, "cd '/var/www/shop/releases/20260707123045' && { php bin/console system:setup; }") + assert.Contains(t, conn.commands, "cd '/var/www/shop/current' && { php bin/console cache:pool:clear cache.http; }") + + // no deployment-helper detection when hooks are configured + assert.Empty(t, commandsContaining(conn.commands, "shopware-deployment-helper")) +} + +func TestDeploySkipsBuildHooks(t *testing.T) { + conn := &fakeConnection{} + deployer := newTestDeployer(t, conn, &shop.EnvironmentDeployment{ + Hooks: shop.EnvironmentDeploymentHooks{Build: []string{"exit 1"}}, + }) + deployer.runLocal = func(context.Context, string, string) error { + t.Fatal("build hook should not run") + return nil + } + + err := deployer.Deploy(t.Context(), Options{SkipBuildHooks: true}) + assert.NoError(t, err) +} + +func TestDeployUsesDeploymentHelperWhenPresent(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "test -f '/var/www/shop/releases/20260707123045/vendor/bin/shopware-deployment-helper'": "found\n", + }, + } + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Deploy(t.Context(), Options{}) + assert.NoError(t, err) + + assert.Contains(t, conn.commands, "cd '/var/www/shop/releases/20260707123045' && { vendor/bin/shopware-deployment-helper run; }") +} + +func TestDeployRefusesUnmanagedCurrent(t *testing.T) { + conn := &fakeConnection{ + failures: map[string]error{ + "[ ! -e '/var/www/shop/current' ]": fmt.Errorf("exit 1"), + }, + } + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Deploy(t.Context(), Options{}) + assert.ErrorContains(t, err, "not a symlink") + assert.Empty(t, conn.streamed) +} + +func TestDeployCleansUpOldReleases(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "1\n2\n3\n4\n", + "readlink": "/var/www/shop/releases/4\n", + }, + } + deployer := newTestDeployer(t, conn, &shop.EnvironmentDeployment{KeepReleases: 2}) + + err := deployer.Deploy(t.Context(), Options{}) + assert.NoError(t, err) + + var removals []string + for _, command := range conn.commands { + if strings.HasPrefix(command, "rm -rf ") { + removals = append(removals, command) + } + } + + assert.ElementsMatch(t, []string{ + "rm -rf '/var/www/shop/releases/1'", + "rm -rf '/var/www/shop/releases/2'", + }, removals) +} + +func TestReleases(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "3\n1\n2\n", + "readlink": "/var/www/shop/releases/2\n", + "BAD_RELEASE": "/var/www/shop/releases/3/BAD_RELEASE\n", + }, + } + deployer := newTestDeployer(t, conn, nil) + + releases, err := deployer.Releases(t.Context()) + assert.NoError(t, err) + + assert.Equal(t, []HostReleases{ + { + Host: "server1", + Releases: []Release{ + {Name: "1"}, + {Name: "2", Active: true}, + {Name: "3", Bad: true}, + }, + }, + }, releases) +} + +func TestRollbackToPreviousRelease(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "1\n2\n3\n", + "readlink": "/var/www/shop/releases/3\n", + }, + } + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Rollback(t.Context(), "") + assert.NoError(t, err) + + assert.Contains(t, conn.commands, "ln -sfn '/var/www/shop/releases/2' '/var/www/shop/current.tmp' && mv -fT '/var/www/shop/current.tmp' '/var/www/shop/current'") + assert.Contains(t, conn.commands, "touch '/var/www/shop/releases/3/BAD_RELEASE'") +} + +func TestRollbackSkipsBadReleases(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "1\n2\n3\n", + "readlink": "/var/www/shop/releases/3\n", + "BAD_RELEASE": "/var/www/shop/releases/2/BAD_RELEASE\n", + }, + } + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Rollback(t.Context(), "") + assert.NoError(t, err) + + assert.Contains(t, conn.commands, "ln -sfn '/var/www/shop/releases/1' '/var/www/shop/current.tmp' && mv -fT '/var/www/shop/current.tmp' '/var/www/shop/current'") +} + +func TestRollbackToExplicitRelease(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "1\n2\n3\n", + "readlink": "/var/www/shop/releases/3\n", + }, + } + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Rollback(t.Context(), "1") + assert.NoError(t, err) + + assert.Contains(t, conn.commands, "ln -sfn '/var/www/shop/releases/1' '/var/www/shop/current.tmp' && mv -fT '/var/www/shop/current.tmp' '/var/www/shop/current'") +} + +func TestRollbackErrors(t *testing.T) { + cases := []struct { + name string + target string + releases string + active string + expected string + }{ + { + name: "no active release", + releases: "1\n2\n", + active: "", + expected: "cannot determine the active release", + }, + { + name: "no earlier release", + releases: "1\n", + active: "/var/www/shop/releases/1\n", + expected: "no earlier release available", + }, + { + name: "already active", + target: "2", + releases: "1\n2\n", + active: "/var/www/shop/releases/2\n", + expected: "already active", + }, + { + name: "unknown release", + target: "5", + releases: "1\n2\n", + active: "/var/www/shop/releases/2\n", + expected: "does not exist", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + conn := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": tc.releases, + "readlink": tc.active, + }, + } + deployer := newTestDeployer(t, conn, nil) + + err := deployer.Rollback(t.Context(), tc.target) + assert.ErrorContains(t, err, tc.expected) + }) + } +} + +func TestNewDeployerRequiresSupportedType(t *testing.T) { + _, err := NewDeployer(t.TempDir(), &shop.EnvironmentConfig{Type: "local"}, &shop.Config{}) + assert.ErrorContains(t, err, "does not support deployments") +} + +func TestNewSSHDeployerRequiresDeploymentPath(t *testing.T) { + _, err := newSSHDeployer(t.TempDir(), &shop.EnvironmentConfig{ + Type: "ssh", + SSH: &shop.EnvironmentSSH{Host: "example.com"}, + }, &shop.Config{}) + assert.ErrorContains(t, err, "ssh.deployment.path") +} + +func TestMultiHostDeployRunsOnAllHosts(t *testing.T) { + conn1 := &fakeConnection{} + conn2 := &fakeConnection{} + deployer := newMultiHostTestDeployer(t, []deployHost{ + {name: "web1", conn: conn1}, + {name: "web2", conn: conn2}, + }, &shop.EnvironmentDeployment{ + Hooks: shop.EnvironmentDeploymentHooks{ + PreSwitch: []string{"echo setup"}, + PostSwitch: []string{"echo done"}, + }, + }) + + err := deployer.Deploy(t.Context(), Options{}) + assert.NoError(t, err) + + for _, conn := range []*fakeConnection{conn1, conn2} { + assert.Equal(t, []string{"tar -xzpf - -C '/var/www/shop/releases/20260707123045'"}, conn.streamed) + assert.Contains(t, conn.commands, "cd '/var/www/shop/releases/20260707123045' && { echo setup; }") + assert.Contains(t, conn.commands, "ln -sfn '/var/www/shop/releases/20260707123045' '/var/www/shop/current.tmp' && mv -fT '/var/www/shop/current.tmp' '/var/www/shop/current'") + assert.Contains(t, conn.commands, "cd '/var/www/shop/current' && { echo done; }") + } +} + +func TestMultiHostDeployAbortsBeforeSwitchWhenHostFails(t *testing.T) { + conn1 := &fakeConnection{} + conn2 := &fakeConnection{ + failures: map[string]error{"echo setup": fmt.Errorf("exit 1")}, + } + deployer := newMultiHostTestDeployer(t, []deployHost{ + {name: "web1", conn: conn1}, + {name: "web2", conn: conn2}, + }, &shop.EnvironmentDeployment{ + Hooks: shop.EnvironmentDeploymentHooks{PreSwitch: []string{"echo setup"}}, + }) + + err := deployer.Deploy(t.Context(), Options{}) + assert.ErrorContains(t, err, "host web2") + + // no host was switched, the running release stays untouched everywhere + assert.Empty(t, commandsContaining(conn1.commands, "mv -fT")) + assert.Empty(t, commandsContaining(conn2.commands, "mv -fT")) +} + +func TestMultiHostRollbackRequiresReleaseOnAllHosts(t *testing.T) { + conn1 := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "1\n2\n", + "readlink": "/var/www/shop/releases/2\n", + }, + } + conn2 := &fakeConnection{ + responses: map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "2\n", + "readlink": "/var/www/shop/releases/2\n", + }, + } + deployer := newMultiHostTestDeployer(t, []deployHost{ + {name: "web1", conn: conn1}, + {name: "web2", conn: conn2}, + }, nil) + + err := deployer.Rollback(t.Context(), "") + assert.ErrorContains(t, err, "release 1 does not exist on host web2") + + assert.Empty(t, commandsContaining(conn1.commands, "mv -fT")) + assert.Empty(t, commandsContaining(conn2.commands, "mv -fT")) +} + +func TestMultiHostRollbackSwitchesAllHosts(t *testing.T) { + responses := map[string]string{ + "ls -1 '/var/www/shop/releases' 2>/dev/null": "1\n2\n", + "readlink": "/var/www/shop/releases/2\n", + } + conn1 := &fakeConnection{responses: responses} + conn2 := &fakeConnection{responses: responses} + deployer := newMultiHostTestDeployer(t, []deployHost{ + {name: "web1", conn: conn1}, + {name: "web2", conn: conn2}, + }, nil) + + err := deployer.Rollback(t.Context(), "") + assert.NoError(t, err) + + for _, conn := range []*fakeConnection{conn1, conn2} { + assert.Contains(t, conn.commands, "ln -sfn '/var/www/shop/releases/1' '/var/www/shop/current.tmp' && mv -fT '/var/www/shop/current.tmp' '/var/www/shop/current'") + assert.Contains(t, conn.commands, "touch '/var/www/shop/releases/2/BAD_RELEASE'") + } +} + +func TestNewSSHDeployerRequiresHost(t *testing.T) { + _, err := newSSHDeployer(t.TempDir(), &shop.EnvironmentConfig{Type: "ssh"}, &shop.Config{}) + assert.ErrorContains(t, err, "ssh.host") +} diff --git a/internal/executor/database.go b/internal/executor/database.go new file mode 100644 index 00000000..74092f45 --- /dev/null +++ b/internal/executor/database.go @@ -0,0 +1,92 @@ +package executor + +import ( + "context" + "fmt" + "net" + "net/url" + "os" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + + "github.com/shopware/shopware-cli/internal/envfile" + "github.com/shopware/shopware-cli/logging" +) + +// defaultMySQLConfig returns the connection defaults used when no +// DATABASE_URL is available. +func defaultMySQLConfig() *mysql.Config { + return &mysql.Config{ + Loc: time.UTC, + Net: "tcp", + ParseTime: false, + AllowNativePasswords: true, + CheckConnLiveness: true, + User: "root", + Passwd: "root", + Addr: "127.0.0.1:3306", + DBName: "shopware", + } +} + +// applyDatabaseURL applies a Symfony DATABASE_URL to the connection config. +func applyDatabaseURL(cfg *mysql.Config, databaseURL string) error { + parsedURI, err := url.Parse(databaseURL) + if err != nil { + return fmt.Errorf("could not parse DATABASE_URL: %w", err) + } + + if parsedURI.User != nil { + cfg.User = parsedURI.User.Username() + + if password, ok := parsedURI.User.Password(); ok { + cfg.Passwd = password + } else { + // Reset password if it is not set + cfg.Passwd = "" + } + } + + if parsedURI.Host != "" { + cfg.Addr = parsedURI.Host + + if parsedURI.Port() == "" { + cfg.Addr = net.JoinHostPort(parsedURI.Host, "3306") + } + } + + if parsedURI.Path != "" { + cfg.DBName = strings.Trim(parsedURI.Path, "/") + } + + return nil +} + +// localDatabaseConnection builds the connection config from the project's +// local Symfony env files. +func localDatabaseConnection(ctx context.Context, projectRoot string) (*mysql.Config, error) { + cfg := defaultMySQLConfig() + + if projectRoot == "" { + return cfg, nil + } + + if err := envfile.LoadSymfonyEnvFile(projectRoot); err != nil { + return nil, err + } + + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + return cfg, nil + } + + logging.FromContext(ctx).Info("Using DATABASE_URL env as default connection string. options can override specific parts (--username=foo)") + + if err := applyDatabaseURL(cfg, databaseURL); err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/internal/executor/database_test.go b/internal/executor/database_test.go new file mode 100644 index 00000000..b19c7064 --- /dev/null +++ b/internal/executor/database_test.go @@ -0,0 +1,143 @@ +package executor + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/shop" +) + +func TestApplyDatabaseURL(t *testing.T) { + cases := []struct { + name string + url string + addr string + user string + password string + dbName string + }{ + { + name: "full url", + url: "mysql://app:secret@db.internal:3307/shopware_prod", + addr: "db.internal:3307", + user: "app", + password: "secret", + dbName: "shopware_prod", + }, + { + name: "default port", + url: "mysql://app:secret@db.internal/shopware", + addr: "db.internal:3306", + user: "app", + password: "secret", + dbName: "shopware", + }, + { + name: "no password resets default", + url: "mysql://app@db.internal:3306/shopware", + addr: "db.internal:3306", + user: "app", + password: "", + dbName: "shopware", + }, + { + name: "percent encoded password", + url: "mysql://app:p%40ss@db.internal:3306/shopware", + addr: "db.internal:3306", + user: "app", + password: "p@ss", + dbName: "shopware", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := defaultMySQLConfig() + + assert.NoError(t, applyDatabaseURL(cfg, tc.url)) + assert.Equal(t, tc.addr, cfg.Addr) + assert.Equal(t, tc.user, cfg.User) + assert.Equal(t, tc.password, cfg.Passwd) + assert.Equal(t, tc.dbName, cfg.DBName) + }) + } +} + +func newDatabaseTestSSHExecutor(t *testing.T, envFiles map[string]map[string]string) *SSHExecutor { + t.Helper() + + envCfg := &shop.EnvironmentConfig{ + Type: TypeSSH, + SSH: &shop.EnvironmentSSH{ + Host: "shop.example.com", + Deployment: &shop.EnvironmentDeployment{Path: "/var/www/shopware"}, + }, + } + + executor, err := newSSHExecutor("", envCfg, &shop.Config{}) + assert.NoError(t, err) + + executor.readEnvFiles = func(_ context.Context, names ...string) (map[string]string, error) { + merged := map[string]string{} + for _, name := range names { + for k, v := range envFiles[name] { + merged[k] = v + } + } + + return merged, nil + } + + return executor +} + +func TestSSHDatabaseConnection(t *testing.T) { + executor := newDatabaseTestSSHExecutor(t, map[string]map[string]string{ + ".env": {"DATABASE_URL": "mysql://app:secret@10.0.0.5:3307/shop"}, + }) + + cfg, err := executor.DatabaseConnection(t.Context()) + assert.NoError(t, err) + + // the address stays the server-side view, only the transport changes + assert.Equal(t, "10.0.0.5:3307", cfg.Addr) + assert.Equal(t, "app", cfg.User) + assert.Equal(t, "secret", cfg.Passwd) + assert.Equal(t, "shop", cfg.DBName) + assert.Equal(t, sshMySQLNetwork, cfg.Net) +} + +func TestSSHDatabaseConnectionAppEnvOverrides(t *testing.T) { + executor := newDatabaseTestSSHExecutor(t, map[string]map[string]string{ + ".env": {"APP_ENV": "prod", "DATABASE_URL": "mysql://base@base:3306/base"}, + ".env.local": {"DATABASE_URL": "mysql://local@local:3306/local"}, + ".env.prod.local": {"DATABASE_URL": "mysql://prod@prod:3306/prod"}, + }) + + cfg, err := executor.DatabaseConnection(t.Context()) + assert.NoError(t, err) + + assert.Equal(t, "prod:3306", cfg.Addr) + assert.Equal(t, "prod", cfg.DBName) +} + +func TestSSHDatabaseConnectionWithoutDatabaseURL(t *testing.T) { + executor := newDatabaseTestSSHExecutor(t, map[string]map[string]string{}) + + cfg, err := executor.DatabaseConnection(t.Context()) + assert.NoError(t, err) + + // defaults stay, flags can override, but the transport is still ssh + assert.Equal(t, "127.0.0.1:3306", cfg.Addr) + assert.Equal(t, sshMySQLNetwork, cfg.Net) +} + +func TestLocalDatabaseConnection(t *testing.T) { + cfg, err := (&LocalExecutor{}).DatabaseConnection(t.Context()) + assert.NoError(t, err) + + assert.Equal(t, "tcp", cfg.Net) + assert.Equal(t, "127.0.0.1:3306", cfg.Addr) +} diff --git a/internal/executor/docker.go b/internal/executor/docker.go index 326de01c..d00f0b57 100644 --- a/internal/executor/docker.go +++ b/internal/executor/docker.go @@ -8,6 +8,8 @@ import ( "strings" "syscall" + "github.com/go-sql-driver/mysql" + adminSdk "github.com/shopware/shopware-cli/internal/admin-api" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/internal/system" @@ -104,6 +106,10 @@ func (d *DockerExecutor) AdminAPIClient(ctx context.Context) (*adminSdk.Client, return adminAPIClient(ctx, d.shopCfg, d.envCfg) } +func (d *DockerExecutor) DatabaseConnection(ctx context.Context) (*mysql.Config, error) { + return localDatabaseConnection(ctx, d.projectRoot) +} + func (d *DockerExecutor) containerWorkdir() string { if d.relDir == "" { return "/var/www/html" diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 179256c9..46a67104 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -10,6 +10,8 @@ import ( "strings" "sync" + "github.com/go-sql-driver/mysql" + adminSdk "github.com/shopware/shopware-cli/internal/admin-api" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" @@ -21,6 +23,7 @@ const ( TypeDocker = "docker" TypeLocal = "local" TypeSymfonyCLI = "symfony-cli" + TypeSSH = "ssh" ) type Executor interface { @@ -36,6 +39,10 @@ type Executor interface { StopEnvironment(ctx context.Context) error EnvironmentStatus(ctx context.Context) (bool, error) AdminAPIClient(ctx context.Context) (*adminSdk.Client, error) + // DatabaseConnection returns the MySQL connection config of the + // environment's database. Remote executors route the returned config + // through their own transport, so it is usable from the local machine. + DatabaseConnection(ctx context.Context) (*mysql.Config, error) } func adminAPIClient(ctx context.Context, cfg *shop.Config, envCfg *shop.EnvironmentConfig) (*adminSdk.Client, error) { diff --git a/internal/executor/factory.go b/internal/executor/factory.go index 3ba74e27..2028a0ea 100644 --- a/internal/executor/factory.go +++ b/internal/executor/factory.go @@ -26,6 +26,8 @@ func New(projectRoot string, cfg *shop.EnvironmentConfig, shopCfg *shop.Config) return &SymfonyCLIExecutor{BinaryPath: path, projectRoot: projectRoot, shopCfg: shopCfg, envCfg: cfg}, nil case TypeDocker: return &DockerExecutor{projectRoot: projectRoot, shopCfg: shopCfg, envCfg: cfg}, nil + case TypeSSH: + return newSSHExecutor(projectRoot, cfg, shopCfg) default: return nil, fmt.Errorf("unsupported environment type: %s", cfg.Type) } diff --git a/internal/executor/local.go b/internal/executor/local.go index 95024455..5061252e 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" + "github.com/go-sql-driver/mysql" + adminSdk "github.com/shopware/shopware-cli/internal/admin-api" "github.com/shopware/shopware-cli/internal/shop" ) @@ -72,6 +74,10 @@ func (l *LocalExecutor) AdminAPIClient(ctx context.Context) (*adminSdk.Client, e return adminAPIClient(ctx, l.shopCfg, l.envCfg) } +func (l *LocalExecutor) DatabaseConnection(ctx context.Context) (*mysql.Config, error) { + return localDatabaseConnection(ctx, l.projectRoot) +} + func (l *LocalExecutor) StartEnvironment(_ context.Context) error { return ErrNotSupported } diff --git a/internal/executor/ssh.go b/internal/executor/ssh.go new file mode 100644 index 00000000..7f9c812d --- /dev/null +++ b/internal/executor/ssh.go @@ -0,0 +1,264 @@ +package executor + +import ( + "context" + "fmt" + "maps" + "net" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "github.com/go-sql-driver/mysql" + "github.com/joho/godotenv" + "github.com/mattn/go-isatty" + + adminSdk "github.com/shopware/shopware-cli/internal/admin-api" + "github.com/shopware/shopware-cli/internal/shell" + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/sshcmd" + "github.com/shopware/shopware-cli/logging" +) + +// SSHExecutor runs commands on the primary host of an ssh environment inside +// the currently deployed release (/current). It wraps the +// system ssh client, so the user's SSH agent and configuration are honored, +// interactive commands work like a plain ssh session and the multiplexed +// connection (ControlMaster) is shared with deployments. +type SSHExecutor struct { + env map[string]string + projectRoot string + relDir string + shopCfg *shop.Config + envCfg *shop.EnvironmentConfig + + // readEnvFiles is injected for tests + readEnvFiles func(ctx context.Context, names ...string) (map[string]string, error) +} + +// newSSHExecutor validates the environment and returns an SSHExecutor. +func newSSHExecutor(projectRoot string, envCfg *shop.EnvironmentConfig, shopCfg *shop.Config) (*SSHExecutor, error) { + if envCfg.SSH == nil || envCfg.SSH.Host == "" { + return nil, fmt.Errorf("the environment is missing the ssh.host setting") + } + + if envCfg.SSH.Deployment == nil || envCfg.SSH.Deployment.Path == "" { + return nil, fmt.Errorf("the environment is missing the ssh.deployment.path setting") + } + + executor := &SSHExecutor{projectRoot: projectRoot, shopCfg: shopCfg, envCfg: envCfg} + executor.readEnvFiles = executor.readRemoteEnvFiles + + return executor, nil +} + +// stdinIsTerminal is a variable so tests produce stable ssh arguments. +var stdinIsTerminal = func() bool { + return isatty.IsTerminal(os.Stdin.Fd()) +} + +func (s *SSHExecutor) ConsoleCommand(ctx context.Context, args ...string) *Process { + return s.command(ctx, append([]string{"php", consoleCommandName(ctx)}, args...)) +} + +func (s *SSHExecutor) ComposerCommand(ctx context.Context, args ...string) *Process { + return s.command(ctx, append([]string{"composer"}, args...)) +} + +func (s *SSHExecutor) PHPCommand(ctx context.Context, args ...string) *Process { + return s.command(ctx, append([]string{"php"}, args...)) +} + +func (s *SSHExecutor) NPMCommand(ctx context.Context, args ...string) *Process { + return s.command(ctx, append([]string{"npm"}, args...)) +} + +func (s *SSHExecutor) command(ctx context.Context, argv []string) *Process { + var extraArgs []string + + // allocate a TTY when we have one, so interactive console commands + // (prompts, progress bars, colors) behave like a plain ssh session + if stdinIsTerminal() { + extraArgs = append(extraArgs, "-t") + } + + cmd := sshcmd.Build(ctx, s.envCfg.SSH, s.remoteCommand(argv), extraArgs...) + logCmd(ctx, cmd) + + return newProcess(cmd) +} + +// remoteBase is the root of the currently deployed release on the server. +func (s *SSHExecutor) remoteBase() string { + return path.Join(strings.TrimRight(s.envCfg.SSH.Deployment.Path, "/"), "current") +} + +func (s *SSHExecutor) remoteWorkdir() string { + if s.relDir == "" { + return s.remoteBase() + } + + return path.Join(s.remoteBase(), filepath.ToSlash(s.relDir)) +} + +// remoteCommand builds the shell command executed on the server: change into +// the deployed release, apply the environment and run the quoted argv. +func (s *SSHExecutor) remoteCommand(argv []string) string { + parts := make([]string, 0, len(s.env)+len(argv)) + + for _, k := range slices.Sorted(maps.Keys(s.env)) { + parts = append(parts, k+"="+shell.Quote(s.env[k])) + } + + for _, arg := range argv { + parts = append(parts, shell.Quote(arg)) + } + + return "cd " + shell.Quote(s.remoteWorkdir()) + " && " + strings.Join(parts, " ") +} + +func (s *SSHExecutor) NormalizePath(hostPath string) string { + if s.projectRoot == "" { + return hostPath + } + + rel, err := filepath.Rel(s.projectRoot, hostPath) + if err != nil { + return hostPath + } + + return path.Join(s.remoteBase(), filepath.ToSlash(rel)) +} + +func (s *SSHExecutor) Type() string { + return TypeSSH +} + +func (s *SSHExecutor) WithEnv(env map[string]string) Executor { + projectRootEnv := []string{"PROJECT_ROOT", "ADMIN_ROOT", "STOREFRONT_ROOT"} + + for _, k := range projectRootEnv { + if _, ok := env[k]; ok { + if strings.HasPrefix(env[k], s.projectRoot) { + env[k] = s.NormalizePath(env[k]) + } + } + } + + return &SSHExecutor{env: mergeEnv(s.env, env), projectRoot: s.projectRoot, relDir: s.relDir, shopCfg: s.shopCfg, envCfg: s.envCfg, readEnvFiles: s.readEnvFiles} +} + +func (s *SSHExecutor) WithRelDir(relDir string) Executor { + return &SSHExecutor{env: s.env, projectRoot: s.projectRoot, relDir: relDir, shopCfg: s.shopCfg, envCfg: s.envCfg, readEnvFiles: s.readEnvFiles} +} + +func (s *SSHExecutor) AdminAPIClient(ctx context.Context) (*adminSdk.Client, error) { + return adminAPIClient(ctx, s.shopCfg, s.envCfg) +} + +// sshMySQLNetwork is the network name registered with the MySQL driver for +// connections that are dialed through the ssh client. +const sshMySQLNetwork = "ssh+tcp" + +// DatabaseConnection reads the deployed release's Symfony env files and +// returns a connection config whose transport is dialed through ssh, so the +// database is reachable from the local machine even when it only listens on +// the server. +func (s *SSHExecutor) DatabaseConnection(ctx context.Context) (*mysql.Config, error) { + cfg := defaultMySQLConfig() + + env, err := s.remoteEnv(ctx) + if err != nil { + return nil, err + } + + if databaseURL := env["DATABASE_URL"]; databaseURL != "" { + logging.FromContext(ctx).Infof("Using DATABASE_URL of the deployed release. options can override specific parts (--username=foo)") + + if err := applyDatabaseURL(cfg, databaseURL); err != nil { + return nil, err + } + } else { + logging.FromContext(ctx).Warnf("No DATABASE_URL found in the deployed release's env files, using defaults. Use the connection options (--host, --username, ...) to override") + } + + // the address stays the database address as seen from the server; the + // registered dialer opens the stream through the ssh connection + sshCfg := s.envCfg.SSH + mysql.RegisterDialContext(sshMySQLNetwork, func(_ context.Context, addr string) (net.Conn, error) { + return sshcmd.Dial(sshCfg, addr) + }) + + cfg.Net = sshMySQLNetwork + + return cfg, nil +} + +// remoteEnv reads the Symfony env files of the deployed release with the same +// precedence as a local project (.env.dist < .env < .env.local, then the +// APP_ENV specific variants). +func (s *SSHExecutor) remoteEnv(ctx context.Context) (map[string]string, error) { + base, err := s.readEnvFiles(ctx, ".env.dist", ".env", ".env.local") + if err != nil { + return nil, err + } + + appEnv := base["APP_ENV"] + if appEnv == "" { + return base, nil + } + + specific, err := s.readEnvFiles(ctx, ".env."+appEnv, ".env."+appEnv+".local") + if err != nil { + return nil, err + } + + maps.Copy(base, specific) + + return base, nil +} + +// readRemoteEnvFiles concatenates the given env files of the deployed release +// in order and parses them, so later files override earlier ones. +func (s *SSHExecutor) readRemoteEnvFiles(ctx context.Context, names ...string) (map[string]string, error) { + reads := make([]string, 0, len(names)) + for _, name := range names { + // the echo separates files that do not end with a newline + reads = append(reads, fmt.Sprintf("cat %s 2>/dev/null; echo", shell.Quote(path.Join(s.remoteBase(), name)))) + } + + output, err := sshcmd.Output(ctx, s.envCfg.SSH, strings.Join(reads, "; ")) + if err != nil { + return nil, fmt.Errorf("cannot read env files of the deployed release: %w", err) + } + + env, err := godotenv.Unmarshal(output) + if err != nil { + return nil, fmt.Errorf("cannot parse env files of the deployed release: %w", err) + } + + return env, nil +} + +func (s *SSHExecutor) StartEnvironment(_ context.Context) error { + return ErrNotSupported +} + +func (s *SSHExecutor) StopEnvironment(_ context.Context) error { + return ErrNotSupported +} + +// EnvironmentStatus reports whether a release is deployed and linked on the server. +func (s *SSHExecutor) EnvironmentStatus(ctx context.Context) (bool, error) { + cmd := sshcmd.Build(ctx, s.envCfg.SSH, "test -e "+shell.Quote(s.remoteBase())) + logCmd(ctx, cmd) + + // a non-zero exit of test -e means no release is deployed, not a failure + if err := cmd.Run(); err != nil { + return false, nil //nolint:nilerr + } + + return true, nil +} diff --git a/internal/executor/ssh_test.go b/internal/executor/ssh_test.go new file mode 100644 index 00000000..11fc7bd4 --- /dev/null +++ b/internal/executor/ssh_test.go @@ -0,0 +1,198 @@ +package executor + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/sshcmd" +) + +func stubSSHEnvironment(t *testing.T) { + t.Helper() + + oldTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + + oldSocketDir := sshcmd.ControlSocketDir + sshcmd.ControlSocketDir = func() string { return "/home/user/.ssh" } + + t.Cleanup(func() { + stdinIsTerminal = oldTerminal + sshcmd.ControlSocketDir = oldSocketDir + }) +} + +// controlMasterArgs are the multiplexing arguments expected by default; on +// Windows ControlMaster is unsupported and never emitted. +var controlMasterArgs = func() []string { + if runtime.GOOS == "windows" { + return nil + } + + return []string{ + "-o", "ControlMaster=auto", + "-o", "ControlPath=/home/user/.ssh/shopware-cli-%C", + "-o", "ControlPersist=60", + } +}() + +func newTestSSHExecutor(t *testing.T) Executor { + t.Helper() + + stubSSHEnvironment(t) + + envCfg := &shop.EnvironmentConfig{ + Type: TypeSSH, + SSH: &shop.EnvironmentSSH{ + Host: "shop.example.com", + Port: 2222, + User: "deploy", + IdentityFile: "/keys/id_ed25519", + Deployment: &shop.EnvironmentDeployment{Path: "/var/www/shopware/"}, + }, + } + + exec, err := New("/local/project", envCfg, &shop.Config{}) + assert.NoError(t, err) + assert.Equal(t, TypeSSH, exec.Type()) + + return exec +} + +func TestSSHExecutorConsoleCommand(t *testing.T) { + exec := newTestSSHExecutor(t) + + p := exec.ConsoleCommand(t.Context(), "cache:clear") + + expected := []string{ + "ssh", + "-p", "2222", + "-i", "/keys/id_ed25519", + } + expected = append(expected, controlMasterArgs...) + expected = append(expected, + "deploy@shop.example.com", + "cd '/var/www/shopware/current' && 'php' 'bin/console' 'cache:clear'", + ) + + assert.Equal(t, expected, p.Cmd.Args) +} + +func TestSSHExecutorControlMasterCanBeDisabled(t *testing.T) { + stubSSHEnvironment(t) + + disabled := false + envCfg := &shop.EnvironmentConfig{ + Type: TypeSSH, + SSH: &shop.EnvironmentSSH{ + Host: "shop.example.com", + ControlMaster: &disabled, + Deployment: &shop.EnvironmentDeployment{Path: "/var/www/shopware"}, + }, + } + + exec, err := New("", envCfg, &shop.Config{}) + assert.NoError(t, err) + + p := exec.PHPCommand(t.Context(), "-v") + + assert.Equal(t, []string{ + "ssh", + "shop.example.com", + "cd '/var/www/shopware/current' && 'php' '-v'", + }, p.Cmd.Args) +} + +func TestSSHExecutorCommandsUseRemoteBinaries(t *testing.T) { + exec := newTestSSHExecutor(t) + + lastArg := func(p *Process) string { + return p.Cmd.Args[len(p.Cmd.Args)-1] + } + + cases := map[string]string{ + "composer": lastArg(exec.ComposerCommand(t.Context(), "install")), + "php": lastArg(exec.PHPCommand(t.Context(), "-v")), + "npm": lastArg(exec.NPMCommand(t.Context(), "ci")), + } + + assert.Contains(t, cases["composer"], "'composer' 'install'") + assert.Contains(t, cases["php"], "'php' '-v'") + assert.Contains(t, cases["npm"], "'npm' 'ci'") + + for _, remote := range cases { + assert.Contains(t, remote, "cd '/var/www/shopware/current' && ") + } +} + +func TestSSHExecutorWithEnvAndRelDir(t *testing.T) { + exec := newTestSSHExecutor(t) + + exec = exec.WithEnv(map[string]string{ + "APP_ENV": "prod", + "PROJECT_ROOT": "/local/project", + }).WithRelDir("custom/plugins") + + p := exec.ConsoleCommand(t.Context(), "cache:clear") + remote := p.Cmd.Args[len(p.Cmd.Args)-1] + + // env variables are sorted, PROJECT_ROOT is mapped to the remote release + assert.Equal(t, "cd '/var/www/shopware/current/custom/plugins' && APP_ENV='prod' PROJECT_ROOT='/var/www/shopware/current' 'php' 'bin/console' 'cache:clear'", remote) +} + +func TestSSHExecutorNormalizePath(t *testing.T) { + exec := newTestSSHExecutor(t) + + assert.Equal(t, "/var/www/shopware/current/custom/plugins/Foo", exec.NormalizePath("/local/project/custom/plugins/Foo")) +} + +func TestSSHExecutorQuotesArguments(t *testing.T) { + exec := newTestSSHExecutor(t) + + p := exec.ConsoleCommand(t.Context(), "system:config:set", "core.basicInformation.shopName", "it's a shop") + remote := p.Cmd.Args[len(p.Cmd.Args)-1] + + assert.Contains(t, remote, `'system:config:set' 'core.basicInformation.shopName' 'it'\''s a shop'`) +} + +func TestSSHExecutorHostKeyOptions(t *testing.T) { + stubSSHEnvironment(t) + + envCfg := &shop.EnvironmentConfig{ + Type: TypeSSH, + SSH: &shop.EnvironmentSSH{ + Host: "shop.example.com", + InsecureIgnoreHostKey: true, + Deployment: &shop.EnvironmentDeployment{Path: "/var/www/shopware"}, + }, + } + + exec, err := New("", envCfg, &shop.Config{}) + assert.NoError(t, err) + + p := exec.PHPCommand(t.Context(), "-v") + + expected := []string{ + "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + } + expected = append(expected, controlMasterArgs...) + expected = append(expected, + "shop.example.com", + "cd '/var/www/shopware/current' && 'php' '-v'", + ) + + assert.Equal(t, expected, p.Cmd.Args) +} + +func TestSSHExecutorRequiresConfig(t *testing.T) { + _, err := New("", &shop.EnvironmentConfig{Type: TypeSSH}, &shop.Config{}) + assert.ErrorContains(t, err, "ssh.host") + + _, err = New("", &shop.EnvironmentConfig{Type: TypeSSH, SSH: &shop.EnvironmentSSH{Host: "example.com"}}, &shop.Config{}) + assert.ErrorContains(t, err, "deployment.path") +} diff --git a/internal/executor/symfony_cli.go b/internal/executor/symfony_cli.go index 39f12316..e87a00ae 100644 --- a/internal/executor/symfony_cli.go +++ b/internal/executor/symfony_cli.go @@ -4,6 +4,8 @@ import ( "context" "os/exec" + "github.com/go-sql-driver/mysql" + adminSdk "github.com/shopware/shopware-cli/internal/admin-api" "github.com/shopware/shopware-cli/internal/shop" ) @@ -75,6 +77,10 @@ func (s *SymfonyCLIExecutor) AdminAPIClient(ctx context.Context) (*adminSdk.Clie return adminAPIClient(ctx, s.shopCfg, s.envCfg) } +func (s *SymfonyCLIExecutor) DatabaseConnection(ctx context.Context) (*mysql.Config, error) { + return localDatabaseConnection(ctx, s.projectRoot) +} + func (s *SymfonyCLIExecutor) StartEnvironment(_ context.Context) error { return ErrNotSupported } diff --git a/internal/shell/quote.go b/internal/shell/quote.go new file mode 100644 index 00000000..27406640 --- /dev/null +++ b/internal/shell/quote.go @@ -0,0 +1,9 @@ +// Package shell provides helpers for building POSIX shell commands. +package shell + +import "strings" + +// Quote quotes a string for safe use in a POSIX shell command. +func Quote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/shell/quote_test.go b/internal/shell/quote_test.go new file mode 100644 index 00000000..b82feea6 --- /dev/null +++ b/internal/shell/quote_test.go @@ -0,0 +1,12 @@ +package shell + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQuote(t *testing.T) { + assert.Equal(t, "'/var/www'", Quote("/var/www")) + assert.Equal(t, `'it'\''s'`, Quote("it's")) +} diff --git a/internal/shop/config.go b/internal/shop/config.go index 10dfb666..b7d34712 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -20,9 +20,109 @@ import ( ) type EnvironmentConfig struct { - Type string `yaml:"type" jsonschema:"enum=local,enum=docker"` + Type string `yaml:"type" jsonschema:"enum=local,enum=docker,enum=ssh"` URL string `yaml:"url,omitempty"` AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` + // SSH connection and deployment settings, used when type is "ssh" + SSH *EnvironmentSSH `yaml:"ssh,omitempty"` +} + +// EnvironmentSSH holds the SSH connection settings of an environment. +// It describes the primary host; additional hosts for multi-server setups can +// be listed under hosts and inherit all unset settings from this block. +type EnvironmentSSH struct { + // Hostname or IP address of the server + Host string `yaml:"host" jsonschema:"required"` + // Additional hosts for multi-server deployments. Each entry inherits unset settings (port, user, authentication) from this block + Hosts []EnvironmentSSHHost `yaml:"hosts,omitempty"` + // SSH port, defaults to 22 + Port int `yaml:"port,omitempty"` + // User to connect as + User string `yaml:"user,omitempty"` + // Password for password based authentication. Requires sshpass for non-interactive use; prefer key based authentication (consider using ${ENV_VAR} substitution instead of a plain value) + Password string `yaml:"password,omitempty"` + // Path to the private key used for authentication. When empty, the SSH agent and the default keys of the ssh client are used. Encrypted keys are handled by the SSH agent or an interactive passphrase prompt + IdentityFile string `yaml:"identity_file,omitempty"` + // Path to the known_hosts file used for host key verification, defaults to the ssh client behavior (~/.ssh/known_hosts) + KnownHostsFile string `yaml:"known_hosts_file,omitempty"` + // When enabled, the host key of the server is not verified (insecure) + InsecureIgnoreHostKey bool `yaml:"insecure_ignore_host_key,omitempty"` + // Reuse one SSH connection for consecutive remote commands (OpenSSH ControlMaster). Enabled by default except on Windows; set to false to leave connection sharing to your own ssh_config + ControlMaster *bool `yaml:"control_master,omitempty"` + // Deployment settings for this environment + Deployment *EnvironmentDeployment `yaml:"deployment,omitempty"` +} + +// EnvironmentSSHHost is an additional host of a multi-server environment. +// Unset settings are inherited from the surrounding EnvironmentSSH block. +type EnvironmentSSHHost struct { + // Hostname or IP address of the server + Host string `yaml:"host" jsonschema:"required"` + // SSH port, inherited from the ssh block when empty + Port int `yaml:"port,omitempty"` + // User to connect as, inherited from the ssh block when empty + User string `yaml:"user,omitempty"` + // Password for password based authentication, inherited from the ssh block when empty + Password string `yaml:"password,omitempty"` + // Path to the private key used for authentication, inherited from the ssh block when empty + IdentityFile string `yaml:"identity_file,omitempty"` +} + +// AllHosts returns the primary host and all additional hosts with the +// inherited settings resolved. +func (s *EnvironmentSSH) AllHosts() []EnvironmentSSH { + primary := *s + primary.Hosts = nil + + hosts := []EnvironmentSSH{primary} + + for _, h := range s.Hosts { + merged := primary + merged.Host = h.Host + + if h.Port != 0 { + merged.Port = h.Port + } + if h.User != "" { + merged.User = h.User + } + if h.Password != "" { + merged.Password = h.Password + } + if h.IdentityFile != "" { + merged.IdentityFile = h.IdentityFile + } + + hosts = append(hosts, merged) + } + + return hosts +} + +// EnvironmentDeployment holds the deployment settings of a remote environment. +type EnvironmentDeployment struct { + // Absolute path on the server where the project is deployed to (contains releases/, shared/ and the current symlink) + Path string `yaml:"path" jsonschema:"required"` + // Amount of releases to keep on the server, defaults to 5 + KeepReleases int `yaml:"keep_releases,omitempty"` + // Files shared between releases, defaults to .env and install.lock + SharedFiles []string `yaml:"shared_files,omitempty"` + // Directories shared between releases, defaults to files, public/media, public/thumbnail, public/sitemap and var/log + SharedDirs []string `yaml:"shared_dirs,omitempty"` + // Additional paths that are not uploaded to the server (on top of the built-in defaults like .git and node_modules) + Exclude []string `yaml:"exclude,omitempty"` + // Hooks executed during the deployment + Hooks EnvironmentDeploymentHooks `yaml:"hooks,omitempty"` +} + +// EnvironmentDeploymentHooks defines commands executed during a deployment. +type EnvironmentDeploymentHooks struct { + // Commands executed locally in the project root before the upload (e.g. shopware-cli project ci .) + Build []string `yaml:"build,omitempty"` + // Commands executed on the server inside the new release before the current symlink is switched. Defaults to running the Shopware Deployment Helper when present + PreSwitch []string `yaml:"pre_switch,omitempty"` + // Commands executed on the server inside the new release after the current symlink was switched + PostSwitch []string `yaml:"post_switch,omitempty"` } type Config struct { diff --git a/internal/shop/config_schema.json b/internal/shop/config_schema.json index f711499c..7c1f3b59 100644 --- a/internal/shop/config_schema.json +++ b/internal/shop/config_schema.json @@ -636,7 +636,8 @@ "type": "string", "enum": [ "local", - "docker" + "docker", + "ssh" ] }, "url": { @@ -644,10 +645,168 @@ }, "admin_api": { "$ref": "#/$defs/ConfigAdminApi" + }, + "ssh": { + "$ref": "#/$defs/EnvironmentSSH", + "description": "SSH connection and deployment settings, used when type is \"ssh\"" } }, "additionalProperties": false, "type": "object" + }, + "EnvironmentDeployment": { + "properties": { + "path": { + "type": "string", + "description": "Absolute path on the server where the project is deployed to (contains releases/, shared/ and the current symlink)" + }, + "keep_releases": { + "type": "integer", + "description": "Amount of releases to keep on the server, defaults to 5" + }, + "shared_files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files shared between releases, defaults to .env and install.lock" + }, + "shared_dirs": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Directories shared between releases, defaults to files, public/media, public/thumbnail, public/sitemap and var/log" + }, + "exclude": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Additional paths that are not uploaded to the server (on top of the built-in defaults like .git and node_modules)" + }, + "hooks": { + "$ref": "#/$defs/EnvironmentDeploymentHooks", + "description": "Hooks executed during the deployment" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path" + ], + "description": "EnvironmentDeployment holds the deployment settings of a remote environment." + }, + "EnvironmentDeploymentHooks": { + "properties": { + "build": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Commands executed locally in the project root before the upload (e.g. shopware-cli project ci .)" + }, + "pre_switch": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Commands executed on the server inside the new release before the current symlink is switched. Defaults to running the Shopware Deployment Helper when present" + }, + "post_switch": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Commands executed on the server inside the new release after the current symlink was switched" + } + }, + "additionalProperties": false, + "type": "object", + "description": "EnvironmentDeploymentHooks defines commands executed during a deployment." + }, + "EnvironmentSSH": { + "properties": { + "host": { + "type": "string", + "description": "Hostname or IP address of the server" + }, + "hosts": { + "items": { + "$ref": "#/$defs/EnvironmentSSHHost" + }, + "type": "array", + "description": "Additional hosts for multi-server deployments. Each entry inherits unset settings (port, user, authentication) from this block" + }, + "port": { + "type": "integer", + "description": "SSH port, defaults to 22" + }, + "user": { + "type": "string", + "description": "User to connect as" + }, + "password": { + "type": "string", + "description": "Password for password based authentication. Requires sshpass for non-interactive use; prefer key based authentication (consider using ${ENV_VAR} substitution instead of a plain value)" + }, + "identity_file": { + "type": "string", + "description": "Path to the private key used for authentication. When empty, the SSH agent and the default keys of the ssh client are used. Encrypted keys are handled by the SSH agent or an interactive passphrase prompt" + }, + "known_hosts_file": { + "type": "string", + "description": "Path to the known_hosts file used for host key verification, defaults to the ssh client behavior (~/.ssh/known_hosts)" + }, + "insecure_ignore_host_key": { + "type": "boolean", + "description": "When enabled, the host key of the server is not verified (insecure)" + }, + "control_master": { + "type": "boolean", + "description": "Reuse one SSH connection for consecutive remote commands (OpenSSH ControlMaster). Enabled by default except on Windows; set to false to leave connection sharing to your own ssh_config" + }, + "deployment": { + "$ref": "#/$defs/EnvironmentDeployment", + "description": "Deployment settings for this environment" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "host" + ], + "description": "EnvironmentSSH holds the SSH connection settings of an environment." + }, + "EnvironmentSSHHost": { + "properties": { + "host": { + "type": "string", + "description": "Hostname or IP address of the server" + }, + "port": { + "type": "integer", + "description": "SSH port, inherited from the ssh block when empty" + }, + "user": { + "type": "string", + "description": "User to connect as, inherited from the ssh block when empty" + }, + "password": { + "type": "string", + "description": "Password for password based authentication, inherited from the ssh block when empty" + }, + "identity_file": { + "type": "string", + "description": "Path to the private key used for authentication, inherited from the ssh block when empty" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "host" + ], + "description": "EnvironmentSSHHost is an additional host of a multi-server environment." } } } \ No newline at end of file diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index 42dc910d..61ffdd95 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -684,3 +684,99 @@ func TestConfigBuildMJMLResolveIncludePaths(t *testing.T) { assert.Equal(t, want, got) }) } + +func TestReadConfigSSHEnvironment(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".shopware-project.yml") + content := []byte(` +url: https://example.com +environments: + production: + type: ssh + url: https://shop.example.com + ssh: + host: shop.example.com + port: 2222 + user: deploy + identity_file: ~/.ssh/id_ed25519 + deployment: + path: /var/www/shopware + keep_releases: 3 + shared_dirs: + - files + - public/media + shared_files: + - .env + hooks: + build: + - shopware-cli project ci . + pre_switch: + - vendor/bin/shopware-deployment-helper run + post_switch: + - php bin/console cache:pool:clear cache.http +`) + + assert.NoError(t, os.WriteFile(configPath, content, 0o644)) + + cfg, err := ReadConfig(t.Context(), configPath, false) + assert.NoError(t, err) + + env, err := cfg.ResolveEnvironment("production") + assert.NoError(t, err) + + assert.Equal(t, "ssh", env.Type) + assert.NotNil(t, env.SSH) + assert.Equal(t, "shop.example.com", env.SSH.Host) + assert.Equal(t, 2222, env.SSH.Port) + assert.Equal(t, "deploy", env.SSH.User) + assert.Equal(t, "~/.ssh/id_ed25519", env.SSH.IdentityFile) + + assert.NotNil(t, env.SSH.Deployment) + assert.Equal(t, "/var/www/shopware", env.SSH.Deployment.Path) + assert.Equal(t, 3, env.SSH.Deployment.KeepReleases) + assert.ElementsMatch(t, []string{"files", "public/media"}, env.SSH.Deployment.SharedDirs) + assert.ElementsMatch(t, []string{".env"}, env.SSH.Deployment.SharedFiles) + assert.Equal(t, []string{"shopware-cli project ci ."}, env.SSH.Deployment.Hooks.Build) + assert.Equal(t, []string{"vendor/bin/shopware-deployment-helper run"}, env.SSH.Deployment.Hooks.PreSwitch) + assert.Equal(t, []string{"php bin/console cache:pool:clear cache.http"}, env.SSH.Deployment.Hooks.PostSwitch) +} + +func TestEnvironmentSSHAllHosts(t *testing.T) { + ssh := &EnvironmentSSH{ + Host: "web1.example.com", + Port: 2222, + User: "deploy", + IdentityFile: "~/.ssh/id_ed25519", + Hosts: []EnvironmentSSHHost{ + {Host: "web2.example.com"}, + {Host: "web3.example.com", Port: 22, User: "other"}, + }, + } + + hosts := ssh.AllHosts() + + assert.Len(t, hosts, 3) + + assert.Equal(t, "web1.example.com", hosts[0].Host) + assert.Empty(t, hosts[0].Hosts) + + // inherited settings + assert.Equal(t, "web2.example.com", hosts[1].Host) + assert.Equal(t, 2222, hosts[1].Port) + assert.Equal(t, "deploy", hosts[1].User) + assert.Equal(t, "~/.ssh/id_ed25519", hosts[1].IdentityFile) + + // per-host overrides + assert.Equal(t, "web3.example.com", hosts[2].Host) + assert.Equal(t, 22, hosts[2].Port) + assert.Equal(t, "other", hosts[2].User) + assert.Equal(t, "~/.ssh/id_ed25519", hosts[2].IdentityFile) +} + +func TestEnvironmentSSHAllHostsSingle(t *testing.T) { + ssh := &EnvironmentSSH{Host: "web1.example.com", User: "deploy"} + + hosts := ssh.AllHosts() + assert.Len(t, hosts, 1) + assert.Equal(t, "web1.example.com", hosts[0].Host) +} diff --git a/internal/sshcmd/dialer.go b/internal/sshcmd/dialer.go new file mode 100644 index 00000000..7f075a0c --- /dev/null +++ b/internal/sshcmd/dialer.go @@ -0,0 +1,111 @@ +package sshcmd + +import ( + "bytes" + "fmt" + "io" + "net" + "os/exec" + "sync" + "time" + + "github.com/shopware/shopware-cli/internal/shop" +) + +// Dial opens a raw TCP stream to addr as seen from the remote host, using the +// ssh client's stdio forwarding (ssh -W). Every stream is an ssh channel on +// the multiplexed master connection, so consecutive dials are cheap. The +// returned net.Conn owns the ssh process; closing the connection ends it. +func Dial(cfg *shop.EnvironmentSSH, addr string) (net.Conn, error) { + args := BaseArgs(cfg) + args = append(args, "-W", addr, Destination(cfg)) + + // the process lifecycle is bound to the connection, not to a dial + // context: the caller may use the connection long after dialing + cmd := exec.Command("ssh", args...) //nolint:noctx + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + conn := &pipeConn{cmd: cmd, stdin: stdin, stdout: stdout, addr: addr} + cmd.Stderr = &conn.stderr + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("cannot start ssh for %s: %w", addr, err) + } + + return conn, nil +} + +// pipeConn adapts the stdio of an ssh -W process to a net.Conn. +type pipeConn struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr bytes.Buffer + addr string + + closeOnce sync.Once +} + +func (c *pipeConn) Read(p []byte) (int, error) { + n, err := c.stdout.Read(p) + if err == io.EOF { + // the stream ended, usually because the remote side could not be + // reached; surface the ssh error instead of a bare EOF + if stderr := bytes.TrimSpace(c.stderr.Bytes()); len(stderr) > 0 { + return n, fmt.Errorf("ssh stream to %s closed: %s", c.addr, stderr) + } + } + + return n, err +} + +func (c *pipeConn) Write(p []byte) (int, error) { + return c.stdin.Write(p) +} + +func (c *pipeConn) Close() error { + c.closeOnce.Do(func() { + _ = c.stdin.Close() + _ = c.stdout.Close() + + if c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + } + + // the process is terminated deliberately, its exit status carries no + // signal worth reporting to the caller + _ = c.cmd.Wait() + }) + + return nil +} + +func (c *pipeConn) LocalAddr() net.Addr { + return sshAddr{addr: "ssh"} +} + +func (c *pipeConn) RemoteAddr() net.Addr { + return sshAddr{addr: c.addr} +} + +// The ssh process has no deadline support; the driver only sets deadlines +// when explicit timeouts are configured, which the CLI does not do. +func (c *pipeConn) SetDeadline(time.Time) error { return nil } +func (c *pipeConn) SetReadDeadline(time.Time) error { return nil } +func (c *pipeConn) SetWriteDeadline(time.Time) error { return nil } + +type sshAddr struct { + addr string +} + +func (a sshAddr) Network() string { return "ssh" } +func (a sshAddr) String() string { return a.addr } diff --git a/internal/sshcmd/sshcmd.go b/internal/sshcmd/sshcmd.go new file mode 100644 index 00000000..5c5de98e --- /dev/null +++ b/internal/sshcmd/sshcmd.go @@ -0,0 +1,155 @@ +// Package sshcmd builds commands for the system ssh client from an +// environment's SSH settings. It is the single SSH transport used by both +// deployments and remote command execution, so all of them share one +// multiplexed connection (ControlMaster) per host. +package sshcmd + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/shopware/shopware-cli/internal/shop" +) + +// ControlSocketDir returns the directory for ControlMaster sockets. It is a +// variable so tests produce stable arguments. +var ControlSocketDir = func() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + // ~/.ssh keeps the socket path short (unix sockets have a ~100 byte path + // limit) and follows the OpenSSH convention for multiplexing sockets + dir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "" + } + + return dir +} + +// sshpassPath locates sshpass for password based authentication. It is a +// variable so tests can stub it. +var sshpassPath = func() string { + path, err := exec.LookPath("sshpass") + if err != nil { + return "" + } + + return path +} + +// controlMasterEnabled reports whether SSH connection multiplexing should be +// used. Windows' OpenSSH build does not support ControlMaster. +func controlMasterEnabled(cfg *shop.EnvironmentSSH) bool { + if runtime.GOOS == "windows" { + return false + } + + if cfg.ControlMaster != nil { + return *cfg.ControlMaster + } + + return true +} + +// BaseArgs returns the ssh client arguments derived from the SSH settings. +func BaseArgs(cfg *shop.EnvironmentSSH) []string { + var args []string + + if cfg.Port != 0 { + args = append(args, "-p", strconv.Itoa(cfg.Port)) + } + + if cfg.IdentityFile != "" { + args = append(args, "-i", expandHome(cfg.IdentityFile)) + } + + if cfg.KnownHostsFile != "" { + args = append(args, "-o", "UserKnownHostsFile="+expandHome(cfg.KnownHostsFile)) + } + + if cfg.InsecureIgnoreHostKey { + args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null") + } + + // reuse one connection for consecutive commands instead of paying the + // TCP + key exchange + authentication handshake on every invocation. + // %C hashes local host, remote user, host and port into a short name. + if controlMasterEnabled(cfg) { + if socketDir := ControlSocketDir(); socketDir != "" { + args = append(args, + "-o", "ControlMaster=auto", + "-o", "ControlPath="+filepath.Join(socketDir, "shopware-cli-%C"), + "-o", "ControlPersist=60", + ) + } + } + + return args +} + +// Destination returns the [user@]host argument for the ssh client. +func Destination(cfg *shop.EnvironmentSSH) string { + if cfg.User != "" { + return cfg.User + "@" + cfg.Host + } + + return cfg.Host +} + +// Build returns an exec.Cmd running the given command on the host over ssh. +// When a password is configured and sshpass is available, it is used so the +// password does not have to be typed interactively. +func Build(ctx context.Context, cfg *shop.EnvironmentSSH, remoteCommand string, extraArgs ...string) *exec.Cmd { + args := BaseArgs(cfg) + args = append(args, extraArgs...) + args = append(args, Destination(cfg), remoteCommand) + + if cfg.Password != "" { + if sshpass := sshpassPath(); sshpass != "" { + cmd := exec.CommandContext(ctx, sshpass, append([]string{"-e", "ssh"}, args...)...) + cmd.Env = append(os.Environ(), "SSHPASS="+cfg.Password) + + return cmd + } + } + + return exec.CommandContext(ctx, "ssh", args...) +} + +// Output runs a command on the host and returns its stdout. +func Output(ctx context.Context, cfg *shop.EnvironmentSSH, remoteCommand string) (string, error) { + cmd := Build(ctx, cfg, remoteCommand) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("remote command %q failed: %w\n%s", remoteCommand, err, bytes.TrimSpace(stderr.Bytes())) + } + + return string(output), nil +} + +func expandHome(path string) string { + if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~"+string(os.PathSeparator)) { + home, err := os.UserHomeDir() + if err != nil { + return path + } + + return filepath.Join(home, strings.TrimPrefix(path[1:], string(os.PathSeparator))) + } + + return path +} diff --git a/internal/sshcmd/sshcmd_test.go b/internal/sshcmd/sshcmd_test.go new file mode 100644 index 00000000..16cbcd17 --- /dev/null +++ b/internal/sshcmd/sshcmd_test.go @@ -0,0 +1,119 @@ +package sshcmd + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/shop" +) + +func stubEnvironment(t *testing.T) { + t.Helper() + + oldSocketDir := ControlSocketDir + ControlSocketDir = func() string { return "/home/user/.ssh" } + + oldSshpass := sshpassPath + sshpassPath = func() string { return "" } + + t.Cleanup(func() { + ControlSocketDir = oldSocketDir + sshpassPath = oldSshpass + }) +} + +func expectedControlMasterArgs() []string { + if runtime.GOOS == "windows" { + return nil + } + + return []string{ + "-o", "ControlMaster=auto", + "-o", "ControlPath=/home/user/.ssh/shopware-cli-%C", + "-o", "ControlPersist=60", + } +} + +func TestBaseArgs(t *testing.T) { + stubEnvironment(t) + + cfg := &shop.EnvironmentSSH{ + Host: "shop.example.com", + Port: 2222, + IdentityFile: "/keys/id_ed25519", + } + + expected := []string{"-p", "2222", "-i", "/keys/id_ed25519"} + expected = append(expected, expectedControlMasterArgs()...) + + assert.Equal(t, expected, BaseArgs(cfg)) +} + +func TestBaseArgsControlMasterDisabled(t *testing.T) { + stubEnvironment(t) + + disabled := false + cfg := &shop.EnvironmentSSH{Host: "shop.example.com", ControlMaster: &disabled} + + assert.Empty(t, BaseArgs(cfg)) +} + +func TestBaseArgsHostKeyOptions(t *testing.T) { + stubEnvironment(t) + + disabled := false + cfg := &shop.EnvironmentSSH{ + Host: "shop.example.com", + KnownHostsFile: "/known_hosts", + InsecureIgnoreHostKey: true, + ControlMaster: &disabled, + } + + assert.Equal(t, []string{ + "-o", "UserKnownHostsFile=/known_hosts", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + }, BaseArgs(cfg)) +} + +func TestDestination(t *testing.T) { + assert.Equal(t, "deploy@shop.example.com", Destination(&shop.EnvironmentSSH{Host: "shop.example.com", User: "deploy"})) + assert.Equal(t, "shop.example.com", Destination(&shop.EnvironmentSSH{Host: "shop.example.com"})) +} + +func TestBuild(t *testing.T) { + stubEnvironment(t) + + disabled := false + cfg := &shop.EnvironmentSSH{Host: "shop.example.com", User: "deploy", ControlMaster: &disabled} + + cmd := Build(t.Context(), cfg, "uptime", "-t") + + assert.Equal(t, []string{"ssh", "-t", "deploy@shop.example.com", "uptime"}, cmd.Args) +} + +func TestBuildUsesSshpassForPasswords(t *testing.T) { + stubEnvironment(t) + sshpassPath = func() string { return "/usr/bin/sshpass" } + + disabled := false + cfg := &shop.EnvironmentSSH{Host: "shop.example.com", Password: "secret", ControlMaster: &disabled} + + cmd := Build(t.Context(), cfg, "uptime") + + assert.Equal(t, []string{"/usr/bin/sshpass", "-e", "ssh", "shop.example.com", "uptime"}, cmd.Args) + assert.Contains(t, cmd.Env, "SSHPASS=secret") +} + +func TestBuildFallsBackToInteractivePassword(t *testing.T) { + stubEnvironment(t) + + disabled := false + cfg := &shop.EnvironmentSSH{Host: "shop.example.com", Password: "secret", ControlMaster: &disabled} + + cmd := Build(t.Context(), cfg, "uptime") + + assert.Equal(t, []string{"ssh", "shop.example.com", "uptime"}, cmd.Args) +}