diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 6571626..3da1242 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -266,7 +266,7 @@ func TestDoctorCommand(t *testing.T) { t.Fatalf("exit code %d, mau 0/1.\n%s", code, out) } s := string(out) - for _, want := range []string{"docker", "audio", "go", "compose", "stacks"} { + for _, want := range []string{"engine", "audio", "go", "compose", "stacks"} { if !strings.Contains(s, want) { t.Fatalf("output doctor tidak memuat %q:\n%s", want, s) } diff --git a/internal/app/app.go b/internal/app/app.go index 91b56b6..677e18d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -147,6 +147,8 @@ type Model struct { compose *docker.ComposeClient engine docker.Info + engineName string + stacksDir string stacks []stack.Stack stacksID int64 @@ -178,7 +180,7 @@ func New() Model { i18n.SetLang(cfg.UI.Language) theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent) - cl, err := docker.New() + cl, engineInfo, err := docker.Detect(cfg.Docker.Host) host, _ := os.Hostname() m := Model{ sound: audio.New(audio.Options{ @@ -186,19 +188,20 @@ func New() Model { Volume: cfg.UI.SoundVolume, MuteOnSSH: cfg.UI.SoundMuteOnSSH, }), - tickID: animation.NewID(), - animating: cfg.UI.AnimationEnabled, - toast: components.NewToast(), - view: viewHome, - section: sectionStacks, - focus: focusSidebar, - tab: tabLogs, - hostname: host, - sys: sysinfo.Read(), - cl: cl, - compose: docker.NewCompose(cfg.Docker.ComposeCommand), - stacksDir: config.ExpandPath(cfg.StacksDir), - loading: true, + tickID: animation.NewID(), + animating: cfg.UI.AnimationEnabled, + toast: components.NewToast(), + view: viewHome, + section: sectionStacks, + focus: focusSidebar, + tab: tabLogs, + hostname: host, + sys: sysinfo.Read(), + cl: cl, + engineName: engineInfo.Name, + compose: docker.NewCompose(cfg.Docker.ComposeCommand, engineInfo.Name), + stacksDir: config.ExpandPath(cfg.StacksDir), + loading: true, tailLines: cfg.UI.LogTailLines, showTimestamps: cfg.UI.ShowTimestamps, diff --git a/internal/app/home.go b/internal/app/home.go index f02de77..02cb076 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -63,6 +63,14 @@ func (m Model) clockPanel(w, h int) string { return panel(i18n.T("section.clock"), "", lines, w, h, false) } +// engineTitle — judul panel runtime (Docker/Podman) sesuai hasil deteksi. +func (m Model) engineTitle() string { + if m.engineName == "podman" { + return "Podman" + } + return "Docker" +} + func (m Model) dockerPanel(w, h int) string { var lines []string switch { @@ -80,7 +88,7 @@ func (m Model) dockerPanel(w, h int) string { styled(theme.Current().Success, i18n.T("state.connected")), ) } - return panel("Docker", i18n.T("host.local"), lines, w, h, false) + return panel(m.engineTitle(), i18n.T("host.local"), lines, w, h, false) } func (m Model) hostPanel(w, h int) string { diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 0d732bd..33911ad 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -25,24 +25,30 @@ func Doctor(w io.Writer) int { fail := 0 - // Docker — dependency inti. - switch cl, err := docker.New(); { + // Konfigurasi dimuat lebih dulu — host & compose dipakai di bawah. + cfg, cfgErr := config.Load() + + // Engine Docker/Podman — dependency inti (auto-detect bila host kosong). + engineName := "" + switch cl, engine, err := docker.Detect(cfg.Docker.Host); { case err != nil: - fmt.Fprintf(w, "docker : %s %v\n", i18n.T("error.fail"), err) + fmt.Fprintf(w, "engine : %s %v\n", i18n.T("error.fail"), err) fail++ default: defer cl.Close() - if err := cl.Ping(ctx); err != nil { - fmt.Fprintf(w, "docker : %s %s\n", i18n.T("error.fail"), - i18n.Tf("doctor.dockerDaemonUnreachable", err)) - fail++ - } else if info, err := cl.Info(ctx); err != nil { - fmt.Fprintf(w, "docker : %s %v\n", i18n.T("error.warn"), err) + engineName = engine.Name + host := engine.Host + if host == "" { + host = i18n.T("doctor.hostDefault") + } + if info, err := cl.Info(ctx); err != nil { + fmt.Fprintf(w, "engine : %s %s %v\n", i18n.T("error.warn"), engine.Name, err) } else { - fmt.Fprintf(w, "docker : %s %s\n", i18n.T("error.ok"), - i18n.Tf("doctor.dockerOk", info.ServerVersion, info.APIVersion, + fmt.Fprintf(w, "engine : %s %s\n", i18n.T("error.ok"), + i18n.Tf("doctor.engineOk", engine.Name, info.ServerVersion, info.APIVersion, info.Running, info.Containers, info.Images)) } + fmt.Fprintf(w, "socket : %s\n", host) } // Audio — opsional; engine init async, tunggu maks 2 dtk. @@ -52,15 +58,12 @@ func Doctor(w io.Writer) int { fmt.Fprintf(w, "go : %s (%s/%s)\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) - // Konfigurasi (dipakai compose, stack, dan output config di bawah). - cfg, cfgErr := config.Load() - - // Compose — dipakai operasi stack (up/down/pull). - compose := docker.NewCompose(cfg.Docker.ComposeCommand) + // Compose — dipakai operasi stack (up/down/pull); "auto" mengikuti engine. + compose := docker.NewCompose(cfg.Docker.ComposeCommand, engineName) if ver, err := compose.Version(ctx); err != nil { fmt.Fprintf(w, "compose : %s %v\n", i18n.T("error.warn"), err) } else { - fmt.Fprintf(w, "compose : %s %s\n", i18n.T("error.ok"), i18n.Tf("doctor.composeOk", ver, cfg.Docker.ComposeCommand)) + fmt.Fprintf(w, "compose : %s %s\n", i18n.T("error.ok"), i18n.Tf("doctor.composeOk", ver, compose.Command())) } cfgPath := config.Path() diff --git a/internal/config/config.go b/internal/config/config.go index 97e59c3..dbb2914 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,9 +21,10 @@ type Config struct { Docker DockerCfg `yaml:"docker"` } -// DockerCfg — pengaturan interaksi Docker. +// DockerCfg — pengaturan interaksi runtime (Docker ATAU Podman). type DockerCfg struct { - ComposeCommand string `yaml:"compose_command"` // "docker compose" atau "docker-compose" + Host string `yaml:"host"` // kosong = auto-detect (docker → podman) + ComposeCommand string `yaml:"compose_command"` // "auto" | "docker compose" | "podman compose" | … } type UI struct { @@ -54,7 +55,7 @@ func Default() Config { LogTailLines: 300, ShowTimestamps: true, }, - Docker: DockerCfg{ComposeCommand: "docker compose"}, + Docker: DockerCfg{ComposeCommand: "auto"}, } } @@ -144,7 +145,7 @@ func (c *Config) normalize() { c.Version = 1 } if c.Docker.ComposeCommand == "" { - c.Docker.ComposeCommand = "docker compose" + c.Docker.ComposeCommand = "auto" } } diff --git a/internal/docker/client.go b/internal/docker/client.go index a70bcaa..11ebcd9 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -17,20 +17,11 @@ import ( ) // Client — pembungkus Docker SDK dengan negosiasi versi API -// (mendukung Docker Engine 20.10+, PRD §9). +// (mendukung Docker Engine 20.10+ DAN Podman dengan API kompatibel-Docker). type Client struct { cli *client.Client } -// New membaca konfigurasi dari env (DOCKER_HOST dll) atau default socket. -func New() (*Client, error) { - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("docker: create client: %w", err) - } - return &Client{cli: cli}, nil -} - func (c *Client) Close() error { return c.cli.Close() } // Info — ringkasan daemon untuk dashboard, doctor, dan status bar. diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 793bafa..b06f338 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -14,14 +14,44 @@ type ComposeClient struct { cmd []string // mis. ["docker","compose"] atau ["docker-compose"] } -func NewCompose(composeCmd string) *ComposeClient { - fields := strings.Fields(composeCmd) - if len(fields) == 0 { - fields = []string{"docker", "compose"} - } - return &ComposeClient{cmd: fields} +// NewCompose — spec "auto" memilih perintah sesuai engine & ketersediaan CLI; +// spec eksplisit (mis. "docker compose") selalu menang. +func NewCompose(spec, engine string) *ComposeClient { + return &ComposeClient{cmd: pickCompose(spec, engine, exec.LookPath)} } +// pickCompose — dipisah agar lookPath dapat di-stub di unit test. +func pickCompose(spec, engine string, lookPath func(string) (string, error)) []string { + if spec != "" && spec != "auto" { + if f := strings.Fields(spec); len(f) > 0 { + return f + } + } + orders := [][]string{ + {"docker", "compose"}, + {"docker-compose"}, + {"podman", "compose"}, + {"podman-compose"}, + } + if engine == "podman" { + orders = [][]string{ + {"podman", "compose"}, + {"podman-compose"}, + {"docker", "compose"}, + {"docker-compose"}, + } + } + for _, o := range orders { + if _, err := lookPath(o[0]); err == nil { + return o + } + } + return []string{"docker", "compose"} // tidak ada → error jelas saat dijalankan +} + +// Command — perintah compose yang benar-benar dipakai (untuk doctor/log). +func (c *ComposeClient) Command() string { return strings.Join(c.cmd, " ") } + // ComposeProject — entri `docker compose ls --all --format json` // (format terverifikasi: {"Name","Status":"running(N)","ConfigFiles":"a,b"}). type ComposeProject struct { diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go index 1788a05..b16c70a 100644 --- a/internal/docker/compose_test.go +++ b/internal/docker/compose_test.go @@ -12,7 +12,7 @@ func TestIntegrationCompose(t *testing.T) { if _, err := os.Stat("/var/run/docker.sock"); err != nil { t.Skip("docker socket tidak ada") } - c := NewCompose("docker compose") + c := NewCompose("docker compose", "docker") ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() diff --git a/internal/docker/engine.go b/internal/docker/engine.go new file mode 100644 index 0000000..8a43a4d --- /dev/null +++ b/internal/docker/engine.go @@ -0,0 +1,97 @@ +package docker + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/client" +) + +// EngineInfo — runtime yang benar-benar terdeteksi (Docker ATAU Podman). +type EngineInfo struct { + Name string // "docker" | "podman" + Host string // unix:///… atau tcp://… (kosong = default/env DOCKER_HOST) +} + +// candidate — endpoint yang dicoba berurutan saat deteksi. +type candidate struct { + name string + host string // kosong = biarkan env/DOCKER_HOST yang menentukan +} + +// engineCandidates — urutan kandidat: override config → DOCKER_HOST → +// docker default → podman rootless (XDG) → podman rootful. +func engineCandidates(override string) []candidate { + switch { + case override != "": + return []candidate{{name: nameFromHost(override), host: override}} + case os.Getenv("DOCKER_HOST") != "": + h := os.Getenv("DOCKER_HOST") + return []candidate{{name: nameFromHost(h), host: ""}} + } + out := []candidate{{name: "docker", host: ""}} + if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { + out = append(out, candidate{ + name: "podman", + host: "unix://" + filepath.Join(xdg, "podman", "podman.sock"), + }) + } + out = append(out, candidate{name: "podman", host: "unix:///run/podman/podman.sock"}) + return out +} + +func nameFromHost(host string) string { + if strings.Contains(host, "podman") { + return "podman" + } + return "docker" +} + +// Detect — client pertama yang ping-nya hidup, plus info runtime-nya. +// Podman mengekspos API kompatibel-Docker di podman.sock, jadi client yang +// sama dipakai untuk keduanya. +func Detect(override string) (*Client, EngineInfo, error) { + connect := func(host string) (*Client, error) { + opts := []client.Opt{client.WithAPIVersionNegotiation()} + if host == "" { + opts = append(opts, client.FromEnv) + } else { + opts = append(opts, client.WithHost(host)) + } + cli, err := client.NewClientWithOpts(opts...) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := cli.Ping(ctx); err != nil { + _ = cli.Close() + return nil, err + } + return &Client{cli: cli}, nil + } + return detectWith(engineCandidates(override), connect) +} + +// detectWith — inti deteksi; connect dapat di-stub di unit test. +func detectWith(cands []candidate, connect func(host string) (*Client, error)) (*Client, EngineInfo, error) { + var lastErr error + for _, c := range cands { + cl, err := connect(c.host) + if err == nil { + return cl, EngineInfo{Name: c.name, Host: c.host}, nil + } + lastErr = err + } + return nil, EngineInfo{}, fmt.Errorf("engine: tidak ada Docker/Podman yang merespons: %w", lastErr) +} + +// New — pintasan auto-detect (kompatibilitas; info runtime diabaikan). +func New() (*Client, error) { + cl, _, err := Detect("") + return cl, err +} diff --git a/internal/docker/engine_test.go b/internal/docker/engine_test.go new file mode 100644 index 0000000..17962b1 --- /dev/null +++ b/internal/docker/engine_test.go @@ -0,0 +1,99 @@ +package docker + +import ( + "fmt" + "testing" +) + +func TestEngineCandidates(t *testing.T) { + t.Setenv("DOCKER_HOST", "") + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + cands := engineCandidates("") + if len(cands) != 3 { + t.Fatalf("harusnya 3 kandidat: %+v", cands) + } + if cands[0].name != "docker" || cands[0].host != "" { + t.Fatalf("kandidat pertama harus docker default: %+v", cands[0]) + } + if cands[1].name != "podman" || cands[1].host != "unix:///run/user/1000/podman/podman.sock" { + t.Fatalf("kandidat kedua harus podman rootless: %+v", cands[1]) + } + if cands[2].host != "unix:///run/podman/podman.sock" { + t.Fatalf("kandidat ketiga harus podman rootful: %+v", cands[2]) + } + + // override menang dan namanya mengikuti isi host + cands = engineCandidates("unix:///run/user/1000/podman/podman.sock") + if len(cands) != 1 || cands[0].name != "podman" { + t.Fatalf("override podman salah: %+v", cands) + } + cands = engineCandidates("tcp://192.168.1.5:2375") + if len(cands) != 1 || cands[0].name != "docker" { + t.Fatalf("override tcp harus docker: %+v", cands) + } + + // DOCKER_HOST diset → satu kandidat, host dibiarkan kosong (FromEnv) + t.Setenv("DOCKER_HOST", "unix:///custom.sock") + cands = engineCandidates("") + if len(cands) != 1 || cands[0].host != "" || cands[0].name != "docker" { + t.Fatalf("DOCKER_HOST salah: %+v", cands) + } +} + +func TestDetectWith(t *testing.T) { + cands := []candidate{{name: "docker", host: "d1"}, {name: "podman", host: "p1"}} + + // docker mati → jatuh ke podman + connect := func(host string) (*Client, error) { + if host == "p1" { + return &Client{}, nil + } + return nil, fmt.Errorf("connection refused") + } + cl, info, err := detectWith(cands, connect) + if err != nil || cl == nil || info.Name != "podman" || info.Host != "p1" { + t.Fatalf("fallback salah: cl=%v info=%+v err=%v", cl, info, err) + } + + // semua mati → error jelas + connect = func(host string) (*Client, error) { return nil, fmt.Errorf("matilah") } + if _, _, err := detectWith(cands, connect); err == nil { + t.Fatal("semua gagal harus error") + } +} + +func TestPickCompose(t *testing.T) { + have := func(bins ...string) func(string) (string, error) { + m := map[string]bool{} + for _, b := range bins { + m[b] = true + } + return func(b string) (string, error) { + if m[b] { + return "/usr/bin/" + b, nil + } + return "", fmt.Errorf("tidak ada") + } + } + + // podman engine: binary podman ada → `podman compose` (subcommand bawaan) + if got := pickCompose("auto", "podman", have("docker", "podman")); got[0] != "podman" || got[1] != "compose" { + t.Fatalf("podman engine: %v", got) + } + // podman engine tanpa binary podman → jatuh ke podman-compose + if got := pickCompose("auto", "podman", have("podman-compose")); got[0] != "podman-compose" { + t.Fatalf("fallback podman-compose: %v", got) + } + // engine docker + if got := pickCompose("auto", "docker", have("docker")); got[0] != "docker" || got[1] != "compose" { + t.Fatalf("docker engine: %v", got) + } + // docker engine, tapi hanya podman yang ada → tetap pakai podman + if got := pickCompose("auto", "docker", have("podman")); got[0] != "podman" { + t.Fatalf("degradasi ke podman: %v", got) + } + // spec eksplisit selalu menang + if got := pickCompose("docker-compose", "podman", have("docker-compose")); got[0] != "docker-compose" { + t.Fatalf("eksplisit: %v", got) + } +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 14c26fb..5b9a2e7 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -118,7 +118,7 @@ footer: toast: demoTitle: "✔ [ SUCCESS ] Conversion Saved" demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/" - dockerError: "✖ [ FAILED ] Docker" + dockerError: "✖ [ FAILED ] Engine" configError: "⚠ [ WARNING ] Config problem — using defaults" themeMode: "Theme mode: %s" timestamps: "Log timestamps: %s" @@ -142,8 +142,8 @@ duration: minutes: "%d mins" doctor: - dockerDaemonUnreachable: "daemon not responding: %v" - dockerOk: "engine %s (API %s) · %d/%d containers running · %d images" + engineOk: "%s %s (API %s) · %d/%d containers running · %d images" + hostDefault: "(default)" stacksMissing: "%s (missing — created during onboarding)" configMissing: "%s (missing — using defaults)" composeOk: "%s · %s" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 16f690d..e7cbc06 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -118,7 +118,7 @@ footer: toast: demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan" demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/" - dockerError: "✖ [ GAGAL ] Docker" + dockerError: "✖ [ GAGAL ] Engine" configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default" themeMode: "Mode tema: %s" timestamps: "Timestamp log: %s" @@ -142,8 +142,8 @@ duration: minutes: "%d mnt" doctor: - dockerDaemonUnreachable: "daemon tidak merespons: %v" - dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image" + engineOk: "%s %s (API %s) · %d/%d kontainer berjalan · %d image" + hostDefault: "(default)" stacksMissing: "%s (belum ada — dibuat saat onboarding)" configMissing: "%s (belum ada — memakai default)" composeOk: "%s · %s"