feat(stack): wrapper CLI compose + scanner stack (REA modul 2/Dockge) — doctor cek compose & hitung stack
CI / test (push) Successful in 24s
CI / test (push) Successful in 24s
This commit is contained in:
1 parent
b84afdb494
commit
8a3b92b155
9 files changed
+357
-6
No files matched your search
+1
-1
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user