Skip to content

Commit f85d27c

Browse files
committed
Add diagnose subcommand
1 parent 7b91fbd commit f85d27c

7 files changed

Lines changed: 351 additions & 9 deletions

File tree

.devcontainer/compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ services:
1010
command: sleep infinity
1111
environment:
1212
- DOCKER_COMMAND=/workspaces/docksider/docksider
13+
- DOCKER_HOST=unix:///var/run/docker.sock
1314
volumes:
1415
- type: bind
1516
source: ../

internal/cmd/diagnose.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
9+
"github.com/moby/moby/client"
10+
"github.com/openclosed-dev/docksider/internal/docker"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type diagnoser struct {
15+
ctx context.Context
16+
out io.Writer
17+
numberOfProblems int
18+
}
19+
20+
func NewDiagnoseCmd() *cobra.Command {
21+
const (
22+
use = "diagnose"
23+
short = "Diagnose the current configuration"
24+
)
25+
26+
cmd := &cobra.Command{
27+
Use: use,
28+
Short: short,
29+
Run: func(cmd *cobra.Command, _ []string) {
30+
diagnoser := diagnoser{
31+
ctx: cmd.Context(),
32+
out: cmd.OutOrStdout(),
33+
}
34+
diagnoser.diagnose()
35+
},
36+
DisableFlagsInUseLine: true,
37+
}
38+
39+
return cmd
40+
}
41+
42+
func (d *diagnoser) diagnose() {
43+
d.checkCommandEnv()
44+
if host, ok := d.checkHostEnv(); ok {
45+
d.checkConnection(host)
46+
}
47+
fmt.Fprintln(d.out, "Diagnostics are done.")
48+
}
49+
50+
func (d *diagnoser) checkCommandEnv() {
51+
52+
value, ok := os.LookupEnv("DOCKER_COMMAND")
53+
if !ok {
54+
d.reportBad("Environment variable DOCKER_COMMAND is not defined.")
55+
return
56+
}
57+
58+
exePath, err := os.Executable()
59+
if err != nil && value != exePath {
60+
d.reportBad(`Environment variable DOCKER_COMMAND has a wrong value.
61+
The actual value is '%s', but the full path of the current executable is '%s'
62+
`,
63+
value, exePath)
64+
return
65+
}
66+
67+
d.reportGood("Environment variable DOCKER_COMMAND has a valid value.")
68+
}
69+
70+
func (d *diagnoser) checkHostEnv() (string, bool) {
71+
72+
const (
73+
example = "tcp://192.168.0.100:2375"
74+
)
75+
76+
host, ok := os.LookupEnv("DOCKER_HOST")
77+
if !ok {
78+
d.reportBad("Environment variable DOCKER_HOST is not defined.")
79+
return "", false
80+
}
81+
82+
if err := docker.ValidateHost(host); err != nil {
83+
d.reportBad(
84+
`Environment variable DOCKER_HOST has an invalid value '%s': %s
85+
A valid example is '%s' if the daemon is listening on the IP address and the port using TCP.`,
86+
host, err, example)
87+
return host, false
88+
}
89+
90+
d.reportGood("Environment variable DOCKER_HOST has a valid value.")
91+
return host, true
92+
}
93+
94+
func (d *diagnoser) checkConnection(host string) {
95+
96+
fmt.Fprintf(d.out,
97+
"Verifying the connectivity to the Docker daemon at '%s' given by DOCKER_HOST...\n",
98+
host)
99+
100+
c, err := docker.NewClientForHost(host)
101+
if err != nil {
102+
d.reportBad("Failed to create a client: %s", err)
103+
return
104+
}
105+
c.Close()
106+
107+
_, err = c.Ping(d.ctx, client.PingOptions{})
108+
if err != nil {
109+
err = docker.WrapError(err)
110+
d.reportBad("The health check for the Docker daemon failed: %s", err)
111+
return
112+
}
113+
114+
d.reportGood("Confirmed that the daemon is running in healthy state at the specified location.")
115+
}
116+
117+
func (d *diagnoser) reportGood(format string, a ...any) {
118+
fmt.Fprintf(d.out, "✅"+format+"\n", a...)
119+
}
120+
121+
func (d *diagnoser) reportBad(format string, a ...any) {
122+
fmt.Fprintf(d.out, "❌"+format+"\n", a...)
123+
d.numberOfProblems++
124+
}

internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func NewRootCmd(version string) *cobra.Command {
3232
root.CompletionOptions.DisableDefaultCmd = true
3333

3434
root.AddCommand(
35+
NewDiagnoseCmd(),
3536
NewLoginCmd(),
3637
NewPsCmd(),
3738
image.NewCmd(),

internal/docker/client.go

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,107 @@
11
package docker
22

33
import (
4+
"errors"
45
"fmt"
6+
"net/url"
57
"os"
68
"runtime"
9+
"strings"
710

811
"github.com/moby/moby/client"
912
)
1013

1114
func NewClient() (*client.Client, error) {
1215

13-
if runtime.GOOS == "windows" {
14-
value := os.Getenv("DOCKER_HOST")
15-
if value == "" {
16-
return nil, fmt.Errorf(`The environment variable DOCKER_HOST is not defined.
17-
It must have a value similar to 'tcp://<host>:<port>'.`)
16+
host, ok := os.LookupEnv("DOCKER_HOST")
17+
if ok {
18+
err := ValidateHost(host)
19+
if err != nil {
20+
return nil, fmt.Errorf("DOCKER_HOST has an invalid value '%s': %w", host, err)
1821
}
22+
} else {
23+
host = client.DefaultDockerHost
1924
}
2025

21-
return client.New(client.WithHostFromEnv())
26+
return NewClientForHost(host)
27+
}
28+
29+
func NewClientForHost(host string) (*client.Client, error) {
30+
return client.New(client.WithHost(host))
31+
}
32+
33+
var protocolsForUnix = []string{"tcp", "ssh", "unix"}
34+
var protocolsForWindows = []string{"tcp", "ssh", "npipe"}
35+
36+
func ValidateHost(value string) error {
37+
38+
if len(strings.TrimSpace(value)) == 0 {
39+
return errors.New("value is blank")
40+
}
41+
42+
scheme, remaining, ok := strings.Cut(value, "://")
43+
if !ok || scheme == "" {
44+
return errors.New("protocol is missing in the URL")
45+
}
46+
47+
switch scheme {
48+
case "tcp":
49+
if err := validateTcpHost(value); err != nil {
50+
return err
51+
}
52+
case "unix":
53+
if err := validateUnixHost(remaining); err != nil {
54+
return err
55+
}
56+
case "ssh", "npipe":
57+
default:
58+
return fmt.Errorf(
59+
"unsupported protocol '%s'; "+
60+
"the protocol must be one of [%s] for Unix and [%s] for Windows; ",
61+
scheme,
62+
strings.Join(protocolsForUnix, ", "),
63+
strings.Join(protocolsForWindows, ", "))
64+
}
65+
66+
return nil
2267
}
2368

2469
func WrapError(err error) error {
2570
if client.IsErrConnectionFailed(err) {
26-
return fmt.Errorf(`failed to connect to the Docker daemon: %w\n
27-
Hint: If the daemon is installed on a WSL distribution, start the instance first.
28-
`, err)
71+
return fmt.Errorf(
72+
`failed to connect to the Docker daemon: %w
73+
Hint: If the daemon is installed on a WSL distribution, start the distribution first.`,
74+
err)
2975
}
3076
return err
3177
}
78+
79+
func validateTcpHost(value string) error {
80+
parsed, err := url.Parse(value)
81+
if err != nil {
82+
return fmt.Errorf("not a URL: %w", err)
83+
}
84+
if parsed.Hostname() == "" {
85+
return errors.New("hostname is missing")
86+
}
87+
if parsed.Port() == "" {
88+
return errors.New("port number is missing")
89+
}
90+
return nil
91+
}
92+
93+
func validateUnixHost(path string) error {
94+
if runtime.GOOS == "windows" {
95+
return errors.New("'unix' protocol is not supported in Windows")
96+
}
97+
if len(strings.TrimSpace(path)) == 0 {
98+
return errors.New("path is blank")
99+
}
100+
if !strings.HasPrefix(path, "/") {
101+
return fmt.Errorf("path is not absolute: '%s'", path)
102+
}
103+
if _, err := os.Stat(path); err != nil {
104+
return fmt.Errorf("file does not exist at '%s'", path)
105+
}
106+
return nil
107+
}

internal/docker/client_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package docker_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/openclosed-dev/docksider/internal/docker"
8+
)
9+
10+
func TestValidHost(t *testing.T) {
11+
12+
cases := []struct {
13+
name string
14+
host string
15+
}{
16+
{"tcp", "tcp://192.168.0.100:2375"},
17+
}
18+
for _, c := range cases {
19+
t.Run(c.name, func(t *testing.T) {
20+
err := docker.ValidateHost(c.host)
21+
if err != nil {
22+
t.Errorf("failed to validate: %s", err)
23+
}
24+
})
25+
}
26+
}
27+
28+
func TestInvalidHost(t *testing.T) {
29+
30+
cases := []struct {
31+
name string
32+
host string
33+
message string
34+
}{
35+
{"empty", "", "value is blank"},
36+
{"blank", " ", "value is blank"},
37+
{"missing protocol", "192.168.0.100:2375", "protocol is missing"},
38+
{"unsupported protocol", "http://localhost", "unsupported protocol"},
39+
{"missing host", "tcp://", "hostname is missing"},
40+
{"missing port", "tcp://192.168.0.100", "port number is missing"},
41+
}
42+
for _, c := range cases {
43+
t.Run(c.name, func(t *testing.T) {
44+
err := docker.ValidateHost(c.host)
45+
if err == nil {
46+
t.Error("failed to detect problem")
47+
}
48+
message := err.Error()
49+
if !strings.HasPrefix(message, c.message) {
50+
t.Errorf("unexpected error message: %s", message)
51+
}
52+
})
53+
}
54+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//go:build !windows
2+
3+
package docker_test
4+
5+
import (
6+
"strings"
7+
"testing"
8+
9+
"github.com/openclosed-dev/docksider/internal/docker"
10+
)
11+
12+
func TestValidHostOnUnix(t *testing.T) {
13+
14+
cases := []struct {
15+
name string
16+
host string
17+
}{
18+
{"unix", "unix:///var/run/docker.sock"},
19+
}
20+
for _, c := range cases {
21+
t.Run(c.name, func(t *testing.T) {
22+
err := docker.ValidateHost(c.host)
23+
if err != nil {
24+
t.Errorf("failed to validate: %s", err)
25+
}
26+
})
27+
}
28+
}
29+
30+
func TestInvalidHostOnUnix(t *testing.T) {
31+
32+
cases := []struct {
33+
name string
34+
host string
35+
message string
36+
}{
37+
{"unix", "unix://", "path is blank"},
38+
{"unix", "unix://localhost/var/run/docker.sock", "path is not absolute"},
39+
{"unix", "unix:///nonexistent", "file does not exist"},
40+
}
41+
for _, c := range cases {
42+
t.Run(c.name, func(t *testing.T) {
43+
err := docker.ValidateHost(c.host)
44+
if err == nil {
45+
t.Error("failed to detect problem")
46+
}
47+
message := err.Error()
48+
if !strings.HasPrefix(message, c.message) {
49+
t.Errorf("unexpected error message: %s", message)
50+
}
51+
})
52+
}
53+
}

0 commit comments

Comments
 (0)