File size: 9,364 Bytes
dfb775d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""Existing-droplet sync builder (rsync + ssh + scp argv arrays).

Pure builder. The orchestrator runs each step via
`mindxtrain.operator.runs.spawn_subprocess_streaming` so every command's
stdout streams back over the SSE pipeline.

Argv is always list-form — never `shell=True` — so user-supplied env values
can't be turned into shell injection. The remote shell snippets are
single-string command bodies (because `ssh` itself joins them on the wire),
but the *arguments* to ssh are still argv elements.
"""

from __future__ import annotations

import os
import shutil
from dataclasses import dataclass
from pathlib import Path

from mindxtrain.deploy.github_push import Step

DEFAULT_REMOTE_PATH = "/workspace/mindxtrain"
DEFAULT_CONTAINER = "rocm/primus:v26.2"
DEFAULT_SSH_KEY = "~/.ssh/id_ed25519"

# Common ssh hardening flags applied everywhere:
#  - BatchMode=yes        : never prompt for a password (would deadlock)
#  - StrictHostKeyChecking=accept-new : trust on first use, refuse changes
#  - ServerAliveInterval=30 : keep the socket open during long bench runs
_SSH_OPTS = (
    "-o", "BatchMode=yes",
    "-o", "StrictHostKeyChecking=accept-new",
    "-o", "ServerAliveInterval=30",
)

# rsync exclude rules. .git intentionally excluded — the cloud-init path
# clones from GitHub; this rsync path syncs working-tree state for fast
# iteration on a droplet that was provisioned manually.
_DEFAULT_EXCLUDES = (
    ".git",
    "__pycache__",
    ".venv",
    ".pytest_cache",
    ".mypy_cache",
    ".ruff_cache",
    "node_modules",
    "out/",
    "dist/",
    "build/",
    "*.egg-info",
)


@dataclass(frozen=True)
class DropletConfig:
    host: str
    user: str = "root"
    ssh_key: str = DEFAULT_SSH_KEY
    remote_path: str = DEFAULT_REMOTE_PATH
    container: str = DEFAULT_CONTAINER
    extras: str = "ml,eval,data,obs"


def required_env() -> tuple[str, ...]:
    """Env vars that must be set for /api/droplet/sync."""
    return ("DROPLET_HOST", "DROPLET_USER")


def missing_env(env: dict[str, str] | None = None) -> list[str]:
    src = env if env is not None else os.environ
    return [k for k in required_env() if not src.get(k)]


def _which_missing() -> list[str]:
    out = []
    for binary in ("rsync", "ssh", "scp"):
        if shutil.which(binary) is None:
            out.append(binary)
    return out


def status_missing(env: dict[str, str] | None = None) -> list[str]:
    return missing_env(env) + _which_missing()


def status_target(env: dict[str, str] | None = None) -> str:
    src = env if env is not None else os.environ
    user = src.get("DROPLET_USER", "")
    host = src.get("DROPLET_HOST", "")
    path = src.get("DROPLET_REMOTE_PATH", DEFAULT_REMOTE_PATH)
    if not (user and host):
        return ""
    return f"{user}@{host}:{path}"


def from_env(env: dict[str, str] | None = None) -> DropletConfig:
    src = env if env is not None else os.environ
    missing = missing_env(env)
    if missing:
        msg = f"droplet config missing env: {', '.join(missing)}"
        raise RuntimeError(msg)
    return DropletConfig(
        host=src["DROPLET_HOST"],
        user=src.get("DROPLET_USER", "root"),
        ssh_key=src.get("DROPLET_SSH_KEY", DEFAULT_SSH_KEY),
        remote_path=src.get("DROPLET_REMOTE_PATH", DEFAULT_REMOTE_PATH),
        container=src.get("DROPLET_CONTAINER", DEFAULT_CONTAINER),
    )


# ---- argv builders -------------------------------------------------------

def build_rsync(cfg: DropletConfig, repo_root: Path) -> list[str]:
    excludes: list[str] = []
    for pat in _DEFAULT_EXCLUDES:
        excludes += ["--exclude", pat]
    ssh_inline = (
        f"ssh -i {cfg.ssh_key} "
        f"-o BatchMode=yes "
        f"-o StrictHostKeyChecking=accept-new"
    )
    # Trailing slashes matter — copy contents of repo_root into remote_path/.
    return [
        "rsync", "-az", "--delete", "--info=progress2",
        *excludes,
        "-e", ssh_inline,
        f"{repo_root.rstrip('/') if isinstance(repo_root, str) else str(repo_root).rstrip('/')}/",
        f"{cfg.user}@{cfg.host}:{cfg.remote_path}/",
    ]


def build_provision_ssh(cfg: DropletConfig) -> list[str]:
    """Idempotent: install podman + pull the container image, skip if already done."""
    remote = (
        "command -v podman >/dev/null 2>&1 || "
        "(sudo apt-get update && sudo apt-get install -y podman); "
        f"podman image exists {cfg.container} || podman pull {cfg.container}"
    )
    return ["ssh", "-i", cfg.ssh_key, *_SSH_OPTS, f"{cfg.user}@{cfg.host}", remote]


def build_bench_ssh(cfg: DropletConfig) -> list[str]:
    """Run `mindxtrain bench --gpu 0 --out plan.json` inside the container.

    `-tt` forces a remote pty so SIGINT from the local ssh client propagates
    through the SSH channel to `podman` and on to the GPU process. Without
    `-tt`, the existing `_REGISTRY.cancel()` SIGINT/SIGTERM in
    `mindxtrain.operator.runs:331` reaches only the local ssh, leaving the
    remote GPU still spinning.
    """
    remote = (
        f"cd {cfg.remote_path} && podman run --rm "
        f"--device /dev/kfd --device /dev/dri "
        f"-v {cfg.remote_path}:{cfg.remote_path} -w {cfg.remote_path} "
        f"{cfg.container} bash -lc "
        f"'pip install -e .[{cfg.extras}] && "
        f"mindxtrain bench --gpu 0 --out plan.json'"
    )
    return ["ssh", "-tt", "-i", cfg.ssh_key, *_SSH_OPTS, f"{cfg.user}@{cfg.host}", remote]


def build_scp_plan_back(cfg: DropletConfig, local_dest: Path) -> list[str]:
    return [
        "scp", "-i", cfg.ssh_key, *_SSH_OPTS,
        f"{cfg.user}@{cfg.host}:{cfg.remote_path}/plan.json",
        str(local_dest),
    ]


def build_ssh_probe(cfg: DropletConfig) -> list[str]:
    """Quick liveness probe — `ssh user@host true`. Used to wait for the box
    to come up after provisioning."""
    return ["ssh", "-i", cfg.ssh_key, *_SSH_OPTS, "-o", "ConnectTimeout=5",
            f"{cfg.user}@{cfg.host}", "true"]


def build_tail_cloud_init(cfg: DropletConfig) -> list[str]:
    """Tail cloud-init's combined output until the bootstrap sentinel exists.

    Streams the log live; exits 0 once the sentinel appears. The remote shell
    here is a small loop, run via ssh's argv (not /bin/sh -c locally).
    """
    from mindxtrain.deploy.cloud_init import BOOTSTRAP_SENTINEL, CLOUD_INIT_LOG

    remote = (
        f"touch {CLOUD_INIT_LOG}; "
        f"tail -n +1 -F {CLOUD_INIT_LOG} & TAIL_PID=$!; "
        f"while [ ! -f {BOOTSTRAP_SENTINEL} ]; do sleep 5; done; "
        f"sleep 2; kill $TAIL_PID 2>/dev/null; true"
    )
    return ["ssh", "-i", cfg.ssh_key, *_SSH_OPTS, f"{cfg.user}@{cfg.host}", remote]


def build_tail_training_log(cfg: DropletConfig) -> list[str]:
    """Tail the droplet's `mindxtrain train` combined log until done sentinel.

    Streams every stdout line from the in-progress training run; exits with
    the captured training exit code once `.train-done` appears. Used by the
    orchestrator's post-bootstrap step to bridge remote training events into
    the operator's run registry (where Coach's SSE picks them up).

    The training command tees its output to a stable path
    (`out/runs/_combined_train.log`) so we don't have to guess the per-recipe
    run_name from the operator side.
    """
    from mindxtrain.deploy.cloud_init import (
        TRAIN_DONE_SENTINEL,
        TRAIN_EXIT_SENTINEL,
    )

    combined_log = f"{cfg.remote_path}/out/runs/_combined_train.log"
    remote = (
        # Make sure the log exists so tail -F doesn't error before training
        # writes its first line.
        f"mkdir -p {cfg.remote_path}/out/runs; "
        f"touch {combined_log}; "
        f"tail -n +1 -F {combined_log} & TAIL_PID=$!; "
        # Block until training is terminal.
        f"while [ ! -f {TRAIN_DONE_SENTINEL} ]; do sleep 5; done; "
        # Give tail one more pass to flush.
        f"sleep 2; kill $TAIL_PID 2>/dev/null; "
        # Propagate the training exit code so the orchestrator can decide
        # succeeded vs failed. Defaults to 1 if the sentinel is missing.
        f"exit $(cat {TRAIN_EXIT_SENTINEL} 2>/dev/null || echo 1)"
    )
    return ["ssh", "-i", cfg.ssh_key, *_SSH_OPTS, f"{cfg.user}@{cfg.host}", remote]


# ---- pipeline assembly ---------------------------------------------------

def sync_steps(
    cfg: DropletConfig,
    repo_root: Path,
    *,
    run_bench: bool = True,
    fetch_plan: bool = True,
    plan_dest: Path | None = None,
) -> list[Step]:
    steps: list[Step] = [
        Step(label="rsync", cmd=build_rsync(cfg, repo_root)),
        Step(label="provision", cmd=build_provision_ssh(cfg)),
    ]
    if run_bench:
        steps.append(Step(label="bench", cmd=build_bench_ssh(cfg)))
    if fetch_plan and run_bench:
        dest = plan_dest or repo_root / "out" / "plan.remote.json"
        dest.parent.mkdir(parents=True, exist_ok=True)
        steps.append(Step(label="scp-plan", cmd=build_scp_plan_back(cfg, dest)))
    return steps


__all__ = [
    "DEFAULT_CONTAINER",
    "DEFAULT_REMOTE_PATH",
    "DEFAULT_SSH_KEY",
    "DropletConfig",
    "build_bench_ssh",
    "build_provision_ssh",
    "build_rsync",
    "build_scp_plan_back",
    "build_ssh_probe",
    "build_tail_cloud_init",
    "from_env",
    "missing_env",
    "required_env",
    "status_missing",
    "status_target",
    "sync_steps",
]