- update_deployment() was using httpx default timeout (5s) instead of the 360s used by deploy_server_to_engine(), causing "The write operation timed out" errors on larger packages - Catch httpx.TimeoutException in both deploy paths with an actionable error message that points to package size as the likely cause - Add proper error handling (ConnectError, HTTPStatusError) and client.close() to update_deployment(), matching deploy_server_to_engine() - Add unit tests covering timeout handling and timeout constant usage
483 lines
17 KiB
Python
483 lines
17 KiB
Python
import base64
|
|
import io
|
|
import socket
|
|
import subprocess
|
|
import tarfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from arcade_cli.deploy import (
|
|
DEPLOY_TIMEOUT_SECONDS,
|
|
create_package_archive,
|
|
deploy_server_to_engine,
|
|
get_required_secrets,
|
|
get_server_info,
|
|
start_server_process,
|
|
update_deployment,
|
|
verify_server_and_get_metadata,
|
|
wait_for_health,
|
|
)
|
|
|
|
# Fixtures
|
|
|
|
|
|
@pytest.fixture
|
|
def test_dir() -> Path:
|
|
"""Return the path to the test directory."""
|
|
return Path(__file__).parent
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_server_dir(test_dir: Path) -> Path:
|
|
"""Return the path to the valid server directory."""
|
|
return test_dir / "test_servers" / "valid_server"
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_server_path(valid_server_dir: Path) -> str:
|
|
"""Return the path to the valid server entrypoint."""
|
|
return str(valid_server_dir / "server.py")
|
|
|
|
|
|
@pytest.fixture
|
|
def invalid_server_path(test_dir: Path) -> str:
|
|
"""Return the path to the invalid server entrypoint."""
|
|
return str(test_dir / "test_servers" / "invalid_server" / "server.py")
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_project_dir(tmp_path: Path) -> Path:
|
|
"""Create a temporary project directory with pyproject.toml."""
|
|
project_dir = tmp_path / "test_project"
|
|
project_dir.mkdir()
|
|
|
|
# Create a basic pyproject.toml
|
|
pyproject_content = """[build-system]
|
|
requires = ["setuptools>=61", "wheel"]
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
[project]
|
|
name = "test_project"
|
|
version = "0.1.0"
|
|
description = "Test project"
|
|
requires-python = ">=3.10"
|
|
"""
|
|
(project_dir / "pyproject.toml").write_text(pyproject_content, encoding="utf-8")
|
|
return project_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def reserved_unreachable_local_url():
|
|
"""Yield a localhost URL that is guaranteed not to have an HTTP listener.
|
|
|
|
Keeps a TCP socket bound (without listen()) so no other process can claim
|
|
the port during the test, avoiding flaky collisions with long-lived local
|
|
dev servers.
|
|
"""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
yield f"http://127.0.0.1:{port}"
|
|
|
|
|
|
# Tests for create_package_archive
|
|
|
|
|
|
def test_create_package_archive_success(valid_server_dir: Path) -> None:
|
|
"""Test creating an archive from a valid directory."""
|
|
archive_base64 = create_package_archive(valid_server_dir)
|
|
|
|
# Verify it returns a base64-encoded string
|
|
assert isinstance(archive_base64, str)
|
|
assert len(archive_base64) > 0
|
|
|
|
# Decode and verify the archive can be extracted
|
|
archive_bytes = base64.b64decode(archive_base64)
|
|
byte_stream = io.BytesIO(archive_bytes)
|
|
|
|
with tarfile.open(fileobj=byte_stream, mode="r:gz") as tar:
|
|
members = tar.getmembers()
|
|
filenames = [m.name for m in members]
|
|
|
|
# Verify expected files are present
|
|
assert any("server.py" in name for name in filenames)
|
|
assert any("pyproject.toml" in name for name in filenames)
|
|
|
|
|
|
def test_create_package_archive_nonexistent_dir(tmp_path: Path) -> None:
|
|
"""Test that archiving a non-existent directory raises ValueError."""
|
|
nonexistent_dir = tmp_path / "does_not_exist"
|
|
|
|
with pytest.raises(ValueError, match="Package directory not found"):
|
|
create_package_archive(nonexistent_dir)
|
|
|
|
|
|
def test_create_package_archive_file_not_dir(tmp_path: Path) -> None:
|
|
"""Test that archiving a file instead of directory raises ValueError."""
|
|
test_file = tmp_path / "test_file.txt"
|
|
test_file.write_text("test content", encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="Package path must be a directory"):
|
|
create_package_archive(test_file)
|
|
|
|
|
|
def test_create_package_archive_excludes_files(tmp_path: Path) -> None:
|
|
"""Test that certain files are excluded from the archive."""
|
|
test_dir = tmp_path / "test_project"
|
|
test_dir.mkdir()
|
|
|
|
# Create files that should be excluded
|
|
(test_dir / ".hidden").write_text("hidden", encoding="utf-8")
|
|
(test_dir / "__pycache__").mkdir()
|
|
(test_dir / "__pycache__" / "cache.pyc").write_text("cache", encoding="utf-8")
|
|
(test_dir / "requirements.lock").write_text("lock", encoding="utf-8")
|
|
(test_dir / "dist").mkdir()
|
|
(test_dir / "dist" / "package.tar.gz").write_text("dist", encoding="utf-8")
|
|
(test_dir / "build").mkdir()
|
|
(test_dir / "build" / "lib").write_text("build", encoding="utf-8")
|
|
|
|
# Create files that should be included
|
|
(test_dir / "main.py").write_text("main", encoding="utf-8")
|
|
(test_dir / "pyproject.toml").write_text("project", encoding="utf-8")
|
|
|
|
archive_base64 = create_package_archive(test_dir)
|
|
archive_bytes = base64.b64decode(archive_base64)
|
|
byte_stream = io.BytesIO(archive_bytes)
|
|
|
|
with tarfile.open(fileobj=byte_stream, mode="r:gz") as tar:
|
|
members = tar.getmembers()
|
|
filenames = [m.name for m in members]
|
|
|
|
# Verify excluded files are not present
|
|
assert not any(".hidden" in name for name in filenames)
|
|
assert not any("__pycache__" in name for name in filenames)
|
|
assert not any(".lock" in name for name in filenames)
|
|
assert not any("dist" in name for name in filenames)
|
|
assert not any("build" in name for name in filenames)
|
|
|
|
# Verify included files are present
|
|
assert any("main.py" in name for name in filenames)
|
|
assert any("pyproject.toml" in name for name in filenames)
|
|
|
|
|
|
# Tests for wait_for_health
|
|
|
|
|
|
def test_wait_for_health_success(valid_server_path: str, capsys) -> None:
|
|
"""Test waiting for a healthy server."""
|
|
process, port = start_server_process(valid_server_path, debug=False)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
|
|
try:
|
|
wait_for_health(base_url, process, timeout=10)
|
|
finally:
|
|
# Clean up
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
|
|
|
|
def test_wait_for_health_process_dies(valid_server_path: str) -> None:
|
|
"""Test handling when process dies during health check."""
|
|
process, port = start_server_process(valid_server_path, debug=False)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
|
|
# Kill the process immediately
|
|
process.kill()
|
|
process.wait()
|
|
|
|
# Mock process object to pass to wait_for_health
|
|
with pytest.raises(ValueError):
|
|
wait_for_health(base_url, process, timeout=2)
|
|
|
|
|
|
# Tests for get_server_info
|
|
|
|
|
|
def test_get_server_info_success(valid_server_path: str, capsys) -> None:
|
|
"""Test extracting server info from a running server."""
|
|
process, port = start_server_process(valid_server_path, debug=False)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
|
|
try:
|
|
# Wait for server to be healthy first
|
|
wait_for_health(base_url, process, timeout=10)
|
|
|
|
server_name, server_version = get_server_info(base_url)
|
|
|
|
assert server_name == "simpleserver"
|
|
assert server_version == "1.0.0"
|
|
finally:
|
|
# Clean up
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
|
|
|
|
def test_get_server_info_invalid_url(reserved_unreachable_local_url: str) -> None:
|
|
"""Test that invalid URL raises ValueError."""
|
|
invalid_url = reserved_unreachable_local_url
|
|
|
|
with pytest.raises(ValueError):
|
|
get_server_info(invalid_url)
|
|
|
|
|
|
# Tests for get_required_secrets
|
|
|
|
|
|
def test_get_required_secrets_with_secrets(valid_server_path: str, capsys) -> None:
|
|
"""Test extracting required secrets from server tools."""
|
|
process, port = start_server_process(valid_server_path, debug=False)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
|
|
try:
|
|
# Wait for server to be healthy first
|
|
wait_for_health(base_url, process, timeout=10)
|
|
|
|
secrets = get_required_secrets(base_url, "simpleserver", "1.0.0", debug=True)
|
|
assert "MY_SECRET_KEY" in secrets
|
|
finally:
|
|
# Clean up
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
|
|
|
|
def test_get_required_secrets_no_secrets(valid_server_path: str) -> None:
|
|
"""Test getting secrets returns set even when checking actual tools."""
|
|
process, port = start_server_process(valid_server_path, debug=False)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
|
|
try:
|
|
# Wait for server to be healthy first
|
|
wait_for_health(base_url, process, timeout=10)
|
|
|
|
secrets = get_required_secrets(base_url, "simpleserver", "1.0.0", debug=False)
|
|
|
|
assert len(secrets) == 1
|
|
finally:
|
|
# Clean up
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
|
|
|
|
def test_get_required_secrets_invalid_url(reserved_unreachable_local_url: str) -> None:
|
|
"""Test that invalid URL raises ValueError."""
|
|
invalid_url = reserved_unreachable_local_url
|
|
|
|
with pytest.raises(
|
|
ValueError, match="Failed to extract tool secrets from /worker/tools endpoint"
|
|
):
|
|
get_required_secrets(invalid_url, "test", "1.0.0")
|
|
|
|
|
|
# Tests for verify_server_and_get_metadata (integration tests)
|
|
|
|
|
|
def test_verify_server_and_get_metadata_success(valid_server_path: str, capsys) -> None:
|
|
"""Test full server verification flow."""
|
|
server_name, server_version, required_secrets = verify_server_and_get_metadata(
|
|
valid_server_path, debug=False
|
|
)
|
|
|
|
# Verify returned values
|
|
assert server_name == "simpleserver"
|
|
assert server_version == "1.0.0"
|
|
assert "MY_SECRET_KEY" in required_secrets
|
|
|
|
|
|
def test_verify_server_and_get_metadata_with_debug(valid_server_path: str, capsys) -> None:
|
|
"""Test server verification with debug mode enabled."""
|
|
server_name, server_version, required_secrets = verify_server_and_get_metadata(
|
|
valid_server_path, debug=True
|
|
)
|
|
|
|
# Verify returned values
|
|
assert server_name == "simpleserver"
|
|
assert server_version == "1.0.0"
|
|
assert "MY_SECRET_KEY" in required_secrets
|
|
|
|
|
|
# Tests for deploy_server_to_engine
|
|
|
|
|
|
@patch("arcade_cli.deploy.get_auth_headers", return_value={"Authorization": "Bearer test"})
|
|
@patch(
|
|
"arcade_cli.deploy.get_org_scoped_url",
|
|
return_value="http://engine/v1/orgs/1/projects/1/deployments",
|
|
)
|
|
def test_deploy_server_to_engine_timeout_raises_helpful_error(
|
|
mock_url: MagicMock, mock_auth: MagicMock
|
|
) -> None:
|
|
"""Test that a timeout during deployment raises a clear, actionable error."""
|
|
with patch("arcade_cli.deploy.httpx.Client") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_client.post.side_effect = httpx.WriteTimeout("The write operation timed out")
|
|
|
|
with pytest.raises(ValueError, match="Deployment request timed out"):
|
|
deploy_server_to_engine("http://engine", {"test": "payload"})
|
|
|
|
|
|
@patch("arcade_cli.deploy.get_auth_headers", return_value={"Authorization": "Bearer test"})
|
|
@patch(
|
|
"arcade_cli.deploy.get_org_scoped_url",
|
|
return_value="http://engine/v1/orgs/1/projects/1/deployments",
|
|
)
|
|
def test_deploy_server_to_engine_timeout_mentions_package_size(
|
|
mock_url: MagicMock, mock_auth: MagicMock
|
|
) -> None:
|
|
"""Test that the timeout error message mentions package size as a likely cause."""
|
|
with patch("arcade_cli.deploy.httpx.Client") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_client.post.side_effect = httpx.ReadTimeout("timed out")
|
|
|
|
with pytest.raises(ValueError, match="large deployment package"):
|
|
deploy_server_to_engine("http://engine", {"test": "payload"})
|
|
|
|
|
|
@patch("arcade_cli.deploy.get_auth_headers", return_value={"Authorization": "Bearer test"})
|
|
@patch(
|
|
"arcade_cli.deploy.get_org_scoped_url",
|
|
return_value="http://engine/v1/orgs/1/projects/1/deployments",
|
|
)
|
|
def test_deploy_server_to_engine_uses_deploy_timeout(
|
|
mock_url: MagicMock, mock_auth: MagicMock
|
|
) -> None:
|
|
"""Test that deploy_server_to_engine uses the DEPLOY_TIMEOUT_SECONDS constant."""
|
|
with patch("arcade_cli.deploy.httpx.Client") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"status": "ok"}
|
|
mock_client.post.return_value = mock_response
|
|
|
|
deploy_server_to_engine("http://engine", {"test": "payload"})
|
|
|
|
mock_client_cls.assert_called_once_with(
|
|
headers={"Authorization": "Bearer test"},
|
|
timeout=DEPLOY_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
|
|
# Tests for update_deployment
|
|
|
|
|
|
@patch("arcade_cli.deploy.get_auth_headers", return_value={"Authorization": "Bearer test"})
|
|
@patch(
|
|
"arcade_cli.deploy.get_org_scoped_url",
|
|
return_value="http://engine/v1/orgs/1/projects/1/deployments/myserver",
|
|
)
|
|
def test_update_deployment_timeout_raises_helpful_error(
|
|
mock_url: MagicMock, mock_auth: MagicMock
|
|
) -> None:
|
|
"""Test that a timeout during deployment update raises a clear, actionable error."""
|
|
with patch("arcade_cli.deploy.httpx.Client") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_client.put.side_effect = httpx.WriteTimeout("The write operation timed out")
|
|
|
|
with pytest.raises(ValueError, match="Deployment update timed out"):
|
|
update_deployment("http://engine", "myserver", {"test": "payload"})
|
|
|
|
|
|
@patch("arcade_cli.deploy.get_auth_headers", return_value={"Authorization": "Bearer test"})
|
|
@patch(
|
|
"arcade_cli.deploy.get_org_scoped_url",
|
|
return_value="http://engine/v1/orgs/1/projects/1/deployments/myserver",
|
|
)
|
|
def test_update_deployment_uses_deploy_timeout(mock_url: MagicMock, mock_auth: MagicMock) -> None:
|
|
"""Test that update_deployment uses the DEPLOY_TIMEOUT_SECONDS constant."""
|
|
with patch("arcade_cli.deploy.httpx.Client") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
update_deployment("http://engine", "myserver", {"test": "payload"})
|
|
|
|
mock_client_cls.assert_called_once_with(
|
|
headers={"Authorization": "Bearer test"},
|
|
timeout=DEPLOY_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
|
|
def test_deploy_timeout_constant() -> None:
|
|
"""Test that the deploy timeout constant is correctly defined."""
|
|
assert DEPLOY_TIMEOUT_SECONDS == 360
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Debug-aware error messages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch("arcade_cli.deploy.find_python_interpreter")
|
|
@patch("arcade_cli.deploy.subprocess.Popen")
|
|
def test_start_server_process_non_debug_message(
|
|
mock_popen: MagicMock, mock_python: MagicMock
|
|
) -> None:
|
|
"""Non-debug mode error should hint at --debug flag."""
|
|
mock_python.return_value = Path("python3")
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = 1 # Process exited immediately
|
|
mock_popen.return_value = mock_proc
|
|
|
|
with pytest.raises(ValueError, match="--debug"):
|
|
start_server_process("server.py", debug=False)
|
|
|
|
|
|
@patch("arcade_cli.deploy.find_python_interpreter")
|
|
@patch("arcade_cli.deploy.subprocess.Popen")
|
|
def test_start_server_process_debug_message(mock_popen: MagicMock, mock_python: MagicMock) -> None:
|
|
"""Debug mode error should NOT tell user to run with --debug (already in debug mode)."""
|
|
mock_python.return_value = Path("python3")
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = 1 # Process exited immediately
|
|
mock_popen.return_value = mock_proc
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
start_server_process("server.py", debug=True)
|
|
|
|
msg = str(exc_info.value)
|
|
assert "--debug" not in msg, "Debug mode error must not tell user to re-run with --debug"
|
|
assert "above" in msg.lower() or "output" in msg.lower()
|
|
|
|
|
|
def test_wait_for_health_non_debug_message(reserved_unreachable_local_url: str) -> None:
|
|
"""Non-debug health timeout should hint at --debug flag."""
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate.return_value = (None, None)
|
|
|
|
with pytest.raises(ValueError, match="--debug"):
|
|
wait_for_health(reserved_unreachable_local_url, mock_proc, timeout=1, debug=False)
|
|
|
|
|
|
def test_wait_for_health_debug_message(reserved_unreachable_local_url: str) -> None:
|
|
"""Debug health timeout should NOT tell user to run with --debug,
|
|
and SHOULD reference checking the output already shown above."""
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate.return_value = (None, None)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
wait_for_health(reserved_unreachable_local_url, mock_proc, timeout=1, debug=True)
|
|
|
|
msg = str(exc_info.value)
|
|
assert "--debug" not in msg, "Debug mode error must not tell user to re-run with --debug"
|
|
assert "above" in msg.lower() or "output" in msg.lower(), (
|
|
f"Debug mode error should reference checking output above; got: {msg!r}"
|
|
)
|