Skip to content

Commit 8ea4e0e

Browse files
feat(installer): bootstrap missing dependencies
The installer only checked for docker, git and curl and then exited with a one line error, so anyone without Docker had to go and read the Docker docs themselves. It now detects the platform and reports every missing piece individually, including Compose v2, an unreachable daemon and the user not being in the docker group. For each one it shows the official install commands for the detected distribution and offers to run them, using tea.ExecProcess so sudo can prompt for a password without corrupting the display. macOS is report only, since Docker Desktop cannot be installed unattended. Adding the user to the docker group only takes effect at login, so when that happens the installer says to reopen the shell rather than pretending it can continue.
1 parent cb8f847 commit 8ea4e0e

3 files changed

Lines changed: 468 additions & 19 deletions

File tree

setup/internal/installer/deps.go

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
package installer
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"os/user"
10+
"runtime"
11+
"strings"
12+
)
13+
14+
const dockerInstallDocs = "https://docs.docker.com/engine/install/"
15+
16+
type Platform struct {
17+
OS string
18+
Distro string
19+
Codename string
20+
Family string
21+
WSL bool
22+
Systemd bool
23+
}
24+
25+
func (p Platform) Label() string {
26+
switch {
27+
case p.OS == "darwin":
28+
return "macOS"
29+
case p.WSL && p.Distro != "":
30+
return p.Distro + " (WSL)"
31+
case p.WSL:
32+
return "Linux (WSL)"
33+
case p.Distro != "":
34+
return p.Distro
35+
default:
36+
return p.OS
37+
}
38+
}
39+
40+
type Dependency struct {
41+
Name string
42+
Detail string
43+
Commands []string
44+
DocURL string
45+
}
46+
47+
type DependencyReport struct {
48+
Platform Platform
49+
Missing []Dependency
50+
Relogin bool
51+
Notes []string
52+
}
53+
54+
func (r DependencyReport) OK() bool {
55+
return len(r.Missing) == 0
56+
}
57+
58+
func (r DependencyReport) Script() string {
59+
var lines []string
60+
for _, dep := range r.Missing {
61+
lines = append(lines, dep.Commands...)
62+
}
63+
if len(lines) == 0 {
64+
return ""
65+
}
66+
return "set -eu\n" + strings.Join(lines, "\n") + "\n"
67+
}
68+
69+
func (r DependencyReport) Instructions() string {
70+
var b strings.Builder
71+
fmt.Fprintf(&b, "Detected platform: %s\n\n", r.Platform.Label())
72+
if r.OK() {
73+
b.WriteString("All dependencies are present.\n")
74+
return b.String()
75+
}
76+
for _, dep := range r.Missing {
77+
fmt.Fprintf(&b, "%s\n %s\n", dep.Name, dep.Detail)
78+
if len(dep.Commands) > 0 {
79+
b.WriteString("\n")
80+
for _, cmd := range dep.Commands {
81+
fmt.Fprintf(&b, " %s\n", cmd)
82+
}
83+
}
84+
if dep.DocURL != "" {
85+
fmt.Fprintf(&b, "\n Official instructions: %s\n", dep.DocURL)
86+
}
87+
b.WriteString("\n")
88+
}
89+
for _, note := range r.Notes {
90+
fmt.Fprintf(&b, "%s\n\n", note)
91+
}
92+
if r.Relogin {
93+
b.WriteString("Group membership only applies to new logins, so after this finishes\n")
94+
b.WriteString("close this shell, open a new one and run the installer again.\n")
95+
}
96+
return b.String()
97+
}
98+
99+
func DetectPlatform() Platform {
100+
p := Platform{OS: runtime.GOOS}
101+
if p.OS != "linux" {
102+
return p
103+
}
104+
p.Distro, p.Codename, p.Family = detectDistro()
105+
p.WSL = detectWSL()
106+
p.Systemd = detectSystemd()
107+
return p
108+
}
109+
110+
func detectDistro() (id, codename, family string) {
111+
file, err := os.Open("/etc/os-release")
112+
if err != nil {
113+
return "", "", ""
114+
}
115+
defer file.Close()
116+
117+
var idLike string
118+
scanner := bufio.NewScanner(file)
119+
for scanner.Scan() {
120+
key, value, found := strings.Cut(scanner.Text(), "=")
121+
if !found {
122+
continue
123+
}
124+
value = strings.Trim(value, `"'`)
125+
switch key {
126+
case "ID":
127+
id = value
128+
case "ID_LIKE":
129+
idLike = value
130+
case "VERSION_CODENAME":
131+
codename = value
132+
}
133+
}
134+
135+
for _, candidate := range append([]string{id}, strings.Fields(idLike)...) {
136+
switch candidate {
137+
case "debian", "ubuntu":
138+
return id, codename, "debian"
139+
case "fedora", "rhel", "centos":
140+
return id, codename, "rhel"
141+
case "arch":
142+
return id, codename, "arch"
143+
case "opensuse", "suse", "opensuse-leap", "opensuse-tumbleweed":
144+
return id, codename, "suse"
145+
}
146+
}
147+
return id, codename, ""
148+
}
149+
150+
func detectWSL() bool {
151+
if os.Getenv("WSL_DISTRO_NAME") != "" {
152+
return true
153+
}
154+
data, err := os.ReadFile("/proc/version")
155+
if err != nil {
156+
return false
157+
}
158+
return strings.Contains(strings.ToLower(string(data)), "microsoft")
159+
}
160+
161+
func detectSystemd() bool {
162+
info, err := os.Stat("/run/systemd/system")
163+
return err == nil && info.IsDir()
164+
}
165+
166+
func packageInstall(p Platform, packages ...string) []string {
167+
joined := strings.Join(packages, " ")
168+
switch p.Family {
169+
case "debian":
170+
return []string{"sudo apt-get update", "sudo apt-get install -y " + joined}
171+
case "rhel":
172+
return []string{"sudo dnf install -y " + joined}
173+
case "arch":
174+
return []string{"sudo pacman -Sy --noconfirm " + joined}
175+
case "suse":
176+
return []string{"sudo zypper install -y " + joined}
177+
default:
178+
return nil
179+
}
180+
}
181+
182+
func dockerEngineCommands(p Platform) []string {
183+
switch p.Family {
184+
case "debian":
185+
repo := p.Distro
186+
if repo != "ubuntu" && repo != "debian" {
187+
repo = "debian"
188+
}
189+
codename := p.Codename
190+
if codename == "" {
191+
codename = "$(. /etc/os-release && echo \"$VERSION_CODENAME\")"
192+
}
193+
return []string{
194+
"sudo apt-get update",
195+
"sudo apt-get install -y ca-certificates curl",
196+
"sudo install -m 0755 -d /etc/apt/keyrings",
197+
fmt.Sprintf("sudo curl -fsSL https://download.docker.com/linux/%s/gpg -o /etc/apt/keyrings/docker.asc", repo),
198+
"sudo chmod a+r /etc/apt/keyrings/docker.asc",
199+
fmt.Sprintf(`echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null`, repo, codename),
200+
"sudo apt-get update",
201+
"sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
202+
}
203+
case "rhel":
204+
repo := "fedora"
205+
if p.Distro != "fedora" {
206+
repo = "centos"
207+
}
208+
return []string{
209+
"sudo dnf -y install dnf-plugins-core",
210+
fmt.Sprintf("sudo dnf config-manager --add-repo https://download.docker.com/linux/%s/docker-ce.repo", repo),
211+
"sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
212+
}
213+
case "arch":
214+
return []string{"sudo pacman -Sy --noconfirm docker docker-compose"}
215+
case "suse":
216+
return []string{"sudo zypper install -y docker docker-compose"}
217+
default:
218+
return nil
219+
}
220+
}
221+
222+
func daemonStartCommands(p Platform) []string {
223+
if p.Systemd {
224+
return []string{"sudo systemctl enable --now docker"}
225+
}
226+
return []string{"sudo service docker start"}
227+
}
228+
229+
func CheckDependenciesReport(ctx context.Context) DependencyReport {
230+
p := DetectPlatform()
231+
report := DependencyReport{Platform: p}
232+
233+
if p.OS == "darwin" {
234+
return macOSReport(ctx, p)
235+
}
236+
237+
for _, tool := range []string{"curl", "git"} {
238+
if _, err := exec.LookPath(tool); err != nil {
239+
report.Missing = append(report.Missing, Dependency{
240+
Name: tool,
241+
Detail: fmt.Sprintf("%s is required and was not found in PATH.", tool),
242+
Commands: packageInstall(p, tool),
243+
})
244+
}
245+
}
246+
247+
dockerFound := true
248+
if _, err := exec.LookPath("docker"); err != nil {
249+
dockerFound = false
250+
commands := dockerEngineCommands(p)
251+
commands = append(commands, daemonStartCommands(p)...)
252+
commands = append(commands, "sudo usermod -aG docker $USER")
253+
report.Missing = append(report.Missing, Dependency{
254+
Name: "docker",
255+
Detail: "Docker Engine is required and was not found in PATH.",
256+
Commands: commands,
257+
DocURL: dockerInstallDocs,
258+
})
259+
report.Relogin = true
260+
}
261+
262+
if dockerFound {
263+
if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
264+
report.Missing = append(report.Missing, Dependency{
265+
Name: "docker compose v2",
266+
Detail: "Docker is installed but the Compose v2 plugin is missing.",
267+
Commands: packageInstall(p, "docker-compose-plugin"),
268+
DocURL: "https://docs.docker.com/compose/install/",
269+
})
270+
}
271+
if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil {
272+
report.Missing = append(report.Missing, dockerDaemonDependency(p, &report))
273+
}
274+
}
275+
276+
if p.WSL && !p.Systemd {
277+
report.Notes = append(report.Notes,
278+
"WSL without systemd: add the following to /etc/wsl.conf, then run\n"+
279+
"'wsl --shutdown' from Windows so the daemon starts on boot.\n\n"+
280+
" [boot]\n systemd=true")
281+
}
282+
283+
return report
284+
}
285+
286+
func dockerDaemonDependency(p Platform, report *DependencyReport) Dependency {
287+
if !inDockerGroup() {
288+
report.Relogin = true
289+
commands := append(daemonStartCommands(p), "sudo usermod -aG docker $USER")
290+
return Dependency{
291+
Name: "docker daemon access",
292+
Detail: "Cannot talk to the Docker daemon, and your user is not in the 'docker' group.",
293+
Commands: commands,
294+
DocURL: "https://docs.docker.com/engine/install/linux-postinstall/",
295+
}
296+
}
297+
return Dependency{
298+
Name: "docker daemon",
299+
Detail: "Docker is installed but the daemon is not responding.",
300+
Commands: daemonStartCommands(p),
301+
DocURL: dockerInstallDocs,
302+
}
303+
}
304+
305+
func inDockerGroup() bool {
306+
current, err := user.Current()
307+
if err != nil {
308+
return false
309+
}
310+
group, err := user.LookupGroup("docker")
311+
if err != nil {
312+
return false
313+
}
314+
ids, err := current.GroupIds()
315+
if err != nil {
316+
return false
317+
}
318+
for _, id := range ids {
319+
if id == group.Gid {
320+
return true
321+
}
322+
}
323+
return false
324+
}
325+
326+
func macOSReport(ctx context.Context, p Platform) DependencyReport {
327+
report := DependencyReport{Platform: p}
328+
if _, err := exec.LookPath("docker"); err != nil {
329+
report.Missing = append(report.Missing, Dependency{
330+
Name: "Docker Desktop",
331+
Detail: "Docker Desktop is required on macOS and cannot be installed unattended.",
332+
Commands: []string{
333+
"brew install --cask docker",
334+
"open -a Docker",
335+
},
336+
DocURL: "https://docs.docker.com/desktop/install/mac-install/",
337+
})
338+
return report
339+
}
340+
if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
341+
report.Missing = append(report.Missing, Dependency{
342+
Name: "docker compose v2",
343+
Detail: "Docker is installed but the Compose v2 plugin is missing.",
344+
Commands: []string{"open -a Docker"},
345+
DocURL: "https://docs.docker.com/compose/install/",
346+
})
347+
}
348+
if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil {
349+
report.Missing = append(report.Missing, Dependency{
350+
Name: "docker daemon",
351+
Detail: "Docker Desktop is installed but not running.",
352+
Commands: []string{"open -a Docker"},
353+
DocURL: "https://docs.docker.com/desktop/install/mac-install/",
354+
})
355+
}
356+
return report
357+
}
358+
359+
func BootstrapCommand(report DependencyReport) *exec.Cmd {
360+
script := report.Script()
361+
if script == "" {
362+
return nil
363+
}
364+
cmd := exec.Command("sh", "-c", script)
365+
cmd.Stdin = os.Stdin
366+
cmd.Stdout = os.Stdout
367+
cmd.Stderr = os.Stderr
368+
return cmd
369+
}

0 commit comments

Comments
 (0)