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