verulla-cli 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
verulla_cli/emulate.py ADDED
@@ -0,0 +1,124 @@
1
+ """Docker-based local emulation for `verulla dev` (M4 T4.3).
2
+
3
+ This mirrors the manifest -> BuildPlan translation layer shared with
4
+ `deploy`, but builds and runs the resulting image locally via the
5
+ `docker` Python SDK instead of shipping it to the control plane.
6
+
7
+ Design note on testability (same honesty pattern as the no-Postgres-locally
8
+ constraint elsewhere in this codebase): there is no Docker daemon in this
9
+ sandbox. `generate_dockerfile` is pure logic and is fully tested for real.
10
+ `DockerRunner`'s orchestration (which SDK calls happen, in what order, with
11
+ what arguments, how errors map to clean messages) is unit-tested against a
12
+ hand-rolled fake Docker client double. A real, skip-guarded integration test
13
+ exercises the actual `docker` SDK end to end on a machine that has Docker.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ from verulla_manifest.dockerfile import generate_dockerfile
21
+ from verulla_manifest.translate import FunctionBuild
22
+
23
+
24
+ class EmulationError(Exception):
25
+ """Raised for any failure building or running a local preview container."""
26
+
27
+
28
+ class DockerRunner:
29
+ """Thin wrapper over the docker SDK; accepts an injected client for testing."""
30
+
31
+ def __init__(self, client=None):
32
+ if client is not None:
33
+ self.client = client
34
+ return
35
+ try:
36
+ import docker
37
+
38
+ self.client = docker.from_env()
39
+ self.client.ping()
40
+ except Exception as exc: # noqa: BLE001 - docker SDK raises many exception types here
41
+ raise EmulationError(
42
+ f"could not connect to Docker (is it installed and running?): {exc}"
43
+ ) from exc
44
+
45
+ def stop_and_remove(self, name: str) -> None:
46
+ import docker.errors
47
+
48
+ try:
49
+ container = self.client.containers.get(name)
50
+ except docker.errors.NotFound:
51
+ return
52
+ try:
53
+ container.stop(timeout=2)
54
+ except docker.errors.APIError:
55
+ pass
56
+ container.remove(force=True)
57
+
58
+ def build_image(self, context_dir: Path, dockerfile_name: str, tag: str) -> str:
59
+ import docker.errors
60
+
61
+ try:
62
+ image, _logs = self.client.images.build(
63
+ path=str(context_dir), dockerfile=dockerfile_name, tag=tag, rm=True
64
+ )
65
+ except docker.errors.BuildError as exc:
66
+ raise EmulationError(f"docker build failed for {tag!r}: {exc}") from exc
67
+ except docker.errors.APIError as exc:
68
+ raise EmulationError(f"docker API error building {tag!r}: {exc}") from exc
69
+ return image.id
70
+
71
+ def check_image(self, context_dir: Path, dockerfile_name: str, tag: str) -> str:
72
+ """Build a smoke-test Dockerfile and return its ROUTE status log lines."""
73
+ import docker.errors
74
+
75
+ try:
76
+ _image, logs = self.client.images.build(
77
+ path=str(context_dir), dockerfile=dockerfile_name, tag=tag, rm=True
78
+ )
79
+ except docker.errors.BuildError as exc:
80
+ logs = getattr(exc, "build_log", [])
81
+ output = "".join(
82
+ item.get("stream", "") if isinstance(item, dict) else str(item)
83
+ for item in logs
84
+ )
85
+ if "ROUTE " in output:
86
+ return output
87
+ detail = output.strip() or str(exc)
88
+ raise EmulationError(f"docker check build failed: {detail}") from exc
89
+ except docker.errors.APIError as exc:
90
+ raise EmulationError(f"docker API error checking routes: {exc}") from exc
91
+ return "".join(
92
+ str(item.get("stream", "")) if isinstance(item, dict) else str(item)
93
+ for item in logs
94
+ )
95
+
96
+ def run_container(self, tag: str, host_port: int, container_port: int, name: str) -> str:
97
+ import docker.errors
98
+
99
+ self.stop_and_remove(name)
100
+ try:
101
+ container = self.client.containers.run(
102
+ tag,
103
+ detach=True,
104
+ ports={f"{container_port}/tcp": host_port},
105
+ name=name,
106
+ )
107
+ except docker.errors.APIError as exc:
108
+ raise EmulationError(f"docker run failed for {name!r}: {exc}") from exc
109
+ return container.id
110
+
111
+
112
+ def emulate_function(build: FunctionBuild, runner: DockerRunner, host_port: int) -> str:
113
+ """Build and run one function's container locally; returns its preview URL."""
114
+ plan = build.build_plan
115
+ if plan.dockerfile_path is not None:
116
+ dockerfile_name = plan.dockerfile_path
117
+ else:
118
+ dockerfile_name = ".verulla.Dockerfile"
119
+ (build.src_root / dockerfile_name).write_text(generate_dockerfile(plan))
120
+ tag = f"verulla-dev-{build.name}:latest"
121
+ container_name = f"verulla-dev-{build.name}"
122
+ runner.build_image(build.src_root, dockerfile_name, tag)
123
+ runner.run_container(tag, host_port, plan.port, container_name)
124
+ return f"http://localhost:{host_port}"