From 8a3b92b1556f8cc40c84c41b07f76ec325b5b47e Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:45:14 +0700 Subject: [PATCH] =?UTF-8?q?feat(stack):=20wrapper=20CLI=20compose=20+=20sc?= =?UTF-8?q?anner=20stack=20(REA=20modul=202/Dockge)=20=E2=80=94=20doctor?= =?UTF-8?q?=20cek=20compose=20&=20hitung=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/e2e_test.go | 2 +- internal/cli/doctor.go | 18 +++++- internal/config/config.go | 16 ++++- internal/docker/compose.go | 103 ++++++++++++++++++++++++++++++++ internal/docker/compose_test.go | 36 +++++++++++ internal/i18n/locales/en.yaml | 2 + internal/i18n/locales/id.yaml | 2 + internal/stack/scanner.go | 99 ++++++++++++++++++++++++++++++ internal/stack/scanner_test.go | 85 ++++++++++++++++++++++++++ 9 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 internal/docker/compose.go create mode 100644 internal/docker/compose_test.go create mode 100644 internal/stack/scanner.go create mode 100644 internal/stack/scanner_test.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index e698ddc..cd3e2eb 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -254,7 +254,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", "stacks"} { + for _, want := range []string{"docker", "audio", "go", "compose", "stacks"} { if !strings.Contains(s, want) { t.Fatalf("output doctor tidak memuat %q:\n%s", want, s) } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 02f0a71..0d732bd 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -14,6 +14,7 @@ import ( "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack" ) // Doctor memeriksa kesehatan sistem dan menulis laporan ke w. @@ -51,8 +52,17 @@ func Doctor(w io.Writer) int { fmt.Fprintf(w, "go : %s (%s/%s)\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) - // Konfigurasi & direktori stack. + // 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) + 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)) + } + cfgPath := config.Path() switch { case cfgErr != nil: @@ -67,7 +77,11 @@ func Doctor(w io.Writer) int { stacks := config.ExpandPath(cfg.StacksDir) if st, err := os.Stat(stacks); err == nil && st.IsDir() { - fmt.Fprintf(w, "stacks : %s\n", stacks) + n := 0 + if list, err := stack.ScanFS(stacks); err == nil { + n = len(list) + } + fmt.Fprintf(w, "stacks : %s\n", i18n.Tf("doctor.stacksCount", stacks, n)) } else { fmt.Fprintf(w, "stacks : %s\n", i18n.Tf("doctor.stacksMissing", stacks)) } diff --git a/internal/config/config.go b/internal/config/config.go index c7d20b3..97e59c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,9 +15,15 @@ import ( ) type Config struct { - Version int `yaml:"version"` - StacksDir string `yaml:"stacks_dir"` - UI UI `yaml:"ui"` + Version int `yaml:"version"` + StacksDir string `yaml:"stacks_dir"` + UI UI `yaml:"ui"` + Docker DockerCfg `yaml:"docker"` +} + +// DockerCfg — pengaturan interaksi Docker. +type DockerCfg struct { + ComposeCommand string `yaml:"compose_command"` // "docker compose" atau "docker-compose" } type UI struct { @@ -48,6 +54,7 @@ func Default() Config { LogTailLines: 300, ShowTimestamps: true, }, + Docker: DockerCfg{ComposeCommand: "docker compose"}, } } @@ -136,6 +143,9 @@ func (c *Config) normalize() { if c.Version == 0 { c.Version = 1 } + if c.Docker.ComposeCommand == "" { + c.Docker.ComposeCommand = "docker compose" + } } // ExpandPath — ubah "~/..." jadi absolut (config menyimpan path gaya user). diff --git a/internal/docker/compose.go b/internal/docker/compose.go new file mode 100644 index 0000000..793bafa --- /dev/null +++ b/internal/docker/compose.go @@ -0,0 +1,103 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// ComposeClient — pembungkus CLI compose (strategi hybrid PRD §3: operasi +// compose dipercayakan ke CLI, bukan reimplementasi API). +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} +} + +// ComposeProject — entri `docker compose ls --all --format json` +// (format terverifikasi: {"Name","Status":"running(N)","ConfigFiles":"a,b"}). +type ComposeProject struct { + Name string `json:"Name"` + Status string `json:"Status"` + ConfigFiles string `json:"ConfigFiles"` +} + +// ConfigFileList — ConfigFiles dipecah per koma. +func (p ComposeProject) ConfigFileList() []string { + var out []string + for _, f := range strings.Split(p.ConfigFiles, ",") { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} + +// List — daftar semua project compose (termasuk yang berhenti). +func (c *ComposeClient) List(ctx context.Context) ([]ComposeProject, error) { + out, err := c.Run(ctx, "", "ls", "--all", "--format", "json") + if err != nil { + return nil, err + } + var projects []ComposeProject + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &projects); err != nil { + return nil, fmt.Errorf("compose ls: parse json: %w", err) + } + return projects, nil +} + +// Version — versi compose ringkas (mis. "5.6.0"). +func (c *ComposeClient) Version(ctx context.Context) (string, error) { + out, err := c.Run(ctx, "", "version", "--short") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// --- operasi stack (dipakai manager & CLI companion) --- + +func (c *ComposeClient) Up(ctx context.Context, dir string) (string, error) { + return c.Run(ctx, dir, "up", "-d", "--remove-orphans") +} +func (c *ComposeClient) Down(ctx context.Context, dir string) (string, error) { + return c.Run(ctx, dir, "down") +} +func (c *ComposeClient) Stop(ctx context.Context, dir string) (string, error) { + return c.Run(ctx, dir, "stop") +} +func (c *ComposeClient) Restart(ctx context.Context, dir string) (string, error) { + return c.Run(ctx, dir, "restart") +} +func (c *ComposeClient) Pull(ctx context.Context, dir string) (string, error) { + return c.Run(ctx, dir, "pull") +} + +// Run — jalankan subperintah compose; dir = folder stack (working dir project). +func (c *ComposeClient) Run(ctx context.Context, dir string, args ...string) (string, error) { + full := append(append([]string{}, c.cmd[1:]...), args...) + cmd := exec.CommandContext(ctx, c.cmd[0], full...) + if dir != "" { + cmd.Dir = dir + } + out, err := cmd.CombinedOutput() + if err != nil { + verb := "?" + if len(args) > 0 { + verb = args[0] + } + if ee, ok := err.(*exec.ExitError); ok { + return string(out), fmt.Errorf("compose %s: exit %d", verb, ee.ExitCode()) + } + return string(out), err + } + return string(out), nil +} diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go new file mode 100644 index 0000000..1788a05 --- /dev/null +++ b/internal/docker/compose_test.go @@ -0,0 +1,36 @@ +package docker + +import ( + "context" + "os" + "testing" + "time" +) + +// TestIntegrationCompose — wrapper CLI compose terhadap daemon nyata. +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") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + ver, err := c.Version(ctx) + if err != nil { + t.Skipf("compose CLI tidak tersedia: %v", err) + } + t.Logf("compose versi %s", ver) + + projects, err := c.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + t.Logf("project compose terdeteksi: %d", len(projects)) + for i, p := range projects { + if i >= 3 { + break + } + t.Logf(" - %-20s %-12s %v", p.Name, p.Status, p.ConfigFileList()) + } +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 7dd9ddd..ff8748f 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -128,6 +128,8 @@ doctor: dockerOk: "engine %s (API %s) · %d/%d containers running · %d images" stacksMissing: "%s (missing — created during onboarding)" configMissing: "%s (missing — using defaults)" + composeOk: "%s · %s" + stacksCount: "%s · %d stacks" cli: unknownCommand: "unknown command: %q" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 523a799..e16ef3a 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -128,6 +128,8 @@ doctor: dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image" stacksMissing: "%s (belum ada — dibuat saat onboarding)" configMissing: "%s (belum ada — memakai default)" + composeOk: "%s · %s" + stacksCount: "%s · %d stack" cli: unknownCommand: "perintah tidak dikenal: %q" diff --git a/internal/stack/scanner.go b/internal/stack/scanner.go new file mode 100644 index 0000000..f77c5be --- /dev/null +++ b/internal/stack/scanner.go @@ -0,0 +1,99 @@ +package stack + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" +) + +// ComposeFileNames — nama file compose yang diterima (REA modul 2 — Dockge). +var ComposeFileNames = []string{"compose.yaml", "docker-compose.yaml", "docker-compose.yml", "compose.yml"} + +// Status — status stack (nilai mengikuti referensi Dockge). +type Status int + +const ( + StatusUnknown Status = iota + StatusCreatedFile + StatusCreatedStack + StatusRunning + StatusExited +) + +// Stack — satu stack terkelola di direktori stack. +type Stack struct { + Name string + Dir string + ComposeFile string + Status Status +} + +// ScanFS — daftar stack dari disk: setiap subfolder yang berisi file compose. +// Folder tidak ada → daftar kosong, bukan error (belum onboarding). +func ScanFS(dir string) ([]Stack, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []Stack + for _, e := range entries { + if !e.IsDir() { + continue + } + stackDir := filepath.Join(dir, e.Name()) + if f := findComposeFile(stackDir); f != "" { + out = append(out, Stack{ + Name: e.Name(), + Dir: stackDir, + ComposeFile: f, + Status: StatusCreatedFile, + }) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func findComposeFile(dir string) string { + for _, name := range ComposeFileNames { + p := filepath.Join(dir, name) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p + } + } + return "" +} + +// Reconcile — overlay status dari `docker compose ls` (parity Dockge: +// nama project compose = nama folder stack). +func Reconcile(stacks []Stack, projects []docker.ComposeProject) []Stack { + byName := make(map[string]Status, len(projects)) + for _, p := range projects { + byName[p.Name] = parseComposeStatus(p.Status) + } + for i := range stacks { + if s, ok := byName[stacks[i].Name]; ok { + stacks[i].Status = s + } + } + return stacks +} + +func parseComposeStatus(s string) Status { + switch { + case strings.HasPrefix(s, "running"): + return StatusRunning + case strings.HasPrefix(s, "exited"): + return StatusExited + case strings.HasPrefix(s, "created"): + return StatusCreatedStack + default: + return StatusUnknown + } +} diff --git a/internal/stack/scanner_test.go b/internal/stack/scanner_test.go new file mode 100644 index 0000000..5eb93c5 --- /dev/null +++ b/internal/stack/scanner_test.go @@ -0,0 +1,85 @@ +package stack + +import ( + "os" + "path/filepath" + "testing" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" +) + +func TestScanFS(t *testing.T) { + root := t.TempDir() + mk := func(rel string) string { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("services: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + return p + } + mk("webapp/compose.yaml") + mk("db/docker-compose.yml") + mk("legacy/docker-compose.yaml") + mk("modern/compose.yml") + _ = os.MkdirAll(filepath.Join(root, "bukan-stack"), 0o755) // folder tanpa compose + _ = os.WriteFile(filepath.Join(root, "file-nyasar.yaml"), []byte(""), 0o644) // file, bukan dir + + stacks, err := ScanFS(root) + if err != nil { + t.Fatalf("ScanFS: %v", err) + } + if len(stacks) != 4 { + t.Fatalf("harusnya 4 stack, dapat %d: %+v", len(stacks), stacks) + } + // terurut nama + want := []string{"db", "legacy", "modern", "webapp"} + for i, s := range stacks { + if s.Name != want[i] { + t.Fatalf("urutan/nama salah: %v", stacks) + } + if s.Status != StatusCreatedFile { + t.Fatalf("status awal harus CREATED_FILE: %+v", s) + } + } + // folder tidak ada → kosong tanpa error + if got, err := ScanFS(filepath.Join(root, "tidak-ada")); err != nil || len(got) != 0 { + t.Fatalf("folder tidak ada: %v %v", got, err) + } +} + +func TestReconcile(t *testing.T) { + stacks := []Stack{ + {Name: "web", Status: StatusCreatedFile}, + {Name: "db", Status: StatusCreatedFile}, + {Name: "other", Status: StatusCreatedFile}, + } + projects := []docker.ComposeProject{ + {Name: "web", Status: "running(2)"}, + {Name: "db", Status: "exited(1)"}, + {Name: "asing", Status: "running(5)"}, // tidak ada di disk → diabaikan + } + out := Reconcile(stacks, projects) + if out[0].Status != StatusRunning || out[1].Status != StatusExited { + t.Fatalf("overlay salah: %+v", out) + } + if out[2].Status != StatusCreatedFile { + t.Fatalf("stack tak dikenal harus tetap CREATED_FILE: %+v", out[2]) + } +} + +func TestParseComposeStatus(t *testing.T) { + cases := map[string]Status{ + "running(2)": StatusRunning, + "exited(0)": StatusExited, + "created": StatusCreatedStack, + "restarting": StatusUnknown, + } + for in, want := range cases { + if got := parseComposeStatus(in); got != want { + t.Errorf("%q → %v, mau %v", in, got, want) + } + } +}