Skip to content

Commit 0ce49b6

Browse files
authored
Merge pull request #247 from scaleapi/setup-agent-files-with-build-context
Adding command to compress files for Cloud Build
2 parents 7d2aeda + 972dc3c commit 0ce49b6

4 files changed

Lines changed: 653 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ xfail_strict = true
179179
asyncio_mode = "auto"
180180
asyncio_default_fixture_loop_scope = "session"
181181
filterwarnings = [
182-
"error"
182+
"error",
183+
"ignore::pydantic.warnings.PydanticDeprecatedSince20",
183184
]
184185

185186
[tool.pyright]

src/agentex/lib/cli/commands/agents.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from agentex.lib.cli.handlers.agent_handlers import (
2727
run_agent,
2828
build_agent,
29+
parse_build_args,
30+
prepare_cloud_build_context,
2931
)
3032
from agentex.lib.cli.handlers.deploy_handlers import (
3133
HelmError,
@@ -164,6 +166,101 @@ def build(
164166
raise typer.Exit(1) from e
165167

166168

169+
@agents.command(name="package")
170+
def package(
171+
manifest: str = typer.Option(..., help="Path to the manifest you want to use"),
172+
tag: str | None = typer.Option(
173+
None,
174+
"--tag",
175+
"-t",
176+
help="Image tag (defaults to deployment.image.tag from manifest, or 'latest')",
177+
),
178+
output: str | None = typer.Option(
179+
None,
180+
"--output",
181+
"-o",
182+
help="Output filename for the tarball (defaults to <agent-name>-<tag>.tar.gz)",
183+
),
184+
build_arg: builtins.list[str] | None = typer.Option( # noqa: B008
185+
None,
186+
"--build-arg",
187+
"-b",
188+
help="Build argument in KEY=VALUE format (can be repeated)",
189+
),
190+
):
191+
"""
192+
Package an agent's build context into a tarball for cloud builds.
193+
194+
Reads manifest.yaml, prepares build context according to include_paths and
195+
dockerignore, then saves a compressed tarball to the current directory.
196+
197+
The tag defaults to the value in deployment.image.tag from the manifest.
198+
199+
Example:
200+
agentex agents package --manifest manifest.yaml
201+
agentex agents package --manifest manifest.yaml --tag v1.0
202+
"""
203+
typer.echo(f"Packaging build context from manifest: {manifest}")
204+
205+
# Validate manifest exists
206+
manifest_path = Path(manifest)
207+
if not manifest_path.exists():
208+
typer.echo(f"Error: manifest not found at {manifest_path}", err=True)
209+
raise typer.Exit(1)
210+
211+
try:
212+
# Prepare the build context (tag defaults from manifest if not provided)
213+
build_context = prepare_cloud_build_context(
214+
manifest_path=str(manifest_path),
215+
tag=tag,
216+
build_args=build_arg,
217+
)
218+
219+
# Determine output filename using the resolved tag
220+
if output:
221+
output_filename = output
222+
else:
223+
output_filename = f"{build_context.agent_name}-{build_context.tag}.tar.gz"
224+
225+
# Save tarball to current working directory
226+
output_path = Path.cwd() / output_filename
227+
output_path.write_bytes(build_context.archive_bytes)
228+
229+
typer.echo(f"\nTarball saved to: {output_path}")
230+
typer.echo(f"Size: {build_context.build_context_size_kb:.1f} KB")
231+
232+
# Output the build parameters needed for cloud build
233+
typer.echo("\n" + "=" * 60)
234+
typer.echo("Build Parameters for Cloud Build API:")
235+
typer.echo("=" * 60)
236+
typer.echo(f" agent_name: {build_context.agent_name}")
237+
typer.echo(f" image_name: {build_context.image_name}")
238+
typer.echo(f" tag: {build_context.tag}")
239+
typer.echo(f" context_file: {output_path}")
240+
241+
if build_arg:
242+
parsed_args = parse_build_args(build_arg)
243+
typer.echo(f" build_args: {parsed_args}")
244+
245+
typer.echo("")
246+
typer.echo("Command:")
247+
build_args_str = ""
248+
if build_arg:
249+
build_args_str = " ".join(f'--build-arg "{arg}"' for arg in build_arg)
250+
build_args_str = f" {build_args_str}"
251+
typer.echo(
252+
f' sgp agentex build --context "{output_path}" '
253+
f'--image-name "{build_context.image_name}" '
254+
f'--tag "{build_context.tag}"{build_args_str}'
255+
)
256+
typer.echo("=" * 60)
257+
258+
except Exception as e:
259+
typer.echo(f"Error packaging build context: {str(e)}", err=True)
260+
logger.exception("Error packaging build context")
261+
raise typer.Exit(1) from e
262+
263+
167264
@agents.command()
168265
def run(
169266
manifest: str = typer.Option(..., help="Path to the manifest you want to use"),

src/agentex/lib/cli/handlers/agent_handlers.py

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from typing import NamedTuple
34
from pathlib import Path
45

56
from rich.console import Console
@@ -8,7 +9,7 @@
89
from agentex.lib.cli.debug import DebugConfig
910
from agentex.lib.utils.logging import make_logger
1011
from agentex.lib.cli.handlers.run_handlers import RunError, run_agent as _run_agent
11-
from agentex.lib.sdk.config.agent_manifest import AgentManifest
12+
from agentex.lib.sdk.config.agent_manifest import AgentManifest, BuildContextManager
1213

1314
logger = make_logger(__name__)
1415
console = Console()
@@ -18,6 +19,17 @@ class DockerBuildError(Exception):
1819
"""An error occurred during docker build"""
1920

2021

22+
class CloudBuildContext(NamedTuple):
23+
"""Contains the prepared build context for cloud builds."""
24+
25+
archive_bytes: bytes
26+
dockerfile_path: str
27+
agent_name: str
28+
tag: str
29+
image_name: str
30+
build_context_size_kb: float
31+
32+
2133
def build_agent(
2234
manifest_path: str,
2335
registry_url: str,
@@ -42,9 +54,7 @@ def build_agent(
4254
The image URL
4355
"""
4456
agent_manifest = AgentManifest.from_yaml(file_path=manifest_path)
45-
build_context_root = (
46-
Path(manifest_path).parent / agent_manifest.build.context.root
47-
).resolve()
57+
build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve()
4858

4959
repository_name = repository_name or agent_manifest.agent.name
5060

@@ -85,9 +95,7 @@ def build_agent(
8595
key, value = arg.split("=", 1)
8696
docker_build_args[key] = value
8797
else:
88-
logger.warning(
89-
f"Invalid build arg format: {arg}. Expected KEY=VALUE"
90-
)
98+
logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE")
9199

92100
if docker_build_args:
93101
docker_build_kwargs["build_args"] = docker_build_args
@@ -100,9 +108,7 @@ def build_agent(
100108
if push:
101109
# Build and push in one step for multi-platform builds
102110
logger.info("Building and pushing image...")
103-
docker_build_kwargs["push"] = (
104-
True # Push directly after build for multi-platform
105-
)
111+
docker_build_kwargs["push"] = True # Push directly after build for multi-platform
106112
docker.buildx.build(**docker_build_kwargs)
107113

108114
logger.info(f"Successfully built and pushed {image_name}")
@@ -146,15 +152,127 @@ def signal_handler(signum, _frame):
146152
shutting_down = True
147153
logger.info(f"Received signal {signum}, shutting down...")
148154
raise KeyboardInterrupt()
149-
155+
150156
# Set up signal handling for the main thread
151157
signal.signal(signal.SIGINT, signal_handler)
152158
signal.signal(signal.SIGTERM, signal_handler)
153-
159+
154160
try:
155161
asyncio.run(_run_agent(manifest_path, debug_config))
156162
except KeyboardInterrupt:
157163
logger.info("Shutdown completed.")
158164
sys.exit(0)
159165
except RunError as e:
160166
raise RuntimeError(str(e)) from e
167+
168+
169+
def parse_build_args(build_args: list[str] | None) -> dict[str, str]:
170+
"""Parse build arguments from KEY=VALUE format to a dictionary.
171+
172+
Args:
173+
build_args: List of build arguments in KEY=VALUE format
174+
175+
Returns:
176+
Dictionary mapping keys to values
177+
"""
178+
result: dict[str, str] = {}
179+
if not build_args:
180+
return result
181+
182+
for arg in build_args:
183+
if "=" in arg:
184+
key, value = arg.split("=", 1)
185+
result[key] = value
186+
else:
187+
logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE")
188+
189+
return result
190+
191+
192+
def prepare_cloud_build_context(
193+
manifest_path: str,
194+
tag: str | None = None,
195+
build_args: list[str] | None = None,
196+
) -> CloudBuildContext:
197+
"""Prepare the build context for cloud-based container builds.
198+
199+
Reads the manifest, prepares the build context by copying files according to
200+
the include_paths and dockerignore, then creates a compressed tar.gz archive
201+
ready for upload to a cloud build service.
202+
203+
Args:
204+
manifest_path: Path to the agent manifest file
205+
tag: Image tag override (if None, reads from manifest's deployment.image.tag)
206+
build_args: List of build arguments in KEY=VALUE format
207+
208+
Returns:
209+
CloudBuildContext containing the archive bytes, dockerfile path, and metadata
210+
"""
211+
agent_manifest = AgentManifest.from_yaml(file_path=manifest_path)
212+
build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve()
213+
214+
agent_name = agent_manifest.agent.name
215+
dockerfile_path = agent_manifest.build.context.dockerfile
216+
217+
# Validate that the Dockerfile exists
218+
full_dockerfile_path = build_context_root / dockerfile_path
219+
if not full_dockerfile_path.exists():
220+
raise FileNotFoundError(
221+
f"Dockerfile not found at: {full_dockerfile_path}\n"
222+
f"Check that 'build.context.dockerfile' in your manifest points to an existing file."
223+
)
224+
if not full_dockerfile_path.is_file():
225+
raise ValueError(
226+
f"Dockerfile path is not a file: {full_dockerfile_path}\n"
227+
f"'build.context.dockerfile' must point to a file, not a directory."
228+
)
229+
230+
# Get tag and repository from manifest if not provided
231+
if tag is None:
232+
if agent_manifest.deployment and agent_manifest.deployment.image:
233+
tag = agent_manifest.deployment.image.tag
234+
else:
235+
tag = "latest"
236+
237+
# Get repository name from manifest (just the repo name, not the full registry URL)
238+
if agent_manifest.deployment and agent_manifest.deployment.image:
239+
repository = agent_manifest.deployment.image.repository
240+
if repository:
241+
# Extract just the repo name (last part after any slashes)
242+
image_name = repository.split("/")[-1]
243+
else:
244+
image_name = "<repository>"
245+
else:
246+
image_name = "<repository>"
247+
248+
logger.info(f"Agent: {agent_name}")
249+
logger.info(f"Image name: {image_name}")
250+
logger.info(f"Build context root: {build_context_root}")
251+
logger.info(f"Dockerfile: {dockerfile_path}")
252+
logger.info(f"Tag: {tag}")
253+
254+
if agent_manifest.build.context.include_paths:
255+
logger.info(f"Include paths: {agent_manifest.build.context.include_paths}")
256+
257+
parsed_build_args = parse_build_args(build_args)
258+
if parsed_build_args:
259+
logger.info(f"Build args: {list(parsed_build_args.keys())}")
260+
261+
logger.info("Preparing build context...")
262+
263+
with agent_manifest.context_manager(build_context_root) as build_context:
264+
# Compress the prepared context using the static zipped method
265+
with BuildContextManager.zipped(root_path=build_context.path) as archive_buffer:
266+
archive_bytes = archive_buffer.read()
267+
268+
build_context_size_kb = len(archive_bytes) / 1024
269+
logger.info(f"Build context size: {build_context_size_kb:.1f} KB")
270+
271+
return CloudBuildContext(
272+
archive_bytes=archive_bytes,
273+
dockerfile_path=build_context.dockerfile_path,
274+
agent_name=agent_name,
275+
tag=tag,
276+
image_name=image_name,
277+
build_context_size_kb=build_context_size_kb,
278+
)

0 commit comments

Comments
 (0)