feat(engine): podman native — auto-detect runtime (docker→podman), compose mengikuti engine, doctor engine-agnostic
CI / test (push) Successful in 25s

This commit is contained in:
oxpoe committed 2026-10-16 02:08:37 +07:00
1 parent 1b9a406aa7
commit 0551fb1387
12 files changed
+292 -60

No files matched your search

+1 -1
View File
@@ -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)
}
+17 -14
View File
@@ -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,
+9 -1
View File
@@ -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 {
+20 -17
View File
@@ -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()
+5 -4
View File
@@ -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"
}
}
+1 -10
View File
@@ -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.
+36 -6
View File
@@ -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 {
+1 -1
View File
@@ -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()
+97
View File
@@ -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
}
+99
View File
@@ -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)
}
}
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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"