# REA — Modul 1: Parser `docker run` → Compose (composerize) > Status: SPEC FINAL · Fase 3 · Target kode: `internal/stack/converter.go` > Analisis: 2026-10-15 · Metode: baca sumber + fixture test (bukan black-box) ## 1. Ringkasan & Tujuan `composerize` mengubah perintah `docker run` menjadi file compose YAML. Di tia, logika ini menjadi **konverter inti** yang dipakai dua-duanya: modal konverter TUI (F-008) dan `tia-love convert` CLI (F-028). Yang diporting: **pipeline parsing, tabel pemetaan flag, dan aturan normalisasi**. Yang tidak diport: fitur merge dengan file compose existing via stdin (ditangani layer stack tia dengan cara berbeda), website, dan UI mereka. ## 2. Sumber | Item | Nilai | |---|---| | Repo | https://github.com/composerize/composerize | | Commit | `3e39eb7` (clone `--depth 1`, 2026-10-15) | | Lisensi | **MIT** — Copyright (c) 2024 Composerize (diverifikasi dari file `LICENSE`) | | File kunci | `packages/composerize/src/{index,logic,mappings}.js`, `cli.js`, `__tests__/*.js` | | Dependency eksternal | `yargs-parser` (tokenizer), `composeverter` (yamlParse/yamlStringify/migrasi format), `deepmerge` | Catatan: `composeverter` adalah paket npm terpisah (tidak ada di repo). Migrasi format (`version: '3'` → common spec) terjadi di sana. Untuk tia kita langsung memproduksi output common-spec, jadi bagian itu tidak perlu diport. ## 3. Alur Eksekusi ``` input (string perintah) │ ├─ 1. split multi-command: /^(?:\s*\$)?\s*(?:docker|podman)\s+/gm │ → setiap segmen harus match ^(run|create|container run|service create), │ kalau bukan → komentar "# ignored : docker/podman " │ ├─ 2. normalisasi teks (urutan PENTING): │ a. hapus komentar baris: /\n\s*#[^\n]+/g │ b. lindungi $(...) : ganti spasi dalam $() dengan '¤' │ c. hapus line continuation: /\\\n/g (backslash + newline) │ d. collapse whitespace: /(\s)+/g → ' ' │ e. trim │ f. perbaiki '-p80' → '-p 80': /\s-p(\d)/g │ g. '\ \' (spasi-backslash-spasi) → ' ' [utf8: '\' literal] │ h. hapus ';' di akhir: /\s*;$/g │ ├─ 3. buang prefix perintah: │ /^(?:\s*\$\s+)?(?:docker|podman)\s+(run|create|container\s+run|service\s+create)/ │ ├─ 4. tokenisasi via yargs-parser: │ configuration: halt-at-non-option=true, camel-case-expansion=false, │ boolean-negation=false │ boolean flags: i interactive t tty d detach rm init help privileged │ P publish-all no-healthcheck oom-kill-disable │ read-only sig-proxy │ → halt-at-non-option: semua token setelah IMAGE menjadi argumen command │ → boolean-negation=false: '--no-healthcheck' tetap key 'no-healthcheck' │ ├─ 5. bersihkan nilai: stripQuotes() + kembalikan '¤' → spasi (dalam $()) │ ├─ 6. loop setiap key → MAPPINGS[key] → ComposeEntry → deepmerge ke service │ key tak dikenal → baris komentar "# --key=value" │ ├─ 7. image = token positional pertama (stripQuotes) │ token positional sisanya → command: gabung dengan spasi │ ├─ 8. post-processing: │ a. network_mode == host → hapus ports │ b. volume source tanpa '/', '\', '$', '<', '>' → dianggap named volume │ → top-level volumes + {external: {name}} + komentar │ c. networks: nilai map kosong → null; semua null → jadikan array nama │ → top-level networks {external: true, name} + komentar │ ├─ 9. bungkus: {version:'3', services:{: service}} — digabung (deepmerge) │ dengan existing compose bila ada │ └─ 10. stringify YAML (indent default library 4, CLI 2) + prepend komentar (ignored options, external network/volume) + migrasi format: latest → common spec (hapus version, tambah "name: "), v2x → "2.4", v3x → "3" ``` ## 4. Struktur Data & Format I/O **Input:** string (satu atau banyak perintah `docker/podman run/create/...`). **Output:** string YAML, atau `SyntaxError` bila tidak ada satu pun perintah valid: ``` must have at least a valid docker/podman run/create/service create/container run command ``` **Tipe nilai flag** (`mappings.js`): | Type | Arti | |---|---| | `Array` | list of string | | `Value` | string apa adanya | | `IntValue` / `FloatValue` | `parseInt` / `parseFloat` | | `Switch` | boolean (`value === 'true'` \|\| `true`) | | `Envs` | `["K=V", "K"]` — split di `=` pertama, sisanya digabung lagi | | `Networks` | `host/bridge/none/container:*` → `network_mode`; selain itu → `networks.{nama}` | | `Map` | nilai tunggal `k=v,k2=v2` → object (nilai numerik → int) | | `MapArray` | seperti Map tapi tiap kemunculan flag jadi satu object (dipakai `--mount`) | | `Ulimits` | `type=soft[:hard]` → `ulimits.: int \| {soft,hard}` | | `Gpus` | `all` \| angka → `deploy.resources.reservations.devices[{driver:nvidia,count,capabilities:[gpu]}]` | | `DeviceBlockIOConfigWeight` | `path:weight` → `[{path, weight:int}]` | | `DeviceBlockIOConfigRate` | `path:rate` → `[{path, rate:int\|string}]` | **Nama service:** dari image → buang registry (`split('/')` terakhir) → buang tag (`split(':')[0]`). Image kosong → `!!!invalid!!!`. ## 5. Aturan & Edge Case | # | Kasus | Perilaku referensi | Catatan untuk tia | |---|---|---|---| | 1 | Komentar di input | Hanya dihapus jika didahului newline (`/\n\s*#/`) — komentar di baris pertama TIDAK terhapus | Tiru regex persis; uji kasus baris pertama | | 2 | `$(pwd)`, `$(id -u)` | Spasi di dalam `$()` dilindungi `¤` agar tokenizer tidak memecah | Harus ditiru — krusial, banyak dipakai di compose nyata | | 3 | Line continuation `\` + newline | Dihapus lalu whitespace di-collapse | Sama | | 4 | `-p80:80` | Diperbaiki jadi `-p 80:80` (hanya untuk `-p` + digit!) | Sama. `-eFOO=bar` **tidak** diperbaiki — perlu uji differential | | 5 | `;` di akhir | Dihapus | Sama | | 6 | `-d` / `--detached` | Diabaikan **diam-diam** (mapping ke path `''`) | Sama | | 7 | `--detach` (panjang) | Muncul sebagai komentar `# --detach` (inkonsisten dgn `-d`) | tia: ignore senyap keduanya (lihat §8) | | 8 | `--rm` | Diabaikan diam-diam | Sama | | 9 | Flag tak dikenal | Komentar `# ignored options for ''` + daftar flag di atas YAML | Sama (tanpa gagal) | | 10 | `--network host` | `network_mode: host` + ports dihapus | Sama | | 11 | `--network appnet` | `networks: [appnet]` + top-level `networks.appnet.external: true, name` + komentar peringatan | Sama (parity dgn docker run: network harus sudah ada) | | 12 | Named volume `-v vol:/data` | `volumes.vol.external: {name: vol}` + komentar | **Berbeda di tia** (lihat §8) | | 13 | `--ip x` tanpa `--network` | Network fallback = `"default"` → `networks.default.ipv4_address` | Tiru; hasilnya agak aneh tapi konsisten | | 14 | `--tmpfs` berulang | Digabung salah jadi `"/a,/b"` (String(array)) | tia: list (lihat §8) | | 15 | `--mount ... ,readonly` | Key tanpa nilai menghasilkan value `undefined` → hilang dari YAML | tia: `readonly: true` (lihat §8) | | 16 | Command setelah image | Token positional digabung dengan spasi (kutip hilang) | Tiru; tandai untuk uji differential | | 17 | Kutip pada nilai | `stripQuotes` hanya membuang kutip terluar yang sepasang | Sama | | 18 | Nilai `-` tunggal / angka | `Map`/`MapArray` mengubah string numerik jadi int | Sama | | 19 | `--volumes-from` | Dipetakan ke `volume_from` | **BUG referensi** — compose spec = `volumes_from`. tia pakai yang benar | | 20 | Tanpa perintah valid | `SyntaxError` dengan pesan spesifik | Pesan i18n di layer UI; inti error setara | ## 6. Perintah Docker/Compose yang Dipanggil Tidak ada — murni transformasi string→YAML. (Verifikasi output: `docker compose config` opsional untuk validasi di luar converter.) ## 7. Tabel Pemetaan Flag (docker run → compose) Diambil dari `mappings.js` + short-flag alias. `[A]`=Array, `[V]`=Value, `[I]`=Int, `[F]`=Float, `[S]`=Switch, `[E]`=Envs, `[N]`=Networks, `[M]`=Map, `[MA]`=MapArray, `[U]`=Ulimits, `[G]`=Gpus. | docker run | compose | tipe | |---|---|---| | `--add-host` | `extra_hosts` | A | | `--blkio-weight` | `blkio_config.weight` | I | | `--blkio-weight-device` | `blkio_config.weight_device` | DeviceBlockIO | | `--cap-add` / `--cap-drop` | `cap_add` / `cap_drop` | A | | `--cgroup-parent` | `cgroup_parent` | V | | `--cgroupns` | `cgroup` | V | | `--cpu-period` `--cpu-quota` `--cpu-rt-period` `--cpu-rt-runtime` | `cpu_period` `cpu_quota` `cpu_rt_period` `cpu_rt_runtime` | V (string!) | | `-c` `--cpu-shares` | `cpu_shares` | I | | `--cpus` | `deploy.resources.limits.cpus` | F | | `--device` | `devices` | A | | `--device-cgroup-rule` | `device_cgroup_rules` | A | | `--device-read-bps` `--device-read-iops` `--device-write-bps` `--device-write-iops` | `blkio_config.device_*` | DeviceBlockIO | | `--dns` `--dns-opt` `--dns-search` | `dns` `dns_opt` `dns_search` | A | | `--domainname` | `domainname` | V | | `--entrypoint` | `entrypoint` | A (satu elemen) | | `--env-file` | `env_file` | A | | `-e` `--env` | `environment` | E | | `--expose` | `expose` | A | | `--gpus` | `deploy.resources.reservations.devices` | G | | `--group-add` | `group_add` | A | | `--health-cmd` `--health-interval` `--health-retries` `--health-start-period` `--health-timeout` | `healthcheck.{test,interval,retries,start_period,timeout}` | V | | `--no-healthcheck` | `healthcheck.disable` | S | | `-h` `--hostname` | `hostname` | V | | `--init` | `init` | S | | `-i` `--interactive` | `stdin_open` | S | | `--ip` `--ip6` | `networks..ipv4_address` / `.ipv6_address` | V | | `--ipc` | `ipc` | V | | `--isolation` | `isolation` | V | | `-l` `--label` | `labels` | A | | `--link` `--link-local-ip` | `links` / `networks..link_local_ips` | A | | `--log-driver` | `logging.driver` | V | | `--log-opt` | `logging.options` | M | | `--mac-address` | `mac_address` | V | | `-m` `--memory` | `deploy.resources.limits.memory` | V | | `--memory-reservation` | `deploy.resources.reservations.memory` | V | | `--memory-swap` / `--memory-swappiness` | `memswap_limit` / `mem_swappiness` | V | | `--mount` | `volumes` (long syntax object) | MA | | `--name` | `container_name` | V | | `--net` `--network` | `networks` / `network_mode` | N | | `--network-alias` | `networks..aliases` | A | | `--oom-kill-disable` | `oom_kill_disable` | S | | `--oom-score-adj` | `oom_score_adj` | V | | `--pid` | `pid` | V | | `--pids-limit` | `deploy.resources.limits.pids` | I | | `--platform` | `platform` | V | | `--privileged` | `privileged` | S | | `-p` `--publish` | `ports` | A | | `--pull` | `pull_policy` | V | | `--read-only` | `read_only` | S | | `--restart` | `restart` | V | | `--runtime` | `runtime` | V | | `--security-opt` | `security_opt` | A | | `--shm-size` | `shm_size` | V | | `--stop-signal` | `stop_signal` | V | | `--stop-timeout` | `stop_grace_period` | V | | `--storage-opt` | `storage_opt` | M | | `--sysctl` | `sysctls` | A | | `--tmpfs` | `tmpfs` | V | | `-t` `--tty` | `tty` | S | | `--ulimit` | `ulimits` | U | | `-u` `--user` | `user` | V | | `--userns` | `userns_mode` | V | | `--uts` | `uts` | V | | `-v` `--volume` | `volumes` | A | | `--volumes-from` | `volumes_from` *(ref: `volume_from` — bug)* | A | | `-w` `--workdir` | `working_dir` | V | | `--rm` `-d` `--detached` | *(diabaikan)* | — | **Tidak ditangani referensi** (tia boleh menambah nanti, catat sebagai ekstensi): `--health-start-interval`, `--annotation`, `--cidfile`, `--label-file`, `--attach`, `-P/--publish-all`, `--sig-proxy`, `--stop-grace-period` (bukan flag docker run; yang benar `--stop-timeout`). ## 8. Test Vectors Format target tia: **common spec** (tanpa `version:`, tanpa `name:` — nama project diurus layer stack), indent 2, urutan key = urutan flag lalu `image` terakhir (mengikuti referensi). ### V1 — basic ``` docker run -p 80:80 foobar/baz:latest ``` ```yaml services: baz: ports: - 80:80 image: foobar/baz:latest ``` ### 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 ``` ```yaml 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 ``` *(nama service = `nginx` karena diambil dari image; `--name` hanya mengisi `container_name`)* ### V3 — multi-line + `$(...)` ``` 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 ``` ```yaml 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 ``` ### V4 — kutip pada nilai ``` docker run --name="foobar" -e GREETING="hello world" nginx ``` ```yaml services: nginx: container_name: foobar environment: - GREETING=hello world image: nginx ``` ### V5 — `-p80` nempel ``` docker run -p8080:80 nginx ``` ```yaml services: nginx: ports: - 8080:80 image: nginx ``` ### V6 — flag tak dikenal ``` docker run -d -z machin --unknown-long=truc nginx ``` ```yaml # ignored options for 'nginx' # -z=machin # --unknown-long=truc services: nginx: image: nginx ``` ### V7 — network bernama (external) ``` docker run --network appnet nginx ``` ```yaml # 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 ``` ### V8 — network mode host (ports dibuang) ``` docker run --network host -p 8080:80 nginx ``` ```yaml services: nginx: network_mode: host image: nginx ``` ### V9 — named volume (perilaku tia; §8 perbedaan #3) ``` docker run -v vol:/data nginx ``` ```yaml services: nginx: volumes: - vol:/data image: nginx volumes: vol: {} ``` ### V10 — `--mount` long syntax ``` docker run --mount type=bind,source=/host,target=/container,readonly nginx ``` ```yaml services: nginx: volumes: - type: bind source: /host target: /container readonly: true image: nginx ``` ### V11 — healthcheck ``` docker run --health-cmd "curl -f http://localhost/" --health-interval 30s --health-retries 3 --health-timeout 5s --health-start-period 10s nginx ``` ```yaml services: nginx: healthcheck: test: curl -f http://localhost/ interval: 30s retries: 3 timeout: 5s start_period: 10s image: nginx ``` ### V12 — log opt (berulang → satu object) ``` docker run --log-opt max-size=1g --log-opt max-file=3 --restart always nginx ``` ```yaml services: nginx: logging: options: max-size: 1g max-file: 3 restart: always image: nginx ``` ### V13 — limits ``` docker run --memory 512m --cpus 1.5 --pids-limit 200 nginx ``` ```yaml services: nginx: deploy: resources: limits: memory: 512m cpus: 1.5 pids: 200 image: nginx ``` ### V14 — gpus ``` docker run --gpus all nvidia/cuda:12.0-base ``` ```yaml services: cuda: deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: - gpu image: nvidia/cuda:12.0-base ``` ### V15 — command setelah image ``` docker run nginx:alpine nginx -g daemon off; ``` ```yaml services: nginx: image: nginx:alpine command: nginx -g daemon off ``` *(`;` akhir dihapus oleh normalisasi)* ### V16 — multi perintah + perintah diabaikan ``` docker run -p 80:80 foo/bar:latest docker stop docker run -p 80:80 foo/buzz:latest ``` ```yaml # ignored : docker/podman stop services: bar: ports: - 80:80 image: foo/bar:latest buzz: ports: - 80:80 image: foo/buzz:latest ``` ### V17 — error ``` foo bar ``` → error (setara): `must have at least a valid docker/podman run/create/service create/container run command` ### V18 — env-file + env ``` docker run --env-file .env -e DEBUG=1 app/api:1.2 ``` ```yaml services: api: env_file: - .env environment: - DEBUG=1 image: app/api:1.2 ``` ### V19 — ulimit ``` docker run --ulimit nofile=1024:2048 --ulimit nproc=65535 nginx ``` ```yaml services: nginx: ulimits: nofile: soft: 1024 hard: 2048 nproc: 65535 image: nginx ``` ### V20 — ip + network ``` docker run --network appnet --ip 10.0.0.5 nginx ``` ```yaml # named network 'appnet' is marked as "external" ... services: nginx: networks: appnet: ipv4_address: 10.0.0.5 image: nginx networks: appnet: external: true name: appnet ``` > **Perlu verifikasi differential** (langsung lawan composerize asli, butuh Node): > kutip di dalam `command`, `-eFOO=bar` (short flag nempel), `--` separator, > `--privileged false`, path Windows (`C:\`), dan format kutip YAML-nya. > Daftar ini masuk korpus uji paritas Fase 3. ## 9. Perbedaan yang Disengaja dari Referensi | # | Perbedaan | Alasan | |---|---|---| | 1 | Output = **common spec** murni: tanpa `version:`, tanpa `name: ` | Tujuannya tia: file stack valid di Compose v2+; nama project ditentukan folder stack, bukan placeholder | | 2 | Indent default **2** (referensi library 4, CLI 2) | Konvensi compose + samakan dengan perilaku CLI referensi | | 3 | **Named volume tidak** di-mark `external` (`volumes: {vol: {}}`) | `docker run -v vol:/data` auto-create volume; `external` mengubah perilaku (harus sudah ada). Network tetap `external: true` karena `docker run --network X` memang menuntut X sudah ada | | 4 | `--volumes-from` → `volumes_from` | `volume_from` adalah bug referensi (bukan field compose spec) | | 5 | `--tmpfs` berulang → list `["/a", "/b"]` | Referensi menggabung jadi `"/a,/b"` (bug) | | 6 | `--mount ...,readonly` → `readonly: true` | Referensi menjatuhkan key tanpa nilai | | 7 | `-d` dan `--detach` sama-sama diabaikan senyap | Referensi inkonsisten (`-d` senyap, `--detach` komentar) | | 8 | Tidak ada merge stdin; "tambah ke stack" ditangani layer stack (baca-tulis YAML compose) | Arsitektur tia: converter tetap murni string→string agar bisa dipakai TUI & CLI dengan hasil identik | | 9 | Pesan error dibungkus i18n (F-023) | Prinsip tia; isi/arti error tetap sama | | 10 | Image tidak ditemukan → **error jelas** (`docker run: image tidak ditemukan`) | Referensi menghasilkan service bernama `!!!invalid!!!` — error tegas lebih jujur untuk CLI & TUI | | 11 | `--health-retries` ditulis sebagai integer | Skema compose mengharapkan int; referensi menulisnya sebagai string | | 12 | Multi-perintah (beberapa `docker run` dalam satu input) belum didukung — menyusul | V16 ditahan sampai dukungan multi-command masuk | ## 10. Rencana Porting Go File: `internal/stack/converter.go` (+ `converter_test.go`, `testdata/`). ``` Tokenizer (tulis sendiri, ~80 baris): 1. preprocess: komentar, $() → placeholder, line-continuation, collapse space 2. split token dgn dukungan kutip tunggal/ganda 3. klasifikasi: --opt=val | --opt val | -x val | -xval | -it (cluster) | positional 4. halt-at-non-option: setelah positional pertama → semua ke args command Parser: map[string][]string (key → nilai; berulang jadi slice, urutan dipertahankan) Mapper: tabel MAPPINGS di Go (key → type + path) Builder: bangun pohon kompose secara TERURUT (yaml.Node / ordered map) → yaml.v3 ``` Keputusan desain: - **Urutan key deterministik** — map Go tidak berurutan; pakai `yaml.Node` (atau tipe ordered map kecil) agar output stabil dan bisa dibandingkan di golden test. - Tabel MAPPINGS ditulis **dari perilaku** (bukan copy kode JS); atribusi di `THIRD_PARTY_NOTICES.md`. - Test vectors V1–V20 di atas dikonsumsi sebagai golden test. Differential test vs composerize asli dijalankan di CI (`node`, npm `composerize`), YAML dinormalisasi (sort key) lalu dibandingkan; target paritas ≥ 95% untuk flag umum — selisih yang tersisa wajib ada di tabel §9. ## 11. Verifikasi Paritas | Item | Status | |---|---| | Test vectors tertulis | ✅ (V1–V20; V16 menunggu multi-command) | | Golden test di Go | ✅ **19 vektor hijau** — `internal/stack/converter_test.go` | | Differential vs composerize (Node) | ⬜ lanjutan Fase 3 — perlu `node`/`npm` di CI | | Laporan paritas | ⬜ lanjutan Fase 3 | Catatan implementasi (ditemukan saat porting, 2026-10-16): - V2 di dokumen ini salah tulis (service `web:` → seharusnya `nginx:`, sebab nama service dari image) — sudah diperbaiki; perilaku referensi mengikuti `getServiceName(image)`. - `--mount ...,readonly` → `readonly: true` (diff #6) terbukti perlu di jalur MapArray. - `res.Output` = teks siap cetak (komentar warning + YAML) — kontrak dipakai CLI `tia-love convert`.