4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
276 lines
9.8 KiB
Python
276 lines
9.8 KiB
Python
"""Tests for platform.deployment.gateway.direct_deploy."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import platform.deployment.gateway.direct_deploy as direct_module
|
|
from platform.deployment.aws.config import EC2_UBUNTU_ROOT_DEVICE_NAME
|
|
|
|
_INSTANCE_ID = "i-direct123"
|
|
_PUBLIC_IP = "1.2.3.4"
|
|
_BASE_AMI = "ami-base123"
|
|
_FAKE_PROFILE = {
|
|
"ProfileName": "opensre-gateway-direct-profile",
|
|
"ProfileArn": "arn:aws:iam::1:instance-profile/p",
|
|
"RoleName": "opensre-gateway-direct-role",
|
|
}
|
|
|
|
|
|
def _stub_deploy(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[str]]:
|
|
"""Stub every external call in deploy_direct() and return captured SSM calls."""
|
|
calls: dict[str, list[str]] = {"ssm_commands": [], "launch_kwargs": []}
|
|
|
|
monkeypatch.setattr(direct_module, "create_instance_profile", lambda *_a, **_kw: _FAKE_PROFILE)
|
|
monkeypatch.setattr(direct_module, "get_latest_ubuntu2204_ami", lambda _region: _BASE_AMI)
|
|
monkeypatch.setattr(
|
|
direct_module, "create_stack_security_group", lambda *_a, **_kw: "sg-direct123"
|
|
)
|
|
|
|
def fake_launch_instance(*_args: object, **kwargs: object) -> dict[str, str]:
|
|
calls["launch_kwargs"].append(kwargs)
|
|
return {"InstanceId": _INSTANCE_ID}
|
|
|
|
monkeypatch.setattr(direct_module, "launch_instance", fake_launch_instance)
|
|
monkeypatch.setattr(
|
|
direct_module,
|
|
"wait_for_running",
|
|
lambda *_a, **_kw: {"PublicIpAddress": _PUBLIC_IP},
|
|
)
|
|
monkeypatch.setattr(direct_module, "wait_for_ssm_registration", lambda *_a, **_kw: None)
|
|
|
|
def fake_run_ssm(instance_id: str, *, commands: list[str], **_kw: object) -> dict[str, str]:
|
|
calls["ssm_commands"].extend(commands)
|
|
return {"status": "Success", "stdout": "", "stderr": ""}
|
|
|
|
monkeypatch.setattr(direct_module, "run_ssm_shell_command", fake_run_ssm)
|
|
monkeypatch.setattr(direct_module, "provision_gateway_via_ssm", lambda *_a, **_kw: None)
|
|
monkeypatch.setattr(direct_module, "wait_for_gateway_ready", lambda *_a, **_kw: None)
|
|
monkeypatch.setattr(direct_module, "_cleanup_existing", lambda **_kw: False)
|
|
|
|
return calls
|
|
|
|
|
|
def test_deploy_direct_returns_all_required_keys(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""deploy_direct() must return all expected output keys."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
_stub_deploy(monkeypatch)
|
|
|
|
outputs = direct_module.deploy_direct(env_vars={}, region="us-east-1")
|
|
|
|
assert outputs["InstanceId"] == _INSTANCE_ID
|
|
assert outputs["PublicIpAddress"] == _PUBLIC_IP
|
|
assert outputs["BaseAmiId"] == _BASE_AMI
|
|
assert outputs["SecurityGroupId"] == "sg-direct123"
|
|
assert "ProfileName" in outputs
|
|
assert "RoleName" in outputs
|
|
# GitRef is no longer in outputs (no git clone with curl installer)
|
|
assert "GitRef" not in outputs
|
|
|
|
|
|
def test_deploy_direct_persists_outputs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""deploy_direct() must write outputs to the expected path."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
_stub_deploy(monkeypatch)
|
|
|
|
direct_module.deploy_direct(env_vars={}, region="us-east-1")
|
|
|
|
stack_name = direct_module._direct_stack_name()
|
|
output_file = tmp_path / f"{stack_name}.json"
|
|
assert output_file.exists()
|
|
data = json.loads(output_file.read_text())
|
|
assert data["InstanceId"] == _INSTANCE_ID
|
|
|
|
|
|
def test_deploy_direct_uses_ubuntu_root_device_for_volume_resize(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Ubuntu AMIs use /dev/sda1; direct deploy must pass that root device name."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
calls = _stub_deploy(monkeypatch)
|
|
|
|
direct_module.deploy_direct(env_vars={}, region="us-east-1")
|
|
|
|
assert calls["launch_kwargs"]
|
|
assert calls["launch_kwargs"][0]["root_device_name"] == EC2_UBUNTU_ROOT_DEVICE_NAME
|
|
|
|
|
|
def test_deploy_direct_uses_curl_installer(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Install commands must use the curl installer URL, not pip or git."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
calls = _stub_deploy(monkeypatch)
|
|
|
|
direct_module.deploy_direct(env_vars={}, region="us-east-1")
|
|
|
|
joined = "\n".join(calls["ssm_commands"])
|
|
assert direct_module._INSTALL_URL in joined
|
|
assert "curl" in joined
|
|
assert "pip install" not in joined
|
|
assert "git+" not in joined
|
|
|
|
|
|
def test_deploy_direct_binary_path_in_service_unit(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The embedded systemd unit must use /usr/local/bin/opensre, not the venv path."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
_stub_deploy(monkeypatch)
|
|
|
|
direct_module.deploy_direct(env_vars={}, region="us-east-1")
|
|
|
|
assert "/usr/local/bin/opensre" in direct_module._SERVICE_UNIT
|
|
assert "/opt/opensre/.venv" not in direct_module._SERVICE_UNIT
|
|
|
|
|
|
def test_deploy_direct_raises_on_install_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""deploy_direct() must raise RuntimeError when the install SSM command fails."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
_stub_deploy(monkeypatch)
|
|
|
|
monkeypatch.setattr(
|
|
direct_module,
|
|
"run_ssm_shell_command",
|
|
lambda *_a, **_kw: {"status": "Failed", "stdout": "", "stderr": "curl error"},
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="Install commands failed"):
|
|
direct_module.deploy_direct(env_vars={}, region="us-east-1")
|
|
|
|
|
|
def test_destroy_direct_terminates_instance_and_cleans_iam(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""destroy_direct() must terminate the instance and delete the IAM profile."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
|
|
stack_name = direct_module._direct_stack_name()
|
|
outputs_file = tmp_path / f"{stack_name}.json"
|
|
outputs_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"InstanceId": _INSTANCE_ID,
|
|
"ProfileName": "opensre-gateway-direct-profile",
|
|
"RoleName": "opensre-gateway-direct-role",
|
|
}
|
|
)
|
|
)
|
|
|
|
terminated: list[str] = []
|
|
deleted_profiles: list[str] = []
|
|
|
|
monkeypatch.setattr(
|
|
direct_module,
|
|
"terminate_instance",
|
|
lambda instance_id, _region: terminated.append(instance_id),
|
|
)
|
|
monkeypatch.setattr(
|
|
direct_module,
|
|
"delete_instance_profile",
|
|
lambda profile, _role, _region: deleted_profiles.append(profile),
|
|
)
|
|
|
|
results = direct_module.destroy_direct(region="us-east-1")
|
|
|
|
assert _INSTANCE_ID in terminated
|
|
assert deleted_profiles == ["opensre-gateway-direct-profile"]
|
|
assert any("ec2-instance" in r for r in results["deleted"])
|
|
assert not outputs_file.exists()
|
|
|
|
|
|
def test_destroy_direct_handles_missing_outputs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""destroy_direct() must not crash when no outputs file exists."""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
monkeypatch.setattr(direct_module, "terminate_instance", lambda *_a, **_kw: None)
|
|
monkeypatch.setattr(direct_module, "delete_instance_profile", lambda *_a, **_kw: None)
|
|
|
|
results = direct_module.destroy_direct(region="us-east-1")
|
|
|
|
assert isinstance(results["deleted"], list)
|
|
assert isinstance(results["failed"], list)
|
|
|
|
|
|
def test_cleanup_existing_cleans_iam_when_no_outputs_file(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""_cleanup_existing() must delete the IAM profile/role even when no outputs file exists.
|
|
|
|
Regression: tagged instances were terminated but the outputs file was absent,
|
|
so destroy_direct() was skipped and IAM resources were silently orphaned.
|
|
"""
|
|
monkeypatch.setattr(direct_module, "_OUTPUTS_DIR", tmp_path)
|
|
# No outputs file — simulates a lost or never-written outputs file.
|
|
|
|
terminated: list[str] = []
|
|
deleted_profiles: list[str] = []
|
|
|
|
monkeypatch.setattr(
|
|
direct_module,
|
|
"find_stack_instance_ids",
|
|
lambda _stack, *, region: ["i-orphan01"], # noqa: ARG005
|
|
)
|
|
monkeypatch.setattr(
|
|
direct_module,
|
|
"terminate_instance",
|
|
lambda instance_id, _region: terminated.append(instance_id),
|
|
)
|
|
monkeypatch.setattr(
|
|
direct_module,
|
|
"delete_instance_profile",
|
|
lambda profile, _role, _region: deleted_profiles.append(profile),
|
|
)
|
|
|
|
ran = direct_module._cleanup_existing(region="us-east-1")
|
|
|
|
assert ran is True
|
|
assert "i-orphan01" in terminated
|
|
# IAM must be cleaned up using derived names, not skipped.
|
|
stack_name = direct_module._direct_stack_name()
|
|
assert deleted_profiles == [f"{stack_name}-profile"]
|
|
|
|
|
|
def test_stack_name_includes_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("OPENSRE_STACK_SUFFIX", "joe")
|
|
assert direct_module._direct_stack_name() == "opensre-gateway-direct-joe"
|
|
|
|
|
|
def test_stack_name_default_when_no_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OPENSRE_STACK_SUFFIX", raising=False)
|
|
assert direct_module._direct_stack_name() == "opensre-gateway-direct"
|
|
|
|
|
|
def test_curl_install_commands_structure() -> None:
|
|
"""_build_curl_install_commands() must contain all required setup steps."""
|
|
commands = direct_module._build_curl_install_commands()
|
|
joined = "\n".join(commands)
|
|
|
|
assert "set -eu" in joined
|
|
assert "useradd" in joined # creates opensre user
|
|
assert direct_module._INSTALL_URL in joined # curl installer URL
|
|
assert "/usr/local/bin" in joined # install dir
|
|
assert "export HOME=/root" in joined # SSM has no HOME set
|
|
assert "OPENSRE_AUTO_LAUNCH=0" in joined # no interactive wizard
|
|
assert "systemctl enable opensre-gateway" in joined # service enabled
|
|
assert "opensre --help" in joined # smoke-check
|