diff --git a/cmd/base.go b/cmd/base.go index 031c8f7..ce4a17c 100644 --- a/cmd/base.go +++ b/cmd/base.go @@ -1,7 +1,7 @@ package cmd import ( - "os" + "fmt" "github.com/fatih/color" "github.com/flywp/server-cli/internal/docker" @@ -18,50 +18,51 @@ var baseCmd = &cobra.Command{ var baseStartCmd = &cobra.Command{ Use: "start", Short: "Start base services", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if err := docker.RunCompose(baseCompose, "up", "-d"); err != nil { - color.Red("Error starting base services: %v", err) - os.Exit(1) - return + return fmt.Errorf("starting base services: %w", err) } if err := docker.RunCompose(baseCompose, "ps"); err != nil { - color.Red("Error checking status of base services: %v", err) + return fmt.Errorf("checking status of base services: %w", err) } color.Green("Base services started successfully") + return nil }, } var baseStopCmd = &cobra.Command{ Use: "stop", Short: "Stop base services", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if err := docker.RunCompose(baseCompose, "down"); err != nil { - color.Red("Error stopping base services:", err) + return fmt.Errorf("stopping base services: %w", err) } color.Green("Base services stopped successfully") + return nil }, } var baseRestartCmd = &cobra.Command{ Use: "restart", Short: "Restart base services", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if err := docker.RunCompose(baseCompose, "down"); err != nil { - color.Red("Error stopping base services:", err) + return fmt.Errorf("stopping base services: %w", err) } if err := docker.RunCompose(baseCompose, "up", "-d"); err != nil { - color.Red("Error starting base services:", err) + return fmt.Errorf("starting base services: %w", err) } if err := docker.RunCompose(baseCompose, "ps"); err != nil { - color.Red("Error checking status of base services:", err) + return fmt.Errorf("checking status of base services: %w", err) } color.Green("Base services restarted successfully") + return nil }, } diff --git a/cmd/global.go b/cmd/global.go index d045ca8..785c2c2 100644 --- a/cmd/global.go +++ b/cmd/global.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "os/exec" @@ -84,90 +85,68 @@ var sitesCmd = &cobra.Command{ var sitesStartCmd = &cobra.Command{ Use: "start", Short: "Start all sites", - Run: func(cmd *cobra.Command, args []string) { - startAllSites() + RunE: func(cmd *cobra.Command, args []string) error { + return forEachSite("Starting", "up", "-d") }, } var sitesStopCmd = &cobra.Command{ Use: "stop", Short: "Stop all sites", - Run: func(cmd *cobra.Command, args []string) { - stopAllSites() + RunE: func(cmd *cobra.Command, args []string) error { + return forEachSite("Stopping", "down") }, } var restartSitesCmd = &cobra.Command{ Use: "restart", Short: "Restart all sites", - Run: func(cmd *cobra.Command, args []string) { - stopAllSites() - startAllSites() + RunE: func(cmd *cobra.Command, args []string) error { + // Start the sites even if some of them failed to stop. + stopErr := forEachSite("Stopping", "down") + startErr := forEachSite("Starting", "up", "-d") + return errors.Join(stopErr, startErr) }, } -func startAllSites() { - sitesDir := "/home/fly" - foundSite := false +// sitesDir holds one directory per site, each with its own docker-compose.yml. +var sitesDir = "/home/fly" +// forEachSite runs docker compose with args for every site in sitesDir. +// It continues after a failed site and returns all failures together. +func forEachSite(verb string, args ...string) error { entries, err := os.ReadDir(sitesDir) if err != nil { - color.Red("Error reading directory %s: %v\n", sitesDir, err) - return + return fmt.Errorf("reading sites directory: %w", err) } + var errs []error + foundSite := false for _, entry := range entries { - if entry.IsDir() { - // Skip hidden directories - if strings.HasPrefix(entry.Name(), ".") { - continue - } - - path := filepath.Join(sitesDir, entry.Name()) - composePath := filepath.Join(path, "docker-compose.yml") - if _, err := os.Stat(composePath); err == nil { - color.Yellow("Starting site in %s\n", filepath.Base(path)) - docker.RunCompose(composePath, "up", "-d") - foundSite = true - } + // Skip files and hidden directories + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue } - } - - if !foundSite { - fmt.Println("No sites found to start.") - } -} - -func stopAllSites() { - sitesDir := "/home/fly" - foundSite := false - entries, err := os.ReadDir(sitesDir) - if err != nil { - color.Red("Error reading directory %s: %v\n", sitesDir, err) - return - } + composePath := filepath.Join(sitesDir, entry.Name(), "docker-compose.yml") + if _, err := os.Stat(composePath); err != nil { + continue + } - for _, entry := range entries { - if entry.IsDir() { - // Skip hidden directories - if strings.HasPrefix(entry.Name(), ".") { - continue - } - - path := filepath.Join(sitesDir, entry.Name()) - composePath := filepath.Join(path, "docker-compose.yml") - if _, err := os.Stat(composePath); err == nil { - color.Yellow("Stopping site in %s\n", filepath.Base(path)) - docker.RunCompose(composePath, "down") - foundSite = true - } + foundSite = true + color.Yellow("%s site in %s", verb, entry.Name()) + if err := docker.RunCompose(composePath, args...); err != nil { + // %v, not %w: a failed child process must not hide this summary. + // The verb tells restart's stop failures from its start failures. + errs = append(errs, fmt.Errorf("%s %s: %v", strings.ToLower(verb), entry.Name(), err)) } } if !foundSite { - fmt.Println("No sites found to stop.") + fmt.Println("No sites found.") } + + return errors.Join(errs...) } func init() { diff --git a/cmd/global_test.go b/cmd/global_test.go new file mode 100644 index 0000000..72b5b11 --- /dev/null +++ b/cmd/global_test.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "errors" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/flywp/server-cli/internal/testutil" +) + +func TestForEachSiteContinuesAfterFailure(t *testing.T) { + fake := testutil.NewFakeDocker(t) + fake.Install(t) + t.Setenv(testutil.EnvExit, "3") + + root := testutil.TempDir(t) + testutil.WriteSite(t, filepath.Join(root, "a.example.com"), "php") + testutil.WriteSite(t, filepath.Join(root, "b.example.com"), "php") + testutil.WriteSite(t, filepath.Join(root, ".fly"), "mysql") // hidden: skipped + + old := sitesDir + sitesDir = root + t.Cleanup(func() { sitesDir = old }) + + err := forEachSite("Starting", "up", "-d") + if err == nil { + t.Fatal("forEachSite() = nil, want an error for the failed sites") + } + + // The summary must be printed, so it must not unwrap to the child's exit status. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + t.Errorf("forEachSite() error unwraps to *exec.ExitError, want a plain summary: %v", err) + } + for _, site := range []string{"a.example.com", "b.example.com"} { + if !strings.Contains(err.Error(), "starting "+site) { + t.Errorf("error %q does not name the phase and site %s", err, site) + } + } + + if calls := fake.Calls(t); len(calls) != 2 { + t.Errorf("docker calls = %q, want one call per site", calls) + } +} + +func TestSitesRestartNamesThePhaseOfEachFailure(t *testing.T) { + fake := testutil.NewFakeDocker(t) + fake.Install(t) + t.Setenv(testutil.EnvExit, "3") + + root := testutil.TempDir(t) + testutil.WriteSite(t, filepath.Join(root, "broken.example.com"), "php") + + old := sitesDir + sitesDir = root + t.Cleanup(func() { sitesDir = old }) + + // The site fails to stop and then fails to start: two different failures. + err := restartSitesCmd.RunE(restartSitesCmd, nil) + if err == nil { + t.Fatal("sites restart = nil, want an error for the failed site") + } + + got := strings.Split(err.Error(), "\n") + want := []string{ + "stopping broken.example.com: exit status 3", + "starting broken.example.com: exit status 3", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("error lines = %q, want %q", got, want) + } +} + +func TestForEachSiteMissingDirectory(t *testing.T) { + old := sitesDir + sitesDir = filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { sitesDir = old }) + + if err := forEachSite("Starting", "up", "-d"); err == nil { + t.Fatal("forEachSite() = nil, want an error for a missing sites directory") + } +} diff --git a/cmd/root.go b/cmd/root.go index d9aca14..c64980d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,9 +1,13 @@ package cmd import ( + "errors" "fmt" + "io" "os" + "os/exec" + "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -11,6 +15,10 @@ var rootCmd = &cobra.Command{ Use: "fly", Short: "Fly CLI for managing WordPress sites", Long: `A CLI tool for managing WordPress sites using Docker and custom commands.`, + // Execute reports errors itself, so that errors from child processes + // are not printed twice and usage is not printed for runtime errors. + SilenceErrors: true, + SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if cmd.Name() != "update" && os.Geteuid() == 0 { return fmt.Errorf("you should not run this command as root") @@ -20,22 +28,33 @@ var rootCmd = &cobra.Command{ }, } -// Execute the root command. +// Execute runs the root command and exits with the resulting status code. func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } + os.Exit(exitCode(rootCmd.Execute(), os.Stderr)) } -func init() { - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. +// exitCode reports err on stderr and returns the exit status for it. +func exitCode(err error, stderr io.Writer) int { + if err == nil { + return 0 + } + + // A child process (docker compose, wp-cli) has already reported its own + // error, so pass its exit status through without printing anything. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if code := exitErr.ExitCode(); code > 0 { + return code + } + return 1 + } - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.server-cli.yaml)") + _, _ = color.New(color.FgRed).Fprintf(stderr, "Error: %v\n", err) + return 1 +} - // Cobra also supports local flags, which will only run - // when this action is called directly. - // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +func init() { + rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + return fmt.Errorf("%w\nRun '%s --help' for usage", err, cmd.CommandPath()) + }) } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..e57126d --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" + "testing" +) + +func TestExitCode(t *testing.T) { + childErr := exec.Command("sh", "-c", "exit 7").Run() + + tests := []struct { + name string + err error + wantCode int + wantStderr string + }{ + {name: "success", err: nil, wantCode: 0}, + {name: "child exit status passes through silently", err: childErr, wantCode: 7}, + {name: "wrapped child exit status", err: fmt.Errorf("starting site: %w", childErr), wantCode: 7}, + {name: "other error is printed", err: errors.New("boom"), wantCode: 1, wantStderr: "Error: boom\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stderr bytes.Buffer + if got := exitCode(tt.err, &stderr); got != tt.wantCode { + t.Errorf("exitCode() = %d, want %d", got, tt.wantCode) + } + if got := stderr.String(); !strings.Contains(got, tt.wantStderr) || (tt.wantStderr == "" && got != "") { + t.Errorf("stderr = %q, want %q", got, tt.wantStderr) + } + }) + } +} diff --git a/cmd/site.go b/cmd/site.go index ed1953b..0b9c1ad 100644 --- a/cmd/site.go +++ b/cmd/site.go @@ -1,9 +1,9 @@ package cmd import ( - "os" + "errors" + "fmt" - "github.com/fatih/color" "github.com/flywp/server-cli/internal/docker" "github.com/flywp/server-cli/internal/utils" "github.com/spf13/cobra" @@ -12,19 +12,41 @@ import ( // Define domain flag as a global variable var domain string +var errNoSite = errors.New(`no docker-compose.yml file found + +You are not inside a site directory. +Please run this command from inside a site directory, e.g: + cd ~/example.com + fly start + +Or specify the domain name: + fly start --domain example.com`) + +// siteComposePath returns the compose file of the site selected by --domain +// or by the current directory. +func siteComposePath() (string, error) { + composePath := utils.FindComposeFile(domain) + if composePath != "" { + return composePath, nil + } + + if domain != "" { + return "", fmt.Errorf("no docker-compose.yml file found for domain %q", domain) + } + + return "", errNoSite +} + var wpCmd = &cobra.Command{ Use: "wp", Short: "Run wp-cli commands", - Run: func(cmd *cobra.Command, args []string) { - composePath := utils.FindComposeFile(domain) - if composePath == "" { - utils.ShowNoComposeError() - return + RunE: func(cmd *cobra.Command, args []string) error { + composePath, err := siteComposePath() + if err != nil { + return err } - if err := docker.RunWPCLI(composePath, args); err != nil { - color.Red("Error running wp-cli: %s", err) - } + return docker.RunWPCLI(composePath, args) }, } @@ -32,16 +54,16 @@ var startCmd = &cobra.Command{ Use: "start", Short: "Start the site", Long: "Start the Docker container for the site", - Run: func(cmd *cobra.Command, args []string) { - composePath := utils.FindComposeFile(domain) - if composePath == "" { - utils.ShowNoComposeError() - return + RunE: func(cmd *cobra.Command, args []string) error { + composePath, err := siteComposePath() + if err != nil { + return err } if err := docker.RunCompose(composePath, "up", "-d"); err != nil { - color.Red("Error starting container:", err) + return fmt.Errorf("starting site: %w", err) } + return nil }, } @@ -49,16 +71,16 @@ var stopCmd = &cobra.Command{ Use: "stop", Short: "Stop the site", Long: "Stop the Docker container for the site", - Run: func(cmd *cobra.Command, args []string) { - composePath := utils.FindComposeFile(domain) - if composePath == "" { - utils.ShowNoComposeError() - return + RunE: func(cmd *cobra.Command, args []string) error { + composePath, err := siteComposePath() + if err != nil { + return err } if err := docker.RunCompose(composePath, "down"); err != nil { - color.Red("Error stopping container:", err) + return fmt.Errorf("stopping site: %w", err) } + return nil }, } @@ -67,39 +89,27 @@ var restartCmd = &cobra.Command{ Short: "Restart the site or a specific container", Long: "Restart the Docker containers for the site. Optionally, specify a container to restart only that container.", Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - composePath := utils.FindComposeFile(domain) - if composePath == "" { - utils.ShowNoComposeError() - return + RunE: func(cmd *cobra.Command, args []string) error { + composePath, err := siteComposePath() + if err != nil { + return err } - if len(args) == 1 { - containerName := args[0] - if err := docker.RunCompose(composePath, "restart", containerName); err != nil { - color.Red("Error restarting container:", err) - } - } else { - if err := docker.RunCompose(composePath, "restart"); err != nil { - color.Red("Error restarting Docker Compose setup:", err) - } + if err := docker.RunCompose(composePath, append([]string{"restart"}, args...)...); err != nil { + return fmt.Errorf("restarting site: %w", err) } + return nil }, } var execCmd = &cobra.Command{ - Use: "exec", + Use: "exec [service] command [args...]", Short: "Execute a command in the Docker container", - Run: func(cmd *cobra.Command, args []string) { - composePath := utils.FindComposeFile(domain) - if composePath == "" { - utils.ShowNoComposeError() - return - } - - if len(args) == 0 { - color.Yellow("No command provided") - return + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + composePath, err := siteComposePath() + if err != nil { + return err } // if the next argument is "php", "nginx" or "openlitespeed", use it as the service name @@ -114,9 +124,7 @@ var execCmd = &cobra.Command{ composeArgs = append(composeArgs, args...) - if err := docker.RunCompose(composePath, composeArgs...); err != nil { - color.Red("Error executing command: %v\n", err) - } + return docker.RunCompose(composePath, composeArgs...) }, } @@ -125,22 +133,13 @@ var logsCmd = &cobra.Command{ Short: "Show logs of the Docker container", Long: `Show logs of Docker container(s). If no container is specified, it shows logs for all containers.`, Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - composePath := utils.FindComposeFile(domain) - if composePath == "" { - utils.ShowNoComposeError() - os.Exit(1) + RunE: func(cmd *cobra.Command, args []string) error { + composePath, err := siteComposePath() + if err != nil { + return err } - composeArgs := []string{"logs"} - if len(args) == 1 { - composeArgs = append(composeArgs, args[0]) - } - - if err := docker.RunCompose(composePath, composeArgs...); err != nil { - color.Red("Error showing logs: %v\n", err) - os.Exit(1) - } + return docker.RunCompose(composePath, append([]string{"logs"}, args...)...) }, } diff --git a/cmd/version.go b/cmd/version.go index e95e7f9..2ec747b 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -24,22 +25,19 @@ var versionCmd = &cobra.Command{ var updateCmd = &cobra.Command{ Use: "update", Short: "Update fly-cli to the latest version", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if os.Geteuid() != 0 { - fmt.Println("Error: The update command must be run as root.") - fmt.Println("Please run 'sudo fly update'") - return + return errors.New("the update command must be run as root, please run 'sudo fly update'") } latestVersion, hasUpdate, err := utils.CheckForUpdates() if err != nil { - fmt.Println("Error checking for updates:", err) - return + return fmt.Errorf("checking for updates: %w", err) } if !hasUpdate { fmt.Println("You are already running the latest version.") - return + return nil } fmt.Printf("New version available: %s\n", latestVersion) @@ -47,20 +45,21 @@ var updateCmd = &cobra.Command{ if !yesFlag { fmt.Print("Do you want to update? (y/n): ") var response string - fmt.Scanln(&response) + // An empty or unreadable answer cancels the update. + _, _ = fmt.Scanln(&response) if response != "y" && response != "Y" { fmt.Println("Update cancelled.") - return + return nil } } fmt.Println("Updating...") if err := utils.SelfUpdate(); err != nil { - fmt.Println("Error updating:", err) - } else { - fmt.Println("Update successful. Please restart fly cli.") - os.Exit(0) + return fmt.Errorf("updating: %w", err) } + + fmt.Println("Update successful. Please restart fly cli.") + return nil }, } diff --git a/internal/testutil/fakedocker.go b/internal/testutil/fakedocker.go new file mode 100644 index 0000000..6c46ee9 --- /dev/null +++ b/internal/testutil/fakedocker.go @@ -0,0 +1,131 @@ +// Package testutil provides helpers for tests that run fly against a fake +// docker command instead of a real Docker installation. +package testutil + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// Environment variables that control the fake docker command. +const ( + // EnvLog is the file that receives one line per docker call. + EnvLog = "FAKE_DOCKER_LOG" + // EnvExit is the exit status of "docker compose" calls (default 0). + EnvExit = "FAKE_DOCKER_EXIT" + // EnvMode selects a failure mode: "no-compose" or "daemon-down". + EnvMode = "FAKE_DOCKER_MODE" +) + +const fakeDockerScript = `#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG" +case "$FAKE_DOCKER_MODE" in +no-compose) + if [ "$1" = compose ]; then + echo "docker: 'compose' is not a docker command." >&2 + exit 1 + fi + ;; +daemon-down) + if [ "$1 $2" != "compose version" ]; then + echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2 + exit 1 + fi + ;; +esac +case "$1 $2" in +"compose version") echo "Docker Compose version v2.40.0"; exit 0 ;; +"version "*) echo "29.0.0"; exit 0 ;; +esac +exit "${FAKE_DOCKER_EXIT:-0}" +` + +// FakeDocker is a fake docker command in its own directory. +type FakeDocker struct { + // Dir holds the docker script. Put it first in PATH. + Dir string + // Log receives the arguments of each docker call, one call per line. + Log string +} + +// NewFakeDocker writes a fake docker command to a temporary directory. +func NewFakeDocker(t *testing.T) *FakeDocker { + t.Helper() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(fakeDockerScript), 0o755); err != nil { + t.Fatal(err) + } + + return &FakeDocker{Dir: dir, Log: filepath.Join(t.TempDir(), "docker.log")} +} + +// Install puts the fake docker first in PATH and points it at its log for +// the duration of the test. +func (f *FakeDocker) Install(t *testing.T) { + t.Helper() + t.Setenv("PATH", f.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(EnvLog, f.Log) +} + +// Env returns the environment for a child process that uses the fake docker. +// The PATH holds only the fake docker and the system directories. +func (f *FakeDocker) Env() []string { + return []string{ + "PATH=" + f.Dir + string(os.PathListSeparator) + "/usr/bin:/bin", + EnvLog + "=" + f.Log, + } +} + +// Calls returns the arguments of each docker call so far. +func (f *FakeDocker) Calls(t *testing.T) []string { + t.Helper() + + data, err := os.ReadFile(f.Log) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + + return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") +} + +// WriteSite creates dir with a docker-compose.yml that defines services and +// returns the path of the compose file. +func WriteSite(t *testing.T, dir string, services ...string) string { + t.Helper() + + var b strings.Builder + b.WriteString("services:\n") + for _, s := range services { + fmt.Fprintf(&b, " %s:\n image: example\n", s) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + composePath := filepath.Join(dir, "docker-compose.yml") + if err := os.WriteFile(composePath, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + + return composePath +} + +// TempDir returns a new temporary directory with symlinks resolved, so that +// it compares equal to the working directory a child process sees. +func TempDir(t *testing.T) string { + t.Helper() + + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + return dir +} diff --git a/internal/utils/error.go b/internal/utils/error.go deleted file mode 100644 index 2f7bc5e..0000000 --- a/internal/utils/error.go +++ /dev/null @@ -1,14 +0,0 @@ -package utils - -import "github.com/fatih/color" - -func ShowNoComposeError() { - color.Red("No docker-compose.yml file found!") - - color.Yellow("You are not inside a site directory.") - color.Yellow("Please run this command from inside a site directory, e.g:") - color.Yellow(" cd ~/example.com") - color.Yellow(" fly start") - color.Yellow("\nOr specify the domain name:") - color.Yellow(" fly start --domain example.com") -} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..2d10b04 --- /dev/null +++ b/main_test.go @@ -0,0 +1,196 @@ +package main + +// End-to-end tests: build the fly binary once and run it against a fake +// docker command, so that exit codes and output are the ones users see. + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/flywp/server-cli/internal/testutil" +) + +var flyBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "fly-e2e") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + flyBin = filepath.Join(dir, "fly") + if out, err := exec.Command("go", "build", "-o", flyBin, ".").CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "building fly: %v\n%s", err, out) + os.Exit(1) + } + + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +type result struct { + code int + stdout, stderr string +} + +// env is a test environment: a home directory with one site in it and a +// fake docker command. +type env struct { + home, site string + docker *testutil.FakeDocker + vars []string +} + +func newEnv(t *testing.T, services ...string) *env { + t.Helper() + + if os.Geteuid() == 0 { + t.Skip("fly refuses to run as root") + } + if len(services) == 0 { + services = []string{"php", "nginx"} + } + + home := testutil.TempDir(t) + site := filepath.Join(home, "example.com") + testutil.WriteSite(t, site, services...) + + return &env{home: home, site: site, docker: testutil.NewFakeDocker(t)} +} + +// run executes fly in dir with the test environment. +func (e *env) run(t *testing.T, dir string, args ...string) result { + t.Helper() + + cmd := exec.Command(flyBin, args...) + cmd.Dir = dir + cmd.Env = append(append(e.docker.Env(), "HOME="+e.home), e.vars...) + + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + + err := cmd.Run() + res := result{stdout: stdout.String(), stderr: stderr.String()} + + var exitErr *exec.ExitError + switch { + case errors.As(err, &exitErr): + res.code = exitErr.ExitCode() + case err != nil: + t.Fatalf("running fly %s: %v", strings.Join(args, " "), err) + } + + return res +} + +func TestChildExitStatusPassesThrough(t *testing.T) { + e := newEnv(t) + e.vars = append(e.vars, testutil.EnvExit+"=7") + + res := e.run(t, e.site, "exec", "--", "php", "sh", "-c", "exit 7") + if res.code != 7 { + t.Errorf("exit code = %d, want 7 (stderr %q)", res.code, res.stderr) + } + if res.stderr != "" { + t.Errorf("stderr = %q, want nothing: the child reports its own error", res.stderr) + } +} + +func TestDockerFailureExitsNonZero(t *testing.T) { + commands := [][]string{ + {"start"}, + {"stop"}, + {"restart"}, + {"restart", "php"}, + {"wp", "--", "plugin", "list"}, + {"exec", "--", "php", "ls"}, + {"logs"}, + {"base", "start"}, + {"base", "stop"}, + {"base", "restart"}, + } + + for _, args := range commands { + t.Run(strings.Join(args, " "), func(t *testing.T) { + e := newEnv(t) + e.vars = append(e.vars, testutil.EnvExit+"=3") + + res := e.run(t, e.site, args...) + if res.code != 3 { + t.Errorf("exit code = %d, want 3 (stderr %q)", res.code, res.stderr) + } + if strings.Contains(res.stdout, "successfully") { + t.Errorf("stdout = %q, want no success message after a failure", res.stdout) + } + if strings.Contains(res.stdout+res.stderr, "%!") { + t.Errorf("output has a format error: stdout %q, stderr %q", res.stdout, res.stderr) + } + }) + } +} + +func TestSuccessExitsZero(t *testing.T) { + e := newEnv(t) + + res := e.run(t, e.site, "start") + if res.code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr %q)", res.code, res.stderr) + } + + want := "compose -f " + filepath.Join(e.site, "docker-compose.yml") + " up -d" + if calls := e.docker.Calls(t); len(calls) != 1 || calls[0] != want { + t.Errorf("docker calls = %q, want [%q]", calls, want) + } +} + +func TestNoSiteIsAnError(t *testing.T) { + e := newEnv(t) + dir := filepath.Join(e.home, "not-a-site") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + + res := e.run(t, dir, "start") + if res.code != 1 { + t.Errorf("exit code = %d, want 1", res.code) + } + if !strings.Contains(res.stderr, "no docker-compose.yml file found") { + t.Errorf("stderr = %q, want the no-site error", res.stderr) + } + if res.stdout != "" { + t.Errorf("stdout = %q, want errors on stderr only", res.stdout) + } +} + +func TestUsageErrors(t *testing.T) { + tests := []struct { + args []string + want string + }{ + {args: []string{"start", "--bogus"}, want: "Run 'fly start --help' for usage"}, + {args: []string{"exec"}, want: "requires at least 1 arg"}, + {args: []string{"update"}, want: "sudo fly update"}, + } + + for _, tt := range tests { + t.Run(strings.Join(tt.args, " "), func(t *testing.T) { + e := newEnv(t) + + res := e.run(t, e.site, tt.args...) + if res.code != 1 { + t.Errorf("exit code = %d, want 1", res.code) + } + if !strings.Contains(res.stderr, tt.want) { + t.Errorf("stderr = %q, want it to contain %q", res.stderr, tt.want) + } + }) + } +}