Support safe AutoDL instance recovery
Controller CI / test (push) Successful in 1s

This commit is contained in:
2026-10-02 15:23:33 +08:00
parent c7c4850e14
commit b58cc1f5f8
3 changed files with 128 additions and 40 deletions
+124 -38
View File
@@ -183,6 +183,12 @@ class AutoDLClient:
# private images. Treat both representations as an empty result.
return list(data.get("list") or [])
def list_instances(self) -> list[dict[str, Any]]:
data = self.request(
"POST", "/api/v1/dev/instance/pro/list", {"page_index": 1, "page_size": 100}
)
return list(data.get("list") or [])
def create(self, spec: dict[str, Any]) -> str:
return str(self.request("POST", "/api/v1/dev/instance/pro/create", spec))
@@ -205,6 +211,13 @@ class AutoDLClient:
"POST", "/api/v1/dev/instance/pro/power_off", {"instance_uuid": instance_uuid}
)
def power_on(self, instance_uuid: str) -> None:
self.request(
"POST",
"/api/v1/dev/instance/pro/power_on",
{"instance_uuid": instance_uuid, "payload": "gpu", "start_command": "sleep 1"},
)
@dataclass(frozen=True)
class Paths:
@@ -431,6 +444,19 @@ def wait_running(client: AutoDLClient, instance_uuid: str, timeout: int = 1800)
raise ControllerError(f"instance failed to reach running state; last={last!r}")
def power_off_and_wait(client: AutoDLClient, instance_uuid: str, timeout: int = 300) -> None:
client.power_off(instance_uuid)
deadline = time.monotonic() + timeout
last = ""
while time.monotonic() < deadline:
last = client.status(instance_uuid)
print(f"instance={instance_uuid} status={last}", flush=True)
if last in {"shutdown", "stopped", "released"}:
return
time.sleep(5)
raise ControllerError(f"instance failed to stop; last={last!r}")
def wait_ssh(target: SshTarget, timeout: int = 600) -> None:
target.known_hosts.parent.mkdir(parents=True, exist_ok=True)
target.known_hosts.touch(mode=0o600, exist_ok=True)
@@ -476,6 +502,16 @@ def provision_worker(target: SshTarget, paths: Paths, runner_token: str) -> None
"install -d -m 700 /opt/ces-runner /run/ces-runner-secrets "
f"{shlex.quote(paths.model_destination)}"
)
verification = target.run(
"nvidia-smi && python -c 'import torch; "
"print(torch.__version__, torch.version.cuda, torch.cuda.is_available(), "
"torch.cuda.get_device_name(), torch.cuda.get_device_capability())'",
check=False,
)
print(verification.stdout, flush=True)
if verification.returncode != 0 or "True" not in verification.stdout:
raise ControllerError("AutoDL image failed the CUDA/PyTorch verification")
print("Transferring SigLIP2 model to AutoDL", flush=True)
target.sync_to(paths.model_source, paths.model_destination)
target.copy_to(binary, "/opt/ces-runner/act_runner")
@@ -491,16 +527,6 @@ def provision_worker(target: SshTarget, paths: Paths, runner_token: str) -> None
runner_config.unlink(missing_ok=True)
token_file.unlink(missing_ok=True)
verification = target.run(
"nvidia-smi && python -c 'import torch; "
"print(torch.__version__, torch.version.cuda, torch.cuda.is_available(), "
"torch.cuda.get_device_name(), torch.cuda.get_device_capability())'",
check=False,
)
print(verification.stdout, flush=True)
if verification.returncode != 0 or "True" not in verification.stdout:
raise ControllerError("AutoDL image failed the CUDA/PyTorch verification")
name = f"ces-autodl-pro6000-{int(time.time())}"
labels = ",".join(RUNNER_LABELS)
register = " ".join(
@@ -558,6 +584,22 @@ def discover(client: AutoDLClient) -> None:
)
def instances(client: AutoDLClient) -> None:
safe = [
{
"uuid": item.get("uuid"),
"name": item.get("name"),
"status": item.get("status"),
"region_name": item.get("region_name"),
"gpu_spec_uuid": item.get("gpu_spec_uuid"),
"req_gpu_amount": item.get("req_gpu_amount"),
"created_at": item.get("created_at"),
}
for item in client.list_instances()
]
print(json.dumps({"pro_instances": safe}, indent=2, ensure_ascii=False))
def provision(args: argparse.Namespace, client: AutoDLClient, paths: Paths) -> None:
if args.confirm_spend != "CREATE":
raise ControllerError("provision requires --confirm-spend CREATE")
@@ -569,7 +611,7 @@ def provision(args: argparse.Namespace, client: AutoDLClient, paths: Paths) -> N
previous_uuid = previous.get("instance_uuid")
if previous_uuid:
previous_status = client.status(str(previous_uuid))
if previous_status not in {"stopped", "released", "error"}:
if previous_status not in {"shutdown", "stopped", "released", "error"}:
raise ControllerError(
f"refusing to create a second billed instance; "
f"{previous_uuid} is {previous_status}"
@@ -580,40 +622,77 @@ def provision(args: argparse.Namespace, client: AutoDLClient, paths: Paths) -> N
instance_uuid = client.create(spec)
write_state(paths, {"instance_uuid": instance_uuid, "phase": "creating"})
print(f"created={instance_uuid}", flush=True)
wait_running(client, instance_uuid)
snapshot = client.snapshot(instance_uuid)
target = SshTarget.from_snapshot(snapshot, paths)
write_state(
paths,
{
"instance_uuid": instance_uuid,
"phase": "provisioning",
"ssh_host": target.host,
"ssh_port": target.port,
},
)
wait_ssh(target)
provision_worker(target, paths, runner_token)
write_state(
paths,
{
"instance_uuid": instance_uuid,
"phase": "runner-online",
"ssh_host": target.host,
"ssh_port": target.port,
},
)
print(f"runner_online=true instance={instance_uuid}", flush=True)
provision_existing_instance(client, instance_uuid, paths, runner_token)
except BaseException:
if instance_uuid:
print(f"provision failed; powering off {instance_uuid}", file=sys.stderr, flush=True)
try:
client.power_off(instance_uuid)
power_off_and_wait(client, instance_uuid)
state = read_state(paths)
state["phase"] = "stopped"
write_state(paths, state)
except Exception as cleanup_error:
print(f"power-off cleanup failed: {cleanup_error}", file=sys.stderr)
raise
def provision_existing_instance(
client: AutoDLClient, instance_uuid: str, paths: Paths, runner_token: str
) -> None:
wait_running(client, instance_uuid)
snapshot = client.snapshot(instance_uuid)
target = SshTarget.from_snapshot(snapshot, paths)
write_state(
paths,
{
"instance_uuid": instance_uuid,
"phase": "provisioning",
"ssh_host": target.host,
"ssh_port": target.port,
},
)
wait_ssh(target)
provision_worker(target, paths, runner_token)
write_state(
paths,
{
"instance_uuid": instance_uuid,
"phase": "runner-online",
"ssh_host": target.host,
"ssh_port": target.port,
},
)
print(f"runner_online=true instance={instance_uuid}", flush=True)
def resume(args: argparse.Namespace, client: AutoDLClient, paths: Paths) -> None:
if args.confirm_spend != "CREATE":
raise ControllerError("resume requires --confirm-spend CREATE")
runner_token = os.environ.get("TRAINING_RUNNER_REGISTRATION_TOKEN", "")
validate_local(paths, runner_token)
ensure_runner(paths)
state = read_state(paths)
instance_uuid = str(state["instance_uuid"])
current_status = client.status(instance_uuid)
if current_status not in {"shutdown", "stopped"}:
raise ControllerError(f"resume requires a stopped instance; current={current_status}")
try:
client.power_on(instance_uuid)
state["phase"] = "starting"
write_state(paths, state)
provision_existing_instance(client, instance_uuid, paths, runner_token)
except BaseException:
print(f"resume failed; powering off {instance_uuid}", file=sys.stderr, flush=True)
try:
power_off_and_wait(client, instance_uuid)
state = read_state(paths)
state["phase"] = "stopped"
write_state(paths, state)
except Exception as cleanup_error:
print(f"power-off cleanup failed: {cleanup_error}", file=sys.stderr)
raise
def status(client: AutoDLClient, paths: Paths) -> None:
state = read_state(paths)
instance_uuid = state["instance_uuid"]
@@ -647,7 +726,7 @@ def shutdown(client: AutoDLClient, paths: Paths) -> None:
target.sync_from("/opt/ces-runner", destination / "runner")
except subprocess.CalledProcessError as exc:
print(f"artifact collection failed: {exc}", file=sys.stderr)
client.power_off(instance_uuid)
power_off_and_wait(client, instance_uuid)
state["phase"] = "stopped"
write_state(paths, state)
print(f"powered_off={instance_uuid}")
@@ -655,7 +734,10 @@ def shutdown(client: AutoDLClient, paths: Paths) -> None:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("operation", choices=("discover", "provision", "status", "shutdown"))
parser.add_argument(
"operation",
choices=("discover", "instances", "provision", "resume", "status", "shutdown"),
)
parser.add_argument("--image-uuid", default="")
parser.add_argument("--framework", default="")
parser.add_argument("--framework-version", default="")
@@ -673,8 +755,12 @@ def main() -> int:
client = AutoDLClient(os.environ.get("AUTODL_API_TOKEN", ""))
if args.operation == "discover":
discover(client)
elif args.operation == "instances":
instances(client)
elif args.operation == "provision":
provision(args, client, paths)
elif args.operation == "resume":
resume(args, client, paths)
elif args.operation == "status":
status(client, paths)
else: