From a8cc9c56c9595ce5161ed6ef48c14dea4695f844 Mon Sep 17 00:00:00 2001 From: mpbb Date: Thu, 29 Jan 2026 22:29:48 +0000 Subject: [PATCH 1/8] Add aws-sim env CLI plugin --- morphcloud/cli_plugins/__init__.py | 5 + morphcloud/cli_plugins/aws_sim.py | 233 ++++++++++++++++++++++ pyproject.toml | 3 + tests/unit/test_cli_env_aws_sim.py | 300 +++++++++++++++++++++++++++++ 4 files changed, 541 insertions(+) create mode 100644 morphcloud/cli_plugins/__init__.py create mode 100644 morphcloud/cli_plugins/aws_sim.py create mode 100644 tests/unit/test_cli_env_aws_sim.py diff --git a/morphcloud/cli_plugins/__init__.py b/morphcloud/cli_plugins/__init__.py new file mode 100644 index 0000000..27d8979 --- /dev/null +++ b/morphcloud/cli_plugins/__init__.py @@ -0,0 +1,5 @@ +"""morphcloud CLI plugin modules. + +Plugins are loaded via the `morphcloud.cli_plugins` entry point group. +""" + diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py new file mode 100644 index 0000000..72203eb --- /dev/null +++ b/morphcloud/cli_plugins/aws_sim.py @@ -0,0 +1,233 @@ +import json +import os +import pathlib +from typing import Any + +import click +import httpx + + +DEFAULT_SIM_AWS_BASE_URL = "https://sim-aws.svc.cloud.morph.so" +DEFAULT_CONNECTOR_IMAGE = "ghcr.io/morph-labs/sim-aws-connector:latest" +DEFAULT_CONNECT_BUNDLE_FILENAME = "aws-sim-connect-bundle.json" +SRC_VALID_MARK_SYSCTL = "net.ipv4.conf.all.src_valid_mark=1" + + +def _get_sim_aws_base_url() -> str: + return os.environ.get("SIM_AWS_BASE_URL", DEFAULT_SIM_AWS_BASE_URL).rstrip("/") + + +def _get_morph_api_key() -> str: + key = os.environ.get("MORPH_API_KEY") + if not key: + raise click.ClickException( + "Error: MORPH_API_KEY environment variable is not set. " + "Set it (export MORPH_API_KEY='...') and retry." + ) + return key + + +def _http_client() -> httpx.Client: + return httpx.Client( + base_url=_get_sim_aws_base_url(), + headers={"Authorization": f"Bearer {_get_morph_api_key()}"}, + timeout=httpx.Timeout(30.0), + ) + + +def _raise_for_status(resp: httpx.Response) -> None: + try: + resp.raise_for_status() + except httpx.HTTPStatusError as e: + body = "" + try: + body = resp.text + except Exception: + body = "" + msg = f"Sim-AWS request failed: {resp.status_code} {resp.reason_phrase}" + if body: + msg = f"{msg}\n{body}" + raise click.ClickException(msg) from e + + +def _request_json(method: str, path: str, *, json_body: Any | None = None) -> Any: + with _http_client() as client: + resp = client.request(method, path, json=json_body) + _raise_for_status(resp) + if resp.status_code == 204: + return None + return resp.json() + + +def _ensure_group(cli_group: click.Group, name: str, help_text: str) -> click.Group: + existing = cli_group.commands.get(name) + if isinstance(existing, click.Group): + return existing + if existing is not None: + raise click.ClickException( + f"Cannot install aws-sim plugin: command '{name}' already exists and is not a group." + ) + new_group = click.Group(name=name, help=help_text) + cli_group.add_command(new_group) + return new_group + + +def _print_json(data: Any) -> None: + click.echo(json.dumps(data, indent=2, sort_keys=True)) + + +def _docker_run_template(bundle_path: str) -> str: + bundle_path_abs = str(pathlib.Path(bundle_path).expanduser().resolve()) + image = os.environ.get("AWS_SIM_CONNECTOR_IMAGE", DEFAULT_CONNECTOR_IMAGE) + return ( + "docker run --rm -it " + "--cap-add=NET_ADMIN " + "--device /dev/net/tun " + f"--sysctl {SRC_VALID_MARK_SYSCTL} " + "-e MORPH_API_KEY " + f"-v {bundle_path_abs}:/bundle.json:ro " + f"{image} " + "--bundle /bundle.json" + ) + + +def _split_csv_args(values: tuple[str, ...]) -> list[str]: + items: list[str] = [] + for raw in values: + for part in raw.split(","): + part = part.strip() + if part: + items.append(part) + return items + + +def _default_regions() -> list[str]: + raw = os.environ.get("SIM_AWS_REGIONS", "").strip() + if raw: + return _split_csv_args((raw,)) + return ["us-east-1"] + + +def _default_services() -> list[str]: + raw = os.environ.get("SIM_AWS_SERVICES", "").strip() + if raw: + return _split_csv_args((raw,)) + return ["s3", "ec2"] + + +def load(cli_group: click.Group) -> None: + """ + Entry point for the `morphcloud.cli_plugins` group. + + Registers: `morphcloud env aws-sim ...` + """ + + env_group = _ensure_group(cli_group, "env", "Manage external environments.") + aws_sim_group = _ensure_group(env_group, "aws-sim", "Manage Sim-AWS environments.") + + @aws_sim_group.command(name="create") + @click.option( + "--region", + "regions", + multiple=True, + help="Region(s); repeatable and/or comma-separated.", + ) + @click.option( + "--service", + "services", + multiple=True, + help="Service(s); repeatable and/or comma-separated.", + ) + @click.option( + "--ttl-seconds", + "ttl_seconds", + type=int, + default=None, + help="Environment TTL in seconds.", + ) + @click.option( + "--name", + "name", + type=str, + default=None, + help="Optional environment name.", + ) + def create(regions: tuple[str, ...], services: tuple[str, ...], ttl_seconds: int | None, name: str | None) -> None: + """Create a Sim-AWS environment.""" + create_body: dict[str, Any] = { + "regions": _split_csv_args(regions) or _default_regions(), + "services": _split_csv_args(services) or _default_services(), + } + if ttl_seconds is not None: + create_body["ttl_seconds"] = ttl_seconds + if name: + create_body["name"] = name + _print_json(_request_json("POST", "/v1/envs", json_body=create_body)) + + @aws_sim_group.command(name="list") + def list_() -> None: + """List Sim-AWS environments.""" + _print_json(_request_json("GET", "/v1/envs")) + + @aws_sim_group.command(name="get") + @click.argument("env_id") + def get(env_id: str) -> None: + """Get a Sim-AWS environment.""" + _print_json(_request_json("GET", f"/v1/envs/{env_id}")) + + @aws_sim_group.command(name="start") + @click.argument("env_id") + def start(env_id: str) -> None: + """Start a Sim-AWS environment.""" + _print_json(_request_json("POST", f"/v1/envs/{env_id}/start")) + + @aws_sim_group.command(name="pause") + @click.argument("env_id") + def pause(env_id: str) -> None: + """Pause a Sim-AWS environment.""" + _print_json(_request_json("POST", f"/v1/envs/{env_id}/pause")) + + @aws_sim_group.command(name="snapshot") + @click.argument("env_id") + def snapshot(env_id: str) -> None: + """Snapshot a Sim-AWS environment.""" + _print_json(_request_json("POST", f"/v1/envs/{env_id}/snapshot")) + + @aws_sim_group.command(name="restore") + @click.argument("env_id") + @click.argument("snapshot_id") + def restore(env_id: str, snapshot_id: str) -> None: + """Restore a Sim-AWS environment from a snapshot_id.""" + _print_json(_request_json("POST", f"/v1/envs/{env_id}/restore", json_body={"snapshot_id": snapshot_id})) + + @aws_sim_group.command(name="delete") + @click.argument("env_id") + def delete(env_id: str) -> None: + """Delete a Sim-AWS environment.""" + _print_json(_request_json("DELETE", f"/v1/envs/{env_id}")) + + @aws_sim_group.command(name="connect") + @click.argument("env_id") + @click.option( + "--output", + "output_path", + default=DEFAULT_CONNECT_BUNDLE_FILENAME, + show_default=True, + help="Write the connect bundle to this path.", + ) + def connect(env_id: str, output_path: str) -> None: + """ + Fetch a connect bundle, write it to a file, and print a connector `docker run` command. + + The connect bundle is sensitive (WireGuard private key); keep the output file safe. + """ + + bundle = _request_json("POST", f"/v1/envs/{env_id}/connect") + output = pathlib.Path(output_path).expanduser() + output.write_text(json.dumps(bundle, indent=2) + "\n", encoding="utf-8") + try: + os.chmod(output, 0o600) + except Exception: + pass + click.echo(f"Wrote connect bundle to: {output}") + click.echo(_docker_run_template(str(output))) diff --git a/pyproject.toml b/pyproject.toml index ee8be6c..644c14a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,9 @@ dev = [ [project.scripts] morphcloud = "morphcloud.cli:cli" +[project.entry-points."morphcloud.cli_plugins"] +aws-sim = "morphcloud.cli_plugins.aws_sim:load" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tests/unit/test_cli_env_aws_sim.py b/tests/unit/test_cli_env_aws_sim.py new file mode 100644 index 0000000..c43495e --- /dev/null +++ b/tests/unit/test_cli_env_aws_sim.py @@ -0,0 +1,300 @@ +import importlib +import sys + +from click.testing import CliRunner + + +def _import_cli_with_aws_sim_plugin(monkeypatch): + import importlib.metadata + + entry_point = importlib.metadata.EntryPoint( + name="aws-sim", + value="morphcloud.cli_plugins.aws_sim:load", + group="morphcloud.cli_plugins", + ) + + def fake_entry_points(*, group=None, **kwargs): + if group == "morphcloud.cli_plugins": + return [entry_point] + return [] + + monkeypatch.setattr(importlib.metadata, "entry_points", fake_entry_points) + sys.modules.pop("morphcloud.cli", None) + return importlib.import_module("morphcloud.cli") + + +def test_env_aws_sim_plugin_registers_commands(monkeypatch): + cli_mod = _import_cli_with_aws_sim_plugin(monkeypatch) + + runner = CliRunner() + result = runner.invoke(cli_mod.cli, ["env", "aws-sim", "--help"]) + assert result.exit_code == 0, result.output + + +def test_env_aws_sim_list_wires_httpx(monkeypatch): + cli_mod = _import_cli_with_aws_sim_plugin(monkeypatch) + + import morphcloud.cli_plugins.aws_sim as aws_sim_mod + + calls = [] + + class StubResponse: + status_code = 200 + reason_phrase = "OK" + text = "" + + def raise_for_status(self): + return None + + def json(self): + return {"ok": True} + + class StubClient: + def __init__(self, *, base_url, headers, timeout): + calls.append({"base_url": str(base_url), "headers": dict(headers), "timeout": timeout}) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def request(self, method, url, json=None): + calls[-1].update({"method": method, "url": url, "json": json}) + return StubResponse() + + monkeypatch.setattr(aws_sim_mod.httpx, "Client", StubClient) + monkeypatch.setenv("SIM_AWS_BASE_URL", "https://example.test") + monkeypatch.setenv("MORPH_API_KEY", "k_test_123") + + runner = CliRunner() + result = runner.invoke(cli_mod.cli, ["env", "aws-sim", "list"]) + assert result.exit_code == 0, result.output + assert calls == [ + { + "base_url": "https://example.test", + "headers": {"Authorization": "Bearer k_test_123"}, + "timeout": calls[0]["timeout"], + "method": "GET", + "url": "/v1/envs", + "json": None, + } + ] + assert "k_test_123" not in result.output + + +def test_env_aws_sim_create_sends_body(monkeypatch): + cli_mod = _import_cli_with_aws_sim_plugin(monkeypatch) + + import morphcloud.cli_plugins.aws_sim as aws_sim_mod + + calls = [] + + class StubResponse: + status_code = 200 + reason_phrase = "OK" + text = "" + + def raise_for_status(self): + return None + + def json(self): + return {"env_id": "awsenv_test"} + + class StubClient: + def __init__(self, *, base_url, headers, timeout): + calls.append({"base_url": str(base_url), "headers": dict(headers), "timeout": timeout}) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def request(self, method, url, json=None): + calls[-1].update({"method": method, "url": url, "json": json}) + return StubResponse() + + monkeypatch.setattr(aws_sim_mod.httpx, "Client", StubClient) + monkeypatch.setenv("SIM_AWS_BASE_URL", "https://example.test") + monkeypatch.setenv("MORPH_API_KEY", "k_test_123") + + runner = CliRunner() + result = runner.invoke( + cli_mod.cli, + [ + "env", + "aws-sim", + "create", + "--region", + "us-east-1", + "--service", + "s3", + "--ttl-seconds", + "3600", + "--name", + "test-env", + ], + ) + assert result.exit_code == 0, result.output + assert calls == [ + { + "base_url": "https://example.test", + "headers": {"Authorization": "Bearer k_test_123"}, + "timeout": calls[0]["timeout"], + "method": "POST", + "url": "/v1/envs", + "json": { + "name": "test-env", + "regions": ["us-east-1"], + "services": ["s3"], + "ttl_seconds": 3600, + }, + } + ] + assert "k_test_123" not in result.output + + +def test_env_aws_sim_restore_sends_snapshot_id(monkeypatch): + cli_mod = _import_cli_with_aws_sim_plugin(monkeypatch) + + import morphcloud.cli_plugins.aws_sim as aws_sim_mod + + calls = [] + + class StubResponse: + status_code = 200 + reason_phrase = "OK" + text = "" + + def raise_for_status(self): + return None + + def json(self): + return {"ok": True} + + class StubClient: + def __init__(self, *, base_url, headers, timeout): + calls.append({"base_url": str(base_url), "headers": dict(headers), "timeout": timeout}) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def request(self, method, url, json=None): + calls[-1].update({"method": method, "url": url, "json": json}) + return StubResponse() + + monkeypatch.setattr(aws_sim_mod.httpx, "Client", StubClient) + monkeypatch.setenv("SIM_AWS_BASE_URL", "https://example.test") + monkeypatch.setenv("MORPH_API_KEY", "k_test_123") + + runner = CliRunner() + result = runner.invoke( + cli_mod.cli, + ["env", "aws-sim", "restore", "awsenv_test", "awssnap_test"], + ) + assert result.exit_code == 0, result.output + assert calls == [ + { + "base_url": "https://example.test", + "headers": {"Authorization": "Bearer k_test_123"}, + "timeout": calls[0]["timeout"], + "method": "POST", + "url": "/v1/envs/awsenv_test/restore", + "json": {"snapshot_id": "awssnap_test"}, + } + ] + assert "k_test_123" not in result.output + + +def test_env_aws_sim_connect_writes_bundle_and_prints_docker_cmd(monkeypatch): + cli_mod = _import_cli_with_aws_sim_plugin(monkeypatch) + + import morphcloud.cli_plugins.aws_sim as aws_sim_mod + + connect_calls = [] + + class StubResponse: + status_code = 200 + reason_phrase = "OK" + text = "" + + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + class StubClient: + def __init__(self, *, base_url, headers, timeout): + self._headers = dict(headers) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def request(self, method, url, json=None): + connect_calls.append({"method": method, "url": url, "headers": self._headers, "json": json}) + return StubResponse( + { + "version": "v1", + "env_id": "awsenv_test", + "instance_id": "morphvm_test", + "tunnel_ws_url": "wss://example.test/tunnel", + "wg": { + "client_address": "10.0.0.2/32", + "client_private_key": "base64-private", + "server_public_key": "base64-pub", + "allowed_ips": ["10.0.0.0/24"], + "endpoint_host": "127.0.0.1", + "endpoint_port": 51820, + "mtu": 1280, + "persistent_keepalive": 25, + }, + "dns": {"nameserver": "10.0.0.1"}, + "tls": {"ca_cert_pem": "pem", "ca_fingerprint_sha256": "fp"}, + "aws": {"gateway_ip": "10.0.0.3", "regions": ["us-east-1"]}, + "auth": { + "mode": "morph_api_key_bearer", + "header_name": "Authorization", + "header_value_template": "Bearer ${MORPH_API_KEY}", + }, + "notes": [], + } + ) + + monkeypatch.setattr(aws_sim_mod.httpx, "Client", StubClient) + monkeypatch.setenv("SIM_AWS_BASE_URL", "https://example.test") + monkeypatch.setenv("MORPH_API_KEY", "k_test_123") + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + cli_mod.cli, + ["env", "aws-sim", "connect", "awsenv_test", "--output", "bundle.json"], + ) + assert result.exit_code == 0, result.output + assert connect_calls == [ + { + "method": "POST", + "url": "/v1/envs/awsenv_test/connect", + "headers": {"Authorization": "Bearer k_test_123"}, + "json": None, + } + ] + assert "bundle.json" in result.output + assert "docker run" in result.output + assert "-e MORPH_API_KEY" in result.output + assert "--cap-add=NET_ADMIN" in result.output + assert "--device /dev/net/tun" in result.output + assert aws_sim_mod.SRC_VALID_MARK_SYSCTL in result.output + assert "k_test_123" not in result.output + From f1bafc024b4b92f2a7afcb8a2126ae9c1e5c173e Mon Sep 17 00:00:00 2001 From: mpbb Date: Fri, 30 Jan 2026 00:59:10 +0000 Subject: [PATCH 2/8] aws-sim: fix printed connector docker command --- morphcloud/cli_plugins/aws_sim.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py index 72203eb..fba73f8 100644 --- a/morphcloud/cli_plugins/aws_sim.py +++ b/morphcloud/cli_plugins/aws_sim.py @@ -79,15 +79,16 @@ def _print_json(data: Any) -> None: def _docker_run_template(bundle_path: str) -> str: bundle_path_abs = str(pathlib.Path(bundle_path).expanduser().resolve()) image = os.environ.get("AWS_SIM_CONNECTOR_IMAGE", DEFAULT_CONNECTOR_IMAGE) + bundle_container_path = "/run/connect-bundle.json" return ( "docker run --rm -it " "--cap-add=NET_ADMIN " "--device /dev/net/tun " f"--sysctl {SRC_VALID_MARK_SYSCTL} " "-e MORPH_API_KEY " - f"-v {bundle_path_abs}:/bundle.json:ro " + f"-v {bundle_path_abs}:{bundle_container_path}:ro " f"{image} " - "--bundle /bundle.json" + "bash" ) From 9744982ab901d790e1d2ee1c2cf0c4c40e92a34a Mon Sep 17 00:00:00 2001 From: mpbb Date: Fri, 30 Jan 2026 03:09:51 +0000 Subject: [PATCH 3/8] aws-sim: print detached connector docker run command --- morphcloud/cli_plugins/aws_sim.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py index fba73f8..fcf4711 100644 --- a/morphcloud/cli_plugins/aws_sim.py +++ b/morphcloud/cli_plugins/aws_sim.py @@ -92,6 +92,22 @@ def _docker_run_template(bundle_path: str) -> str: ) +def _docker_run_detached_template(bundle_path: str, *, container_name: str = "sim-aws-connector") -> str: + bundle_path_abs = str(pathlib.Path(bundle_path).expanduser().resolve()) + image = os.environ.get("AWS_SIM_CONNECTOR_IMAGE", DEFAULT_CONNECTOR_IMAGE) + bundle_container_path = "/run/connect-bundle.json" + return ( + f"docker run -d --name {container_name} " + "--cap-add=NET_ADMIN " + "--device /dev/net/tun " + f"--sysctl {SRC_VALID_MARK_SYSCTL} " + "-e MORPH_API_KEY " + f"-v {bundle_path_abs}:{bundle_container_path}:ro " + f"{image} " + "sleep infinity" + ) + + def _split_csv_args(values: tuple[str, ...]) -> list[str]: items: list[str] = [] for raw in values: @@ -232,3 +248,8 @@ def connect(env_id: str, output_path: str) -> None: pass click.echo(f"Wrote connect bundle to: {output}") click.echo(_docker_run_template(str(output))) + click.echo("") + click.echo("# To run the connector in the background and exec AWS commands against it:") + click.echo(_docker_run_detached_template(str(output))) + click.echo("# Example:") + click.echo("docker exec -it sim-aws-connector aws sqs list-queues --region us-east-1") From 28effc0f77e941d0eea9ae51a69e00f6c5274df9 Mon Sep 17 00:00:00 2001 From: mpbb Date: Fri, 30 Jan 2026 03:18:06 +0000 Subject: [PATCH 4/8] aws-sim: detached connector command includes AWS defaults --- morphcloud/cli_plugins/aws_sim.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py index fcf4711..921228d 100644 --- a/morphcloud/cli_plugins/aws_sim.py +++ b/morphcloud/cli_plugins/aws_sim.py @@ -102,6 +102,10 @@ def _docker_run_detached_template(bundle_path: str, *, container_name: str = "si "--device /dev/net/tun " f"--sysctl {SRC_VALID_MARK_SYSCTL} " "-e MORPH_API_KEY " + "-e AWS_PAGER= " + "-e AWS_EC2_METADATA_DISABLED=true " + "-e AWS_ACCESS_KEY_ID=test " + "-e AWS_SECRET_ACCESS_KEY=test " f"-v {bundle_path_abs}:{bundle_container_path}:ro " f"{image} " "sleep infinity" @@ -250,6 +254,22 @@ def connect(env_id: str, output_path: str) -> None: click.echo(_docker_run_template(str(output))) click.echo("") click.echo("# To run the connector in the background and exec AWS commands against it:") - click.echo(_docker_run_detached_template(str(output))) + default_region = "" + try: + aws = bundle.get("aws") or {} + regions = aws.get("regions") or [] + if isinstance(regions, list) and regions: + default_region = str(regions[0] or "").strip() + except Exception: + default_region = "" + + cmd = _docker_run_detached_template(str(output)) + if default_region: + cmd = cmd.replace( + "-e AWS_PAGER= ", + f"-e AWS_REGION={default_region} -e AWS_DEFAULT_REGION={default_region} -e AWS_PAGER= ", + 1, + ) + click.echo(cmd) click.echo("# Example:") click.echo("docker exec -it sim-aws-connector aws sqs list-queues --region us-east-1") From 0ab8491a2bc816891252444c55e11f074baab637 Mon Sep 17 00:00:00 2001 From: mpbb Date: Fri, 30 Jan 2026 03:42:47 +0000 Subject: [PATCH 5/8] aws-sim: connect launches detached connector container --- morphcloud/cli_plugins/aws_sim.py | 104 ++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py index 921228d..55cd7c6 100644 --- a/morphcloud/cli_plugins/aws_sim.py +++ b/morphcloud/cli_plugins/aws_sim.py @@ -1,6 +1,7 @@ import json import os import pathlib +import subprocess from typing import Any import click @@ -236,9 +237,25 @@ def delete(env_id: str) -> None: show_default=True, help="Write the connect bundle to this path.", ) - def connect(env_id: str, output_path: str) -> None: + @click.option( + "--container-name", + default="sim-aws-connector", + show_default=True, + help="Name for the detached connector container.", + ) + @click.option( + "--replace", + is_flag=True, + help="If set, delete any existing container with --container-name before starting.", + ) + @click.option( + "--no-run", + is_flag=True, + help="If set, do not start the detached connector container; only write the bundle and print commands.", + ) + def connect(env_id: str, output_path: str, container_name: str, replace: bool, no_run: bool) -> None: """ - Fetch a connect bundle, write it to a file, and print a connector `docker run` command. + Fetch a connect bundle, write it to a file, and start a detached connector container. The connect bundle is sensitive (WireGuard private key); keep the output file safe. """ @@ -251,25 +268,76 @@ def connect(env_id: str, output_path: str) -> None: except Exception: pass click.echo(f"Wrote connect bundle to: {output}") - click.echo(_docker_run_template(str(output))) - click.echo("") - click.echo("# To run the connector in the background and exec AWS commands against it:") + default_region = "" - try: - aws = bundle.get("aws") or {} - regions = aws.get("regions") or [] + aws = bundle.get("aws") if isinstance(bundle, dict) else None + if isinstance(aws, dict): + regions = aws.get("regions") if isinstance(regions, list) and regions: default_region = str(regions[0] or "").strip() - except Exception: - default_region = "" - cmd = _docker_run_detached_template(str(output)) + image = os.environ.get("AWS_SIM_CONNECTOR_IMAGE", DEFAULT_CONNECTOR_IMAGE) + bundle_path_abs = str(output.expanduser().resolve()) + + # Always include dummy creds + pager off for smoother UX. + run_args: list[str] = [ + "docker", + "run", + "-d", + "--name", + container_name, + "--cap-add=NET_ADMIN", + "--device", + "/dev/net/tun", + "--sysctl", + SRC_VALID_MARK_SYSCTL, + "-e", + "MORPH_API_KEY", + "-e", + "AWS_PAGER=", + "-e", + "AWS_EC2_METADATA_DISABLED=true", + "-e", + "AWS_ACCESS_KEY_ID=test", + "-e", + "AWS_SECRET_ACCESS_KEY=test", + ] if default_region: - cmd = cmd.replace( - "-e AWS_PAGER= ", - f"-e AWS_REGION={default_region} -e AWS_DEFAULT_REGION={default_region} -e AWS_PAGER= ", - 1, - ) - click.echo(cmd) + run_args += [ + "-e", + f"AWS_REGION={default_region}", + "-e", + f"AWS_DEFAULT_REGION={default_region}", + ] + run_args += [ + "-v", + f"{bundle_path_abs}:/run/connect-bundle.json:ro", + image, + "sleep", + "infinity", + ] + + click.echo("") + click.echo("# Detached connector container command:") + click.echo(" ".join(run_args)) click.echo("# Example:") - click.echo("docker exec -it sim-aws-connector aws sqs list-queues --region us-east-1") + click.echo(f"docker exec -it {container_name} aws sqs list-queues --region us-east-1") + + if no_run: + return + + if replace: + subprocess.run(["docker", "rm", "-f", container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + try: + p = subprocess.run(run_args, text=True, capture_output=True) + except FileNotFoundError as e: + raise click.ClickException("docker is required but was not found on PATH") from e + + if p.returncode != 0: + msg = (p.stderr or p.stdout or "").strip() + raise click.ClickException(f"Failed to start connector container (docker run exit {p.returncode}):\n{msg}") + + container_id = (p.stdout or "").strip() + if container_id: + click.echo(f"Started connector container: {container_name} ({container_id[:12]})") From 98706e21e8013845dad9f4fe4285e27c3049a7e2 Mon Sep 17 00:00:00 2001 From: mpbb Date: Fri, 30 Jan 2026 03:52:02 +0000 Subject: [PATCH 6/8] aws-sim: connect can run without bundle file; emit aws wrapper --- morphcloud/cli_plugins/aws_sim.py | 163 +++++++++++++++++++++++++----- 1 file changed, 136 insertions(+), 27 deletions(-) diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py index 55cd7c6..795a1bd 100644 --- a/morphcloud/cli_plugins/aws_sim.py +++ b/morphcloud/cli_plugins/aws_sim.py @@ -12,6 +12,9 @@ DEFAULT_CONNECTOR_IMAGE = "ghcr.io/morph-labs/sim-aws-connector:latest" DEFAULT_CONNECT_BUNDLE_FILENAME = "aws-sim-connect-bundle.json" SRC_VALID_MARK_SYSCTL = "net.ipv4.conf.all.src_valid_mark=1" +DEFAULT_CONNECTOR_CONTAINER_NAME = "sim-aws-connector" +DEFAULT_CONNECT_HELPERS_ENV = "aws-env.sh" +DEFAULT_CONNECT_HELPERS_WRAPPER = "aws" def _get_sim_aws_base_url() -> str: @@ -112,6 +115,84 @@ def _docker_run_detached_template(bundle_path: str, *, container_name: str = "si "sleep infinity" ) +def _default_region_from_bundle(bundle: Any) -> str: + aws = bundle.get("aws") if isinstance(bundle, dict) else None + if not isinstance(aws, dict): + return "" + regions = aws.get("regions") + if not isinstance(regions, list) or not regions: + return "" + return str(regions[0] or "").strip() + + +def _emit_connect_helpers( + *, + dir_path: pathlib.Path, + container_name: str, + docker_run_detached_cmd: str, +) -> tuple[pathlib.Path, pathlib.Path]: + dir_path.mkdir(parents=True, exist_ok=True) + + env_path = dir_path / DEFAULT_CONNECT_HELPERS_ENV + wrapper_path = dir_path / DEFAULT_CONNECT_HELPERS_WRAPPER + + env_path.write_text( + "\n".join( + [ + "# Source this file to make `aws` call the local ./aws wrapper (relative to this file).", + '_simaws_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"', + f'export AWS_SIM_CONNECTOR_CONTAINER="${{AWS_SIM_CONNECTOR_CONTAINER:-{container_name}}}"', + 'aws() { "$_simaws_dir/aws" "$@"; }', + "", + "# Detached connector container command (CONNECT_BUNDLE_JSON is provided by morphcloud at runtime):", + f"# {docker_run_detached_cmd}", + "", + ] + ), + encoding="utf-8", + ) + + wrapper_path.write_text( + "\n".join( + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "", + 'CONTAINER="${AWS_SIM_CONNECTOR_CONTAINER:-sim-aws-connector}"', + "", + 'if ! docker ps --format "{{.Names}}" | grep -qx "$CONTAINER"; then', + ' echo "ERROR: connector container \'$CONTAINER\' is not running." >&2', + " exit 1", + "fi", + "", + "tty=()", + '[[ -t 0 && -t 1 ]] && tty=(-t)', + "", + 'exec docker exec -i "${tty[@]}" "$CONTAINER" bash -lc \'', + " set -euo pipefail", + " # Import AWS_* (and CA bundle vars) from PID1 env (set by connector entrypoint).", + " while IFS= read -r kv; do", + " case \"$kv\" in", + " AWS_*=*|SSL_CERT_FILE=*|REQUESTS_CA_BUNDLE=*|CURL_CA_BUNDLE=*)", + " export \"$kv\"", + " ;;", + " esac", + " done < <(tr \"\\0\" \"\\n\" list[str]: items: list[str] = [] @@ -233,13 +314,13 @@ def delete(env_id: str) -> None: @click.option( "--output", "output_path", - default=DEFAULT_CONNECT_BUNDLE_FILENAME, - show_default=True, - help="Write the connect bundle to this path.", + default=None, + show_default=False, + help="Optional: write the connect bundle to this path (sensitive).", ) @click.option( "--container-name", - default="sim-aws-connector", + default=DEFAULT_CONNECTOR_CONTAINER_NAME, show_default=True, help="Name for the detached connector container.", ) @@ -251,33 +332,47 @@ def delete(env_id: str) -> None: @click.option( "--no-run", is_flag=True, - help="If set, do not start the detached connector container; only write the bundle and print commands.", + help="If set, do not start the detached connector container; only print commands and emit helper scripts.", ) - def connect(env_id: str, output_path: str, container_name: str, replace: bool, no_run: bool) -> None: + @click.option( + "--emit-dir", + default=".", + show_default=True, + help="Directory to write helper scripts (aws-env.sh + aws wrapper).", + ) + def connect( + env_id: str, + output_path: str | None, + container_name: str, + replace: bool, + no_run: bool, + emit_dir: str, + ) -> None: """ - Fetch a connect bundle, write it to a file, and start a detached connector container. + Fetch a connect bundle and start a detached connector container. - The connect bundle is sensitive (WireGuard private key); keep the output file safe. + By default this does NOT write the bundle to disk. Instead, it passes the bundle to the + connector via the CONNECT_BUNDLE_JSON environment variable. + + If you want a bundle file for debugging, pass --output. """ bundle = _request_json("POST", f"/v1/envs/{env_id}/connect") - output = pathlib.Path(output_path).expanduser() - output.write_text(json.dumps(bundle, indent=2) + "\n", encoding="utf-8") - try: - os.chmod(output, 0o600) - except Exception: - pass - click.echo(f"Wrote connect bundle to: {output}") - default_region = "" - aws = bundle.get("aws") if isinstance(bundle, dict) else None - if isinstance(aws, dict): - regions = aws.get("regions") - if isinstance(regions, list) and regions: - default_region = str(regions[0] or "").strip() + output = None + if output_path: + output = pathlib.Path(output_path).expanduser() + output.write_text(json.dumps(bundle, indent=2) + "\n", encoding="utf-8") + try: + os.chmod(output, 0o600) + except Exception: + pass + click.echo(f"Wrote connect bundle to: {output}") + + default_region = _default_region_from_bundle(bundle) image = os.environ.get("AWS_SIM_CONNECTOR_IMAGE", DEFAULT_CONNECTOR_IMAGE) - bundle_path_abs = str(output.expanduser().resolve()) + requires_morph_api_key = bool(bundle.get("auth")) if isinstance(bundle, dict) else True # Always include dummy creds + pager off for smoother UX. run_args: list[str] = [ @@ -292,7 +387,7 @@ def connect(env_id: str, output_path: str, container_name: str, replace: bool, n "--sysctl", SRC_VALID_MARK_SYSCTL, "-e", - "MORPH_API_KEY", + "CONNECT_BUNDLE_JSON", "-e", "AWS_PAGER=", "-e", @@ -302,6 +397,8 @@ def connect(env_id: str, output_path: str, container_name: str, replace: bool, n "-e", "AWS_SECRET_ACCESS_KEY=test", ] + if requires_morph_api_key: + run_args += ["-e", "MORPH_API_KEY"] if default_region: run_args += [ "-e", @@ -310,16 +407,25 @@ def connect(env_id: str, output_path: str, container_name: str, replace: bool, n f"AWS_DEFAULT_REGION={default_region}", ] run_args += [ - "-v", - f"{bundle_path_abs}:/run/connect-bundle.json:ro", image, "sleep", "infinity", ] + helpers_dir = pathlib.Path(emit_dir).expanduser().resolve() + docker_run_cmd = " ".join(run_args) + env_path, wrapper_path = _emit_connect_helpers( + dir_path=helpers_dir, + container_name=container_name, + docker_run_detached_cmd=docker_run_cmd, + ) + click.echo(f"Wrote helper scripts: {env_path} and {wrapper_path}") + click.echo(f"To enable `aws ...` in your current shell: source {env_path}") + click.echo("") click.echo("# Detached connector container command:") - click.echo(" ".join(run_args)) + click.echo(docker_run_cmd) + click.echo("# (CONNECT_BUNDLE_JSON is provided by this command at runtime; re-run connect to start a new container.)") click.echo("# Example:") click.echo(f"docker exec -it {container_name} aws sqs list-queues --region us-east-1") @@ -330,7 +436,10 @@ def connect(env_id: str, output_path: str, container_name: str, replace: bool, n subprocess.run(["docker", "rm", "-f", container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: - p = subprocess.run(run_args, text=True, capture_output=True) + env = dict(os.environ) + # Compact JSON to avoid newlines; passed to docker via `-e CONNECT_BUNDLE_JSON`. + env["CONNECT_BUNDLE_JSON"] = json.dumps(bundle, separators=(",", ":"), sort_keys=True) + p = subprocess.run(run_args, text=True, capture_output=True, env=env) except FileNotFoundError as e: raise click.ClickException("docker is required but was not found on PATH") from e From 608617726eb61ec6a67ffe422b74129ff662be0a Mon Sep 17 00:00:00 2001 From: mpbb Date: Fri, 30 Jan 2026 03:57:27 +0000 Subject: [PATCH 7/8] aws-sim: connect output less verbose --- morphcloud/cli_plugins/aws_sim.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py index 795a1bd..1edef06 100644 --- a/morphcloud/cli_plugins/aws_sim.py +++ b/morphcloud/cli_plugins/aws_sim.py @@ -422,13 +422,6 @@ def connect( click.echo(f"Wrote helper scripts: {env_path} and {wrapper_path}") click.echo(f"To enable `aws ...` in your current shell: source {env_path}") - click.echo("") - click.echo("# Detached connector container command:") - click.echo(docker_run_cmd) - click.echo("# (CONNECT_BUNDLE_JSON is provided by this command at runtime; re-run connect to start a new container.)") - click.echo("# Example:") - click.echo(f"docker exec -it {container_name} aws sqs list-queues --region us-east-1") - if no_run: return From d283a0273aa60f670272450f37f6509e400daec2 Mon Sep 17 00:00:00 2001 From: mpbb Date: Sat, 31 Jan 2026 09:17:50 +0000 Subject: [PATCH 8/8] aws-sim connect: omit docker command from env helper --- morphcloud/cli_plugins/aws_sim.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/morphcloud/cli_plugins/aws_sim.py b/morphcloud/cli_plugins/aws_sim.py index 1edef06..d361e06 100644 --- a/morphcloud/cli_plugins/aws_sim.py +++ b/morphcloud/cli_plugins/aws_sim.py @@ -129,7 +129,6 @@ def _emit_connect_helpers( *, dir_path: pathlib.Path, container_name: str, - docker_run_detached_cmd: str, ) -> tuple[pathlib.Path, pathlib.Path]: dir_path.mkdir(parents=True, exist_ok=True) @@ -143,10 +142,6 @@ def _emit_connect_helpers( '_simaws_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"', f'export AWS_SIM_CONNECTOR_CONTAINER="${{AWS_SIM_CONNECTOR_CONTAINER:-{container_name}}}"', 'aws() { "$_simaws_dir/aws" "$@"; }', - "", - "# Detached connector container command (CONNECT_BUNDLE_JSON is provided by morphcloud at runtime):", - f"# {docker_run_detached_cmd}", - "", ] ), encoding="utf-8", @@ -413,11 +408,9 @@ def connect( ] helpers_dir = pathlib.Path(emit_dir).expanduser().resolve() - docker_run_cmd = " ".join(run_args) env_path, wrapper_path = _emit_connect_helpers( dir_path=helpers_dir, container_name=container_name, - docker_run_detached_cmd=docker_run_cmd, ) click.echo(f"Wrote helper scripts: {env_path} and {wrapper_path}") click.echo(f"To enable `aws ...` in your current shell: source {env_path}")