This commit is contained in:
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
operation:
|
operation:
|
||||||
description: discover, provision, status, or shutdown
|
description: discover, instances, provision, resume, status, or shutdown
|
||||||
required: true
|
required: true
|
||||||
default: discover
|
default: discover
|
||||||
image_uuid:
|
image_uuid:
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: Validate requested operation
|
- name: Validate requested operation
|
||||||
run: |
|
run: |
|
||||||
case "$OPERATION" in
|
case "$OPERATION" in
|
||||||
discover|provision|status|shutdown) ;;
|
discover|instances|provision|resume|status|shutdown) ;;
|
||||||
*) echo "Unsupported operation" >&2; exit 2 ;;
|
*) echo "Unsupported operation" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ ces-controller --help
|
|||||||
从 Actions 手动运行 `Manage AutoDL GPU runner`:
|
从 Actions 手动运行 `Manage AutoDL GPU runner`:
|
||||||
|
|
||||||
- `discover`:列出账号私有镜像和内置公共基础镜像,不产生 GPU 费用;
|
- `discover`:列出账号私有镜像和内置公共基础镜像,不产生 GPU 费用;
|
||||||
|
- `instances`:只读列出当前 Token 账号下的 Pro 实例;
|
||||||
- `provision`:创建 PRO6000、传输模型并注册 GPU Runner;必须填写镜像 UUID,并把确认值
|
- `provision`:创建 PRO6000、传输模型并注册 GPU Runner;必须填写镜像 UUID,并把确认值
|
||||||
填为 `CREATE`;
|
填为 `CREATE`;
|
||||||
|
- `resume`:对失败后已关机的现有实例继续配置;同样必须填写 `CREATE`;
|
||||||
- `status`:查询实例和 Runner 进程状态;
|
- `status`:查询实例和 Runner 进程状态;
|
||||||
- `shutdown`:回收训练产物并关闭实例,不释放系统盘。
|
- `shutdown`:回收训练产物并关闭实例,不释放系统盘。
|
||||||
|
|
||||||
|
|||||||
+124
-38
@@ -183,6 +183,12 @@ class AutoDLClient:
|
|||||||
# private images. Treat both representations as an empty result.
|
# private images. Treat both representations as an empty result.
|
||||||
return list(data.get("list") or [])
|
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:
|
def create(self, spec: dict[str, Any]) -> str:
|
||||||
return str(self.request("POST", "/api/v1/dev/instance/pro/create", spec))
|
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}
|
"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)
|
@dataclass(frozen=True)
|
||||||
class Paths:
|
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}")
|
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:
|
def wait_ssh(target: SshTarget, timeout: int = 600) -> None:
|
||||||
target.known_hosts.parent.mkdir(parents=True, exist_ok=True)
|
target.known_hosts.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.known_hosts.touch(mode=0o600, 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 "
|
"install -d -m 700 /opt/ces-runner /run/ces-runner-secrets "
|
||||||
f"{shlex.quote(paths.model_destination)}"
|
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)
|
print("Transferring SigLIP2 model to AutoDL", flush=True)
|
||||||
target.sync_to(paths.model_source, paths.model_destination)
|
target.sync_to(paths.model_source, paths.model_destination)
|
||||||
target.copy_to(binary, "/opt/ces-runner/act_runner")
|
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)
|
runner_config.unlink(missing_ok=True)
|
||||||
token_file.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())}"
|
name = f"ces-autodl-pro6000-{int(time.time())}"
|
||||||
labels = ",".join(RUNNER_LABELS)
|
labels = ",".join(RUNNER_LABELS)
|
||||||
register = " ".join(
|
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:
|
def provision(args: argparse.Namespace, client: AutoDLClient, paths: Paths) -> None:
|
||||||
if args.confirm_spend != "CREATE":
|
if args.confirm_spend != "CREATE":
|
||||||
raise ControllerError("provision requires --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")
|
previous_uuid = previous.get("instance_uuid")
|
||||||
if previous_uuid:
|
if previous_uuid:
|
||||||
previous_status = client.status(str(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(
|
raise ControllerError(
|
||||||
f"refusing to create a second billed instance; "
|
f"refusing to create a second billed instance; "
|
||||||
f"{previous_uuid} is {previous_status}"
|
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)
|
instance_uuid = client.create(spec)
|
||||||
write_state(paths, {"instance_uuid": instance_uuid, "phase": "creating"})
|
write_state(paths, {"instance_uuid": instance_uuid, "phase": "creating"})
|
||||||
print(f"created={instance_uuid}", flush=True)
|
print(f"created={instance_uuid}", flush=True)
|
||||||
wait_running(client, instance_uuid)
|
provision_existing_instance(client, instance_uuid, paths, runner_token)
|
||||||
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)
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
if instance_uuid:
|
if instance_uuid:
|
||||||
print(f"provision failed; powering off {instance_uuid}", file=sys.stderr, flush=True)
|
print(f"provision failed; powering off {instance_uuid}", file=sys.stderr, flush=True)
|
||||||
try:
|
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:
|
except Exception as cleanup_error:
|
||||||
print(f"power-off cleanup failed: {cleanup_error}", file=sys.stderr)
|
print(f"power-off cleanup failed: {cleanup_error}", file=sys.stderr)
|
||||||
raise
|
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:
|
def status(client: AutoDLClient, paths: Paths) -> None:
|
||||||
state = read_state(paths)
|
state = read_state(paths)
|
||||||
instance_uuid = state["instance_uuid"]
|
instance_uuid = state["instance_uuid"]
|
||||||
@@ -647,7 +726,7 @@ def shutdown(client: AutoDLClient, paths: Paths) -> None:
|
|||||||
target.sync_from("/opt/ces-runner", destination / "runner")
|
target.sync_from("/opt/ces-runner", destination / "runner")
|
||||||
except subprocess.CalledProcessError as exc:
|
except subprocess.CalledProcessError as exc:
|
||||||
print(f"artifact collection failed: {exc}", file=sys.stderr)
|
print(f"artifact collection failed: {exc}", file=sys.stderr)
|
||||||
client.power_off(instance_uuid)
|
power_off_and_wait(client, instance_uuid)
|
||||||
state["phase"] = "stopped"
|
state["phase"] = "stopped"
|
||||||
write_state(paths, state)
|
write_state(paths, state)
|
||||||
print(f"powered_off={instance_uuid}")
|
print(f"powered_off={instance_uuid}")
|
||||||
@@ -655,7 +734,10 @@ def shutdown(client: AutoDLClient, paths: Paths) -> None:
|
|||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser()
|
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("--image-uuid", default="")
|
||||||
parser.add_argument("--framework", default="")
|
parser.add_argument("--framework", default="")
|
||||||
parser.add_argument("--framework-version", default="")
|
parser.add_argument("--framework-version", default="")
|
||||||
@@ -673,8 +755,12 @@ def main() -> int:
|
|||||||
client = AutoDLClient(os.environ.get("AUTODL_API_TOKEN", ""))
|
client = AutoDLClient(os.environ.get("AUTODL_API_TOKEN", ""))
|
||||||
if args.operation == "discover":
|
if args.operation == "discover":
|
||||||
discover(client)
|
discover(client)
|
||||||
|
elif args.operation == "instances":
|
||||||
|
instances(client)
|
||||||
elif args.operation == "provision":
|
elif args.operation == "provision":
|
||||||
provision(args, client, paths)
|
provision(args, client, paths)
|
||||||
|
elif args.operation == "resume":
|
||||||
|
resume(args, client, paths)
|
||||||
elif args.operation == "status":
|
elif args.operation == "status":
|
||||||
status(client, paths)
|
status(client, paths)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user