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 }