From b278401909f97ee12f347d480a34196ea5f5264f Mon Sep 17 00:00:00 2001 From: nabil1440 <52530910+nabil1440@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:57:53 +0600 Subject: [PATCH] feat(docker): warn when Docker, Compose or the daemon is not available - docker.Check examines the Docker CLI (PATH), the Compose plugin (docker compose version) and the daemon (docker version), with a 5 s timeout for each check. - Commands that need Docker declare it with requireDocker. The root command runs the check first. If a part is not available, fly shows one warning line and exits with status 69 (EX_UNAVAILABLE). - fly status shows the three parts on separate lines and exits 0. Closes #5 --- cmd/base.go | 1 + cmd/global.go | 21 +++--- cmd/root.go | 26 +++++++ cmd/site.go | 1 + internal/docker/probe.go | 117 ++++++++++++++++++++++++++++++++ internal/docker/probe_test.go | 116 +++++++++++++++++++++++++++++++ internal/testutil/fakedocker.go | 25 ++++++- main_test.go | 76 ++++++++++++++++++++- 8 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 internal/docker/probe.go create mode 100644 internal/docker/probe_test.go diff --git a/cmd/base.go b/cmd/base.go index ce4a17c..1bc7088 100644 --- a/cmd/base.go +++ b/cmd/base.go @@ -70,6 +70,7 @@ func init() { baseCmd.AddCommand(baseStartCmd) baseCmd.AddCommand(baseStopCmd) baseCmd.AddCommand(baseRestartCmd) + requireDocker(baseStartCmd, baseStopCmd, baseRestartCmd) rootCmd.AddCommand(baseCmd) } diff --git a/cmd/global.go b/cmd/global.go index 785c2c2..4ca707e 100644 --- a/cmd/global.go +++ b/cmd/global.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "strings" @@ -61,18 +60,13 @@ var statusCmd = &cobra.Command{ color.Green("Nginx directory exists") } - // check if docker is installed - if output, err := exec.Command("docker", "version", "--format", "{{.Server.Version}}").CombinedOutput(); err != nil { - color.Red("Docker is not installed") - } else { - color.Green("Docker is installed, version: %s", strings.TrimSpace(string(output))) - } - - // check if docker is running - if _, err := exec.Command("docker", "version").CombinedOutput(); err != nil { - color.Red("Docker is not running") - } else { - color.Green("Docker is running") + // check the docker CLI, the compose plugin and the daemon separately + for _, s := range docker.Status(cmd.Context()) { + if s.Err != nil { + color.Red("%s is not available: %s", s.Part, s.Err.Detail) + } else { + color.Green("%s is available: %s", s.Part, s.Info) + } } }, } @@ -153,6 +147,7 @@ func init() { sitesCmd.AddCommand(sitesStartCmd) sitesCmd.AddCommand(sitesStopCmd) sitesCmd.AddCommand(restartSitesCmd) + requireDocker(sitesStartCmd, sitesStopCmd, restartSitesCmd) rootCmd.AddCommand(sitesCmd) rootCmd.AddCommand(statusCmd) diff --git a/cmd/root.go b/cmd/root.go index c64980d..2013d85 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "os/exec" "github.com/fatih/color" + "github.com/flywp/server-cli/internal/docker" "github.com/spf13/cobra" ) @@ -24,10 +25,28 @@ var rootCmd = &cobra.Command{ return fmt.Errorf("you should not run this command as root") } + if cmd.Annotations[requiresAnnotation] == "docker" { + return docker.Check(cmd.Context()) + } + return nil }, } +// requiresAnnotation names what a command needs to run. The root command +// checks it before the command runs. +const requiresAnnotation = "requires" + +// requireDocker marks cmds as commands that need Docker. +func requireDocker(cmds ...*cobra.Command) { + for _, c := range cmds { + if c.Annotations == nil { + c.Annotations = map[string]string{} + } + c.Annotations[requiresAnnotation] = "docker" + } +} + // Execute runs the root command and exits with the resulting status code. func Execute() { os.Exit(exitCode(rootCmd.Execute(), os.Stderr)) @@ -39,6 +58,13 @@ func exitCode(err error, stderr io.Writer) int { return 0 } + // Docker is down or not installed: show one warning, not a raw error. + var unavailable *docker.UnavailableError + if errors.As(err, &unavailable) { + _, _ = color.New(color.FgYellow).Fprintf(stderr, "Warning: %v\n", unavailable) + return unavailable.ExitCode() + } + // 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 diff --git a/cmd/site.go b/cmd/site.go index 299356b..0cf3818 100644 --- a/cmd/site.go +++ b/cmd/site.go @@ -199,4 +199,5 @@ func init() { rootCmd.AddCommand(restartCmd) rootCmd.AddCommand(execCmd) rootCmd.AddCommand(logsCmd) + requireDocker(wpCmd, startCmd, stopCmd, restartCmd, execCmd, logsCmd) } diff --git a/internal/docker/probe.go b/internal/docker/probe.go new file mode 100644 index 0000000..711a677 --- /dev/null +++ b/internal/docker/probe.go @@ -0,0 +1,117 @@ +package docker + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +// ExitUnavailable is the exit status of a command that needs Docker when +// Docker is not available (EX_UNAVAILABLE in sysexits.h). +const ExitUnavailable = 69 + +// probeTimeout limits each availability check. +var probeTimeout = 5 * time.Second + +// Part is a part of the Docker installation that fly needs. +type Part string + +const ( + PartCLI Part = "Docker CLI" + PartCompose Part = "Docker Compose plugin" + PartDaemon Part = "Docker daemon" +) + +// UnavailableError reports that a part of Docker is not available. +type UnavailableError struct { + Part Part + Detail string +} + +func (e *UnavailableError) Error() string { + return fmt.Sprintf("%s is not available: %s", e.Part, e.Detail) +} + +// ExitCode returns the exit status for a command that cannot run without Docker. +func (e *UnavailableError) ExitCode() int { + return ExitUnavailable +} + +// PartStatus is the state of one part of Docker. Err is nil when the part is +// available; Info then holds its path or version. +type PartStatus struct { + Part Part + Info string + Err *UnavailableError +} + +// Status checks the Docker CLI, the Compose plugin and the daemon, in that order. +func Status(ctx context.Context) []PartStatus { + path, err := exec.LookPath("docker") + if err != nil { + const skipped = "not checked, because the Docker CLI is not available" + return []PartStatus{ + {Part: PartCLI, Err: &UnavailableError{Part: PartCLI, Detail: "docker not found in PATH"}}, + {Part: PartCompose, Err: &UnavailableError{Part: PartCompose, Detail: skipped}}, + {Part: PartDaemon, Err: &UnavailableError{Part: PartDaemon, Detail: skipped}}, + } + } + + return []PartStatus{ + {Part: PartCLI, Info: path}, + probe(ctx, PartCompose, "compose", "version", "--short"), + probe(ctx, PartDaemon, "version", "--format", "{{.Server.Version}}"), + } +} + +// Check returns an *UnavailableError for the first part of Docker that is +// not available, or nil when Docker can run compose commands. +func Check(ctx context.Context) error { + for _, s := range Status(ctx) { + if s.Err != nil { + return s.Err + } + } + + return nil +} + +// probe runs docker with args and reports part as available if it succeeds. +func probe(ctx context.Context, part Part, args ...string) PartStatus { + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Stdout, cmd.Stderr = &stdout, &stderr + // Do not wait for output from processes that docker started, after a timeout. + cmd.WaitDelay = time.Second + + if err := cmd.Run(); err != nil { + detail := firstLine(stderr.String()) + switch { + case errors.Is(ctx.Err(), context.DeadlineExceeded): + detail = fmt.Sprintf("no answer within %s", probeTimeout) + case detail == "": + detail = err.Error() + } + return PartStatus{Part: part, Err: &UnavailableError{Part: part, Detail: detail}} + } + + return PartStatus{Part: part, Info: strings.TrimSpace(stdout.String())} +} + +// firstLine returns the first non-empty line of s. +func firstLine(s string) string { + for line := range strings.Lines(s) { + if line = strings.TrimSpace(line); line != "" { + return line + } + } + + return "" +} diff --git a/internal/docker/probe_test.go b/internal/docker/probe_test.go new file mode 100644 index 0000000..7f6d386 --- /dev/null +++ b/internal/docker/probe_test.go @@ -0,0 +1,116 @@ +package docker + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flywp/server-cli/internal/testutil" +) + +// useFakeDocker puts a fake docker in PATH that behaves as mode describes. +func useFakeDocker(t *testing.T, mode string) *testutil.FakeDocker { + t.Helper() + + fake := testutil.NewFakeDocker(t) + fake.Install(t) + t.Setenv(testutil.EnvMode, mode) + + return fake +} + +func TestCheck(t *testing.T) { + tests := []struct { + name string + mode string + noDocker bool + wantPart Part + wantDetail string + }{ + {name: "available"}, + {name: "no docker CLI", noDocker: true, wantPart: PartCLI, wantDetail: "docker not found in PATH"}, + {name: "no compose plugin", mode: "no-compose", wantPart: PartCompose, wantDetail: "'compose' is not a docker command"}, + {name: "daemon down", mode: "daemon-down", wantPart: PartDaemon, wantDetail: "Cannot connect to the Docker daemon"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.noDocker { + t.Setenv("PATH", t.TempDir()) + } else { + useFakeDocker(t, tt.mode) + } + + err := Check(context.Background()) + if tt.wantPart == "" { + if err != nil { + t.Fatalf("Check() = %v, want nil", err) + } + return + } + + var unavailable *UnavailableError + if !errors.As(err, &unavailable) { + t.Fatalf("Check() = %v, want an *UnavailableError", err) + } + if unavailable.Part != tt.wantPart || !strings.Contains(unavailable.Detail, tt.wantDetail) { + t.Errorf("Check() = %q, want part %q with detail %q", unavailable, tt.wantPart, tt.wantDetail) + } + if unavailable.ExitCode() != ExitUnavailable { + t.Errorf("ExitCode() = %d, want %d", unavailable.ExitCode(), ExitUnavailable) + } + }) + } +} + +func TestStatus(t *testing.T) { + fake := useFakeDocker(t, "") + + got := Status(context.Background()) + want := []PartStatus{ + {Part: PartCLI, Info: filepath.Join(fake.Dir, "docker")}, + {Part: PartCompose, Info: "2.40.0"}, + {Part: PartDaemon, Info: "29.0.0"}, + } + + if len(got) != len(want) { + t.Fatalf("Status() = %+v, want %+v", got, want) + } + for i := range want { + if got[i].Part != want[i].Part || got[i].Info != want[i].Info || got[i].Err != nil { + t.Errorf("Status()[%d] = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestStatusWithoutDockerCLI(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + for _, s := range Status(context.Background()) { + if s.Err == nil { + t.Errorf("%s: Err = nil, want not available", s.Part) + } + } +} + +func TestCheckTimesOut(t *testing.T) { + useFakeDocker(t, "daemon-hang") + + old := probeTimeout + probeTimeout = time.Second + t.Cleanup(func() { probeTimeout = old }) + + start := time.Now() + err := Check(context.Background()) + + var unavailable *UnavailableError + if !errors.As(err, &unavailable) || unavailable.Part != PartDaemon || !strings.Contains(unavailable.Detail, "no answer") { + t.Errorf("Check() = %v, want the daemon to be reported as not answering", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("Check() took %s, want it to stop soon after the timeout", elapsed) + } +} diff --git a/internal/testutil/fakedocker.go b/internal/testutil/fakedocker.go index 6c46ee9..ce46d5f 100644 --- a/internal/testutil/fakedocker.go +++ b/internal/testutil/fakedocker.go @@ -16,7 +16,8 @@ const ( 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 selects a failure mode: "no-compose", "daemon-down" or + // "daemon-hang". EnvMode = "FAKE_DOCKER_MODE" ) @@ -29,6 +30,11 @@ no-compose) exit 1 fi ;; +daemon-hang) + if [ "$1" = version ]; then + exec sleep 30 + 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 @@ -37,7 +43,7 @@ daemon-down) ;; esac case "$1 $2" in -"compose version") echo "Docker Compose version v2.40.0"; exit 0 ;; +"compose version") echo "2.40.0"; exit 0 ;; "version "*) echo "29.0.0"; exit 0 ;; esac exit "${FAKE_DOCKER_EXIT:-0}" @@ -95,6 +101,21 @@ func (f *FakeDocker) Calls(t *testing.T) []string { return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") } +// ComposeCalls returns the "docker compose -f" calls so far, without the +// calls that check whether Docker is available. +func (f *FakeDocker) ComposeCalls(t *testing.T) []string { + t.Helper() + + var calls []string + for _, c := range f.Calls(t) { + if strings.HasPrefix(c, "compose -f ") { + calls = append(calls, c) + } + } + + return calls +} + // 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 { diff --git a/main_test.go b/main_test.go index 8351241..921b916 100644 --- a/main_test.go +++ b/main_test.go @@ -154,7 +154,7 @@ func TestSuccessExitsZero(t *testing.T) { } want := "compose -f " + filepath.Join(e.site, "docker-compose.yml") + " up -d" - if calls := e.docker.Calls(t); len(calls) != 1 || calls[0] != want { + if calls := e.docker.ComposeCalls(t); len(calls) != 1 || calls[0] != want { t.Errorf("docker calls = %q, want [%q]", calls, want) } } @@ -205,7 +205,7 @@ func TestDomainFlag(t *testing.T) { } } - if calls := e.docker.Calls(t); len(calls) != 1 { + if calls := e.docker.ComposeCalls(t); len(calls) != 1 { t.Errorf("docker calls = %q, want only the call for example.com", calls) } } @@ -277,7 +277,7 @@ func TestFlagsPassThrough(t *testing.T) { } want := "compose -f " + filepath.Join(e.site, "docker-compose.yml") + " " + tt.want - if calls := e.docker.Calls(t); len(calls) != 1 || calls[0] != want { + if calls := e.docker.ComposeCalls(t); len(calls) != 1 || calls[0] != want { t.Errorf("docker calls = %q, want [%q]", calls, want) } }) @@ -293,6 +293,76 @@ func TestExecNeedsACommand(t *testing.T) { } } +func TestDockerUnavailable(t *testing.T) { + modes := []struct { + name string + mode string // fake docker failure mode + noDocker bool // no docker command in PATH at all + want string + }{ + {name: "no docker CLI", noDocker: true, want: "Warning: Docker CLI is not available"}, + {name: "no compose plugin", mode: "no-compose", want: "Warning: Docker Compose plugin is not available"}, + {name: "daemon down", mode: "daemon-down", want: "Warning: Docker daemon is not available"}, + } + commands := [][]string{ + {"start"}, + {"wp", "plugin", "list"}, + {"exec", "php", "ls"}, + {"base", "stop"}, + {"sites", "start"}, + } + + for _, m := range modes { + for _, args := range commands { + t.Run(m.name+"/"+strings.Join(args, " "), func(t *testing.T) { + e := newEnv(t) + if m.noDocker { + e.vars = append(e.vars, "PATH="+testutil.TempDir(t)) + } else { + e.vars = append(e.vars, testutil.EnvMode+"="+m.mode) + } + + res := e.run(t, e.site, args...) + if res.code != 69 { + t.Errorf("exit code = %d, want 69", res.code) + } + lines := strings.Split(strings.TrimSpace(res.stderr), "\n") + if len(lines) != 1 || !strings.HasPrefix(lines[0], m.want) { + t.Errorf("stderr = %q, want one line that starts with %q", res.stderr, m.want) + } + if strings.Contains(res.stderr, "exec:") || strings.Contains(res.stdout, "successfully") { + t.Errorf("output has a raw error or a false success: stdout %q, stderr %q", res.stdout, res.stderr) + } + if calls := e.docker.ComposeCalls(t); len(calls) != 0 { + t.Errorf("compose calls = %q, want none", calls) + } + }) + } + + t.Run(m.name+"/status", func(t *testing.T) { + e := newEnv(t) + if m.noDocker { + e.vars = append(e.vars, "PATH="+testutil.TempDir(t)) + } else { + e.vars = append(e.vars, testutil.EnvMode+"="+m.mode) + } + + res := e.run(t, e.site, "status") + if res.code != 0 { + t.Errorf("exit code = %d, want 0", res.code) + } + for _, part := range []string{"Docker CLI is", "Docker Compose plugin is", "Docker daemon is"} { + if !strings.Contains(res.stdout, part) { + t.Errorf("stdout = %q, want a line for %q", res.stdout, part) + } + } + if !strings.Contains(res.stdout, strings.TrimPrefix(m.want, "Warning: ")) { + t.Errorf("stdout = %q, want %q", res.stdout, strings.TrimPrefix(m.want, "Warning: ")) + } + }) + } +} + func TestUsageErrors(t *testing.T) { tests := []struct { args []string