281 lines
7.0 KiB
Go
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
|
|
}
|