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

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
}