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)
|
t.Fatalf("exit code %d, mau 0/1.\n%s", code, out)
|
||||||
}
|
}
|
||||||
s := string(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) {
|
if !strings.Contains(s, want) {
|
||||||
t.Fatalf("output doctor tidak memuat %q:\n%s", want, s)
|
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/config"
|
||||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
"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/i18n"
|
||||||
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Doctor memeriksa kesehatan sistem dan menulis laporan ke w.
|
// 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)
|
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()
|
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()
|
cfgPath := config.Path()
|
||||||
switch {
|
switch {
|
||||||
case cfgErr != nil:
|
case cfgErr != nil:
|
||||||
@@ -67,7 +77,11 @@ func Doctor(w io.Writer) int {
|
|||||||
|
|
||||||
stacks := config.ExpandPath(cfg.StacksDir)
|
stacks := config.ExpandPath(cfg.StacksDir)
|
||||||
if st, err := os.Stat(stacks); err == nil && st.IsDir() {
|
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 {
|
} else {
|
||||||
fmt.Fprintf(w, "stacks : %s\n", i18n.Tf("doctor.stacksMissing", stacks))
|
fmt.Fprintf(w, "stacks : %s\n", i18n.Tf("doctor.stacksMissing", stacks))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version int `yaml:"version"`
|
Version int `yaml:"version"`
|
||||||
StacksDir string `yaml:"stacks_dir"`
|
StacksDir string `yaml:"stacks_dir"`
|
||||||
UI UI `yaml:"ui"`
|
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 {
|
type UI struct {
|
||||||
@@ -48,6 +54,7 @@ func Default() Config {
|
|||||||
LogTailLines: 300,
|
LogTailLines: 300,
|
||||||
ShowTimestamps: true,
|
ShowTimestamps: true,
|
||||||
},
|
},
|
||||||
|
Docker: DockerCfg{ComposeCommand: "docker compose"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +143,9 @@ func (c *Config) normalize() {
|
|||||||
if c.Version == 0 {
|
if c.Version == 0 {
|
||||||
c.Version = 1
|
c.Version = 1
|
||||||
}
|
}
|
||||||
|
if c.Docker.ComposeCommand == "" {
|
||||||
|
c.Docker.ComposeCommand = "docker compose"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpandPath — ubah "~/..." jadi absolut (config menyimpan path gaya user).
|
// 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"
|
dockerOk: "engine %s (API %s) · %d/%d containers running · %d images"
|
||||||
stacksMissing: "%s (missing — created during onboarding)"
|
stacksMissing: "%s (missing — created during onboarding)"
|
||||||
configMissing: "%s (missing — using defaults)"
|
configMissing: "%s (missing — using defaults)"
|
||||||
|
composeOk: "%s · %s"
|
||||||
|
stacksCount: "%s · %d stacks"
|
||||||
|
|
||||||
cli:
|
cli:
|
||||||
unknownCommand: "unknown command: %q"
|
unknownCommand: "unknown command: %q"
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ doctor:
|
|||||||
dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image"
|
dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image"
|
||||||
stacksMissing: "%s (belum ada — dibuat saat onboarding)"
|
stacksMissing: "%s (belum ada — dibuat saat onboarding)"
|
||||||
configMissing: "%s (belum ada — memakai default)"
|
configMissing: "%s (belum ada — memakai default)"
|
||||||
|
composeOk: "%s · %s"
|
||||||
|
stacksCount: "%s · %d stack"
|
||||||
|
|
||||||
cli:
|
cli:
|
||||||
unknownCommand: "perintah tidak dikenal: %q"
|
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