feat(stack): wrapper CLI compose + scanner stack (REA modul 2/Dockge) — doctor cek compose & hitung stack
CI / test (push) Successful in 24s

This commit is contained in:
oxpoe committed 2026-10-16 01:45:14 +07:00
1 parent b84afdb494
commit 8a3b92b155
9 files changed
+354 -3

No files matched your search

+1 -1
View File
@@ -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)
}
+16 -2
View File
@@ -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))
}
+10
View File
@@ -18,6 +18,12 @@ type Config struct {
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).
+103
View File
@@ -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
}
+36
View File
@@ -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())
}
}
+2
View File
@@ -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"
+2
View File
@@ -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"
+99
View File
@@ -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
}
}
+85
View File
@@ -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)
}
}
}