Add AutoDL Pro runner controller
Controller CI / test (push) Successful in 1s

This commit is contained in:
2026-10-02 01:51:33 +08:00
commit 9bd8b7cc1e
6 changed files with 756 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Controller CI
on:
push:
branches: [main]
jobs:
test:
runs-on: ces-control
timeout-minutes: 10
env:
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps:
- name: Checkout controller source
run: |
git init .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
git -c http.extraHeader="Authorization: token $GITEA_JOB_TOKEN" fetch --depth 1 origin "$GITHUB_SHA"
git checkout --detach FETCH_HEAD
- name: Verify controller
run: |
python3 -m py_compile controller.py
python3 -m unittest discover -s tests -v
- name: Confirm no paid action ran
run: |
echo "Controller CI only validates code; it never creates an AutoDL instance."
+57
View File
@@ -0,0 +1,57 @@
name: Manage AutoDL GPU runner
on:
workflow_dispatch:
inputs:
operation:
description: discover, provision, status, or shutdown
required: true
default: discover
image_uuid:
description: Required only for provision
required: false
default: ""
confirm_spend:
description: Type CREATE to provision a billed instance
required: false
default: ""
jobs:
manage:
runs-on: ces-control
timeout-minutes: 180
env:
AUTODL_API_TOKEN: ${{ secrets.AUTODL_API_TOKEN }}
TRAINING_RUNNER_REGISTRATION_TOKEN: ${{ secrets.TRAINING_RUNNER_REGISTRATION_TOKEN }}
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
OPERATION: ${{ inputs.operation }}
IMAGE_UUID: ${{ inputs.image_uuid }}
CONFIRM_SPEND: ${{ inputs.confirm_spend }}
steps:
- name: Checkout controller source
run: |
git init .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
git -c http.extraHeader="Authorization: token $GITEA_JOB_TOKEN" fetch --depth 1 origin "$GITHUB_SHA"
git checkout --detach FETCH_HEAD
- name: Validate requested operation
run: |
case "$OPERATION" in
discover|provision|status|shutdown) ;;
*) echo "Unsupported operation" >&2; exit 2 ;;
esac
- name: Manage AutoDL Pro instance
run: |
python3 controller.py "$OPERATION" \
--image-uuid "$IMAGE_UUID" \
--confirm-spend "$CONFIRM_SPEND"
- name: Controller host details
if: always()
run: |
echo "host=$(hostname)"
date -Iseconds
test -f /root/.local/state/ces-autodl-controller/state.json && \
python3 -c 'import json; p="/root/.local/state/ces-autodl-controller/state.json"; d=json.load(open(p)); d.pop("root_password", None); print(json.dumps(d, indent=2))' || true
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.runner
config.yaml
state.json
+59
View File
@@ -0,0 +1,59 @@
# AutoDL GPU Runner Controller
该仓库只管理算力,不包含 Evidence Extractor 研究代码。控制流程运行在阿里云的常驻
`ces-control:host` Gitea Runner 上,并通过 AutoDL Pro API 创建和关闭 GPU 实例。
## 边界
- 本仓库:AutoDL Pro 生命周期、模型传输、GPU Runner 注册和产物回收。
- 阿里云:常驻控制 Runner、SigLIP2 权重以及训练产物的持久存储。
- AutoDL:临时 GPU 执行节点。
- 研究仓库:训练代码、配置和 Gitea Actions workflow 的唯一来源。
## Repository Secrets
在本仓库 `Settings → Actions → Secrets` 配置:
- `AUTODL_API_TOKEN`:AutoDL 控制台的开发者 Token。
- `TRAINING_RUNNER_REGISTRATION_TOKEN`:研究仓库 `Create new Runner` 页面显示的
Registration Token。
Secret 只注入阿里云控制任务,不落盘、不打印。AutoDL SSH 私钥保存在阿里云
`/root/.config/ces-controller/autodl_ed25519`,不进入 Gitea。
## 首次启动
阿里云需要先作为本仓库的常驻 Runner 注册一次,且只使用标签:
```text
ces-control:host
```
该 bootstrap 使用的是“本控制器仓库”的 Runner Registration Token,不是 Secret 中用于
AutoDL GPU Runner 的 `TRAINING_RUNNER_REGISTRATION_TOKEN`。
Runner online 后,第一次 push 只执行 `Controller CI`,不会创建实例或产生 AutoDL 费用。
## 管理 workflow
从 Actions 手动运行 `Manage AutoDL GPU runner`:
- `discover`:列出可供 API 使用的私有镜像,不产生 GPU 费用;
- `provision`:创建 PRO6000、传输模型并注册 GPU Runner;必须填写镜像 UUID,并把确认值
填为 `CREATE`;
- `status`:查询实例和 Runner 进程状态;
- `shutdown`:回收训练产物并关闭实例,不释放系统盘。
`provision` 在创建实例前会检查 SigLIP2 的两个权重分片是否完整、AutoDL SSH 私钥权限是否为
`0600`,并预下载及校验 `gitea-runner`。任何配置失败都会尝试关闭刚创建的实例,避免继续计费。
控制器固定使用官方 `gitea-runner 3.5.0` Linux AMD64 二进制及其 SHA-256,下载和校验均在
创建付费实例之前完成。
AutoDL GPU Runner 注册为 host runner,并提供:
```text
ubuntu-latest:host
ces-ci:host
gpu:host
```
+545
View File
@@ -0,0 +1,545 @@
#!/usr/bin/env python3
"""Run AutoDL Pro GPU workers from a persistent Alibaba control host."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shlex
import stat
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
API_BASE = "https://api.autodl.com"
RUNNER_VERSION = "3.5.0"
RUNNER_URL = (
"https://dl.gitea.com/gitea-runner/3.5.0/gitea-runner-3.5.0-linux-amd64"
)
RUNNER_SHA256 = "16dc5c89ad5ac5496369642f01ed29c9bb130a2512e16a8cc3b400d9d0bfea73"
RUNNER_LABELS = ["ubuntu-latest:host", "ces-ci:host", "gpu:host"]
MODEL_FILES = [
"config.json",
"model.safetensors.index.json",
"model-00001-of-00002.safetensors",
"model-00002-of-00002.safetensors",
"preprocessor_config.json",
"tokenizer.json",
"tokenizer.model",
"tokenizer_config.json",
]
class ControllerError(RuntimeError):
pass
class AutoDLClient:
def __init__(self, token: str):
if not token:
raise ControllerError("AUTODL_API_TOKEN is missing")
self.token = token
def request(self, method: str, path: str, body: dict[str, Any]) -> Any:
request = urllib.request.Request(
f"{API_BASE}{path}",
data=json.dumps(body).encode(),
method=method,
headers={"Authorization": self.token, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=45) as response:
result = json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
message = exc.read().decode(errors="replace")[:500]
raise ControllerError(f"AutoDL API {method} {path} returned {exc.code}: {message}") from exc
except (urllib.error.URLError, json.JSONDecodeError) as exc:
raise ControllerError(f"AutoDL API {method} {path} failed: {exc}") from exc
if result.get("code") != "Success":
raise ControllerError(
f"AutoDL API rejected {method} {path}: "
f"code={result.get('code')!r}, msg={result.get('msg')!r}"
)
return result.get("data")
def list_images(self) -> list[dict[str, Any]]:
data = self.request(
"POST",
"/api/v1/dev/instance/pro/image/private/list",
{"page_index": 1, "page_size": 100},
)
return list(data.get("list", []))
def create(self, spec: dict[str, Any]) -> str:
return str(self.request("POST", "/api/v1/dev/instance/pro/create", spec))
def status(self, instance_uuid: str) -> str:
return str(
self.request(
"GET", "/api/v1/dev/instance/pro/status", {"instance_uuid": instance_uuid}
)
)
def snapshot(self, instance_uuid: str) -> dict[str, Any]:
return dict(
self.request(
"GET", "/api/v1/dev/instance/pro/snapshot", {"instance_uuid": instance_uuid}
)
)
def power_off(self, instance_uuid: str) -> None:
self.request(
"POST", "/api/v1/dev/instance/pro/power_off", {"instance_uuid": instance_uuid}
)
@dataclass(frozen=True)
class Paths:
model_source: Path
model_destination: str
artifact_source: str
artifact_destination: Path
ssh_key: Path
known_hosts: Path
state_file: Path
runner_cache: Path
@dataclass(frozen=True)
class SshTarget:
host: str
port: int
key: Path
known_hosts: Path
@classmethod
def from_snapshot(cls, snapshot: dict[str, Any], paths: Paths) -> "SshTarget":
host = str(snapshot.get("proxy_host", "")).strip()
port = int(snapshot.get("ssh_port", 0))
if not host or port <= 0:
raise ControllerError("AutoDL did not return a usable SSH endpoint")
return cls(host, port, paths.ssh_key, paths.known_hosts)
def ssh_options(self) -> list[str]:
return [
"-F",
"/dev/null",
"-o",
"BatchMode=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
f"UserKnownHostsFile={self.known_hosts}",
"-i",
str(self.key),
"-p",
str(self.port),
]
def run(self, command: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["ssh", *self.ssh_options(), f"root@{self.host}", command],
check=check,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
def copy_to(self, source: Path, destination: str) -> None:
options = self.ssh_options()
options[options.index("-p")] = "-P"
subprocess.run(
["scp", *options, str(source), f"root@{self.host}:{destination}"], check=True
)
def sync_to(self, source: Path, destination: str) -> None:
transport = shlex.join(["ssh", *self.ssh_options()])
subprocess.run(
[
"rsync",
"-ah",
"--partial",
"--append-verify",
"-e",
transport,
f"{source}/",
f"root@{self.host}:{destination.rstrip('/')}/",
],
check=True,
)
def sync_from(self, source: str, destination: Path) -> None:
destination.mkdir(parents=True, exist_ok=True)
transport = shlex.join(["ssh", *self.ssh_options()])
subprocess.run(
[
"rsync",
"-ah",
"--partial",
"-e",
transport,
f"root@{self.host}:{source.rstrip('/')}/",
f"{destination}/",
],
check=True,
)
def default_paths() -> Paths:
return Paths(
model_source=Path("/data/ces-models/siglip2-giant"),
model_destination="/root/autodl-tmp/ces-models/siglip2-giant",
artifact_source="/root/autodl-tmp/ces-artifacts",
artifact_destination=Path("/data/ces-artifacts"),
ssh_key=Path("/root/.config/ces-controller/autodl_ed25519"),
known_hosts=Path("/root/.config/ces-controller/known_hosts"),
state_file=Path("/root/.local/state/ces-autodl-controller/state.json"),
runner_cache=Path(f"/var/cache/ces-controller/act_runner-{RUNNER_VERSION}"),
)
def instance_spec(image_uuid: str, disk_gb: int, data_centers: list[str]) -> dict[str, Any]:
if not image_uuid or image_uuid.startswith("REPLACE_"):
raise ControllerError("a real AutoDL image UUID is required")
spec: dict[str, Any] = {
"req_gpu_amount": 1,
"expand_system_disk_by_gb": disk_gb,
"gpu_spec_uuid": "pro6000-p",
"image_uuid": image_uuid,
"cuda_v_from": 128,
"instance_name": "ces-gitea-gpu-runner",
}
if data_centers:
spec["data_center_list"] = data_centers
return spec
def validate_local(paths: Paths, runner_token: str) -> None:
if not runner_token:
raise ControllerError("TRAINING_RUNNER_REGISTRATION_TOKEN is missing")
missing = [
name
for name in MODEL_FILES
if not (paths.model_source / name).is_file()
or (paths.model_source / name).stat().st_size == 0
]
if missing:
raise ControllerError(f"SigLIP2 download is incomplete: {missing}")
if not paths.ssh_key.is_file():
raise ControllerError(f"AutoDL SSH key does not exist: {paths.ssh_key}")
if stat.S_IMODE(paths.ssh_key.stat().st_mode) & 0o077:
raise ControllerError(f"AutoDL SSH key must have mode 0600: {paths.ssh_key}")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def ensure_runner(paths: Paths) -> Path:
if paths.runner_cache.is_file() and sha256(paths.runner_cache) == RUNNER_SHA256:
return paths.runner_cache
paths.runner_cache.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(RUNNER_URL, timeout=300) as response, tempfile.NamedTemporaryFile(
dir=paths.runner_cache.parent, delete=False
) as temporary:
while chunk := response.read(1024 * 1024):
temporary.write(chunk)
downloaded = Path(temporary.name)
if sha256(downloaded) != RUNNER_SHA256:
downloaded.unlink(missing_ok=True)
raise ControllerError("act_runner checksum mismatch")
downloaded.chmod(0o755)
downloaded.replace(paths.runner_cache)
return paths.runner_cache
def write_state(paths: Paths, data: dict[str, Any]) -> None:
paths.state_file.parent.mkdir(parents=True, exist_ok=True)
temporary = paths.state_file.with_suffix(".tmp")
temporary.write_text(json.dumps(data, indent=2), encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(paths.state_file)
def read_state(paths: Paths) -> dict[str, Any]:
if not paths.state_file.is_file():
raise ControllerError(f"state file does not exist: {paths.state_file}")
return json.loads(paths.state_file.read_text(encoding="utf-8"))
def wait_running(client: AutoDLClient, instance_uuid: str, timeout: int = 1800) -> None:
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 == "running":
return
if last in {"error", "released"}:
break
time.sleep(10)
raise ControllerError(f"instance failed to reach running state; 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)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if target.run("true", check=False).returncode == 0:
return
time.sleep(10)
raise ControllerError("AutoDL SSH did not become ready")
def runner_yaml() -> str:
labels = "\n".join(f' - "{label}"' for label in RUNNER_LABELS)
return f"""log:
level: info
runner:
file: /opt/ces-runner/.runner
capacity: 1
timeout: 24h
fetch_timeout: 5s
fetch_interval: 2s
labels:
{labels}
container:
docker_host: "-"
"""
def temp_secret(value: str) -> Path:
descriptor, name = tempfile.mkstemp(prefix="ces-secret-")
path = Path(name)
try:
os.write(descriptor, value.encode())
finally:
os.close(descriptor)
path.chmod(0o600)
return path
def provision_worker(target: SshTarget, paths: Paths, runner_token: str) -> None:
binary = ensure_runner(paths)
target.run(
"install -d -m 700 /opt/ces-runner /run/ces-runner-secrets "
f"{shlex.quote(paths.model_destination)}"
)
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")
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as temporary:
temporary.write(runner_yaml())
runner_config = Path(temporary.name)
token_file = temp_secret(runner_token)
try:
target.copy_to(runner_config, "/opt/ces-runner/config.yaml")
target.copy_to(token_file, "/run/ces-runner-secrets/registration-token")
finally:
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(
[
"cd /opt/ces-runner && chmod 700 act_runner",
"&& chmod 600 config.yaml /run/ces-runner-secrets/registration-token",
"&& rm -f .runner runner.exit",
"&& REGISTRATION_TOKEN=$(cat /run/ces-runner-secrets/registration-token)",
"&& ./act_runner -c config.yaml register --no-interactive",
"--instance https://gitea.entroia.com/",
'--token "$REGISTRATION_TOKEN"',
f"--name {shlex.quote(name)}",
f"--labels {shlex.quote(labels)}",
"&& unset REGISTRATION_TOKEN",
]
)
result = target.run(register, check=False)
target.run("rm -f /run/ces-runner-secrets/registration-token", check=False)
if result.returncode != 0:
raise ControllerError(f"GPU runner registration failed:\n{result.stdout}")
target.run(
"cd /opt/ces-runner && "
"nohup sh -c './act_runner -c config.yaml daemon; "
"code=$?; printf %s \"$code\" > runner.exit' "
"> runner.log 2>&1 < /dev/null & echo $! > runner.pid"
)
time.sleep(3)
health = target.run(
"cd /opt/ces-runner && test -s runner.pid && kill -0 $(cat runner.pid) "
"&& tail -30 runner.log",
check=False,
)
print(health.stdout, flush=True)
if health.returncode != 0:
raise ControllerError("GPU runner daemon did not stay running")
def discover(client: AutoDLClient) -> None:
images = client.list_images()
safe = [
{
"image_uuid": item.get("image_uuid"),
"name": item.get("name"),
"status": item.get("status"),
"image_size": item.get("image_size"),
}
for item in images
]
print(json.dumps({"private_images": 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")
runner_token = os.environ.get("TRAINING_RUNNER_REGISTRATION_TOKEN", "")
validate_local(paths, runner_token)
ensure_runner(paths)
if paths.state_file.is_file():
previous = read_state(paths)
previous_uuid = previous.get("instance_uuid")
if previous_uuid:
previous_status = client.status(str(previous_uuid))
if previous_status not in {"stopped", "released", "error"}:
raise ControllerError(
f"refusing to create a second billed instance; "
f"{previous_uuid} is {previous_status}"
)
spec = instance_spec(args.image_uuid, args.disk_gb, args.data_center)
instance_uuid: str | None = None
try:
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)
except BaseException:
if instance_uuid:
print(f"provision failed; powering off {instance_uuid}", file=sys.stderr, flush=True)
try:
client.power_off(instance_uuid)
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"]
result: dict[str, Any] = {
"instance_uuid": instance_uuid,
"controller_phase": state.get("phase"),
"autodl_status": client.status(instance_uuid),
}
if state.get("ssh_host") and state.get("ssh_port"):
target = SshTarget(state["ssh_host"], int(state["ssh_port"]), paths.ssh_key, paths.known_hosts)
health = target.run(
"cd /opt/ces-runner && test -s runner.pid && kill -0 $(cat runner.pid)", check=False
)
result["runner_process_alive"] = health.returncode == 0
print(json.dumps(result, indent=2))
def shutdown(client: AutoDLClient, paths: Paths) -> None:
state = read_state(paths)
instance_uuid = state["instance_uuid"]
if state.get("ssh_host") and state.get("ssh_port"):
target = SshTarget(state["ssh_host"], int(state["ssh_port"]), paths.ssh_key, paths.known_hosts)
target.run(
"test ! -s /opt/ces-runner/runner.pid || kill $(cat /opt/ces-runner/runner.pid); "
"rm -f /run/ces-runner-secrets/registration-token",
check=False,
)
destination = paths.artifact_destination / instance_uuid
try:
target.sync_from(paths.artifact_source, destination)
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)
state["phase"] = "stopped"
write_state(paths, state)
print(f"powered_off={instance_uuid}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("operation", choices=("discover", "provision", "status", "shutdown"))
parser.add_argument("--image-uuid", default="")
parser.add_argument("--disk-gb", type=int, default=120)
parser.add_argument("--data-center", action="append", default=[])
parser.add_argument("--confirm-spend", default="")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
paths = default_paths()
client = AutoDLClient(os.environ.get("AUTODL_API_TOKEN", ""))
if args.operation == "discover":
discover(client)
elif args.operation == "provision":
provision(args, client, paths)
elif args.operation == "status":
status(client, paths)
else:
shutdown(client, paths)
except (ControllerError, OSError, subprocess.CalledProcessError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+62
View File
@@ -0,0 +1,62 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
import controller
class ControllerTests(unittest.TestCase):
def test_runner_release_is_pinned(self):
self.assertEqual(controller.RUNNER_VERSION, "3.5.0")
self.assertEqual(len(controller.RUNNER_SHA256), 64)
def test_instance_spec_uses_pro6000_and_cuda_128(self):
spec = controller.instance_spec("image-test", 120, [])
self.assertEqual(spec["gpu_spec_uuid"], "pro6000-p")
self.assertEqual(spec["cuda_v_from"], 128)
self.assertNotIn("data_center_list", spec)
def test_instance_spec_preserves_data_center_preference(self):
spec = controller.instance_spec("image-test", 100, ["westDC3"])
self.assertEqual(spec["data_center_list"], ["westDC3"])
def test_instance_spec_rejects_missing_image(self):
with self.assertRaises(controller.ControllerError):
controller.instance_spec("", 120, [])
def test_runner_has_required_host_labels_and_no_docker(self):
rendered = controller.runner_yaml()
self.assertIn('"ubuntu-latest:host"', rendered)
self.assertIn('"ces-ci:host"', rendered)
self.assertIn('"gpu:host"', rendered)
self.assertIn('docker_host: "-"', rendered)
def test_state_is_private_and_round_trips(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
paths = controller.Paths(
root / "model",
"/model",
"/artifacts",
root / "artifacts",
root / "key",
root / "known_hosts",
root / "state.json",
root / "runner",
)
controller.write_state(paths, {"instance_uuid": "pro-test"})
self.assertEqual(controller.read_state(paths)["instance_uuid"], "pro-test")
self.assertEqual(os.stat(paths.state_file).st_mode & 0o777, 0o600)
def test_example_workflow_files_are_valid_json_free(self):
# Guards against accidentally treating workflow YAML as controller state.
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "value.json"
path.write_text(json.dumps({"ok": True}), encoding="utf-8")
self.assertTrue(json.loads(path.read_text())["ok"])
if __name__ == "__main__":
unittest.main()