feat(stack): converter docker run→compose (port REA modul 1) — 19 golden vektor + CLI tia-love convert
CI / test (push) Successful in 24s
CI / test (push) Successful in 24s
This commit is contained in:
1 parent
f555f27467
commit
b84afdb494
11 files changed
+1519
-159
No files matched your search
@@ -0,0 +1,280 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// full — output lengkap seperti tampil ke user (sudah termasuk komentar warning).
|
||||
func full(r ConvertResult) string { return r.Output }
|
||||
|
||||
func checkGolden(t *testing.T, name, cmd, want string) {
|
||||
t.Helper()
|
||||
got, err := Convert(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Convert error: %v", name, err)
|
||||
}
|
||||
g := full(got)
|
||||
if strings.TrimSpace(g) != strings.TrimSpace(want) {
|
||||
t.Fatalf("%s: YAML beda.\n--- dapat ---\n%s\n--- mau ---\n%s", name, g, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertVectors(t *testing.T) {
|
||||
checkGolden(t, "V1 basic", "docker run -p 80:80 foobar/baz:latest", `
|
||||
services:
|
||||
baz:
|
||||
ports:
|
||||
- 80:80
|
||||
image: foobar/baz:latest`)
|
||||
|
||||
checkGolden(t, "V2 flags umum",
|
||||
"docker run -d --name web -p 8080:80 -v ./html:/usr/share/nginx/html:ro -e TZ=Asia/Jakarta --restart unless-stopped nginx:latest", `
|
||||
services:
|
||||
nginx:
|
||||
container_name: web
|
||||
ports:
|
||||
- 8080:80
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
environment:
|
||||
- TZ=Asia/Jakarta
|
||||
restart: unless-stopped
|
||||
image: nginx:latest`)
|
||||
|
||||
checkGolden(t, "V3 multiline + $()", `docker run \
|
||||
-d \
|
||||
--name tor-relay \
|
||||
-e TZ=Europe/London \
|
||||
-e PUID=$(id -u) \
|
||||
-v $(pwd)/tor/data:/data:Z \
|
||||
-p 9001:9001 \
|
||||
--restart always \
|
||||
ilshidur/tor-relay`, `
|
||||
services:
|
||||
tor-relay:
|
||||
container_name: tor-relay
|
||||
environment:
|
||||
- TZ=Europe/London
|
||||
- PUID=$(id -u)
|
||||
volumes:
|
||||
- $(pwd)/tor/data:/data:Z
|
||||
ports:
|
||||
- 9001:9001
|
||||
restart: always
|
||||
image: ilshidur/tor-relay`)
|
||||
|
||||
checkGolden(t, "V4 kutip nilai", `docker run --name="foobar" -e GREETING="hello world" nginx`, `
|
||||
services:
|
||||
nginx:
|
||||
container_name: foobar
|
||||
environment:
|
||||
- GREETING=hello world
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V5 -p nempel", "docker run -p8080:80 nginx", `
|
||||
services:
|
||||
nginx:
|
||||
ports:
|
||||
- 8080:80
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V6 flag tak dikenal", "docker run -d -z machin --unknown-long=truc nginx", `
|
||||
# ignored options for 'nginx'
|
||||
# -z=machin
|
||||
# --unknown-long=truc
|
||||
services:
|
||||
nginx:
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V7 network bernama", "docker run --network appnet nginx", `
|
||||
# named network 'appnet' is marked as "external" (used by service 'nginx'), so either remove "external" from network definition or it needs to be created using: docker network create -d bridge appnet
|
||||
services:
|
||||
nginx:
|
||||
networks:
|
||||
- appnet
|
||||
image: nginx
|
||||
networks:
|
||||
appnet:
|
||||
external: true
|
||||
name: appnet`)
|
||||
|
||||
checkGolden(t, "V8 network host", "docker run --network host -p 8080:80 nginx", `
|
||||
services:
|
||||
nginx:
|
||||
network_mode: host
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V9 named volume", "docker run -v vol:/data nginx", `
|
||||
services:
|
||||
nginx:
|
||||
volumes:
|
||||
- vol:/data
|
||||
image: nginx
|
||||
volumes:
|
||||
vol: {}`)
|
||||
|
||||
checkGolden(t, "V10 mount long syntax",
|
||||
"docker run --mount type=bind,source=/host,target=/container,readonly nginx", `
|
||||
services:
|
||||
nginx:
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /host
|
||||
target: /container
|
||||
readonly: true
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V11 healthcheck",
|
||||
`docker run --health-cmd "curl -f http://localhost/" --health-interval 30s --health-retries 3 --health-timeout 5s --health-start-period 10s nginx`, `
|
||||
services:
|
||||
nginx:
|
||||
healthcheck:
|
||||
test: curl -f http://localhost/
|
||||
interval: 30s
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V12 log opt", "docker run --log-opt max-size=1g --log-opt max-file=3 --restart always nginx", `
|
||||
services:
|
||||
nginx:
|
||||
logging:
|
||||
options:
|
||||
max-size: 1g
|
||||
max-file: 3
|
||||
restart: always
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V13 limits", "docker run --memory 512m --cpus 1.5 --pids-limit 200 nginx", `
|
||||
services:
|
||||
nginx:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
cpus: 1.5
|
||||
pids: 200
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V14 gpus", "docker run --gpus all nvidia/cuda:12.0-base", `
|
||||
services:
|
||||
cuda:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities:
|
||||
- gpu
|
||||
image: nvidia/cuda:12.0-base`)
|
||||
|
||||
checkGolden(t, "V15 command setelah image", "docker run nginx:alpine nginx -g daemon off;", `
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
command: nginx -g daemon off`)
|
||||
|
||||
// V16 (multi perintah) menyusul — dicatat di docs/rea/composerize.md.
|
||||
|
||||
checkGolden(t, "V18 env-file + env", "docker run --env-file .env -e DEBUG=1 app/api:1.2", `
|
||||
services:
|
||||
api:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DEBUG=1
|
||||
image: app/api:1.2`)
|
||||
|
||||
checkGolden(t, "V19 ulimit", "docker run --ulimit nofile=1024:2048 --ulimit nproc=65535 nginx", `
|
||||
services:
|
||||
nginx:
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1024
|
||||
hard: 2048
|
||||
nproc: 65535
|
||||
image: nginx`)
|
||||
|
||||
checkGolden(t, "V20 ip + network", "docker run --network appnet --ip 10.0.0.5 nginx", `
|
||||
# named network 'appnet' is marked as "external" (used by service 'nginx'), so either remove "external" from network definition or it needs to be created using: docker network create -d bridge appnet
|
||||
services:
|
||||
nginx:
|
||||
networks:
|
||||
appnet:
|
||||
ipv4_address: 10.0.0.5
|
||||
image: nginx
|
||||
networks:
|
||||
appnet:
|
||||
external: true
|
||||
name: appnet`)
|
||||
}
|
||||
|
||||
func TestConvertErrors(t *testing.T) {
|
||||
if _, err := Convert("foo bar"); err == nil {
|
||||
t.Fatal("bukan docker run harus error")
|
||||
}
|
||||
if _, err := Convert("docker run -d"); err == nil {
|
||||
t.Fatal("tanpa image harus error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNameFromImage(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"nginx": "nginx",
|
||||
"nginx:latest": "nginx",
|
||||
"foobar/baz:latest": "baz",
|
||||
"registry.local:5000/app:v1": "app",
|
||||
"ghcr.io/org/app": "app",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := serviceNameFromImage(in); got != want {
|
||||
t.Errorf("%s → %s, mau %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package stack
|
||||
|
||||
import "gopkg.in/yaml.v3"
|
||||
|
||||
// svcBuilder — mapping YAML berurut (urutan key = urutan flag, seperti
|
||||
// referensi). Semua mutasi lewat helper agar urutan deterministik.
|
||||
type svcBuilder struct {
|
||||
root *yaml.Node // MappingNode
|
||||
}
|
||||
|
||||
func newSvc() *svcBuilder {
|
||||
return &svcBuilder{root: &yaml.Node{Kind: yaml.MappingNode}}
|
||||
}
|
||||
|
||||
func (s *svcBuilder) get(key string) *yaml.Node {
|
||||
for i := 0; i+1 < len(s.root.Content); i += 2 {
|
||||
if s.root.Content[i].Value == key {
|
||||
return s.root.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svcBuilder) has(key string) bool { return s.get(key) != nil }
|
||||
|
||||
func (s *svcBuilder) set(key string, v *yaml.Node) {
|
||||
for i := 0; i+1 < len(s.root.Content); i += 2 {
|
||||
if s.root.Content[i].Value == key {
|
||||
s.root.Content[i+1] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
s.root.Content = append(s.root.Content, strNode(key), v)
|
||||
}
|
||||
|
||||
func (s *svcBuilder) del(key string) {
|
||||
for i := 0; i+1 < len(s.root.Content); i += 2 {
|
||||
if s.root.Content[i].Value == key {
|
||||
s.root.Content = append(s.root.Content[:i], s.root.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureMap — mapping node pada path, dibuat bila belum ada.
|
||||
func (s *svcBuilder) ensureMap(path ...string) *yaml.Node {
|
||||
cur := s.root
|
||||
for _, k := range path {
|
||||
next := nodeKey(cur, k)
|
||||
if next == nil {
|
||||
next = &yaml.Node{Kind: yaml.MappingNode}
|
||||
appendKV(cur, strNode(k), next)
|
||||
}
|
||||
if next.Kind != yaml.MappingNode {
|
||||
next.Kind = yaml.MappingNode
|
||||
next.Content = nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func nodeKey(m *yaml.Node, k string) *yaml.Node {
|
||||
for i := 0; i+1 < len(m.Content); i += 2 {
|
||||
if m.Content[i].Value == k {
|
||||
return m.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendKV(m *yaml.Node, k, v *yaml.Node) {
|
||||
m.Content = append(m.Content, k, v)
|
||||
}
|
||||
|
||||
// setPath — set scalar/value pada path (mengganti bila sudah ada).
|
||||
func (s *svcBuilder) setPath(path []string, v *yaml.Node) {
|
||||
if len(path) == 0 {
|
||||
return
|
||||
}
|
||||
parent := s.ensureMap(path[:len(path)-1]...)
|
||||
last := path[len(path)-1]
|
||||
if old := nodeKey(parent, last); old != nil {
|
||||
*old = *v
|
||||
return
|
||||
}
|
||||
appendKV(parent, strNode(last), v)
|
||||
}
|
||||
|
||||
// appendAt — tambah item ke sequence pada path (dibuat bila belum ada).
|
||||
func (s *svcBuilder) appendAt(path []string, item *yaml.Node) {
|
||||
if len(path) == 0 {
|
||||
return
|
||||
}
|
||||
parent := s.ensureMap(path[:len(path)-1]...)
|
||||
last := path[len(path)-1]
|
||||
seq := nodeKey(parent, last)
|
||||
if seq == nil {
|
||||
seq = &yaml.Node{Kind: yaml.SequenceNode}
|
||||
appendKV(parent, strNode(last), seq)
|
||||
}
|
||||
if seq.Kind != yaml.SequenceNode {
|
||||
*seq = yaml.Node{Kind: yaml.SequenceNode}
|
||||
}
|
||||
seq.Content = append(seq.Content, item)
|
||||
}
|
||||
|
||||
// mapPairs — set key:value berurutan ke dalam mapping pada path.
|
||||
func (s *svcBuilder) mapPairs(path []string, pairs [][2]string) {
|
||||
m := s.ensureMap(path...)
|
||||
for _, kv := range pairs {
|
||||
if old := nodeKey(m, kv[0]); old != nil {
|
||||
*old = *strNode(kv[1])
|
||||
continue
|
||||
}
|
||||
// nilai numerik → int (parity MapArray/Map referensi)
|
||||
appendKV(m, strNode(kv[0]), scalarAuto(kv[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// scalarAuto — "2048" → int, selain itu string.
|
||||
func scalarAuto(v string) *yaml.Node {
|
||||
if isIntStr(v) {
|
||||
return intNode(parseInt(v))
|
||||
}
|
||||
return strNode(v)
|
||||
}
|
||||
Reference in new issue
Block a user