Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ ate-env manifest template \
--snapshots-bucket gs://$GOOGLE_CLOUD_PROJECT/ate-env/ | kubectl-ate create actor-template -f -
```

That template's container is the guest image itself. To run another image unmodified, name
it with `--task-image`: the guest is then mounted into it as a read-only OCI image volume at
`/ate` and started from there, so the task image is never rebuilt.

```bash
ate-env manifest template --template py312 \
--task-image docker.io/library/python@sha256:<digest> \
--guest-image <registry>/ate-env-guest@sha256:<digest> \
--snapshots-bucket <object-storage-url> | kubectl-ate create actor-template -f -
```

See [docs/task-images/README.md](docs/task-images/README.md) for the full guide, including
creating environments from an image on demand, [docs/task-images/RUNTIMES.md](docs/task-images/RUNTIMES.md)
for injecting a second runtime as a layer, and [docs/task-images/DESIGN.md](docs/task-images/DESIGN.md)
for the design.

Then create and use an environment:

```bash
Expand All @@ -74,6 +90,11 @@ kubectl port-forward -n ate-env svc/ate-env-api 7777:7777 &
# Create an environment.
ate-env create dev1

# Or run any digest-pinned image unmodified. ate-env-api derives a template
# from default-template on first use (guest mounted in as an image volume)
# and reuses it for later environments on the same image.
ate-env create py1 --image docker.io/library/python@sha256:<digest>

# Execute a shell command inside the environment.
ate-env dev1 shell 'echo hello > /note.txt'

