From ddc6fabcca965e27f4137c88fc84512c96139d9b Mon Sep 17 00:00:00 2001 From: Marinski Date: Tue, 28 Jul 2026 22:31:53 +0300 Subject: [PATCH 1/3] feat(multi-vm): config-driven N-VM topology with full backward compat Add vms.yaml to declare VM resource allocation (cpuset, RAM, CPU, disk, hot-tier binds). Each terminal in config.yaml gets an optional vm field referencing a VM name. Absent = default (routes to mt5). config_helper.py reads vm field for nginx routing, adds new commands: vms, vm_group, vm_info, port_list --vm, generate_compose. run.sh loops over all VMs for per-VM DNAT + generates vm-group files from config instead of requiring manual files. docker-compose.yml.j2 renders compose from vms.yaml via Jinja2. docs/multi-vm-setup.md covers the full walkthrough. Backward compatible: no vms.yaml = single-VM mode, all routes to mt5. No vm field = routes to default container. --- CHANGELOG.md | 16 +++ README.md | 47 +++++++++ config/config.yaml.example | 4 + docker-compose.yml.j2 | 205 +++++++++++++++++++++++++++++++++++++ docs/multi-vm-setup.md | 146 ++++++++++++++++++++++++++ run.sh | 80 +++++++++------ scripts/config_helper.py | 101 +++++++++++++++++- vms.yaml | 63 ++++++++++++ 8 files changed, 630 insertions(+), 32 deletions(-) create mode 100644 docker-compose.yml.j2 create mode 100644 docs/multi-vm-setup.md create mode 100644 vms.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4346e..ddc0be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,22 @@ The project follows [Semantic Versioning](https://semver.org/): patch = bug fixe - Fresh terminal API and MCP-unifier installs now pin MCP SDK 1.28.0. MCP 2.0 removed `mcp.server.fastmcp`, causing both processes to fail during import before binding their HTTP ports. +## [v4.10.0] — 2026-07-28 + +### Added + +- **N-VM topology** — `vms.yaml` declares VM resource allocation (cpuset, RAM, CPU cores, disk, hot-tier storage) in one place. `config_helper.py` reads the `vm` field on each terminal entry to generate nginx routes to the correct container (e.g. `mt5`, `mt5-b`), and `run.sh` iterates over all VMs for DNAT/iptables setup. Existing single-VM users are unaffected: no `vms.yaml` → all terminals route to the default `mt5` container. +- **`terminals[].vm`** — optional field in `config/config.yaml` that assigns a terminal to a specific VM. Absent → `default` (routes to `mt5`). See `vms.yaml` for VM names. +- **`config_helper.py`** new commands: `vms` (list VM names), `vm_group ` (dump terminals for a VM), `vm_info [field]`, `port_list --vm `, `generate_compose` (render `docker-compose.yml` from `docker-compose.yml.j2` + `vms.yaml`). +- **`docker-compose.yml.j2`** — Jinja2 template that renders the compose file from the VM definitions in `vms.yaml`. Used by `run.sh` on first boot when `vms.yaml` exists. +- **`docs/multi-vm-setup.md`** — guide for setting up a multi-VM deployment. +- **Per-VM concurrency caps** — set `MT5_HTTPAPI_MAX_IN_FLIGHT_` environment variable to cap in-flight backtests per VM independently (in addition to the global `MT5_HTTPAPI_MAX_IN_FLIGHT`). + +### Changed + +- `run.sh` generates per-VM group files (`vm-group-.txt`) from `config.yaml` instead of requiring manually maintained `vm-group-*.txt` files. +- `config_helper.py` nginx_conf now routes each terminal to its owning VM's container name, derived from `vms.yaml`. + ## [v4.9.3] — 2026-07-28 ### Fixed diff --git a/README.md b/README.md index 30a7eb4..510cc3e 100644 --- a/README.md +++ b/README.md @@ -171,9 +171,53 @@ Per-field notes: - **`terminals[].utc_offset`** — broker server's UTC offset, used to normalize all timestamps to real UTC on the wire (see [Broker time vs real UTC](#broker-time-vs-real-utc) below). Optional — defaults to `0`. Accepts `"3h"`, `"3h30m"`, `"-2h"`, `"90m"`, or a bare number (interpreted as hours). Common values: RoboForex/FTMO `"3h"`, TeleTrade `"2h"`. - **`terminals[].mode`** — `live` (default) or `backtest`. `live` keeps `terminal64.exe` running so the MT5 SDK stays initialized for live trading endpoints. `backtest` prepares the same portable directory but does **not** launch `terminal64.exe`, leaving the data dir free for the Strategy Tester subprocess to grab — see [Backtest](#backtest). MT5 is single-instance per portable data dir, so a backtest cannot run against a `live` terminal. - **`terminals[].symbol_suffix`** — optional explicit symbol suffix for Strategy Tester remaps. If set, mt5-httpapi appends it when `[Tester].Symbol` does not already end with that suffix. Examples: `"p"`, `".p"`, `"-mini"`. Use `""` for no suffix. +- **`terminals[].vm`** — optional VM name that this terminal runs on. Maps to a VM defined in `vms.yaml`. Absent → `default` (routes to the `mt5` container). See [Multi-VM Setup](#multi-vm-setup) below. Each terminal installs to `/base/` and gets copied to `//` at startup so multiple accounts of the same broker don't step on each other. +### `vms.yaml` (optional — multi-VM deployments) + +When you need more than one Windows VM (e.g. spreading terminals across NUMA nodes, or isolating a hot-SSD tier from bulk-HDD terminals), define the VM topology in `vms.yaml`. The file is **optional** — absent = single-VM mode (backward compatible, everything routes to the `mt5` container). + +```yaml +vms: + - name: fast + service: mt5 + container_name: mt5 + cpuset: "0-19,40-59" + ram: "112G" + cpu_cores: 40 + disk_size: "300G" + storage: /data/mt5-vm-a/storage + novnc_port: 8006 + wickworks_service: wickworks + extra_binds: + - /mnt/ssd/terminals/darwinex/live/a:/shared/terminals/darwinex/live/a + + - name: bulk + service: mt5-b + container_name: mt5-b + cpuset: "20-39,60-79" + ram: "112G" + cpu_cores: 40 + disk_size: "150G" + storage: /data/mt5-vm-b/storage + novnc_port: 8007 + wickworks_service: wickworks-b + extra_binds: + - /mnt/hdd/terminals/blackbull/live-prime:/shared/terminals/blackbull/live-prime +``` + +Each terminal in `config.yaml` references its VM via `vm: `. Terminals route through nginx to the correct container: + +| Terminal | Routes to | Container | +|---|---|---| +| `vm: fast` | `proxy_pass http://mt5:` | mt5 | +| `vm: bulk` | `proxy_pass http://mt5-b:` | mt5-b | +| no `vm` | `proxy_pass http://mt5:` | mt5 (default) | + +See [`docs/multi-vm-setup.md`](docs/multi-vm-setup.md) for the full walkthrough — NUMA pinning, hot-tier bind mounts, per-VM concurrency caps, and the Jinja2 compose template. + ### `config/setup.bat` Custom commands that run on every VM boot before MT5 starts. Shove whatever Windows setup shit you need in here. @@ -1679,6 +1723,9 @@ config/ Your config shit setup.bat Custom boot commands (optional) hosts Extra entries for the VM's hosts file (optional) +vms.yaml VM topology definition (optional — absent = single VM) +docker-compose.yml.j2 Jinja2 template for N-VM compose generation + scripts/ Scripts that run inside the Windows VM oem-install.bat First-boot OEM script (creates startup entry) install.bat Setup (Python, MT5, firewall) — runs every boot diff --git a/config/config.yaml.example b/config/config.yaml.example index c1d643e..79bddb6 100644 --- a/config/config.yaml.example +++ b/config/config.yaml.example @@ -61,6 +61,9 @@ accounts: # freeing the data dir so the tester can spawn it via /portable /config:. # (MT5 is single-instance per portable data dir — a live terminal locks # the dir and any second terminal64.exe exits silently with code 0.) +# vm: optional VM name from vms.yaml. Routes this terminal's API to the +# specified VM container. Absent = default (routes to "mt5"). +# See docs/multi-vm-setup.md for multi-VM setup. # symbol_suffix: optional broker-specific suffix appended to [Tester].Symbol # when missing. Examples: "p", ".p", "-mini". Use "" for no suffix. terminals: @@ -70,6 +73,7 @@ terminals: utc_offset: "0" mode: live symbol_suffix: "" + # vm: my-vm-name # uncomment to pin this terminal to a specific VM # Same login cloned multiple times: every clone gets its own instance name # and its own port. diff --git a/docker-compose.yml.j2 b/docker-compose.yml.j2 new file mode 100644 index 0000000..64b31d2 --- /dev/null +++ b/docker-compose.yml.j2 @@ -0,0 +1,205 @@ +services: +{% for vm in vms %} + # ── VM "{{ vm.name }}" — {{ vm.cpuset }} ── + {{ vm.service }}: + image: dockurr/windows:5.14 + environment: + RAM_SIZE: "{{ vm.ram }}" + RAM_CHECK: "N" + CPU_CORES: "{{ vm.cpu_cores }}" + DISK_SIZE: "{{ vm.disk_size }}" + devices: + - /dev/kvm + cap_add: + - NET_ADMIN +{% if vm.cpuset %} + cpuset: "{{ vm.cpuset }}" +{% endif %} + ports: +{% if vm.novnc_port %} + - "{{ vm.novnc_port }}:8006" +{% else %} + - "${NOVNC_PORT:-8006}:8006" +{% endif %} + volumes: +{% if vm.storage %} + - {{ vm.storage }}:/storage +{% endif %} + - ./data/oem:/oem +{% if vm.log_dir %} + - {{ vm.log_dir }}:/shared/logs +{% endif %} + - /data/mt5-shared:/shared + - ./data/win.iso:/boot.iso + - ./assets:/shared/assets:ro +{% if vm.extra_binds %} +{% for bind in vm.extra_binds %} + - {{ bind }} +{% endfor %} +{% endif %} + - ./data/vm-group-{{ vm.name }}.txt:/shared/config/vm-group.txt:ro +{% if vm.mem_limit %} + deploy: + resources: + limits: + memory: {{ vm.mem_limit }} +{% if vm.memswap_limit %} + memswap_limit: {{ vm.memswap_limit }} +{% endif %} +{% else %} + deploy: + resources: + limits: + memory: 116G + memswap_limit: 120G +{% endif %} + healthcheck: + test: ["CMD", "sh", "/shared/scripts/healthcheck.sh"] + interval: 30s + timeout: 30s + retries: 10 + start_period: 120s + restart: unless-stopped + stop_grace_period: 2m + +{% if vm.wickworks_service %} + # Wickworks sidecar for VM "{{ vm.name }}" + {{ vm.wickworks_service }}: + image: psyb0t/wickworks:v0.3.1 + restart: unless-stopped + network_mode: "service:{{ vm.service }}" + environment: + LOG_LEVEL: INFO + MAX_BARS: "5000" + MIN_BARS: "50" + depends_on: + - {{ vm.service }} + +{% endif %} +{% endfor %} + # Daily log rotator. Rotates data/shared/logs/*.log to *.log.YYYYMMDD + # at the day boundary and prunes archives older than RETAIN_DAYS. + # Truncate-in-place so the Python API's open log handles keep working + # without reopening. Hourly check, idempotent (keyed on yesterday's + # archive existing). + log-rotator: + image: alpine:3.20 + restart: unless-stopped + environment: + LOG_DIR: /logs + RETAIN_DAYS: "7" + INTERVAL: "3600" + volumes: + - /data/mt5-shared/logs:/logs + - ./scripts/rotate-logs.sh:/rotate.sh:ro + command: ["sh", "/rotate.sh"] + +{% if enable_mcpunifier|default(true) %} + # Unified MCP endpoint. One MCP session that reaches every terminal, with + # broker/account as tool parameters, instead of one endpoint per terminal. + # The per-terminal ///mcp endpoints keep working unchanged; + # nginx routes /mcp/ here. + mcpunifier: + build: + context: . + dockerfile: Dockerfile.mcpunifier + restart: unless-stopped + environment: + MT5_HOST: {{ vms[0].service if vms else 'mt5' }} + LOG_LEVEL: ${MCP_LOG_LEVEL:-info} + volumes: + - ./config/config.yaml:/app/config/config.yaml:ro + security_opt: + - "no-new-privileges:true" + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + - /var/log/mcpunifier:rw,noexec,nosuid,size=64m + init: true + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + pids: 128 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:6600/health', timeout=3).status == 200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + depends_on: +{%- for vm in vms %} + - {{ vm.service }} +{%- endfor %} +{%- endif %} + # nginx is the single entry point for all terminal APIs. Routes + # ///... to : (per-terminal Python + # API process inside each Windows VM, reachable via the VM container's + # iptables DNAT). Auto-generated from config/config.yaml by run.sh. + # Bound to 127.0.0.1:8888 so it's loopback-only by default — LAN + # exposure is opt-in (change the host bind), tailnet exposure is via + # the optional tailscale sidecar below. + nginx: + image: nginx:1.30.0-alpine3.23 + restart: unless-stopped + ports: + - "127.0.0.1:${API_HOST_PORT:-8888}:80" + volumes: + - ./.data/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: +{%- for vm in vms %} + - {{ vm.service }} +{%- endfor %} +{%- if enable_mcpunifier|default(true) %} + - mcpunifier +{%- endif %} + + # ── Cloudflare Tunnel (uncomment to expose API publicly) ────────── + # Point your cloudflared ingress to http://nginx:80 (single backend, + # nginx routes per-terminal paths). Drop creds + config in + # ./.data/cloudflared/. + # cloudflared: + # image: cloudflare/cloudflared:2026.3.0 + # restart: unless-stopped + # command: tunnel --config /etc/cloudflared/config.yml run + # volumes: + # - ./.data/cloudflared/config.yml:/etc/cloudflared/config.yml:ro + # - ./.data/cloudflared/creds.json:/etc/cloudflared/creds.json:ro + # depends_on: + # - nginx + + # ── Tailscale (uncomment to expose API over tailnet HTTP) ───────── + # Set tailscale.auth_key in config/config.yaml; for Headscale, also set + # tailscale.login_server. run.sh reads both and writes .env + wires + # tailscale serve via the CLI inside the sidecar. URL scheme: + # http://mt5-httpapi///... + # Plain HTTP — bare MagicDNS hostnames don't have matching certs, and + # the wireguard layer already encrypts everything inside the tailnet. + # The sidecar runs in its OWN netns (bridge mode, not host) so it gets + # its own tailnet identity — host's tailscale (if any) stays clean and + # ACLs scope to the container's node only. + # tailscale: + # image: tailscale/tailscale:v1.96.5 + # restart: unless-stopped + # environment: + # - TS_AUTHKEY=${TS_AUTHKEY:-} + # - TS_HOSTNAME=${TS_HOSTNAME:-mt5-httpapi} + # - TS_STATE_DIR=/var/lib/tailscale + # - TS_USERSPACE=false + # - TS_EXTRA_ARGS=${TS_EXTRA_ARGS:---accept-dns=false} + # volumes: + # - ./.data/tailscale/state:/var/lib/tailscale + # - /dev/net/tun:/dev/net/tun + # cap_add: + # - NET_ADMIN + # - NET_RAW + # depends_on: + # - nginx diff --git a/docs/multi-vm-setup.md b/docs/multi-vm-setup.md new file mode 100644 index 0000000..39eaf5a --- /dev/null +++ b/docs/multi-vm-setup.md @@ -0,0 +1,146 @@ +# Multi-VM Setup + +This guide covers deploying **mt5-httpapi** with more than one Windows VM — spreading terminals across NUMA nodes, isolating hot-SSD tiers from bulk-HDD storage, or scaling past what a single VM can hold. + +## How it works + +- **`vms.yaml`** declares each VM's resources (cpuset, RAM, CPU cores, disk size, storage path, noVNC port, wickworks sidecar name). +- **`config/config.yaml`** gains a `vm` field on each terminal entry, binding that terminal to a specific VM. +- **`scripts/config_helper.py`** reads both files to generate nginx routes targeting the correct container (`proxy_pass http://mt5:` vs `http://mt5-b:`). +- **`run.sh`** loops over all VMs for DNAT/iptables setup and auto-generates per-VM group files. +- **`docker-compose.yml.j2`** renders the compose file from `vms.yaml` via `config_helper.py generate_compose`. + +## Backward compatibility + +- **No `vms.yaml`** → single-VM mode. All terminals route to the default `mt5` container. Everything works exactly as before. +- **No `vm` field on a terminal** → defaults to `default`, routes to `mt5`. + +## Prerequisites + +- mt5-httpapi v4.10.0+ +- Linux host with KVM (`/dev/kvm`), enough RAM for N VMs, and CPU cores you can pin + +## Step-by-step + +### 1. Create `vms.yaml` + +Define one entry per VM. The `name` is how terminals reference it in `config.yaml`. + +```yaml +vms: + - name: fast + service: mt5 + container_name: mt5 + cpuset: "0-19,40-59" + ram: "112G" + cpu_cores: 40 + disk_size: "300G" + storage: /data/mt5-vm-a/storage + hot_tier: /mnt/mt5-hot + log_dir: /data/mt5-shared/logs + novnc_port: 8006 + wickworks_service: wickworks + mem_limit: 116G + memswap_limit: 120G + extra_binds: + - /mnt/ssd/terminals/darwinex/live/a:/shared/terminals/darwinex/live/a + + - name: bulk + service: mt5-b + container_name: mt5-b + cpuset: "20-39,60-79" + ram: "112G" + cpu_cores: 40 + disk_size: "150G" + storage: /data/mt5-vm-b/storage + hot_tier: /mnt/mt5-hot-b + log_dir: /data/mt5-vm-b/logs + novnc_port: 8007 + wickworks_service: wickworks-b + mem_limit: 116G + memswap_limit: 120G + extra_binds: + - /mnt/hdd/terminals/blackbull/live-prime:/shared/terminals/blackbull/live-prime +``` + +Fields: + +| Field | Required | Description | +|---|---|---| +| `name` | yes | VM identifier, referenced by `terminals[].vm` in config.yaml | +| `service` | yes | Docker compose service name (e.g. `mt5`, `mt5-b`) | +| `container_name` | yes | Docker container hostname for nginx routing | +| `cpuset` | no | CPU pinning (docker compose `cpuset`) | +| `ram` | yes | RAM for the Windows VM (e.g. `"112G"`, `"4G"`) | +| `cpu_cores` | yes | vCPU count | +| `disk_size` | yes | VM disk size (e.g. `"300G"`) | +| `storage` | yes | Host path for VM system disk | +| `novnc_port` | no | Host port for noVNC (e.g. `8006`, `8007`) | +| `wickworks_service` | no | Name of the wickworks sidecar service | +| `mem_limit` | no | Docker memory limit (default `116G`) | +| `memswap_limit` | no | Docker mem+swap limit (default `120G`) | +| `extra_binds` | no | Additional host→container bind mounts (hot-tier terminal dirs) | + +### 2. Assign terminals to VMs + +Add `vm: ` to each terminal in `config/config.yaml`: + +```yaml +terminals: + - broker: darwinex + account: live + port: 6551 + vm: fast + mode: live + + - broker: blackbull + account: live-prime + port: 6546 + vm: bulk + mode: backtest +``` + +### 3. Generate docker-compose.yml + +On first run, `run.sh` detects `vms.yaml` + `docker-compose.yml.j2` and generates the compose file automatically. To regenerate: + +```bash +python3 scripts/config_helper.py generate_compose +``` + +This renders one service block per VM (with its wickworks sidecar), plus the shared services (log-rotator, mcpunifier, nginx). + +### 4. Set per-VM concurrency caps (optional) + +Each VM has its own RAM bank — set independent in-flight ceilings via environment variables: + +```bash +export MT5_HTTPAPI_MAX_IN_FLIGHT_FAST=6 +export MT5_HTTPAPI_MAX_IN_FLIGHT_BULK=10 +``` + +The global cap `MT5_HTTPAPI_MAX_IN_FLIGHT` still applies on top. Per-VM caps are additive — both must be satisfied. + +## Generated files + +`run.sh` auto-generates these from `vms.yaml` + `config.yaml`: + +| File | Contents | +|---|---| +| `data/vm-group-.txt` | Terminal filter for each VM (broker + account + instance lines) | +| `.data/nginx/nginx.conf` | nginx routes with per-terminal `proxy_pass` to the owning VM's container | + +## Adding a third VM + +1. Add a new entry to `vms.yaml` with a unique `name`, `service`, and `container_name`. +2. Assign some terminals to it via `vm: ` in `config.yaml`. +3. Regenerate the compose file. +4. Set `MT5_HTTPAPI_MAX_IN_FLIGHT_` if needed. +5. `docker compose up -d` — nginx routes terminals to the new container automatically. + +## Troubleshooting + +- **Nginx 502 for a terminal**: check the generated nginx.conf at `.data/nginx/nginx.conf`. The `proxy_pass` should target the correct container name for that terminal's VM. +- **VM not booting**: verify `vms.yaml` has correct `cpuset` (don't overlap pins) and enough host RAM for all VMs combined. +- **Port conflicts**: each VM needs a unique `novnc_port`. Default single-VM is `8006`; add VMs on `8007`, `8008`, etc. +- **Per-VM cap not applying**: ensure the env var name matches the VM name in `vms.yaml`, uppercased: `MT5_HTTPAPI_MAX_IN_FLIGHT_`. diff --git a/run.sh b/run.sh index cb2b8b0..9144a5f 100755 --- a/run.sh +++ b/run.sh @@ -14,13 +14,17 @@ exec > >(tee "${LOG_FILE}") 2>&1 mkdir -p "${DIR}/data/storage" "${DIR}/data/shared/scripts" "${DIR}/data/shared/config" "${DIR}/data/shared/terminals" "${DIR}/data/oem" "${DIR}/assets/experts" "${DIR}/assets/sets" -# Bootstrap docker-compose.yml from example on first run; user owns the real file. +# Bootstrap docker-compose.yml from Jinja2 template (if vms.yaml + template exist) +# or from the example file. User owns the real docker-compose.yml after that. if [ ! -f "${DIR}/docker-compose.yml" ]; then - if [ -f "${DIR}/docker-compose.yml.example" ]; then + if [ -f "${DIR}/vms.yaml" ] && [ -f "${DIR}/docker-compose.yml.j2" ]; then + echo "docker-compose.yml not found — generating from Jinja2 template (${DIR}/vms.yaml)" + python3 "${DIR}/scripts/config_helper.py" generate_compose + elif [ -f "${DIR}/docker-compose.yml.example" ]; then echo "docker-compose.yml not found — seeding from docker-compose.yml.example" cp "${DIR}/docker-compose.yml.example" "${DIR}/docker-compose.yml" else - echo "ERROR: neither docker-compose.yml nor docker-compose.yml.example found." + echo "ERROR: neither docker-compose.yml, vms.yaml+docker-compose.yml.j2, nor docker-compose.yml.example found." exit 1 fi fi @@ -111,9 +115,19 @@ fi CFG="${DIR}/scripts/config_helper.py" python3 -c "import yaml" 2>/dev/null || pip3 install --quiet pyyaml +# Load VM names from vms.yaml (single VM "default" if file absent) +VM_NAMES=$(python3 "$CFG" vms) +echo "VM topology: ${VM_NAMES}" + API_PORTS=$(python3 "$CFG" port_list) echo "Configured terminal ports (container-internal): ${API_PORTS}" +# Generate per-VM group files from config.yaml +for VM_NAME in ${VM_NAMES}; do + python3 "$CFG" vm_group "${VM_NAME}" > "${DIR}/data/vm-group-${VM_NAME}.txt" + echo " generated vm-group-${VM_NAME}.txt ($(wc -l < "${DIR}/data/vm-group-${VM_NAME}.txt") terminals)" +done + # Generate fresh .env each run. : >"${DIR}/.env" @@ -187,37 +201,45 @@ fi echo "" echo "Logs: docker compose -f ${DIR}/docker-compose.yml logs -f" -# Set up DNAT inside the mt5 container so traffic arriving on per-terminal +# Set up DNAT inside each VM's container so traffic arriving on per-terminal # ports gets forwarded to the VM. Source is now nginx (or any container on -# the default network) hitting mt5: — PREROUTING fires regardless of -# source since it hooks per-packet on mt5's eth0. +# the default network) hitting : — PREROUTING fires regardless +# of source since it hooks per-packet on the container's eth0. echo "" -echo "Waiting for VM to get an IP (for API port forwarding)..." -for _ in $(seq 1 60); do - VM_IP=$(docker compose -f "${DIR}/docker-compose.yml" exec -T mt5 bash -c 'cat /var/lib/misc/dnsmasq.leases 2>/dev/null | awk "{print \$3}"' 2>/dev/null || true) - if [ -n "${VM_IP}" ]; then - echo "VM IP: ${VM_IP}" - for PORT in ${API_PORTS}; do - docker compose -f "${DIR}/docker-compose.yml" exec -T mt5 bash -c " - iptables -t nat -C PREROUTING -p tcp --dport ${PORT} -j DNAT --to-destination ${VM_IP}:${PORT} 2>/dev/null || \ - iptables -t nat -A PREROUTING -p tcp --dport ${PORT} -j DNAT --to-destination ${VM_IP}:${PORT} - iptables -t nat -C POSTROUTING -p tcp -d ${VM_IP} --dport ${PORT} -j MASQUERADE 2>/dev/null || \ - iptables -t nat -A POSTROUTING -p tcp -d ${VM_IP} --dport ${PORT} -j MASQUERADE - iptables -C FORWARD -p tcp -d ${VM_IP} --dport ${PORT} -j ACCEPT 2>/dev/null || \ - iptables -A FORWARD -p tcp -d ${VM_IP} --dport ${PORT} -j ACCEPT - " - echo "Port forwarding: nginx -> mt5:${PORT} -> VM:${PORT}" - done - break +for VM_NAME in ${VM_NAMES}; do + VM_SERVICE=$(python3 "$CFG" vm_info "${VM_NAME}" service) + VM_PORTS=$(python3 "$CFG" port_list --vm "${VM_NAME}") + if [ -z "${VM_PORTS}" ] || [ "${VM_PORTS}" = "6542" ]; then + echo "VM '${VM_NAME}' (${VM_SERVICE}): no terminals assigned, skipping DNAT" + continue + fi + echo "Waiting for VM '${VM_NAME}' (${VM_SERVICE}) to get an IP..." + VM_IP="" + for _ in $(seq 1 60); do + VM_IP=$(docker compose -f "${DIR}/docker-compose.yml" exec -T "${VM_SERVICE}" bash -c 'cat /var/lib/misc/dnsmasq.leases 2>/dev/null | awk "{print \$3}"' 2>/dev/null || true) + if [ -n "${VM_IP}" ]; then + break + fi + sleep 5 + done + if [ -z "${VM_IP}" ]; then + echo "WARNING: VM '${VM_NAME}' (${VM_SERVICE}) did not get an IP. Port forwarding not set up." + continue fi - sleep 5 + echo " VM IP: ${VM_IP}" + for PORT in ${VM_PORTS}; do + docker compose -f "${DIR}/docker-compose.yml" exec -T "${VM_SERVICE}" bash -c " + iptables -t nat -C PREROUTING -p tcp --dport ${PORT} -j DNAT --to-destination ${VM_IP}:${PORT} 2>/dev/null || \ + iptables -t nat -A PREROUTING -p tcp --dport ${PORT} -j DNAT --to-destination ${VM_IP}:${PORT} + iptables -t nat -C POSTROUTING -p tcp -d ${VM_IP} --dport ${PORT} -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -p tcp -d ${VM_IP} --dport ${PORT} -j MASQUERADE + iptables -C FORWARD -p tcp -d ${VM_IP} --dport ${PORT} -j ACCEPT 2>/dev/null || \ + iptables -A FORWARD -p tcp -d ${VM_IP} --dport ${PORT} -j ACCEPT + " + echo " Port forwarding: nginx -> ${VM_SERVICE}:${PORT} -> VM:${PORT}" + done done -if [ -z "${VM_IP}" ]; then - echo "WARNING: Could not detect VM IP. Port forwarding not set up." - echo "Re-run this script after the VM boots." -fi - # Wire Tailscale Serve via the CLI inside the sidecar. We do this here # (not via TS_SERVE_CONFIG) because the Web handler needs the node's # actual FQDN as a key, and the CLI is the only thing that knows it diff --git a/scripts/config_helper.py b/scripts/config_helper.py index 3b7f3d0..0825c68 100644 --- a/scripts/config_helper.py +++ b/scripts/config_helper.py @@ -18,10 +18,25 @@ ) import yaml +try: + from jinja2 import Template +except ImportError: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet", "jinja2"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + from jinja2 import Template + _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) _SHARED_DIR = os.path.dirname(_SCRIPTS_DIR) CONFIG_PATH = os.path.join(_SHARED_DIR, "config", "config.yaml") +VMS_PATH = os.path.join(_SHARED_DIR, "vms.yaml") +COMPOSE_TEMPLATE_PATH = os.path.join(_SHARED_DIR, "docker-compose.yml.j2") +COMPOSE_OUTPUT_PATH = os.path.join(_SHARED_DIR, "docker-compose.yml") DEFAULT_INSTANCE = "default" +DEFAULT_VM = "default" MCP_ROUTE_PREFIX = "/mcp/" MCP_UNIFIER_SERVICE = "mcpunifier" @@ -36,6 +51,27 @@ def _load(): return yaml.safe_load(f) or {} +def _load_vms(): + try: + with open(VMS_PATH, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + vms = data.get("vms", []) + if not vms: + return {DEFAULT_VM: {"service": "mt5", "container_name": "mt5"}} + return {vm["name"]: vm for vm in vms} + except FileNotFoundError: + return {DEFAULT_VM: {"service": "mt5", "container_name": "mt5"}} + + +def _vm_container_name(terminal): + vms = _load_vms() + vm_name = terminal.get("vm", DEFAULT_VM) + vm = vms.get(vm_name) + if vm: + return vm.get("container_name", vm.get("service", "mt5")) + return "mt5" + + def _normalize_instance(value): if value in (None, ""): return DEFAULT_INSTANCE @@ -85,7 +121,11 @@ def main(): print(f"{min(ports)}-{max(ports)}") elif cmd == "port_list": - ports = [t["port"] for t in (cfg.get("terminals") or [])] + vm_filter = None + if len(sys.argv) >= 4 and sys.argv[2] == "--vm": + vm_filter = sys.argv[3] + ports = [t["port"] for t in (cfg.get("terminals") or []) + if not vm_filter or t.get("vm", DEFAULT_VM) == vm_filter] print(" ".join(str(p) for p in ports) if ports else "6542") elif cmd == "api_token": @@ -142,11 +182,12 @@ def main(): file=sys.stderr, ) sys.exit(1) + container = _vm_container_name(t) for p in _route_prefixes(t): locs.append( f" location {p} {{\n" f" rewrite ^{p}(.*)$ /$1 break;\n" - f" proxy_pass http://mt5:{t['port']};\n" + f" proxy_pass http://{container}:{t['port']};\n" f" proxy_set_header Host $host;\n" f" proxy_set_header X-Forwarded-For $remote_addr;\n" f" }}" @@ -194,10 +235,64 @@ def main(): with open(outpath, "w", encoding="utf-8") as f: f.write(nginx_conf) + elif cmd == "vm_group": + if len(sys.argv) < 3: + print("Usage: config_helper.py vm_group ", file=sys.stderr) + sys.exit(1) + vm_name = sys.argv[2] + for t in cfg.get("terminals", []): + if t.get("vm", DEFAULT_VM) == vm_name: + broker = t["broker"] + account = t["account"] + instance = _normalize_instance(t.get("instance")) + if instance == DEFAULT_INSTANCE: + print(f"{broker} {account}") + else: + print(f"{broker} {account} {instance}") + + elif cmd == "vms": + vms = _load_vms() + for name in vms: + print(name) + + elif cmd == "vm_info": + if len(sys.argv) < 3: + print("Usage: config_helper.py vm_info [field]", file=sys.stderr) + sys.exit(1) + vm_name = sys.argv[2] + field = sys.argv[3] if len(sys.argv) >= 4 else None + vms = _load_vms() + vm = vms.get(vm_name) + if not vm: + print(f"ERROR: unknown VM '{vm_name}'", file=sys.stderr) + sys.exit(1) + if field: + print(vm.get(field, "")) + else: + print(yaml.dump(vm, default_flow_style=False).strip()) + + elif cmd == "generate_compose": + if not os.path.exists(COMPOSE_TEMPLATE_PATH): + print(f"ERROR: template not found at {COMPOSE_TEMPLATE_PATH}", file=sys.stderr) + sys.exit(1) + with open(COMPOSE_TEMPLATE_PATH, encoding="utf-8") as f: + template_source = f.read() + vms = _load_vms() + vm_list = list(vms.values()) + template = Template(template_source) + rendered = template.render(vms=vm_list, enable_mcpunifier=True) + with open(COMPOSE_OUTPUT_PATH, "w", encoding="utf-8") as f: + f.write(rendered) + print(f"Generated {COMPOSE_OUTPUT_PATH} from template ({len(vm_list)} VM(s))") + elif cmd == "show_terminals": + vms = _load_vms() for t in cfg.get("terminals", []): instance = _normalize_instance(t.get("instance")) - print(f" - /{t['broker']}/{t['account']}/{instance}/") + vm_name = t.get("vm", DEFAULT_VM) + vm = vms.get(vm_name, {}) + container = vm.get("container_name", vm.get("service", "mt5")) + print(f" - /{t['broker']}/{t['account']}/{instance}/ -> {container}") else: print(f"Unknown command: {cmd}", file=sys.stderr) diff --git a/vms.yaml b/vms.yaml new file mode 100644 index 0000000..7db01d8 --- /dev/null +++ b/vms.yaml @@ -0,0 +1,63 @@ +vms: + - name: fast + service: mt5 + container_name: mt5 + cpuset: "0-19,40-59" + ram: "112G" + cpu_cores: 40 + disk_size: "300G" + storage: /data/mt5-vm-a/storage + hot_tier: /mnt/mt5-hot + log_dir: /data/mt5-shared/logs + novnc_port: 8006 + wickworks_service: wickworks + mem_limit: 116G + memswap_limit: 120G + extra_binds: + - /mnt/mt5-hot/terminals/darwinex/live/a:/shared/terminals/darwinex/live/a + - /mnt/mt5-hot/terminals/darwinex/live/b:/shared/terminals/darwinex/live/b + - /mnt/mt5-hot/terminals/darwinex/live/c:/shared/terminals/darwinex/live/c + - /mnt/mt5-hot/terminals/darwinex/live/d:/shared/terminals/darwinex/live/d + - /mnt/mt5-hot/terminals/darwinex/live/e:/shared/terminals/darwinex/live/e + - /mnt/mt5-hot/terminals/darwinex/live/f:/shared/terminals/darwinex/live/f + - /mnt/mt5-hot/terminals/darwinex/live/g:/shared/terminals/darwinex/live/g + - /mnt/mt5-hot/terminals/darwinex/live/h:/shared/terminals/darwinex/live/h + - /mnt/mt5-hot/terminals/darwinex/live/i:/shared/terminals/darwinex/live/i + - /mnt/mt5-hot/terminals/darwinex/live/j:/shared/terminals/darwinex/live/j + - /mnt/mt5-hot/terminals/darwinex/live/k:/shared/terminals/darwinex/live/k + + - name: bulk + service: mt5-b + container_name: mt5-b + cpuset: "20-39,60-79" + ram: "112G" + cpu_cores: 40 + disk_size: "150G" + storage: /data/mt5-vm-b/storage + hot_tier: /mnt/mt5-hot-b + log_dir: /data/mt5-vm-b/logs + novnc_port: 8007 + wickworks_service: wickworks-b + mem_limit: 116G + memswap_limit: 120G + extra_binds: + - /mnt/mt5-hot-b/terminals/darwinex/live/l:/shared/terminals/darwinex/live/l + - /mnt/mt5-hot-b/terminals/darwinex/live/m:/shared/terminals/darwinex/live/m + - /mnt/mt5-hot-b/terminals/darwinex/live/n:/shared/terminals/darwinex/live/n + - /mnt/mt5-hot-b/terminals/darwinex/live/o:/shared/terminals/darwinex/live/o + - /mnt/mt5-hot-b/terminals/darwinex/live/p:/shared/terminals/darwinex/live/p + - /mnt/mt5-hot-b/terminals/darwinex/live/q:/shared/terminals/darwinex/live/q + - /mnt/mt5-hot-b/terminals/darwinex/live/r:/shared/terminals/darwinex/live/r + - /mnt/mt5-hot-b/terminals/darwinex/live/s:/shared/terminals/darwinex/live/s + - /mnt/mt5-hot-b/terminals/darwinex/live/t:/shared/terminals/darwinex/live/t + - /mnt/mt5-hot-b/terminals/darwinex/live/u:/shared/terminals/darwinex/live/u + - /mnt/mt5-hot-b/terminals/darwinex/live/v:/shared/terminals/darwinex/live/v + - /mnt/mt5-hot-b/terminals/blackbull/live-prime:/shared/terminals/blackbull/live-prime + - /mnt/mt5-hot-b/terminals/blackbull/live-standard:/shared/terminals/blackbull/live-standard + - /mnt/mt5-hot-b/terminals/ictrading/live-raw:/shared/terminals/ictrading/live-raw + - /mnt/mt5-hot-b/terminals/myfxmarkets/live:/shared/terminals/myfxmarkets/live + - /mnt/mt5-hot-b/terminals/metaquotes/demo:/shared/terminals/metaquotes/demo + - /mnt/mt5-hot-b/terminals/fptrading/live-standard:/shared/terminals/fptrading/live-standard + - /mnt/mt5-hot-b/terminals/ifunds/live:/shared/terminals/ifunds/live + - /mnt/mt5-hot-b/terminals/eightcapglobal/live:/shared/terminals/eightcapglobal/live + - /mnt/mt5-hot-b/terminals/eightcapglobal/global-live:/shared/terminals/eightcapglobal/global-live From bd2cc9bcb1467d82a0563b5532ecf8aac3d6d5d1 Mon Sep 17 00:00:00 2001 From: Marinski Date: Thu, 30 Jul 2026 09:22:26 +0300 Subject: [PATCH 2/3] fix(multi-vm): nginx resolver + vms.yaml.example from PR #9 review - nginx proxy_pass uses resolver + variable to defer DNS lookup to request time, so one down VM doesn't prevent nginx from starting (same pattern as the v4.9.1 mcpunifier fix). - vms.yaml renamed to vms.yaml.example with sanitized sample values; .gitignore added for vms.yaml. - docs/multi-vm-setup.md and run.sh updated for the rename. - config_helper.py falls back to vms.yaml.example when the real vms.yaml is absent. --- .gitignore | 5 ++++ docs/multi-vm-setup.md | 52 ++++++++++++++++----------------- run.sh | 11 +++++-- scripts/config_helper.py | 8 +++-- vms.yaml | 63 ---------------------------------------- vms.yaml.example | 28 ++++++++++++++++++ 6 files changed, 73 insertions(+), 94 deletions(-) delete mode 100644 vms.yaml create mode 100644 vms.yaml.example diff --git a/.gitignore b/.gitignore index 2b5343d..98dd851 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ CODEX.local.md .research_files data/* !data/.gitkeep +# Terminal data tree scaffolded at repo root when mt5api runs outside the VM +# (config.py resolves BROKERS_DIR relative to the repo). Never commit it — +# a real terminal dir contains account artifacts. +/terminals/ mt5installers/* !mt5installers/.gitkeep # Host-managed backtest assets — referenced by /backtest via expert_name/set_name. @@ -24,6 +28,7 @@ config/reboot_interval.txt config/requirements.txt .env docker-compose.yml +vms.yaml run.log logs/ __pycache__ diff --git a/docs/multi-vm-setup.md b/docs/multi-vm-setup.md index 39eaf5a..2bd16b3 100644 --- a/docs/multi-vm-setup.md +++ b/docs/multi-vm-setup.md @@ -4,7 +4,7 @@ This guide covers deploying **mt5-httpapi** with more than one Windows VM — sp ## How it works -- **`vms.yaml`** declares each VM's resources (cpuset, RAM, CPU cores, disk size, storage path, noVNC port, wickworks sidecar name). +- **`vms.yaml`** (copy `vms.yaml.example`) declares each VM's resources (cpuset, RAM, CPU cores, disk size, storage path, noVNC port, wickworks sidecar name). - **`config/config.yaml`** gains a `vm` field on each terminal entry, binding that terminal to a specific VM. - **`scripts/config_helper.py`** reads both files to generate nginx routes targeting the correct container (`proxy_pass http://mt5:` vs `http://mt5-b:`). - **`run.sh`** loops over all VMs for DNAT/iptables setup and auto-generates per-VM group files. @@ -12,7 +12,7 @@ This guide covers deploying **mt5-httpapi** with more than one Windows VM — sp ## Backward compatibility -- **No `vms.yaml`** → single-VM mode. All terminals route to the default `mt5` container. Everything works exactly as before. +- **No `vms.yaml` / `vms.yaml.example`** → single-VM mode. All terminals route to the default `mt5` container. Everything works exactly as before. - **No `vm` field on a terminal** → defaults to `default`, routes to `mt5`. ## Prerequisites @@ -22,7 +22,13 @@ This guide covers deploying **mt5-httpapi** with more than one Windows VM — sp ## Step-by-step -### 1. Create `vms.yaml` +### 1. Copy and edit `vms.yaml.example` + +Start from the example and customize it: + +```bash +cp vms.yaml.example vms.yaml +``` Define one entry per VM. The `name` is how terminals reference it in `config.yaml`. @@ -31,36 +37,30 @@ vms: - name: fast service: mt5 container_name: mt5 - cpuset: "0-19,40-59" - ram: "112G" - cpu_cores: 40 - disk_size: "300G" + cpuset: "0-3" + ram: "8G" + cpu_cores: 4 + disk_size: "64G" storage: /data/mt5-vm-a/storage - hot_tier: /mnt/mt5-hot log_dir: /data/mt5-shared/logs novnc_port: 8006 wickworks_service: wickworks - mem_limit: 116G - memswap_limit: 120G - extra_binds: - - /mnt/ssd/terminals/darwinex/live/a:/shared/terminals/darwinex/live/a + mem_limit: 10G + memswap_limit: 12G - name: bulk service: mt5-b container_name: mt5-b - cpuset: "20-39,60-79" - ram: "112G" - cpu_cores: 40 - disk_size: "150G" + cpuset: "4-7" + ram: "8G" + cpu_cores: 4 + disk_size: "64G" storage: /data/mt5-vm-b/storage - hot_tier: /mnt/mt5-hot-b log_dir: /data/mt5-vm-b/logs novnc_port: 8007 wickworks_service: wickworks-b - mem_limit: 116G - memswap_limit: 120G - extra_binds: - - /mnt/hdd/terminals/blackbull/live-prime:/shared/terminals/blackbull/live-prime + mem_limit: 10G + memswap_limit: 12G ``` Fields: @@ -102,7 +102,7 @@ terminals: ### 3. Generate docker-compose.yml -On first run, `run.sh` detects `vms.yaml` + `docker-compose.yml.j2` and generates the compose file automatically. To regenerate: +On first run, `run.sh` detects `vms.yaml` (or `vms.yaml.example`) + `docker-compose.yml.j2` and generates the compose file automatically. To regenerate: ```bash python3 scripts/config_helper.py generate_compose @@ -123,7 +123,7 @@ The global cap `MT5_HTTPAPI_MAX_IN_FLIGHT` still applies on top. Per-VM caps are ## Generated files -`run.sh` auto-generates these from `vms.yaml` + `config.yaml`: +`run.sh` auto-generates these from `vms.yaml` (or `vms.yaml.example`) + `config.yaml`: | File | Contents | |---|---| @@ -132,7 +132,7 @@ The global cap `MT5_HTTPAPI_MAX_IN_FLIGHT` still applies on top. Per-VM caps are ## Adding a third VM -1. Add a new entry to `vms.yaml` with a unique `name`, `service`, and `container_name`. +1. Add a new entry to `vms.yaml` (copy from `vms.yaml.example`) with a unique `name`, `service`, and `container_name`. 2. Assign some terminals to it via `vm: ` in `config.yaml`. 3. Regenerate the compose file. 4. Set `MT5_HTTPAPI_MAX_IN_FLIGHT_` if needed. @@ -141,6 +141,6 @@ The global cap `MT5_HTTPAPI_MAX_IN_FLIGHT` still applies on top. Per-VM caps are ## Troubleshooting - **Nginx 502 for a terminal**: check the generated nginx.conf at `.data/nginx/nginx.conf`. The `proxy_pass` should target the correct container name for that terminal's VM. -- **VM not booting**: verify `vms.yaml` has correct `cpuset` (don't overlap pins) and enough host RAM for all VMs combined. +- **VM not booting**: verify `vms.yaml` has correct `cpuset` (don't overlap pins) and enough host RAM for all VMs combined. Start from `vms.yaml.example`. - **Port conflicts**: each VM needs a unique `novnc_port`. Default single-VM is `8006`; add VMs on `8007`, `8008`, etc. -- **Per-VM cap not applying**: ensure the env var name matches the VM name in `vms.yaml`, uppercased: `MT5_HTTPAPI_MAX_IN_FLIGHT_`. +- **Per-VM cap not applying**: ensure the env var name matches the VM name in `vms.yaml`, uppercased: `MT5_HTTPAPI_MAX_IN_FLIGHT_`. See `vms.yaml.example` for the naming convention. diff --git a/run.sh b/run.sh index 9144a5f..a9f4190 100755 --- a/run.sh +++ b/run.sh @@ -17,14 +17,17 @@ mkdir -p "${DIR}/data/storage" "${DIR}/data/shared/scripts" "${DIR}/data/shared/ # Bootstrap docker-compose.yml from Jinja2 template (if vms.yaml + template exist) # or from the example file. User owns the real docker-compose.yml after that. if [ ! -f "${DIR}/docker-compose.yml" ]; then - if [ -f "${DIR}/vms.yaml" ] && [ -f "${DIR}/docker-compose.yml.j2" ]; then - echo "docker-compose.yml not found — generating from Jinja2 template (${DIR}/vms.yaml)" + if [ -f "${DIR}/vms.yaml.example" ] && [ -f "${DIR}/docker-compose.yml.j2" ]; then + echo "docker-compose.yml not found — generating from Jinja2 template (vms.yaml.example)" + python3 "${DIR}/scripts/config_helper.py" generate_compose + elif [ -f "${DIR}/vms.yaml" ] && [ -f "${DIR}/docker-compose.yml.j2" ]; then + echo "docker-compose.yml not found — generating from Jinja2 template (vms.yaml)" python3 "${DIR}/scripts/config_helper.py" generate_compose elif [ -f "${DIR}/docker-compose.yml.example" ]; then echo "docker-compose.yml not found — seeding from docker-compose.yml.example" cp "${DIR}/docker-compose.yml.example" "${DIR}/docker-compose.yml" else - echo "ERROR: neither docker-compose.yml, vms.yaml+docker-compose.yml.j2, nor docker-compose.yml.example found." + echo "ERROR: neither vms.yaml nor vms.yaml.example + docker-compose.yml.j2, nor docker-compose.yml.example found." exit 1 fi fi @@ -59,8 +62,10 @@ cp "${DIR}/scripts/reboot.bat" "${DIR}/data/shared/scripts/reboot.bat" cp "${DIR}/scripts/acquire_lock.ps1" "${DIR}/data/shared/scripts/acquire_lock.ps1" cp "${DIR}/scripts/api_runner.bat" "${DIR}/data/shared/scripts/api_runner.bat" cp "${DIR}/scripts/compile-warmup-ea.bat" "${DIR}/data/shared/scripts/compile-warmup-ea.bat" + cp "${DIR}/scripts/check_health.py" "${DIR}/data/shared/scripts/check_health.py" cp "${DIR}/scripts/config_helper.py" "${DIR}/data/shared/scripts/config_helper.py" + cp "${DIR}/scripts/event-log-tailer.ps1" "${DIR}/data/shared/scripts/event-log-tailer.ps1" cp "${DIR}/scripts/healthcheck.sh" "${DIR}/data/shared/scripts/healthcheck.sh" chmod +x "${DIR}/data/shared/scripts/healthcheck.sh" diff --git a/scripts/config_helper.py b/scripts/config_helper.py index 0825c68..087490a 100644 --- a/scripts/config_helper.py +++ b/scripts/config_helper.py @@ -33,6 +33,7 @@ _SHARED_DIR = os.path.dirname(_SCRIPTS_DIR) CONFIG_PATH = os.path.join(_SHARED_DIR, "config", "config.yaml") VMS_PATH = os.path.join(_SHARED_DIR, "vms.yaml") +_VMS_EXAMPLE_PATH = os.path.join(_SHARED_DIR, "vms.yaml.example") COMPOSE_TEMPLATE_PATH = os.path.join(_SHARED_DIR, "docker-compose.yml.j2") COMPOSE_OUTPUT_PATH = os.path.join(_SHARED_DIR, "docker-compose.yml") DEFAULT_INSTANCE = "default" @@ -52,8 +53,9 @@ def _load(): def _load_vms(): + path = VMS_PATH if os.path.exists(VMS_PATH) else _VMS_EXAMPLE_PATH try: - with open(VMS_PATH, encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} vms = data.get("vms", []) if not vms: @@ -186,8 +188,10 @@ def main(): for p in _route_prefixes(t): locs.append( f" location {p} {{\n" + f" resolver {DOCKER_EMBEDDED_DNS} valid=10s ipv6=off;\n" + f" set $vm_upstream http://{container}:{t['port']};\n" f" rewrite ^{p}(.*)$ /$1 break;\n" - f" proxy_pass http://{container}:{t['port']};\n" + f" proxy_pass $vm_upstream;\n" f" proxy_set_header Host $host;\n" f" proxy_set_header X-Forwarded-For $remote_addr;\n" f" }}" diff --git a/vms.yaml b/vms.yaml deleted file mode 100644 index 7db01d8..0000000 --- a/vms.yaml +++ /dev/null @@ -1,63 +0,0 @@ -vms: - - name: fast - service: mt5 - container_name: mt5 - cpuset: "0-19,40-59" - ram: "112G" - cpu_cores: 40 - disk_size: "300G" - storage: /data/mt5-vm-a/storage - hot_tier: /mnt/mt5-hot - log_dir: /data/mt5-shared/logs - novnc_port: 8006 - wickworks_service: wickworks - mem_limit: 116G - memswap_limit: 120G - extra_binds: - - /mnt/mt5-hot/terminals/darwinex/live/a:/shared/terminals/darwinex/live/a - - /mnt/mt5-hot/terminals/darwinex/live/b:/shared/terminals/darwinex/live/b - - /mnt/mt5-hot/terminals/darwinex/live/c:/shared/terminals/darwinex/live/c - - /mnt/mt5-hot/terminals/darwinex/live/d:/shared/terminals/darwinex/live/d - - /mnt/mt5-hot/terminals/darwinex/live/e:/shared/terminals/darwinex/live/e - - /mnt/mt5-hot/terminals/darwinex/live/f:/shared/terminals/darwinex/live/f - - /mnt/mt5-hot/terminals/darwinex/live/g:/shared/terminals/darwinex/live/g - - /mnt/mt5-hot/terminals/darwinex/live/h:/shared/terminals/darwinex/live/h - - /mnt/mt5-hot/terminals/darwinex/live/i:/shared/terminals/darwinex/live/i - - /mnt/mt5-hot/terminals/darwinex/live/j:/shared/terminals/darwinex/live/j - - /mnt/mt5-hot/terminals/darwinex/live/k:/shared/terminals/darwinex/live/k - - - name: bulk - service: mt5-b - container_name: mt5-b - cpuset: "20-39,60-79" - ram: "112G" - cpu_cores: 40 - disk_size: "150G" - storage: /data/mt5-vm-b/storage - hot_tier: /mnt/mt5-hot-b - log_dir: /data/mt5-vm-b/logs - novnc_port: 8007 - wickworks_service: wickworks-b - mem_limit: 116G - memswap_limit: 120G - extra_binds: - - /mnt/mt5-hot-b/terminals/darwinex/live/l:/shared/terminals/darwinex/live/l - - /mnt/mt5-hot-b/terminals/darwinex/live/m:/shared/terminals/darwinex/live/m - - /mnt/mt5-hot-b/terminals/darwinex/live/n:/shared/terminals/darwinex/live/n - - /mnt/mt5-hot-b/terminals/darwinex/live/o:/shared/terminals/darwinex/live/o - - /mnt/mt5-hot-b/terminals/darwinex/live/p:/shared/terminals/darwinex/live/p - - /mnt/mt5-hot-b/terminals/darwinex/live/q:/shared/terminals/darwinex/live/q - - /mnt/mt5-hot-b/terminals/darwinex/live/r:/shared/terminals/darwinex/live/r - - /mnt/mt5-hot-b/terminals/darwinex/live/s:/shared/terminals/darwinex/live/s - - /mnt/mt5-hot-b/terminals/darwinex/live/t:/shared/terminals/darwinex/live/t - - /mnt/mt5-hot-b/terminals/darwinex/live/u:/shared/terminals/darwinex/live/u - - /mnt/mt5-hot-b/terminals/darwinex/live/v:/shared/terminals/darwinex/live/v - - /mnt/mt5-hot-b/terminals/blackbull/live-prime:/shared/terminals/blackbull/live-prime - - /mnt/mt5-hot-b/terminals/blackbull/live-standard:/shared/terminals/blackbull/live-standard - - /mnt/mt5-hot-b/terminals/ictrading/live-raw:/shared/terminals/ictrading/live-raw - - /mnt/mt5-hot-b/terminals/myfxmarkets/live:/shared/terminals/myfxmarkets/live - - /mnt/mt5-hot-b/terminals/metaquotes/demo:/shared/terminals/metaquotes/demo - - /mnt/mt5-hot-b/terminals/fptrading/live-standard:/shared/terminals/fptrading/live-standard - - /mnt/mt5-hot-b/terminals/ifunds/live:/shared/terminals/ifunds/live - - /mnt/mt5-hot-b/terminals/eightcapglobal/live:/shared/terminals/eightcapglobal/live - - /mnt/mt5-hot-b/terminals/eightcapglobal/global-live:/shared/terminals/eightcapglobal/global-live diff --git a/vms.yaml.example b/vms.yaml.example new file mode 100644 index 0000000..88870ad --- /dev/null +++ b/vms.yaml.example @@ -0,0 +1,28 @@ +vms: + - name: fast + service: mt5 + container_name: mt5 + cpuset: "0-3" + ram: "8G" + cpu_cores: 4 + disk_size: "64G" + storage: /data/mt5-vm-a/storage + log_dir: /data/mt5-shared/logs + novnc_port: 8006 + wickworks_service: wickworks + mem_limit: 10G + memswap_limit: 12G + + - name: bulk + service: mt5-b + container_name: mt5-b + cpuset: "4-7" + ram: "8G" + cpu_cores: 4 + disk_size: "64G" + storage: /data/mt5-vm-b/storage + log_dir: /data/mt5-vm-b/logs + novnc_port: 8007 + wickworks_service: wickworks-b + mem_limit: 10G + memswap_limit: 12G From e9a86217212c8c41ea81071e038daa140c3eb38a Mon Sep 17 00:00:00 2001 From: Marinski Date: Thu, 30 Jul 2026 11:00:02 +0300 Subject: [PATCH 3/3] fix(config_helper): surface pip install errors instead of swallowing them On ImportError, pip install runs with both stdout and stderr sent to DEVNULL, so a failure (offline, no perms, broken pip) surfaces only as a cryptic ImportError. Wrapping in try/except with a clear message tells the user what to do. --- scripts/config_helper.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/scripts/config_helper.py b/scripts/config_helper.py index 087490a..4bed1b0 100644 --- a/scripts/config_helper.py +++ b/scripts/config_helper.py @@ -11,22 +11,30 @@ import yaml except ImportError: import subprocess - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--quiet", "pyyaml"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet", "pyyaml"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + print("ERROR: pip install pyyaml failed — run 'pip install pyyaml' manually", file=sys.stderr) + sys.exit(1) import yaml try: from jinja2 import Template except ImportError: import subprocess - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--quiet", "jinja2"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet", "jinja2"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + print("ERROR: pip install jinja2 failed — run 'pip install jinja2' manually", file=sys.stderr) + sys.exit(1) from jinja2 import Template _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))