feat(stack): converter docker run→compose (port REA modul 1) — 19 golden vektor + CLI tia-love convert
CI / test (push) Successful in 24s
CI / test (push) Successful in 24s
This commit is contained in:
1 parent
f555f27467
commit
b84afdb494
11 files changed
+1519
-159
No files matched your search
@@ -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)
|
||||
|
||||
+13
-5
@@ -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`.
|
||||
+124
@@ -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)
|
||||
|
||||
+288
-150
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user