Fix blocking sync I/O on the event loop across containers, jobs, and xmlrpc
DevPlace CI / test (push) Failing after 26m53s
DevPlace CI / test (push) Failing after 26m53s
The container/workspace reachability probes (socket connect + HTTP check) ran synchronously with real timeouts inside async request handlers and the live-view relay's 3-4s ticks, freezing the whole event loop whenever a container wasn't cleanly reachable - the likely cause of the periodic app-wide stalls. Converted the probe chain (api._port_reachable/_http_probe, editor_reachable, instance_runtime, provision.editor_ready/view) to real async I/O and parallelized the admin container/workspace list decorators. Also fixes: XmlrpcService.on_disable blocked up to 10s on a synchronous subprocess.wait inside an async method (now matches TelegramService's async-subprocess pattern); JobService._sweep_expired ran every job kind's cleanup() - including shutil.rmtree on large directories - synchronously on every tick, now offloaded via asyncio.to_thread for all job kinds at once; and several smaller blocking reads/writes on request/service paths (attachment-to-gitea mirroring, stealth chunked downloads, dbapi/isslop file reads, job payload/report I/O) moved off the loop thread. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -501,7 +501,7 @@ async def mirror_attachment_to_gitea(uid):
|
|||||||
return None
|
return None
|
||||||
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
|
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
|
||||||
try:
|
try:
|
||||||
data = path.read_bytes()
|
data = await asyncio.to_thread(path.read_bytes)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
|
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ async def container_instance_page(request: Request, uid: str):
|
|||||||
"events": store.list_events(inst["uid"]),
|
"events": store.list_events(inst["uid"]),
|
||||||
"schedules": store.list_schedules(inst["uid"]),
|
"schedules": store.list_schedules(inst["uid"]),
|
||||||
"stats": api.instance_stats(inst["uid"]),
|
"stats": api.instance_stats(inst["uid"]),
|
||||||
"runtime": api.instance_runtime(inst),
|
"runtime": await api.instance_runtime(inst),
|
||||||
"can_manage": can_manage,
|
"can_manage": can_manage,
|
||||||
"admin_section": "containers",
|
"admin_section": "containers",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
@@ -31,14 +32,14 @@ from devplacepy.utils import create_notification, generate_uid, not_found, requi
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _decorate(rows: list[dict]) -> list[dict]:
|
async def _decorate(rows: list[dict]) -> list[dict]:
|
||||||
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
||||||
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
||||||
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||||
projects = get_projects_by_uids(list(project_uids)) if project_uids else {}
|
projects = get_projects_by_uids(list(project_uids)) if project_uids else {}
|
||||||
|
views = await asyncio.gather(*(provision.view(row) for row in rows))
|
||||||
decorated = []
|
decorated = []
|
||||||
for row in rows:
|
for row, view in zip(rows, views):
|
||||||
view = provision.view(row)
|
|
||||||
owner = owners.get(row.get("workspace_owner_uid", "")) or {}
|
owner = owners.get(row.get("workspace_owner_uid", "")) or {}
|
||||||
project = projects.get(row.get("project_uid", "")) or {}
|
project = projects.get(row.get("project_uid", "")) or {}
|
||||||
view["owner_username"] = owner.get("username", "")
|
view["owner_username"] = owner.get("username", "")
|
||||||
@@ -79,7 +80,7 @@ async def admin_workspaces(request: Request):
|
|||||||
if not isinstance(admin, dict):
|
if not isinstance(admin, dict):
|
||||||
return admin
|
return admin
|
||||||
context = {
|
context = {
|
||||||
"workspaces": _decorate(_all_workspaces()),
|
"workspaces": await _decorate(_all_workspaces()),
|
||||||
"flags": flags.list_flags(),
|
"flags": flags.list_flags(),
|
||||||
"admin_section": "workspaces",
|
"admin_section": "workspaces",
|
||||||
"user": admin,
|
"user": admin,
|
||||||
@@ -104,7 +105,7 @@ async def admin_workspaces_data(request: Request):
|
|||||||
if not isinstance(admin, dict):
|
if not isinstance(admin, dict):
|
||||||
return admin
|
return admin
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"workspaces": _decorate(_all_workspaces()), "flags": flags.list_flags()}
|
{"workspaces": await _decorate(_all_workspaces()), "flags": flags.list_flags()}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -141,7 +142,8 @@ async def dbapi_query_result(request: Request, uid: str):
|
|||||||
if not path.is_relative_to(DBAPI_DIR.resolve()) or not path.is_file():
|
if not path.is_relative_to(DBAPI_DIR.resolve()) or not path.is_file():
|
||||||
return error(404, "Result not available")
|
return error(404, "Result not available")
|
||||||
queue.touch_job(uid, get_int_setting("dbquery_retention_seconds", 604800))
|
queue.touch_job(uid, get_int_setting("dbquery_retention_seconds", 604800))
|
||||||
return JSONResponse(json.loads(path.read_text(encoding="utf-8")))
|
text = await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||||
|
return JSONResponse(json.loads(text))
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/query/{uid}/ws")
|
@router.websocket("/query/{uid}/ws")
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async def instance_detail(request: Request, project_slug: str, uid: str):
|
|||||||
"events": store.list_events(uid),
|
"events": store.list_events(uid),
|
||||||
"schedules": store.list_schedules(uid),
|
"schedules": store.list_schedules(uid),
|
||||||
"stats": api.instance_stats(uid),
|
"stats": api.instance_stats(uid),
|
||||||
"runtime": api.instance_runtime(inst),
|
"runtime": await api.instance_runtime(inst),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async def workspace_page(request: Request, slug: str):
|
|||||||
profile = editor.resolve(user["uid"], instance)
|
profile = editor.resolve(user["uid"], instance)
|
||||||
context = {
|
context = {
|
||||||
"project": project,
|
"project": project,
|
||||||
"workspace": provision.view(instance) if instance else None,
|
"workspace": await provision.view(instance) if instance else None,
|
||||||
"has_workspace": bool(instance),
|
"has_workspace": bool(instance),
|
||||||
"viewer_can_workspace": True,
|
"viewer_can_workspace": True,
|
||||||
"workspace_count": provision.count_for_owner(user["uid"]),
|
"workspace_count": provision.count_for_owner(user["uid"]),
|
||||||
@@ -130,7 +130,7 @@ async def workspace_open(request: Request, slug: str):
|
|||||||
)
|
)
|
||||||
provision.write_manifest(instance)
|
provision.write_manifest(instance)
|
||||||
return action_result(
|
return action_result(
|
||||||
request, f"/projects/{slug}/workspace", data=provision.view(instance)
|
request, f"/projects/{slug}/workspace", data=await provision.view(instance)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,12 +187,12 @@ async def projects_page(
|
|||||||
model=ProjectsOut,
|
model=ProjectsOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _editor_launch(project: dict, user: dict) -> dict:
|
async def _editor_launch(project: dict, user: dict) -> dict:
|
||||||
from devplacepy.services.containers.workspace import editor, provision
|
from devplacepy.services.containers.workspace import editor, provision
|
||||||
|
|
||||||
blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
|
blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
|
||||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||||
if not instance or not provision.editor_ready(instance):
|
if not instance or not await provision.editor_ready(instance):
|
||||||
return blank
|
return blank
|
||||||
slug = project["slug"] or project["uid"]
|
slug = project["slug"] or project["uid"]
|
||||||
profile = editor.resolve(user["uid"], instance)
|
profile = editor.resolve(user["uid"], instance)
|
||||||
@@ -262,7 +262,7 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
|||||||
)
|
)
|
||||||
viewer_can_workspace = can_open_workspace(project, user)
|
viewer_can_workspace = can_open_workspace(project, user)
|
||||||
editor_launch = (
|
editor_launch = (
|
||||||
_editor_launch(project, user)
|
await _editor_launch(project, user)
|
||||||
if viewer_can_workspace
|
if viewer_can_workspace
|
||||||
else {"url": "", "mode": "tab", "width": 0, "height": 0}
|
else {"url": "", "mode": "tab", "width": 0, "height": 0}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -435,7 +436,7 @@ async def isslop_source(request: Request, uid: str, path: str, line: int = 0):
|
|||||||
source_path = (store.media_dir_for(uid) / source_name).resolve()
|
source_path = (store.media_dir_for(uid) / source_name).resolve()
|
||||||
if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file():
|
if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file():
|
||||||
raise not_found("Source not available for this file")
|
raise not_found("Source not available for this file")
|
||||||
text = source_path.read_text(encoding="utf-8", errors="replace")
|
text = await asyncio.to_thread(source_path.read_text, encoding="utf-8", errors="replace")
|
||||||
signals = store.decode_json(result.get("signals"), [])
|
signals = store.decode_json(result.get("signals"), [])
|
||||||
marked: dict[int, list] = {}
|
marked: dict[int, list] = {}
|
||||||
for signal in signals:
|
for signal in signals:
|
||||||
|
|||||||
@@ -674,26 +674,33 @@ def instance_stats(instance_uid: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
async def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
||||||
try:
|
try:
|
||||||
with socket.create_connection((host, port), timeout=timeout):
|
_, writer = await asyncio.wait_for(
|
||||||
return True
|
asyncio.open_connection(host, port), timeout=timeout
|
||||||
except OSError:
|
)
|
||||||
|
except (OSError, asyncio.TimeoutError):
|
||||||
return False
|
return False
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def editor_reachable(instance: dict) -> bool:
|
async def editor_reachable(instance: dict) -> bool:
|
||||||
port = int(instance.get("editor_port") or 0)
|
port = int(instance.get("editor_port") or 0)
|
||||||
host, target_port = tunnel_target(instance, port)
|
host, target_port = tunnel_target(instance, port)
|
||||||
if not host or not target_port:
|
if not host or not target_port:
|
||||||
return False
|
return False
|
||||||
return _port_reachable(host, target_port)
|
return await _port_reachable(host, target_port)
|
||||||
|
|
||||||
|
|
||||||
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
async def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
||||||
try:
|
try:
|
||||||
with stealth.stealth_sync_client(timeout=timeout) as client:
|
async with stealth.stealth_async_client(timeout=timeout) as client:
|
||||||
response = client.get(f"http://{host}:{port}/")
|
response = await client.get(f"http://{host}:{port}/")
|
||||||
return f"HTTP {response.status_code}"
|
return f"HTTP {response.status_code}"
|
||||||
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
|
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
|
||||||
return f"unreachable: {type(exc).__name__}"
|
return f"unreachable: {type(exc).__name__}"
|
||||||
@@ -760,7 +767,7 @@ def tunnel_target(instance: dict, container_port: int) -> tuple:
|
|||||||
return reachable_target(instance, container_port, port_maps)
|
return reachable_target(instance, container_port, port_maps)
|
||||||
|
|
||||||
|
|
||||||
def instance_runtime(instance: dict) -> dict:
|
async def instance_runtime(instance: dict) -> dict:
|
||||||
boot = (instance.get("boot_command") or "").strip()
|
boot = (instance.get("boot_command") or "").strip()
|
||||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||||
container_ip = (instance.get("container_ip") or "").strip()
|
container_ip = (instance.get("container_ip") or "").strip()
|
||||||
@@ -775,13 +782,13 @@ def instance_runtime(instance: dict) -> dict:
|
|||||||
"container": int(mapping.get("container") or 0),
|
"container": int(mapping.get("container") or 0),
|
||||||
"host": host_port,
|
"host": host_port,
|
||||||
"proto": mapping.get("proto", "tcp"),
|
"proto": mapping.get("proto", "tcp"),
|
||||||
"reachable": _port_reachable(probe_host, host_port)
|
"reachable": await _port_reachable(probe_host, host_port)
|
||||||
if host_port
|
if host_port
|
||||||
else False,
|
else False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ingress_serving = (
|
ingress_serving = (
|
||||||
_http_probe(target_host, target_port)
|
await _http_probe(target_host, target_port)
|
||||||
if target_host and target_port
|
if target_host and target_port
|
||||||
else "no ingress port mapped"
|
else "no ingress port mapped"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -241,12 +241,12 @@ def write_manifest(instance: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def editor_ready(instance: dict) -> bool:
|
async def editor_ready(instance: dict) -> bool:
|
||||||
if instance.get("suspended_at"):
|
if instance.get("suspended_at"):
|
||||||
return False
|
return False
|
||||||
if instance.get("status") != store.ST_RUNNING:
|
if instance.get("status") != store.ST_RUNNING:
|
||||||
return False
|
return False
|
||||||
return api.editor_reachable(instance)
|
return await api.editor_reachable(instance)
|
||||||
|
|
||||||
|
|
||||||
def phase(instance: dict, ready: bool) -> str:
|
def phase(instance: dict, ready: bool) -> str:
|
||||||
@@ -264,12 +264,12 @@ def phase(instance: dict, ready: bool) -> str:
|
|||||||
return PHASE_STOPPED
|
return PHASE_STOPPED
|
||||||
|
|
||||||
|
|
||||||
def view(instance: dict) -> dict:
|
async def view(instance: dict) -> dict:
|
||||||
owner_uid = instance.get("workspace_owner_uid", "")
|
owner_uid = instance.get("workspace_owner_uid", "")
|
||||||
limits = quota.resolve(owner_uid, instance)
|
limits = quota.resolve(owner_uid, instance)
|
||||||
disk_used = int(instance.get("disk_bytes") or 0)
|
disk_used = int(instance.get("disk_bytes") or 0)
|
||||||
egress_used = int(instance.get("egress_bytes") or 0)
|
egress_used = int(instance.get("egress_bytes") or 0)
|
||||||
ready = editor_ready(instance)
|
ready = await editor_ready(instance)
|
||||||
current = phase(instance, ready)
|
current = phase(instance, ready)
|
||||||
return {
|
return {
|
||||||
"uid": instance.get("uid", ""),
|
"uid": instance.get("uid", ""),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class DbApiJobService(JobService):
|
|||||||
rows, truncated = await self._stream(uid, verdict.sql, max_rows)
|
rows, truncated = await self._stream(uid, verdict.sql, max_rows)
|
||||||
|
|
||||||
output_dir = self.result_dir(uid)
|
output_dir = self.result_dir(uid)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
||||||
result = {
|
result = {
|
||||||
"sql": verdict.sql,
|
"sql": verdict.sql,
|
||||||
"row_count": len(rows),
|
"row_count": len(rows),
|
||||||
@@ -96,8 +96,10 @@ class DbApiJobService(JobService):
|
|||||||
"suspicious": verdict.suspicious,
|
"suspicious": verdict.suspicious,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
}
|
}
|
||||||
(output_dir / "result.json").write_text(
|
await asyncio.to_thread(
|
||||||
json.dumps(result, default=str), encoding="utf-8"
|
(output_dir / "result.json").write_text,
|
||||||
|
json.dumps(result, default=str),
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
hub.publish(
|
hub.publish(
|
||||||
uid,
|
uid,
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class ContainerController:
|
|||||||
"instance": inst["name"],
|
"instance": inst["name"],
|
||||||
"status": inst["status"],
|
"status": inst["status"],
|
||||||
"ingress_url": self._ingress_url(inst),
|
"ingress_url": self._ingress_url(inst),
|
||||||
"runtime": api.instance_runtime(inst),
|
"runtime": await api.instance_runtime(inst),
|
||||||
"stats": api.instance_stats(inst["uid"]),
|
"stats": api.instance_stats(inst["uid"]),
|
||||||
},
|
},
|
||||||
default=str,
|
default=str,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -73,23 +74,23 @@ class WorkspaceController:
|
|||||||
instance = await provision.ensure(project, user)
|
instance = await provision.ensure(project, user)
|
||||||
instance = provision.resume(instance)
|
instance = provision.resume(instance)
|
||||||
provision.write_manifest(instance)
|
provision.write_manifest(instance)
|
||||||
view = provision.view(instance)
|
view = await provision.view(instance)
|
||||||
view["editor_url"] = (
|
view["editor_url"] = (
|
||||||
f"/projects/{project.get('slug') or project['uid']}/workspace"
|
f"/projects/{project.get('slug') or project['uid']}/workspace"
|
||||||
)
|
)
|
||||||
return view
|
return view
|
||||||
|
|
||||||
def _workspace_status(self, args: dict) -> Any:
|
async def _workspace_status(self, args: dict) -> Any:
|
||||||
return provision.view(self._resolve(args.get("project_slug", "")))
|
return await provision.view(self._resolve(args.get("project_slug", "")))
|
||||||
|
|
||||||
def _workspace_list(self, args: dict) -> Any:
|
async def _workspace_list(self, args: dict) -> Any:
|
||||||
table = get_table("instances")
|
table = get_table("instances")
|
||||||
filters: dict[str, Any] = {"is_workspace": 1, "deleted_at": None}
|
filters: dict[str, Any] = {"is_workspace": 1, "deleted_at": None}
|
||||||
if not self.admin:
|
if not self.admin:
|
||||||
filters["workspace_owner_uid"] = self.owner_id
|
filters["workspace_owner_uid"] = self.owner_id
|
||||||
return {
|
rows = list(table.find(**filters))
|
||||||
"workspaces": [provision.view(row) for row in table.find(**filters)]
|
views = await asyncio.gather(*(provision.view(row) for row in rows))
|
||||||
}
|
return {"workspaces": list(views)}
|
||||||
|
|
||||||
def _workspace_stop(self, args: dict) -> Any:
|
def _workspace_stop(self, args: dict) -> Any:
|
||||||
instance = self._resolve(args.get("project_slug", ""))
|
instance = self._resolve(args.get("project_slug", ""))
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class JobService(BaseService):
|
|||||||
self._reap()
|
self._reap()
|
||||||
self._recover_orphans(min_age_seconds=self.job_timeout_seconds())
|
self._recover_orphans(min_age_seconds=self.job_timeout_seconds())
|
||||||
self._refill()
|
self._refill()
|
||||||
self._sweep_expired()
|
await self._sweep_expired()
|
||||||
|
|
||||||
def _reap(self) -> None:
|
def _reap(self) -> None:
|
||||||
for uid in list(self._inflight):
|
for uid in list(self._inflight):
|
||||||
@@ -208,7 +208,7 @@ class JobService(BaseService):
|
|||||||
)
|
)
|
||||||
self.log(f"Recovered orphaned job {uid} (retry {retry_count + 1})")
|
self.log(f"Recovered orphaned job {uid} (retry {retry_count + 1})")
|
||||||
|
|
||||||
def _sweep_expired(self) -> None:
|
async def _sweep_expired(self) -> None:
|
||||||
table = get_table("jobs")
|
table = get_table("jobs")
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for row in table.find(kind=self.kind, status=queue.DONE):
|
for row in table.find(kind=self.kind, status=queue.DONE):
|
||||||
@@ -217,7 +217,7 @@ class JobService(BaseService):
|
|||||||
continue
|
continue
|
||||||
job = queue.get_job(row["uid"])
|
job = queue.get_job(row["uid"])
|
||||||
try:
|
try:
|
||||||
self.cleanup(job)
|
await asyncio.to_thread(self.cleanup, job)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log(f"Cleanup failed for {row['uid']}: {exc}")
|
self.log(f"Cleanup failed for {row['uid']}: {exc}")
|
||||||
table.delete(uid=row["uid"])
|
table.delete(uid=row["uid"])
|
||||||
|
|||||||
@@ -59,15 +59,17 @@ class DeepsearchService(JobService):
|
|||||||
payload = dict(job.get("payload", {}))
|
payload = dict(job.get("payload", {}))
|
||||||
query = payload.get("query", "")
|
query = payload.get("query", "")
|
||||||
output_dir = self.session_dir(uid)
|
output_dir = self.session_dir(uid)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
||||||
(output_dir / "control.json").write_text(
|
await asyncio.to_thread(
|
||||||
json.dumps({"state": "running"}), encoding="utf-8"
|
(output_dir / "control.json").write_text,
|
||||||
|
json.dumps({"state": "running"}),
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
payload["collection"] = self.collection_name(uid)
|
payload["collection"] = self.collection_name(uid)
|
||||||
payload["model"] = self.get_config()["deepsearch_model"] or INTERNAL_MODEL
|
payload["model"] = self.get_config()["deepsearch_model"] or INTERNAL_MODEL
|
||||||
payload["cached_hashes"] = self._cached_hashes(database)
|
payload["cached_hashes"] = self._cached_hashes(database)
|
||||||
payload_path = output_dir / "payload.json"
|
payload_path = output_dir / "payload.json"
|
||||||
payload_path.write_text(json.dumps(payload), encoding="utf-8")
|
await asyncio.to_thread(payload_path.write_text, json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
actor_kind = job.get("owner_kind") or "system"
|
actor_kind = job.get("owner_kind") or "system"
|
||||||
actor_uid = job.get("owner_id") if job.get("owner_kind") == "user" else None
|
actor_uid = job.get("owner_id") if job.get("owner_kind") == "user" else None
|
||||||
@@ -89,11 +91,11 @@ class DeepsearchService(JobService):
|
|||||||
metadata={"query": query, "error": str(exc)[:200]},
|
metadata={"query": query, "error": str(exc)[:200]},
|
||||||
links=[audit.job(uid)],
|
links=[audit.job(uid)],
|
||||||
)
|
)
|
||||||
shutil.rmtree(output_dir, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, output_dir, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
report = self._load_report(output_dir)
|
report = await self._load_report(output_dir)
|
||||||
self._persist_cache(database, output_dir)
|
await self._persist_cache(database, output_dir)
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
database.update_deepsearch_session(
|
database.update_deepsearch_session(
|
||||||
@@ -158,12 +160,13 @@ class DeepsearchService(JobService):
|
|||||||
if row.get("url_hash")
|
if row.get("url_hash")
|
||||||
]
|
]
|
||||||
|
|
||||||
def _persist_cache(self, database, output_dir: Path) -> None:
|
async def _persist_cache(self, database, output_dir: Path) -> None:
|
||||||
path = output_dir / "url_cache.json"
|
path = output_dir / "url_cache.json"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
entries = json.loads(path.read_text(encoding="utf-8"))
|
text = await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||||
|
entries = json.loads(text)
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return
|
return
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -244,12 +247,13 @@ class DeepsearchService(JobService):
|
|||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
def _load_report(self, output_dir: Path) -> dict:
|
async def _load_report(self, output_dir: Path) -> dict:
|
||||||
report_path = output_dir / "report.json"
|
report_path = output_dir / "report.json"
|
||||||
if not report_path.is_file():
|
if not report_path.is_file():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
return json.loads(report_path.read_text(encoding="utf-8"))
|
text = await asyncio.to_thread(report_path.read_text, encoding="utf-8")
|
||||||
|
return json.loads(text)
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@@ -141,21 +141,23 @@ class IsslopService(JobService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
run_dir = self.run_dir(uid)
|
run_dir = self.run_dir(uid)
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(run_dir.mkdir, parents=True, exist_ok=True)
|
||||||
payload_path = run_dir / "payload.json"
|
payload_path = run_dir / "payload.json"
|
||||||
payload_path.write_text(json.dumps(self._worker_payload(job)), encoding="utf-8")
|
await asyncio.to_thread(
|
||||||
|
payload_path.write_text, json.dumps(self._worker_payload(job)), encoding="utf-8"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, source_url, uid)
|
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, source_url, uid)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
await self._relay(persister, WorkerEvent(KIND_ERROR, f"Workspace rejected: {error}", {}))
|
await self._relay(persister, WorkerEvent(KIND_ERROR, f"Workspace rejected: {error}", {}))
|
||||||
shutil.rmtree(run_dir, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, run_dir, ignore_errors=True)
|
||||||
raise RuntimeError(str(error))
|
raise RuntimeError(str(error))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
final = await self._run_worker(uid, persister, payload_path, workspace)
|
final = await self._run_worker(uid, persister, payload_path, workspace)
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(remove_workspace, workspace)
|
await asyncio.to_thread(remove_workspace, workspace)
|
||||||
shutil.rmtree(run_dir, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, run_dir, ignore_errors=True)
|
||||||
|
|
||||||
if final.kind == KIND_ERROR:
|
if final.kind == KIND_ERROR:
|
||||||
audit.record_system(
|
audit.record_system(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import zlib
|
import zlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -52,9 +53,9 @@ class PlanningReportService(JobService):
|
|||||||
|
|
||||||
final_name = self._final_name(job.get("preferred_name", ""), markdown)
|
final_name = self._final_name(job.get("preferred_name", ""), markdown)
|
||||||
target_dir = PLANNING_REPORTS_DIR / _directory_for(uid)
|
target_dir = PLANNING_REPORTS_DIR / _directory_for(uid)
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(target_dir.mkdir, parents=True, exist_ok=True)
|
||||||
final_path = target_dir / final_name
|
final_path = target_dir / final_name
|
||||||
final_path.write_text(markdown, encoding="utf-8")
|
await asyncio.to_thread(final_path.write_text, markdown, encoding="utf-8")
|
||||||
bytes_out = len(markdown.encode("utf-8"))
|
bytes_out = len(markdown.encode("utf-8"))
|
||||||
except Exception:
|
except Exception:
|
||||||
audit.record_system(
|
audit.record_system(
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ class SeoService(JobService):
|
|||||||
payload = job.get("payload", {})
|
payload = job.get("payload", {})
|
||||||
target = payload.get("url", "")
|
target = payload.get("url", "")
|
||||||
output_dir = self.report_dir(uid)
|
output_dir = self.report_dir(uid)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
||||||
payload_path = output_dir / "payload.json"
|
payload_path = output_dir / "payload.json"
|
||||||
payload_path.write_text(json.dumps(payload), encoding="utf-8")
|
await asyncio.to_thread(payload_path.write_text, json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
actor_kind = "user" if job.get("owner_kind") == "user" else (
|
actor_kind = "user" if job.get("owner_kind") == "user" else (
|
||||||
job.get("owner_kind") or "system"
|
job.get("owner_kind") or "system"
|
||||||
@@ -63,10 +63,10 @@ class SeoService(JobService):
|
|||||||
metadata={"target": target, "error": str(exc)[:200]},
|
metadata={"target": target, "error": str(exc)[:200]},
|
||||||
links=[audit.job(uid)],
|
links=[audit.job(uid)],
|
||||||
)
|
)
|
||||||
shutil.rmtree(output_dir, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, output_dir, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
report = self._load_report(output_dir)
|
report = await self._load_report(output_dir)
|
||||||
hub.publish(
|
hub.publish(
|
||||||
uid,
|
uid,
|
||||||
{
|
{
|
||||||
@@ -154,12 +154,13 @@ class SeoService(JobService):
|
|||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
def _load_report(self, output_dir: Path) -> dict:
|
async def _load_report(self, output_dir: Path) -> dict:
|
||||||
report_path = output_dir / "report.json"
|
report_path = output_dir / "report.json"
|
||||||
if not report_path.is_file():
|
if not report_path.is_file():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
return json.loads(report_path.read_text(encoding="utf-8"))
|
text = await asyncio.to_thread(report_path.read_text, encoding="utf-8")
|
||||||
|
return json.loads(text)
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ class ZipService(JobService):
|
|||||||
try:
|
try:
|
||||||
item_count = await asyncio.to_thread(self._materialize, source, staging)
|
item_count = await asyncio.to_thread(self._materialize, source, staging)
|
||||||
tmp_dir = ZIPS_DIR / _directory_for(uid)
|
tmp_dir = ZIPS_DIR / _directory_for(uid)
|
||||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(tmp_dir.mkdir, parents=True, exist_ok=True)
|
||||||
tmp_zip = tmp_dir / f"{generate_uid()}.zip"
|
tmp_zip = tmp_dir / f"{generate_uid()}.zip"
|
||||||
stats = await self._run_worker(staging, tmp_zip)
|
stats = await self._run_worker(staging, tmp_zip)
|
||||||
final_name = self._final_name(job.get("preferred_name", ""), stats["crc32"])
|
final_name = self._final_name(job.get("preferred_name", ""), stats["crc32"])
|
||||||
final_path = tmp_dir / final_name
|
final_path = tmp_dir / final_name
|
||||||
tmp_zip.replace(final_path)
|
await asyncio.to_thread(tmp_zip.replace, final_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
audit.record_system(
|
audit.record_system(
|
||||||
"job.zip.failed",
|
"job.zip.failed",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async def _container_detail(match: re.Match) -> Optional[dict]:
|
|||||||
"events": store.list_events(uid),
|
"events": store.list_events(uid),
|
||||||
"schedules": store.list_schedules(uid),
|
"schedules": store.list_schedules(uid),
|
||||||
"stats": api.instance_stats(uid),
|
"stats": api.instance_stats(uid),
|
||||||
"runtime": api.instance_runtime(inst),
|
"runtime": await api.instance_runtime(inst),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ async def _workspace_detail(match: re.Match) -> Optional[dict]:
|
|||||||
project = _instance_project(inst)
|
project = _instance_project(inst)
|
||||||
slug = project.get("slug") or project.get("uid") or ""
|
slug = project.get("slug") or project.get("uid") or ""
|
||||||
return {
|
return {
|
||||||
"workspace": provision.view(inst),
|
"workspace": await provision.view(inst),
|
||||||
"editor_url": (
|
"editor_url": (
|
||||||
f"/projects/{slug}/containers/instances/{inst['uid']}/code/" if slug else ""
|
f"/projects/{slug}/containers/instances/{inst['uid']}/code/" if slug else ""
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
||||||
@@ -24,48 +24,50 @@ class XmlrpcService(BaseService):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__("xmlrpc", interval_seconds=XMLRPC_INTERVAL_SECONDS)
|
super().__init__("xmlrpc", interval_seconds=XMLRPC_INTERVAL_SECONDS)
|
||||||
self._process: subprocess.Popen | None = None
|
self._process: asyncio.subprocess.Process | None = None
|
||||||
|
|
||||||
def _alive(self) -> bool:
|
def _alive(self) -> bool:
|
||||||
return self._process is not None and self._process.poll() is None
|
return self._process is not None and self._process.returncode is None
|
||||||
|
|
||||||
def _spawn(self) -> None:
|
async def _spawn(self) -> None:
|
||||||
self._process = subprocess.Popen(
|
self._process = await asyncio.create_subprocess_exec(
|
||||||
[sys.executable, "-m", SERVER_MODULE],
|
sys.executable,
|
||||||
stdout=subprocess.DEVNULL,
|
"-m",
|
||||||
stderr=subprocess.DEVNULL,
|
SERVER_MODULE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
self.log(
|
self.log(
|
||||||
f"Forking XML-RPC server started (pid {self._process.pid}) on "
|
f"Forking XML-RPC server started (pid {self._process.pid}) on "
|
||||||
f"{XMLRPC_BIND}:{XMLRPC_PORT}"
|
f"{XMLRPC_BIND}:{XMLRPC_PORT}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _terminate(self) -> None:
|
async def _terminate(self) -> None:
|
||||||
if not self._alive():
|
process = self._process
|
||||||
self._process = None
|
|
||||||
return
|
|
||||||
self._process.terminate()
|
|
||||||
try:
|
|
||||||
self._process.wait(timeout=TERMINATE_TIMEOUT_SECONDS)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self._process.kill()
|
|
||||||
self._process.wait()
|
|
||||||
self.log("Forking XML-RPC server stopped")
|
|
||||||
self._process = None
|
self._process = None
|
||||||
|
if process is None or process.returncode is not None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=TERMINATE_TIMEOUT_SECONDS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
self.log("Forking XML-RPC server stopped")
|
||||||
|
|
||||||
async def on_enable(self) -> None:
|
async def on_enable(self) -> None:
|
||||||
if not self._alive():
|
if not self._alive():
|
||||||
self._spawn()
|
await self._spawn()
|
||||||
|
|
||||||
async def on_disable(self) -> None:
|
async def on_disable(self) -> None:
|
||||||
self._terminate()
|
await self._terminate()
|
||||||
|
|
||||||
async def run_once(self) -> None:
|
async def run_once(self) -> None:
|
||||||
if self._alive():
|
if self._alive():
|
||||||
self.log(f"XML-RPC server healthy (pid {self._process.pid})")
|
self.log(f"XML-RPC server healthy (pid {self._process.pid})")
|
||||||
return
|
return
|
||||||
self.log("XML-RPC server not running, starting it")
|
self.log("XML-RPC server not running, starting it")
|
||||||
self._spawn()
|
await self._spawn()
|
||||||
|
|
||||||
def collect_metrics(self) -> dict:
|
def collect_metrics(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ class ChromeStealthClient:
|
|||||||
dest_type: str = "empty",
|
dest_type: str = "empty",
|
||||||
) -> DownloadResult:
|
) -> DownloadResult:
|
||||||
directory = Path(destination)
|
directory = Path(destination)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(directory.mkdir, parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
target = resolve_destination(directory, url, filename)
|
target = resolve_destination(directory, url, filename)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
@@ -505,10 +505,13 @@ class ChromeStealthClient:
|
|||||||
try:
|
try:
|
||||||
async with self.stream("GET", url, referer=referer, dest=dest_type) as response:
|
async with self.stream("GET", url, referer=referer, dest=dest_type) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
with target.open("wb") as handle:
|
handle = await asyncio.to_thread(target.open, "wb")
|
||||||
|
try:
|
||||||
async for chunk in response.aiter_bytes(chunk_size):
|
async for chunk in response.aiter_bytes(chunk_size):
|
||||||
handle.write(chunk)
|
await asyncio.to_thread(handle.write, chunk)
|
||||||
written += len(chunk)
|
written += len(chunk)
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(handle.close)
|
||||||
logger.info("Downloaded %s -> %s (%d bytes)", url, target, written)
|
logger.info("Downloaded %s -> %s (%d bytes)", url, target, written)
|
||||||
return DownloadResult(
|
return DownloadResult(
|
||||||
url=url,
|
url=url,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ def test_transitional_phases_are_exactly_starting_and_stopping():
|
|||||||
def test_editor_ready_when_the_editor_port_accepts_connections(listener):
|
def test_editor_ready_when_the_editor_port_accepts_connections(listener):
|
||||||
port = listener.getsockname()[1]
|
port = listener.getsockname()[1]
|
||||||
row = _instance(container_ip="127.0.0.1", editor_port=port)
|
row = _instance(container_ip="127.0.0.1", editor_port=port)
|
||||||
assert provision.editor_ready(row) is True
|
assert asyncio.run(provision.editor_ready(row)) is True
|
||||||
|
|
||||||
|
|
||||||
def test_editor_ready_is_false_when_nothing_listens():
|
def test_editor_ready_is_false_when_nothing_listens():
|
||||||
@@ -126,12 +127,12 @@ def test_editor_ready_is_false_when_nothing_listens():
|
|||||||
port = probe.getsockname()[1]
|
port = probe.getsockname()[1]
|
||||||
probe.close()
|
probe.close()
|
||||||
row = _instance(container_ip="127.0.0.1", editor_port=port)
|
row = _instance(container_ip="127.0.0.1", editor_port=port)
|
||||||
assert provision.editor_ready(row) is False
|
assert asyncio.run(provision.editor_ready(row)) is False
|
||||||
|
|
||||||
|
|
||||||
def test_editor_ready_is_false_without_a_reachable_target(listener):
|
def test_editor_ready_is_false_without_a_reachable_target(listener):
|
||||||
row = _instance(container_ip="", ports_json="[]")
|
row = _instance(container_ip="", ports_json="[]")
|
||||||
assert provision.editor_ready(row) is False
|
assert asyncio.run(provision.editor_ready(row)) is False
|
||||||
|
|
||||||
|
|
||||||
def test_editor_ready_is_false_unless_the_container_runs(listener):
|
def test_editor_ready_is_false_unless_the_container_runs(listener):
|
||||||
@@ -140,7 +141,7 @@ def test_editor_ready_is_false_unless_the_container_runs(listener):
|
|||||||
if status == store.ST_RUNNING:
|
if status == store.ST_RUNNING:
|
||||||
continue
|
continue
|
||||||
row = _instance(container_ip="127.0.0.1", editor_port=port, status=status)
|
row = _instance(container_ip="127.0.0.1", editor_port=port, status=status)
|
||||||
assert provision.editor_ready(row) is False
|
assert asyncio.run(provision.editor_ready(row)) is False
|
||||||
|
|
||||||
|
|
||||||
def test_editor_ready_is_false_while_suspended(listener):
|
def test_editor_ready_is_false_while_suspended(listener):
|
||||||
@@ -148,7 +149,7 @@ def test_editor_ready_is_false_while_suspended(listener):
|
|||||||
row = _instance(
|
row = _instance(
|
||||||
container_ip="127.0.0.1", editor_port=port, suspended_at="2026-01-01T00:00:00"
|
container_ip="127.0.0.1", editor_port=port, suspended_at="2026-01-01T00:00:00"
|
||||||
)
|
)
|
||||||
assert provision.editor_ready(row) is False
|
assert asyncio.run(provision.editor_ready(row)) is False
|
||||||
|
|
||||||
|
|
||||||
def test_view_carries_the_phase_its_label_readiness_and_the_owner(listener):
|
def test_view_carries_the_phase_its_label_readiness_and_the_owner(listener):
|
||||||
@@ -166,13 +167,13 @@ def test_view_carries_the_phase_its_label_readiness_and_the_owner(listener):
|
|||||||
"ports_json": "[]",
|
"ports_json": "[]",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
view = provision.view(instance)
|
view = asyncio.run(provision.view(instance))
|
||||||
assert view["phase"] == provision.PHASE_READY
|
assert view["phase"] == provision.PHASE_READY
|
||||||
assert view["phase_label"] == "Ready"
|
assert view["phase_label"] == "Ready"
|
||||||
assert view["editor_ready"] is True
|
assert view["editor_ready"] is True
|
||||||
assert view["owner_uid"] == OWNER
|
assert view["owner_uid"] == OWNER
|
||||||
|
|
||||||
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED})
|
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED})
|
||||||
view = provision.view(store.get_instance(instance["uid"]))
|
view = asyncio.run(provision.view(store.get_instance(instance["uid"])))
|
||||||
assert view["phase"] == provision.PHASE_STOPPING
|
assert view["phase"] == provision.PHASE_STOPPING
|
||||||
assert view["editor_ready"] is True
|
assert view["editor_ready"] is True
|
||||||
|
|||||||
Reference in New Issue
Block a user