diff --git a/pyproject.toml b/pyproject.toml index b9629dbcd..aa57f7516 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,7 +179,8 @@ xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" filterwarnings = [ - "error" + "error", + "ignore::pydantic.warnings.PydanticDeprecatedSince20", ] [tool.pyright] diff --git a/src/agentex/lib/cli/commands/agents.py b/src/agentex/lib/cli/commands/agents.py index 3c1abdf8a..9f5bbbe21 100644 --- a/src/agentex/lib/cli/commands/agents.py +++ b/src/agentex/lib/cli/commands/agents.py @@ -26,6 +26,9 @@ from agentex.lib.cli.handlers.agent_handlers import ( run_agent, build_agent, + prepare_cloud_build_context, + parse_build_args, + CloudBuildContext, ) from agentex.lib.cli.handlers.deploy_handlers import ( HelmError, @@ -164,6 +167,101 @@ def build( raise typer.Exit(1) from e +@agents.command(name="package") +def package( + manifest: str = typer.Option(..., help="Path to the manifest you want to use"), + tag: str | None = typer.Option( + None, + "--tag", + "-t", + help="Image tag (defaults to deployment.image.tag from manifest, or 'latest')", + ), + output: str | None = typer.Option( + None, + "--output", + "-o", + help="Output filename for the tarball (defaults to -.tar.gz)", + ), + build_arg: builtins.list[str] | None = typer.Option( # noqa: B008 + None, + "--build-arg", + "-b", + help="Build argument in KEY=VALUE format (can be repeated)", + ), +): + """ + Package an agent's build context into a tarball for cloud builds. + + Reads manifest.yaml, prepares build context according to include_paths and + dockerignore, then saves a compressed tarball to the current directory. + + The tag defaults to the value in deployment.image.tag from the manifest. + + Example: + agentex agents package --manifest manifest.yaml + agentex agents package --manifest manifest.yaml --tag v1.0 + """ + typer.echo(f"Packaging build context from manifest: {manifest}") + + # Validate manifest exists + manifest_path = Path(manifest) + if not manifest_path.exists(): + typer.echo(f"Error: manifest not found at {manifest_path}", err=True) + raise typer.Exit(1) + + try: + # Prepare the build context (tag defaults from manifest if not provided) + build_context = prepare_cloud_build_context( + manifest_path=str(manifest_path), + tag=tag, + build_args=build_arg, + ) + + # Determine output filename using the resolved tag + if output: + output_filename = output + else: + output_filename = f"{build_context.agent_name}-{build_context.tag}.tar.gz" + + # Save tarball to current working directory + output_path = Path.cwd() / output_filename + output_path.write_bytes(build_context.archive_bytes) + + typer.echo(f"\nTarball saved to: {output_path}") + typer.echo(f"Size: {build_context.build_context_size_kb:.1f} KB") + + # Output the build parameters needed for cloud build + typer.echo("\n" + "=" * 60) + typer.echo("Build Parameters for Cloud Build API:") + typer.echo("=" * 60) + typer.echo(f" agent_name: {build_context.agent_name}") + typer.echo(f" image_name: {build_context.image_name}") + typer.echo(f" tag: {build_context.tag}") + typer.echo(f" context_file: {output_path}") + + if build_arg: + parsed_args = parse_build_args(build_arg) + typer.echo(f" build_args: {parsed_args}") + + typer.echo("") + typer.echo("Command:") + build_args_str = "" + if build_arg: + build_args_str = " ".join(f'--build-arg "{arg}"' for arg in build_arg) + build_args_str = f" {build_args_str}" + typer.echo( + f' sgp agentex build --context "{output_path}" ' + f'--image-name "{build_context.image_name}" ' + f'--tag "{build_context.tag}"{build_args_str}' + ) + typer.echo("=" * 60) + + except Exception as e: + typer.echo(f"Error packaging build context: {str(e)}", err=True) + logger.exception("Error packaging build context") + raise typer.Exit(1) from e + + @agents.command() def run( manifest: str = typer.Option(..., help="Path to the manifest you want to use"), diff --git a/src/agentex/lib/cli/handlers/agent_handlers.py b/src/agentex/lib/cli/handlers/agent_handlers.py index 816080181..41cf44f67 100644 --- a/src/agentex/lib/cli/handlers/agent_handlers.py +++ b/src/agentex/lib/cli/handlers/agent_handlers.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import NamedTuple from rich.console import Console from python_on_whales import DockerException, docker @@ -8,7 +9,7 @@ from agentex.lib.cli.debug import DebugConfig from agentex.lib.utils.logging import make_logger from agentex.lib.cli.handlers.run_handlers import RunError, run_agent as _run_agent -from agentex.lib.sdk.config.agent_manifest import AgentManifest +from agentex.lib.sdk.config.agent_manifest import AgentManifest, BuildContextManager logger = make_logger(__name__) console = Console() @@ -18,6 +19,17 @@ class DockerBuildError(Exception): """An error occurred during docker build""" +class CloudBuildContext(NamedTuple): + """Contains the prepared build context for cloud builds.""" + + archive_bytes: bytes + dockerfile_path: str + agent_name: str + tag: str + image_name: str + build_context_size_kb: float + + def build_agent( manifest_path: str, registry_url: str, @@ -42,9 +54,7 @@ def build_agent( The image URL """ agent_manifest = AgentManifest.from_yaml(file_path=manifest_path) - build_context_root = ( - Path(manifest_path).parent / agent_manifest.build.context.root - ).resolve() + build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve() repository_name = repository_name or agent_manifest.agent.name @@ -85,9 +95,7 @@ def build_agent( key, value = arg.split("=", 1) docker_build_args[key] = value else: - logger.warning( - f"Invalid build arg format: {arg}. Expected KEY=VALUE" - ) + logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE") if docker_build_args: docker_build_kwargs["build_args"] = docker_build_args @@ -100,9 +108,7 @@ def build_agent( if push: # Build and push in one step for multi-platform builds logger.info("Building and pushing image...") - docker_build_kwargs["push"] = ( - True # Push directly after build for multi-platform - ) + docker_build_kwargs["push"] = True # Push directly after build for multi-platform docker.buildx.build(**docker_build_kwargs) logger.info(f"Successfully built and pushed {image_name}") @@ -146,11 +152,11 @@ def signal_handler(signum, _frame): shutting_down = True logger.info(f"Received signal {signum}, shutting down...") raise KeyboardInterrupt() - + # Set up signal handling for the main thread signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - + try: asyncio.run(_run_agent(manifest_path, debug_config)) except KeyboardInterrupt: @@ -158,3 +164,115 @@ def signal_handler(signum, _frame): sys.exit(0) except RunError as e: raise RuntimeError(str(e)) from e + + +def parse_build_args(build_args: list[str] | None) -> dict[str, str]: + """Parse build arguments from KEY=VALUE format to a dictionary. + + Args: + build_args: List of build arguments in KEY=VALUE format + + Returns: + Dictionary mapping keys to values + """ + result: dict[str, str] = {} + if not build_args: + return result + + for arg in build_args: + if "=" in arg: + key, value = arg.split("=", 1) + result[key] = value + else: + logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE") + + return result + + +def prepare_cloud_build_context( + manifest_path: str, + tag: str | None = None, + build_args: list[str] | None = None, +) -> CloudBuildContext: + """Prepare the build context for cloud-based container builds. + + Reads the manifest, prepares the build context by copying files according to + the include_paths and dockerignore, then creates a compressed tar.gz archive + ready for upload to a cloud build service. + + Args: + manifest_path: Path to the agent manifest file + tag: Image tag override (if None, reads from manifest's deployment.image.tag) + build_args: List of build arguments in KEY=VALUE format + + Returns: + CloudBuildContext containing the archive bytes, dockerfile path, and metadata + """ + agent_manifest = AgentManifest.from_yaml(file_path=manifest_path) + build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve() + + agent_name = agent_manifest.agent.name + dockerfile_path = agent_manifest.build.context.dockerfile + + # Validate that the Dockerfile exists + full_dockerfile_path = build_context_root / dockerfile_path + if not full_dockerfile_path.exists(): + raise FileNotFoundError( + f"Dockerfile not found at: {full_dockerfile_path}\n" + f"Check that 'build.context.dockerfile' in your manifest points to an existing file." + ) + if not full_dockerfile_path.is_file(): + raise ValueError( + f"Dockerfile path is not a file: {full_dockerfile_path}\n" + f"'build.context.dockerfile' must point to a file, not a directory." + ) + + # Get tag and repository from manifest if not provided + if tag is None: + if agent_manifest.deployment and agent_manifest.deployment.image: + tag = agent_manifest.deployment.image.tag + else: + tag = "latest" + + # Get repository name from manifest (just the repo name, not the full registry URL) + if agent_manifest.deployment and agent_manifest.deployment.image: + repository = agent_manifest.deployment.image.repository + if repository: + # Extract just the repo name (last part after any slashes) + image_name = repository.split("/")[-1] + else: + image_name = "" + else: + image_name = "" + + logger.info(f"Agent: {agent_name}") + logger.info(f"Image name: {image_name}") + logger.info(f"Build context root: {build_context_root}") + logger.info(f"Dockerfile: {dockerfile_path}") + logger.info(f"Tag: {tag}") + + if agent_manifest.build.context.include_paths: + logger.info(f"Include paths: {agent_manifest.build.context.include_paths}") + + parsed_build_args = parse_build_args(build_args) + if parsed_build_args: + logger.info(f"Build args: {list(parsed_build_args.keys())}") + + logger.info("Preparing build context...") + + with agent_manifest.context_manager(build_context_root) as build_context: + # Compress the prepared context using the static zipped method + with BuildContextManager.zipped(root_path=build_context.path) as archive_buffer: + archive_bytes = archive_buffer.read() + + build_context_size_kb = len(archive_bytes) / 1024 + logger.info(f"Build context size: {build_context_size_kb:.1f} KB") + + return CloudBuildContext( + archive_bytes=archive_bytes, + dockerfile_path=build_context.dockerfile_path, + agent_name=agent_name, + tag=tag, + image_name=image_name, + build_context_size_kb=build_context_size_kb, + ) diff --git a/src/agentex/lib/sdk/config/deployment_config.py b/src/agentex/lib/sdk/config/deployment_config.py index 1ba5b348f..8f1a18b49 100644 --- a/src/agentex/lib/sdk/config/deployment_config.py +++ b/src/agentex/lib/sdk/config/deployment_config.py @@ -2,7 +2,7 @@ from typing import Any, Dict -from pydantic import Field +from pydantic import ConfigDict, Field from agentex.lib.utils.model_utils import BaseModel diff --git a/tests/lib/cli/test_agent_handlers.py b/tests/lib/cli/test_agent_handlers.py new file mode 100644 index 000000000..4d9f6e260 --- /dev/null +++ b/tests/lib/cli/test_agent_handlers.py @@ -0,0 +1,424 @@ +"""Tests for agent_handlers module - prepare_cloud_build_context and package CLI command.""" + +from __future__ import annotations + +import os +import tarfile +import tempfile +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from agentex.lib.cli.handlers.agent_handlers import ( + CloudBuildContext, + parse_build_args, + prepare_cloud_build_context, +) +from agentex.lib.cli.commands.agents import agents + + +runner = CliRunner() + + +class TestParseBuildArgs: + """Tests for parse_build_args helper function.""" + + def test_parse_empty_build_args(self): + """Test parsing None or empty list returns empty dict.""" + assert parse_build_args(None) == {} + assert parse_build_args([]) == {} + + def test_parse_single_build_arg(self): + """Test parsing a single KEY=VALUE argument.""" + result = parse_build_args(["FOO=bar"]) + assert result == {"FOO": "bar"} + + def test_parse_multiple_build_args(self): + """Test parsing multiple KEY=VALUE arguments.""" + result = parse_build_args(["FOO=bar", "BAZ=qux", "NUM=123"]) + assert result == {"FOO": "bar", "BAZ": "qux", "NUM": "123"} + + def test_parse_build_arg_with_equals_in_value(self): + """Test that values containing '=' are handled correctly.""" + result = parse_build_args(["URL=https://example.com?foo=bar"]) + assert result == {"URL": "https://example.com?foo=bar"} + + def test_parse_invalid_build_arg_ignored(self): + """Test that invalid format args (no '=') are ignored.""" + result = parse_build_args(["VALID=value", "invalid_no_equals"]) + assert result == {"VALID": "value"} + + +class TestPrepareCloudBuildContext: + """Tests for prepare_cloud_build_context function.""" + + @pytest.fixture + def temp_agent_dir(self) -> Path: + """Create a temporary agent directory with minimal required files.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + # Create a minimal Dockerfile + dockerfile = agent_dir / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\nCMD ['echo', 'hello']") + + # Create a simple Python file to include + src_dir = agent_dir / "src" + src_dir.mkdir() + (src_dir / "main.py").write_text("print('hello')") + + # Create manifest.yaml + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: + - src + dockerfile: Dockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false + +deployment: + image: + repository: test-repo/test-agent + tag: v1.0.0 +""" + ) + + yield agent_dir + + @pytest.fixture + def temp_agent_dir_no_deployment(self) -> Path: + """Create a temporary agent directory without deployment config.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + dockerfile = agent_dir / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim") + + src_dir = agent_dir / "src" + src_dir.mkdir() + (src_dir / "main.py").write_text("print('hello')") + + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: + - src + dockerfile: Dockerfile + +agent: + name: test-agent-no-deploy + acp_type: sync + description: Test agent without deployment config + temporal: + enabled: false +""" + ) + + yield agent_dir + + def test_prepare_cloud_build_context_returns_cloud_build_context( + self, temp_agent_dir: Path + ): + """Test that prepare_cloud_build_context returns a CloudBuildContext.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path) + + assert isinstance(result, CloudBuildContext) + assert result.agent_name == "test-agent" + assert result.tag == "v1.0.0" # From manifest deployment.image.tag + assert result.image_name == "test-agent" # Last part of repository + assert result.dockerfile_path == "Dockerfile" + assert len(result.archive_bytes) > 0 + assert result.build_context_size_kb > 0 + + def test_prepare_cloud_build_context_with_tag_override(self, temp_agent_dir: Path): + """Test that tag parameter overrides manifest tag.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path, tag="custom-tag") + + assert result.tag == "custom-tag" + + def test_prepare_cloud_build_context_defaults_to_latest_when_no_deployment( + self, temp_agent_dir_no_deployment: Path + ): + """Test that tag defaults to 'latest' when no deployment config exists.""" + manifest_path = str(temp_agent_dir_no_deployment / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path) + + assert result.tag == "latest" + assert result.image_name == "" # No repository in deployment config + + def test_prepare_cloud_build_context_archive_is_valid_tarball( + self, temp_agent_dir: Path + ): + """Test that the archive bytes are a valid tar.gz file.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path) + + # Write to temp file and verify it's a valid tar.gz + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as f: + f.write(result.archive_bytes) + temp_tar_path = f.name + + try: + with tarfile.open(temp_tar_path, "r:gz") as tar: + names = tar.getnames() + # Should contain Dockerfile and src/main.py + assert "Dockerfile" in names + assert "src/main.py" in names + finally: + os.unlink(temp_tar_path) + + def test_prepare_cloud_build_context_missing_dockerfile_raises_error(self): + """Test that missing Dockerfile raises FileNotFoundError.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + # Create manifest pointing to non-existent Dockerfile + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: [] + dockerfile: NonExistentDockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false +""" + ) + + with pytest.raises(FileNotFoundError, match="Dockerfile not found"): + prepare_cloud_build_context(manifest_path=str(manifest)) + + def test_prepare_cloud_build_context_dockerfile_is_directory_raises_error(self): + """Test that Dockerfile path pointing to directory raises ValueError.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + # Create a directory instead of a file for Dockerfile + dockerfile_dir = agent_dir / "Dockerfile" + dockerfile_dir.mkdir() + + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: [] + dockerfile: Dockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false +""" + ) + + with pytest.raises(ValueError, match="not a file"): + prepare_cloud_build_context(manifest_path=str(manifest)) + + def test_prepare_cloud_build_context_with_build_args(self, temp_agent_dir: Path): + """Test that build_args are accepted (they're logged but not included in archive).""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + # Should not raise - build_args are accepted even though they're just logged + result = prepare_cloud_build_context( + manifest_path=manifest_path, + build_args=["ARG1=value1", "ARG2=value2"], + ) + + assert isinstance(result, CloudBuildContext) + + +class TestPackageCommand: + """Tests for the 'agentex agents package' CLI command.""" + + @pytest.fixture + def temp_agent_dir(self) -> Path: + """Create a temporary agent directory with minimal required files.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + dockerfile = agent_dir / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\nCMD ['echo', 'hello']") + + src_dir = agent_dir / "src" + src_dir.mkdir() + (src_dir / "main.py").write_text("print('hello')") + + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: + - src + dockerfile: Dockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false + +deployment: + image: + repository: test-repo/test-agent + tag: v1.0.0 +""" + ) + + yield agent_dir + + def test_package_command_creates_tarball(self, temp_agent_dir: Path): + """Test that package command creates a tarball file.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + # Change to temp dir so output goes there + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke(agents, ["package", "--manifest", manifest_path]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Tarball saved to:" in result.output + + # Check that tarball was created + expected_tarball = temp_agent_dir / "test-agent-v1.0.0.tar.gz" + assert expected_tarball.exists() + + # Verify it's a valid tar.gz + with tarfile.open(expected_tarball, "r:gz") as tar: + names = tar.getnames() + assert "Dockerfile" in names + finally: + os.chdir(original_cwd) + + def test_package_command_with_custom_tag(self, temp_agent_dir: Path): + """Test package command with custom tag override.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, ["package", "--manifest", manifest_path, "--tag", "custom-tag"] + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + + # Check that tarball with custom tag was created + expected_tarball = temp_agent_dir / "test-agent-custom-tag.tar.gz" + assert expected_tarball.exists() + finally: + os.chdir(original_cwd) + + def test_package_command_with_custom_output(self, temp_agent_dir: Path): + """Test package command with custom output filename.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, + ["package", "--manifest", manifest_path, "--output", "my-custom-output.tar.gz"], + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + + expected_tarball = temp_agent_dir / "my-custom-output.tar.gz" + assert expected_tarball.exists() + finally: + os.chdir(original_cwd) + + def test_package_command_missing_manifest(self, temp_agent_dir: Path): + """Test package command fails gracefully with missing manifest.""" + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, ["package", "--manifest", "nonexistent-manifest.yaml"] + ) + + assert result.exit_code == 1 + assert "manifest not found" in result.output + finally: + os.chdir(original_cwd) + + def test_package_command_shows_build_parameters(self, temp_agent_dir: Path): + """Test that package command outputs build parameters for cloud build.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke(agents, ["package", "--manifest", manifest_path]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Build Parameters for Cloud Build API:" in result.output + assert "agent_name:" in result.output + assert "test-agent" in result.output + assert "image_name:" in result.output + assert "tag:" in result.output + finally: + os.chdir(original_cwd) + + def test_package_command_with_build_args(self, temp_agent_dir: Path): + """Test package command with build arguments.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, + [ + "package", + "--manifest", + manifest_path, + "--build-arg", + "ARG1=value1", + "--build-arg", + "ARG2=value2", + ], + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "build_args:" in result.output + finally: + os.chdir(original_cwd)