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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func init() {
baseCmd.AddCommand(baseStartCmd)
baseCmd.AddCommand(baseStopCmd)
baseCmd.AddCommand(baseRestartCmd)
requireDocker(baseStartCmd, baseStopCmd, baseRestartCmd)

rootCmd.AddCommand(baseCmd)
}
21 changes: 8 additions & 13 deletions cmd/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

Expand Down Expand Up @@ -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)
}
}
},
}
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/exec"

"github.com/fatih/color"
"github.com/flywp/server-cli/internal/docker"
"github.com/spf13/cobra"
)

Expand All @@ -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))
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,5 @@ func init() {
rootCmd.AddCommand(restartCmd)
rootCmd.AddCommand(execCmd)
rootCmd.AddCommand(logsCmd)
requireDocker(wpCmd, startCmd, stopCmd, restartCmd, execCmd, logsCmd)
}
117 changes: 117 additions & 0 deletions internal/docker/probe.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
116 changes: 116 additions & 0 deletions internal/docker/probe_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 23 additions & 2 deletions internal/testutil/fakedocker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand All @@ -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}"
Expand Down Expand Up @@ -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 {
Expand Down
Loading