From b84afdb49489b4aa1dc1fca34034a36bf3a739cc Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:42:02 +0700 Subject: [PATCH] =?UTF-8?q?feat(stack):=20converter=20docker=20run?= =?UTF-8?q?=E2=86=92compose=20(port=20REA=20modul=201)=20=E2=80=94=2019=20?= =?UTF-8?q?golden=20vektor=20+=20CLI=20tia-love=20convert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/tia-love/main.go | 7 +- docs/rea/composerize.md | 18 +- e2e/e2e_test.go | 124 +++++++++ internal/app/app.go | 438 ++++++++++++++++++++----------- internal/cli/convert.go | 27 ++ internal/i18n/locales/en.yaml | 4 +- internal/i18n/locales/id.yaml | 4 +- internal/stack/converter.go | 280 ++++++++++++++++++++ internal/stack/converter_test.go | 237 +++++++++++++++++ internal/stack/mappings.go | 412 +++++++++++++++++++++++++++++ internal/stack/service.go | 127 +++++++++ 11 files changed, 1519 insertions(+), 159 deletions(-) create mode 100644 internal/cli/convert.go create mode 100644 internal/stack/converter.go create mode 100644 internal/stack/converter_test.go create mode 100644 internal/stack/mappings.go create mode 100644 internal/stack/service.go diff --git a/cmd/tia-love/main.go b/cmd/tia-love/main.go index d0376a4..bd67d19 100644 --- a/cmd/tia-love/main.go +++ b/cmd/tia-love/main.go @@ -18,14 +18,15 @@ func usageText() string { %s tia-love %s + tia-love convert %s tia-love doctor %s tia-love version %s %s `, i18n.T("usage.usage"), i18n.T("usage.open"), - i18n.T("usage.doctor"), i18n.T("usage.version"), - i18n.T("usage.more")) + i18n.T("usage.convert"), i18n.T("usage.doctor"), + i18n.T("usage.version"), i18n.T("usage.more")) } func main() { @@ -36,6 +37,8 @@ func main() { return case "doctor": os.Exit(cli.Doctor(os.Stdout)) + case "convert": + os.Exit(cli.Convert(os.Args[2:], os.Stdout, os.Stderr)) default: fmt.Fprintf(os.Stderr, "%s\n\n%s", i18n.Tf("cli.unknownCommand", os.Args[1]), usageText()) os.Exit(2) diff --git a/docs/rea/composerize.md b/docs/rea/composerize.md index ba164a6..ebadf5b 100644 --- a/docs/rea/composerize.md +++ b/docs/rea/composerize.md @@ -242,7 +242,7 @@ docker run -d --name web -p 8080:80 -v ./html:/usr/share/nginx/html:ro -e TZ=Asi ``` ```yaml services: - web: + nginx: container_name: web ports: - 8080:80 @@ -544,6 +544,9 @@ networks: | 7 | `-d` dan `--detach` sama-sama diabaikan senyap | Referensi inkonsisten (`-d` senyap, `--detach` komentar) | | 8 | Tidak ada merge stdin; "tambah ke stack" ditangani layer stack (baca-tulis YAML compose) | Arsitektur tia: converter tetap murni string→string agar bisa dipakai TUI & CLI dengan hasil identik | | 9 | Pesan error dibungkus i18n (F-023) | Prinsip tia; isi/arti error tetap sama | +| 10 | Image tidak ditemukan → **error jelas** (`docker run: image tidak ditemukan`) | Referensi menghasilkan service bernama `!!!invalid!!!` — error tegas lebih jujur untuk CLI & TUI | +| 11 | `--health-retries` ditulis sebagai integer | Skema compose mengharapkan int; referensi menulisnya sebagai string | +| 12 | Multi-perintah (beberapa `docker run` dalam satu input) belum didukung — menyusul | V16 ditahan sampai dukungan multi-command masuk | ## 10. Rencana Porting Go @@ -572,7 +575,12 @@ Keputusan desain: | Item | Status | |---|---| -| Test vectors tertulis | ✅ (V1–V20) | -| Golden test di Go | ⬜ Fase 3 | -| Differential vs composerize (Node) | ⬜ Fase 3 — perlu `node`/`npm` di CI | -| Laporan paritas | ⬜ Fase 3 | +| Test vectors tertulis | ✅ (V1–V20; V16 menunggu multi-command) | +| Golden test di Go | ✅ **19 vektor hijau** — `internal/stack/converter_test.go` | +| Differential vs composerize (Node) | ⬜ lanjutan Fase 3 — perlu `node`/`npm` di CI | +| Laporan paritas | ⬜ lanjutan Fase 3 | + +Catatan implementasi (ditemukan saat porting, 2026-10-16): +- V2 di dokumen ini salah tulis (service `web:` → seharusnya `nginx:`, sebab nama service dari image) — sudah diperbaiki; perilaku referensi mengikuti `getServiceName(image)`. +- `--mount ...,readonly` → `readonly: true` (diff #6) terbukti perlu di jalur MapArray. +- `res.Output` = teks siap cetak (komentar warning + YAML) — kontrak dipakai CLI `tia-love convert`. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 5e9f13a..e698ddc 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -451,6 +451,130 @@ func TestEventsActivity(t *testing.T) { } } +// clickText — temukan substr di snapshot layar, kirim klik SGR ke tengahnya. +// Dipakai untuk membuktikan elemen UI mana pun bisa diklik. +func (u *tui) clickText(substr string) bool { + lines := strings.Split(u.screen(), "\n") + for y, line := range lines { + i := strings.Index(line, substr) + if i < 0 { + continue + } + x := len([]rune(line[:i])) + len([]rune(substr))/2 + u.send(fmt.Sprintf("\x1b[<0;%d;%dM\x1b[<0;%d;%dm", x+1, y+1, x+1, y+1)) + return true + } + return false +} + +// TestMouseFooterHint — hint di status bar dapat diklik (klik [T] → ganti tema). +func TestMouseFooterHint(t *testing.T) { + u := startTUI(t) + u.waitContains("beranda", 5*time.Second) + if !u.clickText("[T]") { + t.Fatal("hint [T] tidak ditemukan di footer") + } + u.waitContains("Mode tema", 3*time.Second) + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + +// TestMouseTabBar — tab detail dapat diklik langsung (Statistik & Proses). +func TestMouseTabBar(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + t.Skip("docker socket tidak ada") + } + cl, err := docker.New() + if err != nil { + t.Skipf("docker: %v", err) + } + defer cl.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + items, err := cl.ListContainers(ctx) + if err != nil || len(items) == 0 { + t.Skipf("tidak ada kontainer (err=%v)", err) + } + name := items[0].Name + if len(name) > 12 { + name = name[:12] + } + + u := startTUI(t) + u.waitContains("beranda", 5*time.Second) + u.send("1") + u.waitContains("[1-4]", 3*time.Second) // footer workspace = sudah pindah view + u.waitContains(name, 5*time.Second) + if !u.clickText("Statistik") { + t.Fatal("tab Statistik tidak ditemukan") + } + u.waitContains("[Statistik]", 3*time.Second) // tab aktif ditandai kurung + if !u.clickText("Proses") { + t.Fatal("tab Proses tidak ditemukan") + } + u.waitContains("PID", 5*time.Second) + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + +// TestMouseHomeContainer — klik baris kontainer di beranda → masuk workspace +// pada kontainer itu. +func TestMouseHomeContainer(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + t.Skip("docker socket tidak ada") + } + cl, err := docker.New() + if err != nil { + t.Skipf("docker: %v", err) + } + defer cl.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + items, err := cl.ListContainers(ctx) + if err != nil || len(items) == 0 { + t.Skipf("tidak ada kontainer (err=%v)", err) + } + name := items[0].Name + if len(name) > 12 { + name = name[:12] + } + + u := startTUI(t) + u.waitContains("beranda", 5*time.Second) + u.waitContains(name, 5*time.Second) // tampil di panel Kontainer beranda + if !u.clickText(name) { + t.Fatal("baris kontainer tidak ditemukan") + } + u.waitContains("[1-4]", 3*time.Second) // footer workspace = bukti pindah view + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + +// TestConvertCommand — CLI `tia-love convert`: hasil di stdout, exit code jelas. +func TestConvertCommand(t *testing.T) { + out, err := exec.Command(binPath, "convert", "docker run -p 80:80 --name web nginx").CombinedOutput() + if err != nil { + t.Fatalf("convert gagal: %v (%s)", err, out) + } + s := string(out) + for _, want := range []string{"services:", "container_name: web", "80:80", "image: nginx"} { + if !strings.Contains(s, want) { + t.Fatalf("output convert tidak memuat %q:\n%s", want, s) + } + } + err = exec.Command(binPath, "convert", "bukan perintah docker").Run() + ee, ok := err.(*exec.ExitError) + if !ok || ee.ExitCode() != 1 { + t.Fatalf("perintah invalid harus exit 1, dapat %v", err) + } +} + func TestThemeCycle(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) diff --git a/internal/app/app.go b/internal/app/app.go index aec0bdb..bf7dfab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -432,157 +432,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height case tea.KeyPressMsg: - switch msg.String() { - case "q", "ctrl+c": - m.stopLogs() - if m.eventsCancel != nil { - m.eventsCancel() - } - m.sound.Play(audio.ModalClose) - return m, tea.Quit - case "esc": - if m.view == viewHome { - m.sound.Play(audio.ModalClose) - return m, tea.Quit - } - if m.focus == focusContent { - m.focus = focusSidebar - m.sound.Play(audio.Click) - break - } - m.view = viewHome - m.stopLogs() - m.sound.Play(audio.Click) - case "0": - m.view = viewHome - m.stopLogs() - m.sound.Play(audio.Click) - case "1", "enter": - m.jumpSection(sectionContainers) - m.sound.Play(audio.Click) - return m, m.restartData() - case "2": - m.jumpSection(sectionImages) - m.sound.Play(audio.Click) - return m, m.restartData() - case "3": - m.jumpSection(sectionVolumes) - m.sound.Play(audio.Click) - return m, m.restartData() - case "4": - m.jumpSection(sectionNetworks) - m.sound.Play(audio.Click) - return m, m.restartData() - case "a": // reduced motion - m.animating = !m.animating - if m.animating { - m.tickID = animation.NewID() - return m, animation.Next(m.tickID) - } - case "r": // muat ulang semua data docker + stream event - if m.cl == nil { - break - } - m.loading = true - m.sound.Play(audio.Click) - cmds := append(loadAll(m.cl), m.restartData()) - cmds = append(cmds, m.startEvents()) - return m, tea.Batch(cmds...) - case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal - cur := theme.Current() - next := theme.CycleMode(cur.Mode) - theme.Set(next, cur.AccentName) - m.toast.Show(components.LevelInfo, - i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))), - i18n.T("theme.switchHint")) - m.sound.Play(audio.Click) - case "t": // demo toast (sampai modal asli datang) - m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody")) - m.sound.Play(audio.NotifySuccess) - case "tab": - if m.view == viewList { - if m.focus == focusSidebar { - m.focus = focusContent - } else { - m.focus = focusSidebar - } - m.sound.Play(audio.Click) - } - case "h", "left", "[": - if m.view == viewList && m.section == sectionContainers { - return m, m.cycleTab(-1) - } - case "l", "right", "]": - if m.view == viewList && m.section == sectionContainers { - return m, m.cycleTab(1) - } - case "G": // ekor log (live) / dasar dokumen (config & env) - if m.view == viewList && m.focus == focusContent { - switch m.tab { - case tabConfig, tabEnv: - m.detailScroll = m.detailScrollMax() - default: - m.logOffset = 0 - } - m.sound.Play(audio.Click) - } - case "S": // toggle timestamp log - m.showTimestamps = !m.showTimestamps - label := i18n.T("state.off") - if m.showTimestamps { - label = i18n.T("state.on") - } - m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint")) - m.sound.Play(audio.Click) - return m, m.restartData() - case "j", "down": - if m.view == viewList && m.focus == focusContent { - switch m.tab { - case tabConfig, tabEnv: - if m.detailScroll < m.detailScrollMax() { - m.detailScroll++ - } - default: // tab Log - if m.logOffset > 0 { - m.logOffset-- - } - } - m.sound.Play(audio.Navigate) - break - } - if c := m.cur(); c < m.sectionLen()-1 { - m.setCur(c + 1) - } - m.sound.Play(audio.Navigate) - return m, m.restartData() - case "k", "up": - if m.view == viewList && m.focus == focusContent { - switch m.tab { - case tabConfig, tabEnv: - if m.detailScroll > 0 { - m.detailScroll-- - } - default: // tab Log - if m.logOffset < len(m.logs)-1 { - m.logOffset++ - } - } - m.sound.Play(audio.Navigate) - break - } - if c := m.cur(); c > 0 { - m.setCur(c - 1) - } - m.sound.Play(audio.Navigate) - return m, m.restartData() - } - + return m, m.handleKey(msg.String()) case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { - m.sound.Play(audio.Click) if m.toast.Active() { m.toast = components.NewToast() // klik = tutup toast } + // Elemen UI eksplisit dulu (F-022): hint footer, tab bar, panel beranda. + if cmd := m.mouseUI(msg.X, msg.Y); cmd != nil { + return m, cmd + } + m.sound.Play(audio.Click) if m.view == viewList { if msg.X >= m.leftWidth() { m.focus = focusContent @@ -951,12 +811,11 @@ func (m Model) View() tea.View { case viewHome: header += dim(" · " + i18n.T("nav.home")) body = m.homeBody() - footer = m.footerLine(i18n.T("hint.footerHome")) default: header += dim(" · " + i18n.T("nav.containers")) body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) - footer = m.footerLine(i18n.T("hint.footerContainers")) } + footer = m.footerLine() parts := []string{} if t := m.toast.View(max(m.width, 20)); t != "" { @@ -970,12 +829,141 @@ func (m Model) View() tea.View { return v } -func (m Model) footerLine(hints string) string { +func (m Model) footerPlain() string { + hints := i18n.T("hint.footerHome") + if m.view == viewList { + hints = i18n.T("hint.footerContainers") + } anim := "—" if m.animating { anim = fmt.Sprintf("%d", m.fps) } - return dim(truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width)) + return truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width) +} + +func (m Model) footerLine() string { return dim(m.footerPlain()) } + +// mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang +// tampak seperti tombol/tab/hint HARUS sampai di sini. +func (m *Model) mouseUI(x, y int) tea.Cmd { + // 1) Hint footer: klik "[k]" (atau labelnya) menjalankan aksi k. + if y == m.height-2 { + if key, ok := m.footerKeyAt(x); ok { + return m.handleKey(key) + } + } + // 2) Tab bar detail (view kontainers). + if m.view == viewList && m.section == sectionContainers && m.sectionLen() > 0 { + if idx, ok := m.tabAt(x, y); ok { + m.tab = idx + m.focus = focusContent + m.sound.Play(audio.Click) + return m.restartData() + } + } + // 3) Panel beranda (baris kontainer & panel Docker). + if m.view == viewHome { + if cmd := m.homeClick(x, y); cmd != nil { + return cmd + } + } + return nil +} + +// footerKeyAt — posisi rune x berada di segmen hint mana; kembalikan tombolnya. +func (m *Model) footerKeyAt(x int) (string, bool) { + runes := []rune(m.footerPlain()) + for i := 0; i < len(runes); i++ { + if runes[i] != '[' { + continue + } + j := i + 1 + for j < len(runes) && runes[j] != ']' { + j++ + } + if j >= len(runes) { + return "", false + } + end := j + 1 + for end < len(runes) && runes[end] != '·' { // segmen sampai pemisah + end++ + } + if x >= i && x < end { + k := strings.Split(string(runes[i+1:j]), "/")[0] + if dash := strings.IndexByte(k, '-'); dash > 0 { + k = k[:dash] // "1-4" → "1" + } + if strings.EqualFold(k, "tab") { + k = "tab" + } + return k, true + } + i = j + } + return "", false +} + +// tabAt — posisi klik pada baris tab bar → indeks tab. +func (m Model) tabAt(x, y int) (int, bool) { + if y != m.headerRows()+1 { // baris konten pertama = tab bar + return 0, false + } + xl := x - (m.leftWidth() + 2) + if xl < 0 { + return 0, false + } + labels := []string{ + i18n.T("tab.logs"), i18n.T("tab.stats"), i18n.T("tab.config"), + i18n.T("tab.env"), i18n.T("tab.top"), + } + pos := 0 + for i, l := range labels { + w := len([]rune(l)) + if i == m.tab { + w += 2 // tanda aktif: [Label] + } + if xl >= pos && xl < pos+w { + return i, true + } + pos += w + 2 // pemisah " " + } + return 0, false +} + +// homeClick — panel beranda: baris kontainer → buka workspace pada item itu; +// klik panel Docker → buka bagian kontainer. +func (m *Model) homeClick(x, y int) tea.Cmd { + W := max(m.width, 60) + bodyH := m.bodyHeight() + row1H := 9 + row2H := bodyH - row1H + if row2H < 5 { + row1H = 6 + row2H = max(bodyH-row1H, 3) + } + w1 := int(float64(W) * 0.36) + w2 := int(float64(W) * 0.30) + k1 := int(float64(W) * 0.55) + + top := m.headerRows() + row1H // border atas panel baris-2 + first := top + 1 // konten baris pertama (daftar kontainer) + + switch { + case y >= first && y < first+max(row2H-2, 0) && x < k1: + idx := y - first + if len(m.containers) == 0 || idx >= len(m.containers) { + return nil + } + m.jumpSection(sectionContainers) + m.setCur(idx) + m.sound.Play(audio.Click) + return m.restartData() + case y >= m.headerRows()+1 && y < m.headerRows()+row1H && x >= w1 && x < w1+w2: + m.jumpSection(sectionContainers) + m.sound.Play(audio.Click) + return m.restartData() + } + return nil } func (m Model) sidebarView() string { @@ -1368,3 +1356,153 @@ func colorizeLog(line string) string { return theme.Style(c).Bold(true).Render(tok) }) } + +// handleKey — SATU jalur aksi, dipakai keyboard DAN klik mouse +// (tab bar, hint footer, baris beranda). Tambah aksi = tambah case di sini. +func (m *Model) handleKey(key string) tea.Cmd { + switch key { + case "q", "ctrl+c": + m.stopLogs() + if m.eventsCancel != nil { + m.eventsCancel() + } + m.sound.Play(audio.ModalClose) + return tea.Quit + case "esc": + if m.view == viewHome { + m.sound.Play(audio.ModalClose) + return tea.Quit + } + if m.focus == focusContent { + m.focus = focusSidebar + m.sound.Play(audio.Click) + break + } + m.view = viewHome + m.stopLogs() + m.sound.Play(audio.Click) + case "0": + m.view = viewHome + m.stopLogs() + m.sound.Play(audio.Click) + case "1", "enter": + m.jumpSection(sectionContainers) + m.sound.Play(audio.Click) + return m.restartData() + case "2": + m.jumpSection(sectionImages) + m.sound.Play(audio.Click) + return m.restartData() + case "3": + m.jumpSection(sectionVolumes) + m.sound.Play(audio.Click) + return m.restartData() + case "4": + m.jumpSection(sectionNetworks) + m.sound.Play(audio.Click) + return m.restartData() + case "a": // reduced motion + m.animating = !m.animating + if m.animating { + m.tickID = animation.NewID() + return animation.Next(m.tickID) + } + case "r": // muat ulang semua data docker + stream event + if m.cl == nil { + break + } + m.loading = true + m.sound.Play(audio.Click) + cmds := append(loadAll(m.cl), m.restartData()) + cmds = append(cmds, m.startEvents()) + return tea.Batch(cmds...) + case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal + cur := theme.Current() + next := theme.CycleMode(cur.Mode) + theme.Set(next, cur.AccentName) + m.toast.Show(components.LevelInfo, + i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))), + i18n.T("theme.switchHint")) + m.sound.Play(audio.Click) + case "t": // demo toast (sampai modal asli datang) + m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody")) + m.sound.Play(audio.NotifySuccess) + case "tab": + if m.view == viewList { + if m.focus == focusSidebar { + m.focus = focusContent + } else { + m.focus = focusSidebar + } + m.sound.Play(audio.Click) + } + case "h", "left", "[": + if m.view == viewList && m.section == sectionContainers { + return m.cycleTab(-1) + } + case "l", "right", "]": + if m.view == viewList && m.section == sectionContainers { + return m.cycleTab(1) + } + case "G": // ekor log (live) / dasar dokumen (config & env) + if m.view == viewList && m.focus == focusContent { + switch m.tab { + case tabConfig, tabEnv: + m.detailScroll = m.detailScrollMax() + default: + m.logOffset = 0 + } + m.sound.Play(audio.Click) + } + case "S": // toggle timestamp log + m.showTimestamps = !m.showTimestamps + label := i18n.T("state.off") + if m.showTimestamps { + label = i18n.T("state.on") + } + m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint")) + m.sound.Play(audio.Click) + return m.restartData() + case "j", "down": + if m.view == viewList && m.focus == focusContent { + switch m.tab { + case tabConfig, tabEnv: + if m.detailScroll < m.detailScrollMax() { + m.detailScroll++ + } + default: // tab Log + if m.logOffset > 0 { + m.logOffset-- + } + } + m.sound.Play(audio.Navigate) + break + } + if c := m.cur(); c < m.sectionLen()-1 { + m.setCur(c + 1) + } + m.sound.Play(audio.Navigate) + return m.restartData() + case "k", "up": + if m.view == viewList && m.focus == focusContent { + switch m.tab { + case tabConfig, tabEnv: + if m.detailScroll > 0 { + m.detailScroll-- + } + default: // tab Log + if m.logOffset < len(m.logs)-1 { + m.logOffset++ + } + } + m.sound.Play(audio.Navigate) + break + } + if c := m.cur(); c > 0 { + m.setCur(c - 1) + } + m.sound.Play(audio.Navigate) + return m.restartData() + } + return nil +} diff --git a/internal/cli/convert.go b/internal/cli/convert.go new file mode 100644 index 0000000..c5d7e0d --- /dev/null +++ b/internal/cli/convert.go @@ -0,0 +1,27 @@ +// Package cli — mode CLI pendamping (F-028). +package cli + +import ( + "fmt" + "io" + "strings" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack" +) + +// Convert — `tia-love convert "docker run …"`: cetak compose YAML. +// Logika inti di internal/stack (dipakai TUI & CLI — hasil identik, F-028). +func Convert(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, i18n.T("cli.convertUsage")) + return 2 + } + res, err := stack.Convert(strings.Join(args, " ")) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + fmt.Fprintln(stdout, res.Output) + return 0 +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 1f5a00d..7dd9ddd 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -131,10 +131,12 @@ doctor: cli: unknownCommand: "unknown command: %q" + convertUsage: "usage: tia-love convert \"docker run -d -p 80:80 nginx\"" usage: usage: "Usage:" open: "Open the interactive TUI" + convert: "Convert a docker run command → compose YAML (stdout)" doctor: "Check system health (docker, audio, stacks)" version: "Show version & build info" - more: "Other CLI subcommands (convert, ps, up, down, update) arriving in Phase 2–3 — see docs/PRD.md §F-028." + more: "Other CLI subcommands (ps, up, down, update) arriving in Phase 3 — see docs/PRD.md §F-028." diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 52f9557..523a799 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -131,10 +131,12 @@ doctor: cli: unknownCommand: "perintah tidak dikenal: %q" + convertUsage: "pemakaian: tia-love convert \"docker run -d -p 80:80 nginx\"" usage: usage: "Pemakaian:" open: "Buka TUI interaktif" + convert: "Konversi perintah docker run → compose YAML (stdout)" doctor: "Periksa kesehatan sistem (docker, audio, stack)" version: "Tampilkan versi & build info" - more: "Subcommand CLI lain (convert, ps, up, down, update) menyusul di Phase 2–3 — lihat docs/PRD.md §F-028." + more: "Subcommand CLI lain (ps, up, down, update) menyusul di Phase 3 — lihat docs/PRD.md §F-028." diff --git a/internal/stack/converter.go b/internal/stack/converter.go new file mode 100644 index 0000000..f8213f6 --- /dev/null +++ b/internal/stack/converter.go @@ -0,0 +1,280 @@ +// Package stack — manajemen stack & konverter docker run → compose. +// +// Converter ini adalah hasil porting perilaku composerize (MIT) yang +// dispesifikasikan di docs/rea/composerize.md — tulis ulang, bukan salin. +package stack + +import ( + "fmt" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +// ConvertResult — hasil konversi. +// Output = teks siap cetak (komentar warning + YAML). +type ConvertResult struct { + Output string + ServiceName string + Warnings []string +} + +// Convert — porting pipeline composerize: +// normalisasi → tokenisasi → klasifikasi flag → mapping → post-processing → +// emit YAML common-spec (tanpa `version:`, indent 2). +func Convert(cmd string) (ConvertResult, error) { + input := normalize(cmd) + + body, ok := stripCommandPrefix(input) + if !ok { + return ConvertResult{}, fmt.Errorf("bukan perintah docker/podman run: %q", strings.TrimSpace(cmd)) + } + + tokens := tokenize(body) + order, params, positionals := parseTokens(tokens) + + if len(positionals) == 0 { + return ConvertResult{}, fmt.Errorf("docker run: image tidak ditemukan") + } + image := stripQuotes(positionals[0]) + serviceName := serviceNameFromImage(image) + + svc, warns, err := buildService(order, params, serviceName) + if err != nil { + return ConvertResult{}, err + } + + // image & command ditambahkan terakhir (urutan key mengikuti referensi). + svc.set("image", strNode(image)) + if len(positionals) > 1 { + svc.set("command", strNode(strings.Join(positionals[1:], " "))) + } + + // network_mode host → ports dibuang (parity referensi). + if svc.has("network_mode") && svc.get("network_mode").Value == "host" { + svc.del("ports") + } + + res := ConvertResult{ServiceName: serviceName, Warnings: warns} + top, extraWarn := finishTopLevel(svc, serviceName) + res.Warnings = append(res.Warnings, extraWarn...) + + doc := &yaml.Node{Kind: yaml.DocumentNode} + doc.Content = []*yaml.Node{top} + + var sb strings.Builder + for _, w := range res.Warnings { + sb.WriteString("# " + w + "\n") + } + enc := yaml.NewEncoder(&sb) + enc.SetIndent(2) + if err := enc.Encode(doc); err != nil { + return ConvertResult{}, fmt.Errorf("emit yaml: %w", err) + } + _ = enc.Close() + res.Output = strings.TrimRight(sb.String(), "\n") + return res, nil +} + +// --- normalisasi (urutan persis mengikuti referensi) --- + +var ( + reComments = regexp.MustCompile(`\n\s*#[^\n]*`) + reShellSub = regexp.MustCompile(`\$\([^)]*\)`) + rePfix = regexp.MustCompile(`\s-p(\d)`) + reSpaces = regexp.MustCompile(`(\s)+`) + reSpBslash = regexp.MustCompile(`\s\\\s`) + reSemi = regexp.MustCompile(`\s*;$`) + rePrefix = regexp.MustCompile(`^(?:\s*\$\s+)?(?:docker|podman)\s+(run|create|container\s+run|service\s+create)\s+`) +) + +func normalize(s string) string { + s = strings.ReplaceAll(s, "\\\n", "") + s = reComments.ReplaceAllString(s, "") + s = reShellSub.ReplaceAllStringFunc(s, func(m string) string { + return strings.ReplaceAll(m, " ", "\u00a4") // lindungi spasi dalam $(...) + }) + s = reSpaces.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + s = rePfix.ReplaceAllString(s, " -p $1") + s = reSpBslash.ReplaceAllString(s, " ") + s = reSemi.ReplaceAllString(s, "") + return s +} + +func stripCommandPrefix(s string) (string, bool) { + if m := rePrefix.FindString(s); m != "" { + return s[len(m):], true + } + return "", false +} + +// --- tokenisasi (quote-aware; kutip dibuang seperti shell) --- + +func tokenize(s string) []string { + var tokens []string + var cur strings.Builder + quote := rune(0) + flush := func() { + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + } + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + case r == ' ' || r == '\t': + flush() + default: + cur.WriteRune(r) + } + } + flush() + return tokens +} + +// --- klasifikasi token → key: values + positional (halt-at-non-option) --- + +// booleanFlags — daftar persis dari referensi (token tanpa nilai). +var booleanFlags = map[string]bool{ + "i": true, "interactive": true, "t": true, "tty": true, + "d": true, "detach": true, "rm": true, "init": true, "help": true, + "privileged": true, "P": true, "publish-all": true, + "no-healthcheck": true, "oom-kill-disable": true, "read-only": true, + "sig-proxy": true, +} + +func parseTokens(tokens []string) ([]string, map[string][]string, []string) { + params := map[string][]string{} + var order []string // urutan kemunculan key (emit deterministik) + var positionals []string + positionalMode := false + + add := func(k, v string) { + if _, seen := params[k]; !seen { + order = append(order, k) + } + params[k] = append(params[k], v) + } + + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + if positionalMode || !strings.HasPrefix(tok, "-") || tok == "-" { + positionalMode = true // halt-at-non-option + positionals = append(positionals, tok) + continue + } + switch { + case tok == "--": + positionalMode = true + case strings.HasPrefix(tok, "--"): + body := tok[2:] + if k, v, ok := strings.Cut(body, "="); ok { + add(k, v) + continue + } + if booleanFlags[body] { + add(body, "true") + continue + } + // ambil token berikut sebagai nilai bila bukan opsi lain + if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { + i++ + add(body, tokens[i]) + } else { + add(body, "true") + } + default: // "-x", "-xVALUE", "-it" (cluster boolean) + body := tok[1:] + if len(body) == 0 { + continue + } + if len(body) == 1 { + if booleanFlags[body] { + add(body, "true") + continue + } + if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { + i++ + add(body, tokens[i]) + } else { + add(body, "true") + } + continue + } + // semua karakter boolean → cluster; selain itu key=char pertama + allBool := true + for _, c := range body { + if !booleanFlags[string(c)] { + allBool = false + break + } + } + if allBool { + for _, c := range body { + add(string(c), "true") + } + } else { + add(string(body[0]), body[1:]) + } + } + } + + return order, params, positionals +} + +// --- util nilai --- + +func stripQuotes(v string) string { + if len(v) >= 2 { + first, last := v[0], v[len(v)-1] + if (first == '\'' || first == '"') && last == first { + v = v[1 : len(v)-1] + } + } + return strings.ReplaceAll(v, "\u00a4", " ") // kembalikan spasi dalam $(...) +} + +func serviceNameFromImage(image string) string { + name := image + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + if i := strings.Index(name, ":"); i >= 0 { + name = name[:i] + } + if name == "" { + name = "service" + } + return name +} + +func strNode(v string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v} +} + +func intNode(v int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("%d", v)} +} + +func floatNode(v float64) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: trimFloat(v)} +} + +func boolNode() *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"} +} + +func trimFloat(f float64) string { + s := fmt.Sprintf("%v", f) + return s +} diff --git a/internal/stack/converter_test.go b/internal/stack/converter_test.go new file mode 100644 index 0000000..80fa94d --- /dev/null +++ b/internal/stack/converter_test.go @@ -0,0 +1,237 @@ +package stack + +import ( + "strings" + "testing" +) + +// full — output lengkap seperti tampil ke user (sudah termasuk komentar warning). +func full(r ConvertResult) string { return r.Output } + +func checkGolden(t *testing.T, name, cmd, want string) { + t.Helper() + got, err := Convert(cmd) + if err != nil { + t.Fatalf("%s: Convert error: %v", name, err) + } + g := full(got) + if strings.TrimSpace(g) != strings.TrimSpace(want) { + t.Fatalf("%s: YAML beda.\n--- dapat ---\n%s\n--- mau ---\n%s", name, g, want) + } +} + +func TestConvertVectors(t *testing.T) { + checkGolden(t, "V1 basic", "docker run -p 80:80 foobar/baz:latest", ` +services: + baz: + ports: + - 80:80 + image: foobar/baz:latest`) + + checkGolden(t, "V2 flags umum", + "docker run -d --name web -p 8080:80 -v ./html:/usr/share/nginx/html:ro -e TZ=Asia/Jakarta --restart unless-stopped nginx:latest", ` +services: + nginx: + container_name: web + ports: + - 8080:80 + volumes: + - ./html:/usr/share/nginx/html:ro + environment: + - TZ=Asia/Jakarta + restart: unless-stopped + image: nginx:latest`) + + checkGolden(t, "V3 multiline + $()", `docker run \ + -d \ + --name tor-relay \ + -e TZ=Europe/London \ + -e PUID=$(id -u) \ + -v $(pwd)/tor/data:/data:Z \ + -p 9001:9001 \ + --restart always \ + ilshidur/tor-relay`, ` +services: + tor-relay: + container_name: tor-relay + environment: + - TZ=Europe/London + - PUID=$(id -u) + volumes: + - $(pwd)/tor/data:/data:Z + ports: + - 9001:9001 + restart: always + image: ilshidur/tor-relay`) + + checkGolden(t, "V4 kutip nilai", `docker run --name="foobar" -e GREETING="hello world" nginx`, ` +services: + nginx: + container_name: foobar + environment: + - GREETING=hello world + image: nginx`) + + checkGolden(t, "V5 -p nempel", "docker run -p8080:80 nginx", ` +services: + nginx: + ports: + - 8080:80 + image: nginx`) + + checkGolden(t, "V6 flag tak dikenal", "docker run -d -z machin --unknown-long=truc nginx", ` +# ignored options for 'nginx' +# -z=machin +# --unknown-long=truc +services: + nginx: + image: nginx`) + + checkGolden(t, "V7 network bernama", "docker run --network appnet nginx", ` +# named network 'appnet' is marked as "external" (used by service 'nginx'), so either remove "external" from network definition or it needs to be created using: docker network create -d bridge appnet +services: + nginx: + networks: + - appnet + image: nginx +networks: + appnet: + external: true + name: appnet`) + + checkGolden(t, "V8 network host", "docker run --network host -p 8080:80 nginx", ` +services: + nginx: + network_mode: host + image: nginx`) + + checkGolden(t, "V9 named volume", "docker run -v vol:/data nginx", ` +services: + nginx: + volumes: + - vol:/data + image: nginx +volumes: + vol: {}`) + + checkGolden(t, "V10 mount long syntax", + "docker run --mount type=bind,source=/host,target=/container,readonly nginx", ` +services: + nginx: + volumes: + - type: bind + source: /host + target: /container + readonly: true + image: nginx`) + + checkGolden(t, "V11 healthcheck", + `docker run --health-cmd "curl -f http://localhost/" --health-interval 30s --health-retries 3 --health-timeout 5s --health-start-period 10s nginx`, ` +services: + nginx: + healthcheck: + test: curl -f http://localhost/ + interval: 30s + retries: 3 + timeout: 5s + start_period: 10s + image: nginx`) + + checkGolden(t, "V12 log opt", "docker run --log-opt max-size=1g --log-opt max-file=3 --restart always nginx", ` +services: + nginx: + logging: + options: + max-size: 1g + max-file: 3 + restart: always + image: nginx`) + + checkGolden(t, "V13 limits", "docker run --memory 512m --cpus 1.5 --pids-limit 200 nginx", ` +services: + nginx: + deploy: + resources: + limits: + memory: 512m + cpus: 1.5 + pids: 200 + image: nginx`) + + checkGolden(t, "V14 gpus", "docker run --gpus all nvidia/cuda:12.0-base", ` +services: + cuda: + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: + - gpu + image: nvidia/cuda:12.0-base`) + + checkGolden(t, "V15 command setelah image", "docker run nginx:alpine nginx -g daemon off;", ` +services: + nginx: + image: nginx:alpine + command: nginx -g daemon off`) + + // V16 (multi perintah) menyusul — dicatat di docs/rea/composerize.md. + + checkGolden(t, "V18 env-file + env", "docker run --env-file .env -e DEBUG=1 app/api:1.2", ` +services: + api: + env_file: + - .env + environment: + - DEBUG=1 + image: app/api:1.2`) + + checkGolden(t, "V19 ulimit", "docker run --ulimit nofile=1024:2048 --ulimit nproc=65535 nginx", ` +services: + nginx: + ulimits: + nofile: + soft: 1024 + hard: 2048 + nproc: 65535 + image: nginx`) + + checkGolden(t, "V20 ip + network", "docker run --network appnet --ip 10.0.0.5 nginx", ` +# named network 'appnet' is marked as "external" (used by service 'nginx'), so either remove "external" from network definition or it needs to be created using: docker network create -d bridge appnet +services: + nginx: + networks: + appnet: + ipv4_address: 10.0.0.5 + image: nginx +networks: + appnet: + external: true + name: appnet`) +} + +func TestConvertErrors(t *testing.T) { + if _, err := Convert("foo bar"); err == nil { + t.Fatal("bukan docker run harus error") + } + if _, err := Convert("docker run -d"); err == nil { + t.Fatal("tanpa image harus error") + } +} + +func TestServiceNameFromImage(t *testing.T) { + cases := map[string]string{ + "nginx": "nginx", + "nginx:latest": "nginx", + "foobar/baz:latest": "baz", + "registry.local:5000/app:v1": "app", + "ghcr.io/org/app": "app", + } + for in, want := range cases { + if got := serviceNameFromImage(in); got != want { + t.Errorf("%s → %s, mau %s", in, got, want) + } + } +} diff --git a/internal/stack/mappings.go b/internal/stack/mappings.go new file mode 100644 index 0000000..1614a69 --- /dev/null +++ b/internal/stack/mappings.go @@ -0,0 +1,412 @@ +package stack + +import ( + "fmt" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +type argType int + +const ( + tArray argType = iota + tValue + tInt + tFloat + tSwitch + tEnvs + tNetworks + tMap + tMapArray + tUlimits + tGpus + tBlkWeight + tBlkRate +) + +type mapping struct { + typ argType + path string +} + +// mappings — tabel pemetaan flag docker run → compose (docs/rea/composerize.md §7). +var mappings = map[string]mapping{ + "add-host": {tArray, "extra_hosts"}, + "blkio-weight": {tInt, "blkio_config/weight"}, + "blkio-weight-device": {tBlkWeight, "blkio_config/weight_device"}, + "cap-add": {tArray, "cap_add"}, + "cap-drop": {tArray, "cap_drop"}, + "cgroup-parent": {tValue, "cgroup_parent"}, + "cgroupns": {tValue, "cgroup"}, + "cpu-period": {tValue, "cpu_period"}, + "cpu-quota": {tValue, "cpu_quota"}, + "cpu-rt-period": {tValue, "cpu_rt_period"}, + "cpu-rt-runtime": {tValue, "cpu_rt_runtime"}, + "cpu-shares": {tInt, "cpu_shares"}, + "cpus": {tFloat, "deploy/resources/limits/cpus"}, + "detached": {tSwitch, ""}, + "device-cgroup-rule": {tArray, "device_cgroup_rules"}, + "device-read-bps": {tBlkRate, "blkio_config/device_read_bps"}, + "device-read-iops": {tBlkRate, "blkio_config/device_read_iops"}, + "device-write-bps": {tBlkRate, "blkio_config/device_write_bps"}, + "device-write-iops": {tBlkRate, "blkio_config/device_write_iops"}, + "device": {tArray, "devices"}, + "dns-opt": {tArray, "dns_opt"}, + "dns-search": {tArray, "dns_search"}, + "dns": {tArray, "dns"}, + "domainname": {tValue, "domainname"}, + "entrypoint": {tArray, "entrypoint"}, + "env-file": {tArray, "env_file"}, + "env": {tEnvs, "environment"}, + "expose": {tArray, "expose"}, + "gpus": {tGpus, "deploy"}, + "group-add": {tArray, "group_add"}, + "health-cmd": {tValue, "healthcheck/test"}, + "health-interval": {tValue, "healthcheck/interval"}, + "health-retries": {tInt, "healthcheck/retries"}, + "health-start-period": {tValue, "healthcheck/start_period"}, + "health-timeout": {tValue, "healthcheck/timeout"}, + "hostname": {tValue, "hostname"}, + "init": {tSwitch, "init"}, + "interactive": {tSwitch, "stdin_open"}, + "ip6": {tValue, "networks/{net}/ipv6_address"}, + "ip": {tValue, "networks/{net}/ipv4_address"}, + "ipc": {tValue, "ipc"}, + "isolation": {tValue, "isolation"}, + "label": {tArray, "labels"}, + "link-local-ip": {tArray, "networks/{net}/link_local_ips"}, + "link": {tArray, "links"}, + "log-driver": {tValue, "logging/driver"}, + "log-opt": {tMap, "logging/options"}, + "mac-address": {tValue, "mac_address"}, + "memory-reservation": {tValue, "deploy/resources/reservations/memory"}, + "memory-swap": {tValue, "memswap_limit"}, + "memory-swappiness": {tValue, "mem_swappiness"}, + "memory": {tValue, "deploy/resources/limits/memory"}, + "mount": {tMapArray, "volumes"}, + "name": {tValue, "container_name"}, + "net": {tNetworks, "network_mode"}, + "network-alias": {tArray, "networks/{net}/aliases"}, + "network": {tNetworks, "network_mode"}, + "no-healthcheck": {tSwitch, "healthcheck/disable"}, + "oom-kill-disable": {tSwitch, "oom_kill_disable"}, + "oom-score-adj": {tValue, "oom_score_adj"}, + "pid": {tValue, "pid"}, + "pids-limit": {tInt, "deploy/resources/limits/pids"}, + "platform": {tValue, "platform"}, + "privileged": {tSwitch, "privileged"}, + "publish": {tArray, "ports"}, + "pull": {tValue, "pull_policy"}, + "read-only": {tSwitch, "read_only"}, + "restart": {tValue, "restart"}, + "rm": {tSwitch, ""}, + "runtime": {tValue, "runtime"}, + "security-opt": {tArray, "security_opt"}, + "shm-size": {tValue, "shm_size"}, + "stop-signal": {tValue, "stop_signal"}, + "stop-timeout": {tValue, "stop_grace_period"}, + "storage-opt": {tMap, "storage_opt"}, + "sysctl": {tArray, "sysctls"}, + "tmpfs": {tArray, "tmpfs"}, + "tty": {tSwitch, "tty"}, + "ulimit": {tUlimits, "ulimits"}, + "user": {tValue, "user"}, + "userns": {tValue, "userns_mode"}, + "uts": {tValue, "uts"}, + "volume": {tArray, "volumes"}, + "volumes-from": {tArray, "volumes_from"}, + "workdir": {tValue, "working_dir"}, +} + +// alias short-flag (tabel referensi). +var shortAliases = map[string]string{ + "v": "volume", "p": "publish", "e": "env", "l": "label", "h": "hostname", + "u": "user", "w": "workdir", "c": "cpu-shares", "t": "tty", + "i": "interactive", "m": "memory", "d": "detached", +} + +// ignoredSilently — flag yang memang tidak punya padanan compose. +var ignoredSilently = map[string]bool{"d": true, "detached": true, "rm": true} + +// buildService — mapping semua params → service; kembalikan warnings. +func buildService(order []string, params map[string][]string, serviceName string) (*svcBuilder, []string, error) { + svc := newSvc() + network := "default" + if v := firstOf(params["net"], params["network"]); v != "" { + network = v + } + + var warns []string + var ignored []string + + for _, key := range order { + vals := params[key] + if ignoredSilently[key] { + continue + } + if alias, ok := shortAliases[key]; ok { + // alias memakai mapping induk, tapi key asli tetap untuk error msg + applyMapping(svc, alias, vals, network) + continue + } + m, ok := mappings[key] + if !ok { + dash := "--" + if len(key) == 1 { + dash = "-" + } + for _, v := range vals { + if v == "true" { + ignored = append(ignored, dash+key) + } else { + ignored = append(ignored, fmt.Sprintf("%s%s=%s", dash, key, v)) + } + } + continue + } + if m.path == "" { + continue + } + applyMapping(svc, key, vals, network) + } + + if len(ignored) > 0 { + warns = append(warns, fmt.Sprintf("ignored options for '%s'", serviceName)) + warns = append(warns, ignored...) + } + return svc, warns, nil +} + +func applyMapping(svc *svcBuilder, key string, vals []string, network string) { + m, ok := mappings[key] + if !ok { + return + } + path := splitPath(strings.ReplaceAll(m.path, "{net}", network)) + + switch m.typ { + case tArray: + for _, v := range vals { + svc.appendAt(path, strNode(v)) + } + case tValue: + svc.setPath(path, strNode(lastOf(vals))) + case tInt: + svc.setPath(path, intNode(parseInt(lastOf(vals)))) + case tFloat: + f, _ := strconv.ParseFloat(lastOf(vals), 64) + svc.setPath(path, floatNode(f)) + case tSwitch: + if lastOf(vals) == "true" { + svc.setPath(path, boolNode()) + } + case tEnvs: + for _, v := range vals { + if k, rest, ok := strings.Cut(v, "="); ok { + svc.appendAt(path, strNode(k+"="+stripQuotes(rest))) + } else { + svc.appendAt(path, strNode(v)) + } + } + case tNetworks: + v := lastOf(vals) + switch { + case v == "host" || v == "bridge" || v == "none" || strings.HasPrefix(v, "container:"): + svc.setPath([]string{"network_mode"}, strNode(v)) + default: + svc.ensureMap("networks", v) // {nama: {}} + } + case tMap: + pairs := parsePairs(strings.Join(vals, ",")) + svc.mapPairs(path, pairs) + case tMapArray: + for _, v := range vals { + item := &yaml.Node{Kind: yaml.MappingNode} + for _, kv := range parsePairs(v) { + // bare flag (mis. `readonly`) → true (diff disengaja vs referensi). + if kv[1] == "" { + appendKV(item, strNode(kv[0]), boolNode()) + continue + } + appendKV(item, strNode(kv[0]), scalarAuto(kv[1])) + } + svc.appendAt(path, item) + } + case tUlimits: + for _, v := range vals { + name, val, ok := strings.Cut(v, "=") + if !ok || name == "" { + continue + } + if soft, hard, ok := strings.Cut(val, ":"); ok { + m := &yaml.Node{Kind: yaml.MappingNode} + appendKV(m, strNode("soft"), intNode(parseInt(soft))) + appendKV(m, strNode("hard"), intNode(parseInt(hard))) + svc.setPath(append(path, name), m) + } else { + svc.setPath(append(path, name), intNode(parseInt(val))) + } + } + case tGpus: + v := lastOf(vals) + dev := &yaml.Node{Kind: yaml.MappingNode} + appendKV(dev, strNode("driver"), strNode("nvidia")) + if v == "all" { + appendKV(dev, strNode("count"), strNode("all")) + } else { + appendKV(dev, strNode("count"), intNode(parseInt(v))) + } + caps := &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{strNode("gpu")}} + appendKV(dev, strNode("capabilities"), caps) + svc.appendAt([]string{"deploy", "resources", "reservations", "devices"}, dev) + case tBlkWeight: + for _, v := range vals { + p, w, _ := strings.Cut(v, ":") + item := &yaml.Node{Kind: yaml.MappingNode} + appendKV(item, strNode("path"), strNode(p)) + appendKV(item, strNode("weight"), intNode(parseInt(w))) + svc.appendAt(path, item) + } + case tBlkRate: + for _, v := range vals { + p, r, _ := strings.Cut(v, ":") + item := &yaml.Node{Kind: yaml.MappingNode} + appendKV(item, strNode("path"), strNode(p)) + appendKV(item, strNode("rate"), scalarAuto(r)) + svc.appendAt(path, item) + } + } +} + +// --- post-processing level atas --- + +// finishTopLevel — susun {services, networks, volumes}: named network → +// external (parity docker run), named volume → dibuat oleh compose (diff §9). +func finishTopLevel(svc *svcBuilder, serviceName string) (*yaml.Node, []string) { + var warns []string + top := &yaml.Node{Kind: yaml.MappingNode} + + services := &yaml.Node{Kind: yaml.MappingNode} + appendKV(services, strNode(serviceName), svc.root) + appendKV(top, strNode("services"), services) + + // networks: bentuk map {nama: {...}} (dengan detail) atau sequence → external. + if nets := svc.get("networks"); nets != nil { + var names []string + allEmpty := true + for i := 0; i+1 < len(nets.Content); i += 2 { + names = append(names, nets.Content[i].Value) + if nets.Kind == yaml.MappingNode && nets.Content[i+1].Kind == yaml.MappingNode && len(nets.Content[i+1].Content) > 0 { + allEmpty = false + } + } + if nets.Kind == yaml.SequenceNode { + for _, n := range nets.Content { + names = append(names, n.Value) + } + } + // sequence sederhana bila tak ada detail per-network + if allEmpty { + seq := &yaml.Node{Kind: yaml.SequenceNode} + for _, n := range names { + seq.Content = append(seq.Content, strNode(n)) + } + svc.set("networks", seq) + } + + if len(names) > 0 { + tn := &yaml.Node{Kind: yaml.MappingNode} + for _, n := range names { + entry := &yaml.Node{Kind: yaml.MappingNode} + appendKV(entry, strNode("external"), boolNode()) + appendKV(entry, strNode("name"), strNode(n)) + appendKV(tn, strNode(n), entry) + warns = append(warns, fmt.Sprintf( + "named network '%s' is marked as \"external\" (used by service '%s'), so either remove \"external\" from network definition or it needs to be created using: docker network create -d bridge %s", + n, serviceName, n)) + } + appendKV(top, strNode("networks"), tn) + } + } + + // volumes: named volume (tanpa path/host) → deklarasi kosong (auto-create). + if vols := svc.get("volumes"); vols != nil && vols.Kind == yaml.SequenceNode { + tv := &yaml.Node{Kind: yaml.MappingNode} + for _, item := range vols.Content { + source := "" + if item.Kind == yaml.ScalarNode { + source, _, _ = strings.Cut(item.Value, ":") + } else if item.Kind == yaml.MappingNode { + if s := nodeKey(item, "source"); s != nil && s.Kind == yaml.ScalarNode { + source = s.Value + } + } + if isNamedVolume(source) { + if nodeKey(tv, source) == nil { + appendKV(tv, strNode(source), &yaml.Node{Kind: yaml.MappingNode}) + } + } + } + if len(tv.Content) > 0 { + appendKV(top, strNode("volumes"), tv) + } + } + + return top, warns +} + +// isNamedVolume — heuristik referensi: bukan path/host/ekspresi shell. +func isNamedVolume(source string) bool { + return source != "" && + !strings.ContainsAny(source, `/\$<>`) && + !strings.Contains(source, "\\") +} + +// --- util kecil --- + +func splitPath(p string) []string { + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +func firstOf(vals ...[]string) string { + for _, v := range vals { + if len(v) > 0 { + return v[0] // net/network: pakai kemunculan pertama + } + } + return "" +} + +func lastOf(vals []string) string { + if len(vals) == 0 { + return "" + } + return vals[len(vals)-1] +} + +func parsePairs(s string) [][2]string { + var out [][2]string + for _, part := range strings.Split(s, ",") { + k, v, _ := strings.Cut(part, "=") + out = append(out, [2]string{k, v}) + } + return out +} + +func isIntStr(s string) bool { + if s == "" { + return false + } + _, err := strconv.Atoi(s) + return err == nil +} + +func parseInt(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/internal/stack/service.go b/internal/stack/service.go new file mode 100644 index 0000000..dbd330c --- /dev/null +++ b/internal/stack/service.go @@ -0,0 +1,127 @@ +package stack + +import "gopkg.in/yaml.v3" + +// svcBuilder — mapping YAML berurut (urutan key = urutan flag, seperti +// referensi). Semua mutasi lewat helper agar urutan deterministik. +type svcBuilder struct { + root *yaml.Node // MappingNode +} + +func newSvc() *svcBuilder { + return &svcBuilder{root: &yaml.Node{Kind: yaml.MappingNode}} +} + +func (s *svcBuilder) get(key string) *yaml.Node { + for i := 0; i+1 < len(s.root.Content); i += 2 { + if s.root.Content[i].Value == key { + return s.root.Content[i+1] + } + } + return nil +} + +func (s *svcBuilder) has(key string) bool { return s.get(key) != nil } + +func (s *svcBuilder) set(key string, v *yaml.Node) { + for i := 0; i+1 < len(s.root.Content); i += 2 { + if s.root.Content[i].Value == key { + s.root.Content[i+1] = v + return + } + } + s.root.Content = append(s.root.Content, strNode(key), v) +} + +func (s *svcBuilder) del(key string) { + for i := 0; i+1 < len(s.root.Content); i += 2 { + if s.root.Content[i].Value == key { + s.root.Content = append(s.root.Content[:i], s.root.Content[i+2:]...) + return + } + } +} + +// ensureMap — mapping node pada path, dibuat bila belum ada. +func (s *svcBuilder) ensureMap(path ...string) *yaml.Node { + cur := s.root + for _, k := range path { + next := nodeKey(cur, k) + if next == nil { + next = &yaml.Node{Kind: yaml.MappingNode} + appendKV(cur, strNode(k), next) + } + if next.Kind != yaml.MappingNode { + next.Kind = yaml.MappingNode + next.Content = nil + } + cur = next + } + return cur +} + +func nodeKey(m *yaml.Node, k string) *yaml.Node { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == k { + return m.Content[i+1] + } + } + return nil +} + +func appendKV(m *yaml.Node, k, v *yaml.Node) { + m.Content = append(m.Content, k, v) +} + +// setPath — set scalar/value pada path (mengganti bila sudah ada). +func (s *svcBuilder) setPath(path []string, v *yaml.Node) { + if len(path) == 0 { + return + } + parent := s.ensureMap(path[:len(path)-1]...) + last := path[len(path)-1] + if old := nodeKey(parent, last); old != nil { + *old = *v + return + } + appendKV(parent, strNode(last), v) +} + +// appendAt — tambah item ke sequence pada path (dibuat bila belum ada). +func (s *svcBuilder) appendAt(path []string, item *yaml.Node) { + if len(path) == 0 { + return + } + parent := s.ensureMap(path[:len(path)-1]...) + last := path[len(path)-1] + seq := nodeKey(parent, last) + if seq == nil { + seq = &yaml.Node{Kind: yaml.SequenceNode} + appendKV(parent, strNode(last), seq) + } + if seq.Kind != yaml.SequenceNode { + *seq = yaml.Node{Kind: yaml.SequenceNode} + } + seq.Content = append(seq.Content, item) +} + +// mapPairs — set key:value berurutan ke dalam mapping pada path. +func (s *svcBuilder) mapPairs(path []string, pairs [][2]string) { + m := s.ensureMap(path...) + for _, kv := range pairs { + if old := nodeKey(m, kv[0]); old != nil { + *old = *strNode(kv[1]) + continue + } + // nilai numerik → int (parity MapArray/Map referensi) + appendKV(m, strNode(kv[0]), scalarAuto(kv[1])) + } +} + +// scalarAuto — "2048" → int, selain itu string. +func scalarAuto(v string) *yaml.Node { + if isIntStr(v) { + return intNode(parseInt(v)) + } + return strNode(v) +}