feat(stack): converter docker run→compose (port REA modul 1) — 19 golden vektor + CLI tia-love convert
CI / test (push) Successful in 24s

This commit is contained in:
oxpoe committed 2026-10-16 01:42:02 +07:00
1 parent f555f27467
commit b84afdb494
11 files changed
+1519 -159

No files matched your search

+5 -2
View File
@@ -18,14 +18,15 @@ func usageText() string {
%s %s
tia-love %s tia-love %s
tia-love convert %s
tia-love doctor %s tia-love doctor %s
tia-love version %s tia-love version %s
%s %s
`, `,
i18n.T("usage.usage"), i18n.T("usage.open"), i18n.T("usage.usage"), i18n.T("usage.open"),
i18n.T("usage.doctor"), i18n.T("usage.version"), i18n.T("usage.convert"), i18n.T("usage.doctor"),
i18n.T("usage.more")) i18n.T("usage.version"), i18n.T("usage.more"))
} }
func main() { func main() {
@@ -36,6 +37,8 @@ func main() {
return return
case "doctor": case "doctor":
os.Exit(cli.Doctor(os.Stdout)) os.Exit(cli.Doctor(os.Stdout))
case "convert":
os.Exit(cli.Convert(os.Args[2:], os.Stdout, os.Stderr))
default: default:
fmt.Fprintf(os.Stderr, "%s\n\n%s", i18n.Tf("cli.unknownCommand", os.Args[1]), usageText()) fmt.Fprintf(os.Stderr, "%s\n\n%s", i18n.Tf("cli.unknownCommand", os.Args[1]), usageText())
os.Exit(2) os.Exit(2)
+13 -5
View File
@@ -242,7 +242,7 @@ docker run -d --name web -p 8080:80 -v ./html:/usr/share/nginx/html:ro -e TZ=Asi
``` ```
```yaml ```yaml
services: services:
web: nginx:
container_name: web container_name: web
ports: ports:
- 8080:80 - 8080:80
@@ -544,6 +544,9 @@ networks:
| 7 | `-d` dan `--detach` sama-sama diabaikan senyap | Referensi inkonsisten (`-d` senyap, `--detach` komentar) | | 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 | | 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 | | 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 ## 10. Rencana Porting Go
@@ -572,7 +575,12 @@ Keputusan desain:
| Item | Status | | Item | Status |
|---|---| |---|---|
| Test vectors tertulis | ✅ (V1–V20) | | Test vectors tertulis | ✅ (V1–V20; V16 menunggu multi-command) |
| Golden test di Go | ⬜ Fase 3 | | Golden test di Go | ✅ **19 vektor hijau** — `internal/stack/converter_test.go` |
| Differential vs composerize (Node) | ⬜ Fase 3 — perlu `node`/`npm` di CI | | Differential vs composerize (Node) | ⬜ lanjutan Fase 3 — perlu `node`/`npm` di CI |
| Laporan paritas | ⬜ Fase 3 | | 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`.
+124
View File
@@ -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) { func TestThemeCycle(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("beranda", 5*time.Second) u.waitContains("beranda", 5*time.Second)
+288 -150
View File
@@ -432,157 +432,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg: case tea.KeyPressMsg:
switch msg.String() { return m, m.handleKey(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()
}
case tea.MouseClickMsg: case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft { if msg.Button == tea.MouseLeft {
m.sound.Play(audio.Click)
if m.toast.Active() { if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast 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 m.view == viewList {
if msg.X >= m.leftWidth() { if msg.X >= m.leftWidth() {
m.focus = focusContent m.focus = focusContent
@@ -951,12 +811,11 @@ func (m Model) View() tea.View {
case viewHome: case viewHome:
header += dim(" · " + i18n.T("nav.home")) header += dim(" · " + i18n.T("nav.home"))
body = m.homeBody() body = m.homeBody()
footer = m.footerLine(i18n.T("hint.footerHome"))
default: default:
header += dim(" · " + i18n.T("nav.containers")) header += dim(" · " + i18n.T("nav.containers"))
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
footer = m.footerLine(i18n.T("hint.footerContainers"))
} }
footer = m.footerLine()
parts := []string{} parts := []string{}
if t := m.toast.View(max(m.width, 20)); t != "" { if t := m.toast.View(max(m.width, 20)); t != "" {
@@ -970,12 +829,141 @@ func (m Model) View() tea.View {
return v 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 := "—" anim := "—"
if m.animating { if m.animating {
anim = fmt.Sprintf("%d", m.fps) 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 { func (m Model) sidebarView() string {
@@ -1368,3 +1356,153 @@ func colorizeLog(line string) string {
return theme.Style(c).Bold(true).Render(tok) 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
}
+27
View File
@@ -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
}
+3 -1
View File
@@ -131,10 +131,12 @@ doctor:
cli: cli:
unknownCommand: "unknown command: %q" unknownCommand: "unknown command: %q"
convertUsage: "usage: tia-love convert \"docker run -d -p 80:80 nginx\""
usage: usage:
usage: "Usage:" usage: "Usage:"
open: "Open the interactive TUI" open: "Open the interactive TUI"
convert: "Convert a docker run command → compose YAML (stdout)"
doctor: "Check system health (docker, audio, stacks)" doctor: "Check system health (docker, audio, stacks)"
version: "Show version & build info" 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."
+3 -1
View File
@@ -131,10 +131,12 @@ doctor:
cli: cli:
unknownCommand: "perintah tidak dikenal: %q" unknownCommand: "perintah tidak dikenal: %q"
convertUsage: "pemakaian: tia-love convert \"docker run -d -p 80:80 nginx\""
usage: usage:
usage: "Pemakaian:" usage: "Pemakaian:"
open: "Buka TUI interaktif" open: "Buka TUI interaktif"
convert: "Konversi perintah docker run → compose YAML (stdout)"
doctor: "Periksa kesehatan sistem (docker, audio, stack)" doctor: "Periksa kesehatan sistem (docker, audio, stack)"
version: "Tampilkan versi & build info" 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."
+280
View File
@@ -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
}
+237
View File
@@ -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)
}
}
}
+412
View File
@@ -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
}
+127
View File
@@ -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)
}