413 lines
13 KiB
Go
413 lines
13 KiB
Go
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
|
|
}
|