Expand Down Expand Up @@ -152,7 +173,7 @@ Manages the lifecycle of isolated execution environments (defined in [`proto/ate

| RPC | Description |
| --- | ----------- |
| `CreateEnvironment` | Creates and starts a new environment actor from an ActorTemplate |
| `CreateEnvironment` | Creates and starts a new environment actor from an ActorTemplate, or from a digest-pinned `image` on top of one: the template becomes the base, the image the container, and the guest is mounted in as a read-only image volume |
| `GetEnvironment` | Retrieves environment details and status |
| `SuspendEnvironment` | Suspends and checkpoints the environment to snapshot storage |
| `DeleteEnvironment` | Deletes the environment permanently |
Expand Down
16 changes: 15 additions & 1 deletion clients/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ env = await client.create("dev1", template_name="my-template",
template_atespace="my-atespace")
```

To run an arbitrary OCI image, pass it pinned by digest. The server derives
an ActorTemplate from the template above, which acts as the base: the image
becomes the container and the `ate-env-guest` is mounted into it as a
read-only image volume, so the image runs unmodified. The derived template
is created on first use and shared by every environment on that image:

```python
env = await client.create("py1", image="docker.io/library/python@sha256:…")
print((await env.info()).template.name) # default-template-<12 hex of the digest>
```

To get a handle to an environment that already exists (no RPC is made):

```python
Expand Down Expand Up @@ -336,4 +347,7 @@ ATE_ENV_API_TARGET=127.0.0.1:17777 .venv/bin/pytest tests/e2e/test_full_stack.py

`ATE_ENV_TEMPLATE` optionally overrides the ActorTemplate used for the
test environment; `ATE_ENV_READY_TIMEOUT` (default 180s) bounds the wait
for the environment to start serving.
for the environment to start serving. `ATE_ENV_TASK_IMAGE`, a digest-pinned
image that has `sh`, enables the create-from-image test, which runs that
image unmodified with the guest injected and checks the derived template is
reused by a second environment.
40 changes: 20 additions & 20 deletions clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,36 +28,36 @@



DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x61teenv/v1alpha/env.proto\x12\x0e\x61teenv.v1alpha\"*\n\x08Template\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"\x8a\x01\n\x0b\x45nvironment\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\x12*\n\x08template\x18\x03 \x01(\x0b\x32\x18.ateenv.v1alpha.Template\x12\x31\n\x06status\x18\x04 \x01(\x0e\x32!.ateenv.v1alpha.EnvironmentStatus\"d\n\x18\x43reateEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\x12*\n\x08template\x18\x03 \x01(\x0b\x32\x18.ateenv.v1alpha.Template\"M\n\x19\x43reateEnvironmentResponse\x12\x30\n\x0b\x65nvironment\x18\x01 \x01(\x0b\x32\x1b.ateenv.v1alpha.Environment\"5\n\x15GetEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"J\n\x16GetEnvironmentResponse\x12\x30\n\x0b\x65nvironment\x18\x01 \x01(\x0b\x32\x1b.ateenv.v1alpha.Environment\"9\n\x19SuspendEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"\x1c\n\x1aSuspendEnvironmentResponse\"8\n\x18\x44\x65leteEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"\x1b\n\x19\x44\x65leteEnvironmentResponse*\xbd\x02\n\x11\x45nvironmentStatus\x12\"\n\x1e\x45NVIRONMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1f\n\x1b\x45NVIRONMENT_STATUS_RESUMING\x10\x01\x12\x1e\n\x1a\x45NVIRONMENT_STATUS_RUNNING\x10\x02\x12!\n\x1d\x45NVIRONMENT_STATUS_SUSPENDING\x10\x03\x12 \n\x1c\x45NVIRONMENT_STATUS_SUSPENDED\x10\x04\x12\x1e\n\x1a\x45NVIRONMENT_STATUS_PAUSING\x10\x05\x12\x1d\n\x19\x45NVIRONMENT_STATUS_PAUSED\x10\x06\x12\x1e\n\x1a\x45NVIRONMENT_STATUS_CRASHED\x10\x07\x12\x1f\n\x1b\x45NVIRONMENT_STATUS_DELETING\x10\x08\x32\xb6\x03\n\x12\x45nvironmentService\x12h\n\x11\x43reateEnvironment\x12(.ateenv.v1alpha.CreateEnvironmentRequest\x1a).ateenv.v1alpha.CreateEnvironmentResponse\x12_\n\x0eGetEnvironment\x12%.ateenv.v1alpha.GetEnvironmentRequest\x1a&.ateenv.v1alpha.GetEnvironmentResponse\x12k\n\x12SuspendEnvironment\x12).ateenv.v1alpha.SuspendEnvironmentRequest\x1a*.ateenv.v1alpha.SuspendEnvironmentResponse\x12h\n\x11\x44\x65leteEnvironment\x12(.ateenv.v1alpha.DeleteEnvironmentRequest\x1a).ateenv.v1alpha.DeleteEnvironmentResponseBCZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alphab\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x61teenv/v1alpha/env.proto\x12\x0e\x61teenv.v1alpha\"*\n\x08Template\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"\x8a\x01\n\x0b\x45nvironment\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\x12*\n\x08template\x18\x03 \x01(\x0b\x32\x18.ateenv.v1alpha.Template\x12\x31\n\x06status\x18\x04 \x01(\x0e\x32!.ateenv.v1alpha.EnvironmentStatus\"s\n\x18\x43reateEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\x12*\n\x08template\x18\x03 \x01(\x0b\x32\x18.ateenv.v1alpha.Template\x12\r\n\x05image\x18\x04 \x01(\t\"M\n\x19\x43reateEnvironmentResponse\x12\x30\n\x0b\x65nvironment\x18\x01 \x01(\x0b\x32\x1b.ateenv.v1alpha.Environment\"5\n\x15GetEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"J\n\x16GetEnvironmentResponse\x12\x30\n\x0b\x65nvironment\x18\x01 \x01(\x0b\x32\x1b.ateenv.v1alpha.Environment\"9\n\x19SuspendEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"\x1c\n\x1aSuspendEnvironmentResponse\"8\n\x18\x44\x65leteEnvironmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x10\n\x08\x61tespace\x18\x02 \x01(\t\"\x1b\n\x19\x44\x65leteEnvironmentResponse*\xbd\x02\n\x11\x45nvironmentStatus\x12\"\n\x1e\x45NVIRONMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1f\n\x1b\x45NVIRONMENT_STATUS_RESUMING\x10\x01\x12\x1e\n\x1a\x45NVIRONMENT_STATUS_RUNNING\x10\x02\x12!\n\x1d\x45NVIRONMENT_STATUS_SUSPENDING\x10\x03\x12 \n\x1c\x45NVIRONMENT_STATUS_SUSPENDED\x10\x04\x12\x1e\n\x1a\x45NVIRONMENT_STATUS_PAUSING\x10\x05\x12\x1d\n\x19\x45NVIRONMENT_STATUS_PAUSED\x10\x06\x12\x1e\n\x1a\x45NVIRONMENT_STATUS_CRASHED\x10\x07\x12\x1f\n\x1b\x45NVIRONMENT_STATUS_DELETING\x10\x08\x32\xb6\x03\n\x12\x45nvironmentService\x12h\n\x11\x43reateEnvironment\x12(.ateenv.v1alpha.CreateEnvironmentRequest\x1a).ateenv.v1alpha.CreateEnvironmentResponse\x12_\n\x0eGetEnvironment\x12%.ateenv.v1alpha.GetEnvironmentRequest\x1a&.ateenv.v1alpha.GetEnvironmentResponse\x12k\n\x12SuspendEnvironment\x12).ateenv.v1alpha.SuspendEnvironmentRequest\x1a*.ateenv.v1alpha.SuspendEnvironmentResponse\x12h\n\x11\x44\x65leteEnvironment\x12(.ateenv.v1alpha.DeleteEnvironmentRequest\x1a).ateenv.v1alpha.DeleteEnvironmentResponseBCZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alphab\x06proto3')

_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ateenv.v1alpha.env_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
_globals['DESCRIPTOR']._options = None
_globals['DESCRIPTOR']._serialized_options = b'ZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alpha'
_globals['_ENVIRONMENTSTATUS']._serialized_start=718
_globals['_ENVIRONMENTSTATUS']._serialized_end=1035
_globals['_ENVIRONMENTSTATUS']._serialized_start=733
_globals['_ENVIRONMENTSTATUS']._serialized_end=1050
_globals['_TEMPLATE']._serialized_start=44
_globals['_TEMPLATE']._serialized_end=86
_globals['_ENVIRONMENT']._serialized_start=89
_globals['_ENVIRONMENT']._serialized_end=227
_globals['_CREATEENVIRONMENTREQUEST']._serialized_start=229
_globals['_CREATEENVIRONMENTREQUEST']._serialized_end=329
_globals['_CREATEENVIRONMENTRESPONSE']._serialized_start=331
_globals['_CREATEENVIRONMENTRESPONSE']._serialized_end=408
_globals['_GETENVIRONMENTREQUEST']._serialized_start=410
_globals['_GETENVIRONMENTREQUEST']._serialized_end=463
_globals['_GETENVIRONMENTRESPONSE']._serialized_start=465
_globals['_GETENVIRONMENTRESPONSE']._serialized_end=539
_globals['_SUSPENDENVIRONMENTREQUEST']._serialized_start=541
_globals['_SUSPENDENVIRONMENTREQUEST']._serialized_end=598
_globals['_SUSPENDENVIRONMENTRESPONSE']._serialized_start=600
_globals['_SUSPENDENVIRONMENTRESPONSE']._serialized_end=628
_globals['_DELETEENVIRONMENTREQUEST']._serialized_start=630
_globals['_DELETEENVIRONMENTREQUEST']._serialized_end=686
_globals['_DELETEENVIRONMENTRESPONSE']._serialized_start=688
_globals['_DELETEENVIRONMENTRESPONSE']._serialized_end=715
_globals['_ENVIRONMENTSERVICE']._serialized_start=1038
_globals['_ENVIRONMENTSERVICE']._serialized_end=1476
_globals['_CREATEENVIRONMENTREQUEST']._serialized_end=344
_globals['_CREATEENVIRONMENTRESPONSE']._serialized_start=346
_globals['_CREATEENVIRONMENTRESPONSE']._serialized_end=423
_globals['_GETENVIRONMENTREQUEST']._serialized_start=425
_globals['_GETENVIRONMENTREQUEST']._serialized_end=478
_globals['_GETENVIRONMENTRESPONSE']._serialized_start=480
_globals['_GETENVIRONMENTRESPONSE']._serialized_end=554
_globals['_SUSPENDENVIRONMENTREQUEST']._serialized_start=556
_globals['_SUSPENDENVIRONMENTREQUEST']._serialized_end=613
_globals['_SUSPENDENVIRONMENTRESPONSE']._serialized_start=615
_globals['_SUSPENDENVIRONMENTRESPONSE']._serialized_end=643
_globals['_DELETEENVIRONMENTREQUEST']._serialized_start=645
_globals['_DELETEENVIRONMENTREQUEST']._serialized_end=701
_globals['_DELETEENVIRONMENTRESPONSE']._serialized_start=703
_globals['_DELETEENVIRONMENTRESPONSE']._serialized_end=730
_globals['_ENVIRONMENTSERVICE']._serialized_start=1053
_globals['_ENVIRONMENTSERVICE']._serialized_end=1491
# @@protoc_insertion_point(module_scope)
6 changes: 4 additions & 2 deletions clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,16 @@ class Environment(_message.Message):
def __init__(self, id: _Optional[str] = ..., atespace: _Optional[str] = ..., template: _Optional[_Union[Template, _Mapping]] = ..., status: _Optional[_Union[EnvironmentStatus, str]] = ...) -> None: ...

class CreateEnvironmentRequest(_message.Message):
__slots__ = ("id", "atespace", "template")
__slots__ = ("id", "atespace", "template", "image")
ID_FIELD_NUMBER: _ClassVar[int]
ATESPACE_FIELD_NUMBER: _ClassVar[int]
TEMPLATE_FIELD_NUMBER: _ClassVar[int]
IMAGE_FIELD_NUMBER: _ClassVar[int]
id: str
atespace: str
template: Template
def __init__(self, id: _Optional[str] = ..., atespace: _Optional[str] = ..., template: _Optional[_Union[Template, _Mapping]] = ...) -> None: ...
image: str
def __init__(self, id: _Optional[str] = ..., atespace: _Optional[str] = ..., template: _Optional[_Union[Template, _Mapping]] = ..., image: _Optional[str] = ...) -> None: ...

class CreateEnvironmentResponse(_message.Message):
__slots__ = ("environment",)
Expand Down
10 changes: 9 additions & 1 deletion clients/python/src/ate_env/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,21 @@ async def create(
atespace: str = DEFAULT_ATESPACE,
template_name: str | None = None,
template_atespace: str | None = None,
image: str | None = None,
) -> Env:
"""Register and start a new environment; returns a handle to it.

The server fills defaults for the template (name "default-template" in
atespace "ate-env") when none is given.

With image (a digest-pinned OCI reference, repo@sha256:...), the
environment runs that image unmodified: the server derives an
ActorTemplate from the template, which then acts as the base, with the
image as the container and the ate-env-guest mounted into it as an
image volume. The derived template is created on first use and reused
for later environments on the same image; info() reports its name.
"""
req = env_pb2.CreateEnvironmentRequest(id=id, atespace=atespace)
req = env_pb2.CreateEnvironmentRequest(id=id, atespace=atespace, image=image or "")
if template_name or template_atespace:
req.template.name = template_name or ""
req.template.atespace = template_atespace or ""
Expand Down
87 changes: 87 additions & 0 deletions clients/python/tests/e2e/test_full_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
Unlike test_guest_daemon.py, this exercises the whole path: environment
lifecycle against the Substrate control plane, and guest operations proxied
by ate-env-api through the atenet router into the environment's actor.

Set ATE_ENV_TASK_IMAGE to a digest-pinned image that has `sh` (for example
docker.io/library/python@sha256:...) to also run the create-from-image test,
which checks that an unmodified image runs with the guest injected as an
image volume and that a second environment reuses the derived template.
"""

from __future__ import annotations
Expand All @@ -36,19 +41,25 @@

import pytest

import grpc

from ate_env import (
Client,
EnvError,
EnvironmentStatus,
InvalidArgumentError,
OutputSource,
NotFoundError,
ProcessStatus,
RpcError,
)

TARGET = os.environ.get("ATE_ENV_API_TARGET")
READY_TIMEOUT = float(os.environ.get("ATE_ENV_READY_TIMEOUT", "180"))
# Optional ActorTemplate override; the server default is "default-template".
TEMPLATE = os.environ.get("ATE_ENV_TEMPLATE")
# Optional digest-pinned task image for the create-from-image test.
TASK_IMAGE = os.environ.get("ATE_ENV_TASK_IMAGE")

pytestmark = pytest.mark.skipif(
not TARGET,
Expand Down Expand Up @@ -164,3 +175,79 @@ async def test_full_lifecycle(client):
return
await asyncio.sleep(2)
pytest.fail(f"environment {env_id} still exists after delete")


async def test_create_from_image_rejects_unpinned_image(client):
# Validated before anything touches the control plane.
with pytest.raises(InvalidArgumentError):
await client.create(f"pye2e-img-{uuid.uuid4().hex[:8]}", image="python:3.12-slim")


async def test_create_from_image_needs_a_base_template(client):
missing = f"pye2e-nobase-{uuid.uuid4().hex[:8]}"
with pytest.raises(RpcError) as excinfo:
await client.create(
f"pye2e-img-{uuid.uuid4().hex[:8]}",
template_name=missing,
image="docker.io/library/busybox@sha256:" + "0" * 64,
)
assert excinfo.value.code == grpc.StatusCode.FAILED_PRECONDITION
assert missing in str(excinfo.value)


@pytest.mark.skipif(
not TASK_IMAGE,
reason="set ATE_ENV_TASK_IMAGE=repo@sha256:... (an image with sh) for the create-from-image test",
)
async def test_create_from_image(client):
base = TEMPLATE or "default-template"
want_template = f"{base}-{TASK_IMAGE.split('@sha256:', 1)[1][:12]}"
envs = []
try:
env = await client.create(
f"pye2e-img-{uuid.uuid4().hex[:8]}", template_name=TEMPLATE, image=TASK_IMAGE
)
envs.append(env)

# The derived template is reported at once, before the actor serves.
info = await env.info()
assert info.template is not None
assert info.template.name == want_template
assert info.template.atespace == env.atespace

await _wait_until_serving(env)

# The process runs in the task image's rootfs, with the guest coming
# from the image volume rather than from the image itself.
result = await env.shell(
"test -x /ate/ko-app/ate-env-guest && echo guest:volume; "
"test -e /ko-app/ate-env-guest && echo guest:baked; "
"test -r /etc/os-release && echo rootfs:ok"
)
assert result.exit_code == 0, result
lines = result.stdout.splitlines()
assert "guest:volume" in lines, result
assert "guest:baked" not in lines, "the task image should not carry the guest"
assert "rootfs:ok" in lines, result

# The workspace is writable and the guest file path works unchanged.
content = os.urandom(64 * 1024 + 1)
path = f"/tmp/pye2e-img-{uuid.uuid4().hex[:8]}.bin"
assert await env.write_file(path, content) == len(content)
assert await env.read_file_bytes(path) == content

# A second environment on the same image reuses the derived template
# instead of minting another one.
env2 = await client.create(
f"pye2e-img-{uuid.uuid4().hex[:8]}", template_name=TEMPLATE, image=TASK_IMAGE
)
envs.append(env2)
assert (await env2.info()).template.name == want_template
await _wait_until_serving(env2)
assert (await env2.shell("echo second")).stdout == "second\n"
finally:
for e in envs:
try:
await e.delete()
except EnvError as exc:
pytest.fail(f"cleanup delete of {e.id} failed: {exc}")
7 changes: 7 additions & 0 deletions clients/python/tests/fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ async def CreateEnvironment(self, request, context):
template.name = request.template.name
if request.template.atespace:
template.atespace = request.template.atespace
if request.image:
# Mirror ate-env-api: the template becomes the base of one derived
# per image, named after the digest.
if "@sha256:" not in request.image:
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "image is not pinned by digest")
digest = request.image.split("@sha256:", 1)[1]
template.name = f"{template.name}-{digest[:12]}"
environment = env_pb2.Environment(
id=request.id,
atespace=atespace,
Expand Down
24 changes: 24 additions & 0 deletions clients/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ async def test_create_omits_template_when_not_given(fake_stack):
assert not fakes.environments.last_create_request.HasField("template")


async def test_create_with_image_reports_derived_template(fake_stack):
client, fakes = fake_stack
image = "docker.io/library/python@sha256:" + "0123456789abcdef" * 4
env = await client.create("dev3", image=image)
assert fakes.environments.last_create_request.image == image
info = await env.info()
assert info.template is not None
assert info.template.name == "default-template-0123456789ab"


async def test_create_with_image_and_base_template(fake_stack):
client, fakes = fake_stack
image = "docker.io/library/python@sha256:" + "0123456789abcdef" * 4
env = await client.create("dev4", template_name="py-base", image=image)
info = await env.info()
assert info.template.name == "py-base-0123456789ab"


async def test_create_omits_image_when_not_given(fake_stack):
client, fakes = fake_stack
await client.create("dev5")
assert fakes.environments.last_create_request.image == ""


async def test_create_duplicate_maps_to_rpc_error_with_code(fake_stack):
client, _ = fake_stack
await client.create("dev1")
Expand Down
Loading
Loading