Files
tia-love/internal/stack/converter.go
T

281 lines
7.0 KiB
Go

// 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
}