diff --git a/agentkit/toolkit/builders/local_docker.py b/agentkit/toolkit/builders/local_docker.py index bb9d2dc..c00a124 100644 --- a/agentkit/toolkit/builders/local_docker.py +++ b/agentkit/toolkit/builders/local_docker.py @@ -317,12 +317,18 @@ def build(self, config: LocalDockerBuilderConfig) -> BuildResult: "build_script": docker_build_config.build_script, } - def generate_dockerfile_content() -> str: - """Generate Dockerfile content.""" - from io import StringIO + from agentkit.toolkit.docker.dockerfile.metadata import ( + calculate_template_hash, + ) - StringIO() + template_path = Path(template_dir) / docker_config.template_name + template_hash = calculate_template_hash(template_path) + config_hash_dict["dockerfile_template"] = docker_config.template_name + config_hash_dict["dockerfile_template_hash"] = template_hash + + def generate_dockerfile_content() -> str: + """Generate Dockerfile content.""" # Use renderer to render to string template = renderer.env.get_template(docker_config.template_name) rendered = template.render(**context) diff --git a/agentkit/toolkit/builders/ve_pipeline.py b/agentkit/toolkit/builders/ve_pipeline.py index a54a8dd..16a8a77 100644 --- a/agentkit/toolkit/builders/ve_pipeline.py +++ b/agentkit/toolkit/builders/ve_pipeline.py @@ -625,6 +625,16 @@ def _render_dockerfile( "build_script": docker_build_config.build_script, } + from agentkit.toolkit.docker.dockerfile.metadata import ( + calculate_template_hash, + ) + + template_path = Path(template_dir) / config.dockerfile_template + template_hash = calculate_template_hash(template_path) + + config_hash_dict["dockerfile_template"] = config.dockerfile_template + config_hash_dict["dockerfile_template_hash"] = template_hash + renderer = DockerfileRenderer(template_dir) # Content generator function (captures context via closure) diff --git a/agentkit/toolkit/docker/dockerfile/metadata.py b/agentkit/toolkit/docker/dockerfile/metadata.py index f51fd8c..b78d43a 100644 --- a/agentkit/toolkit/docker/dockerfile/metadata.py +++ b/agentkit/toolkit/docker/dockerfile/metadata.py @@ -18,6 +18,7 @@ import hashlib import json import logging +from pathlib import Path from dataclasses import dataclass from datetime import datetime from typing import Optional @@ -26,6 +27,25 @@ logger = logging.getLogger(__name__) +def calculate_template_hash(template_path: Path) -> str: + try: + return hashlib.sha256(template_path.read_bytes()).hexdigest()[:16] + except Exception as e: + version = "unknown" + try: + from agentkit.version import VERSION + + version = VERSION + except Exception: + version = "unknown" + + logger.warning( + "Failed to read Dockerfile template for hashing: %s (%s)", template_path, e + ) + seed = f"unreadable-template:{template_path}:{version}" + return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16] + + class DockerfileDecision(Enum): """Dockerfile 决策类型""" diff --git a/agentkit/toolkit/resources/templates/python/Dockerfile.j2 b/agentkit/toolkit/resources/templates/python/Dockerfile.j2 index 3a4b771..a204c87 100644 --- a/agentkit/toolkit/resources/templates/python/Dockerfile.j2 +++ b/agentkit/toolkit/resources/templates/python/Dockerfile.j2 @@ -2,7 +2,7 @@ {% if base_image %} FROM {{ base_image }} {% else %} -FROM agentkit-cn-beijing.cr.volces.com/base/py-simple:python{{ language_version }}-bookworm-slim-latest +FROM agentkit-prod-public-cn-beijing.cr.volces.com/base/py-simple:python{{ language_version }}-bookworm-slim-latest {% endif %} ENV UV_SYSTEM_PYTHON=1 UV_COMPILE_BYTECODE=1 PYTHONUNBUFFERED=1 DOCKER_CONTAINER=1 diff --git a/agentkit/version.py b/agentkit/version.py index 93c6bca..212588e 100644 --- a/agentkit/version.py +++ b/agentkit/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -VERSION = "0.4.5" +VERSION = "0.5.0" diff --git a/pyproject.toml b/pyproject.toml index 3f5c770..34b3398 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agentkit-sdk-python" -version = "0.4.5" +version = "0.5.0" description = "Python SDK for transforming any AI agent into a production-ready application. Framework-agnostic primitives for runtime, memory, authentication, and tools with volcengine-managed infrastructure." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/toolkit/docker/test_dockerfile_manager_regeneration.py b/tests/toolkit/docker/test_dockerfile_manager_regeneration.py new file mode 100644 index 0000000..d549a99 --- /dev/null +++ b/tests/toolkit/docker/test_dockerfile_manager_regeneration.py @@ -0,0 +1,155 @@ +from pathlib import Path + +from agentkit.toolkit.docker.dockerfile.manager import DockerfileManager +from agentkit.toolkit.docker.dockerfile.metadata import MetadataExtractor + + +def test_python_template_default_base_image_domain() -> None: + template_path = ( + Path(__file__).resolve().parents[3] + / "agentkit" + / "toolkit" + / "resources" + / "templates" + / "python" + / "Dockerfile.j2" + ) + content = template_path.read_text(encoding="utf-8") + assert ( + "FROM agentkit-prod-public-cn-beijing.cr.volces.com/base/py-simple:python{{ language_version }}-bookworm-slim-latest" + in content + ) + + +def test_managed_dockerfile_regenerates_when_template_hash_changes( + tmp_path: Path, +) -> None: + manager = DockerfileManager(tmp_path) + + config_hash_dict_v1 = { + "language": "Python", + "language_version": "3.12", + "entry_point": "agent.py", + "dependencies_file": "requirements.txt", + "dockerfile_template": "Dockerfile.j2", + "dockerfile_template_hash": "hash_v1", + "docker_build": {"base_image": None, "build_script": None}, + } + + def content_generator() -> str: + return "FROM python:3.12-slim\n" + + generated_1, dockerfile_path_1 = manager.prepare_dockerfile( + config_hash_dict=config_hash_dict_v1, + content_generator=content_generator, + force_regenerate=False, + ) + assert generated_1 is True + assert dockerfile_path_1 == str(tmp_path / "Dockerfile") + + dockerfile_content_1 = (tmp_path / "Dockerfile").read_text(encoding="utf-8") + metadata_1 = MetadataExtractor.extract(dockerfile_content_1) + assert metadata_1.is_managed is True + assert metadata_1.config_hash == MetadataExtractor.calculate_config_hash( + config_hash_dict_v1 + ) + + config_hash_dict_v2 = dict(config_hash_dict_v1) + config_hash_dict_v2["dockerfile_template_hash"] = "hash_v2" + + generated_2, dockerfile_path_2 = manager.prepare_dockerfile( + config_hash_dict=config_hash_dict_v2, + content_generator=content_generator, + force_regenerate=False, + ) + assert generated_2 is True + assert dockerfile_path_2 == str(tmp_path / "Dockerfile") + + backup_dir = tmp_path / ".agentkit" / "dockerfile_backups" + backups = list(backup_dir.glob("Dockerfile.backup.*")) + assert len(backups) == 1 + + dockerfile_content_2 = (tmp_path / "Dockerfile").read_text(encoding="utf-8") + metadata_2 = MetadataExtractor.extract(dockerfile_content_2) + assert metadata_2.is_managed is True + assert metadata_2.config_hash == MetadataExtractor.calculate_config_hash( + config_hash_dict_v2 + ) + + +def test_managed_dockerfile_not_regenerated_when_config_hash_unchanged( + tmp_path: Path, +) -> None: + manager = DockerfileManager(tmp_path) + + config_hash_dict_v1 = { + "language": "Python", + "language_version": "3.12", + "entry_point": "agent.py", + "dependencies_file": "requirements.txt", + "dockerfile_template": "Dockerfile.j2", + "dockerfile_template_hash": "hash_v1", + "docker_build": {"base_image": None, "build_script": None}, + } + + def content_generator() -> str: + return "FROM python:3.12-slim\n" + + generated_1, _ = manager.prepare_dockerfile( + config_hash_dict=config_hash_dict_v1, + content_generator=content_generator, + force_regenerate=False, + ) + assert generated_1 is True + + generated_2, _ = manager.prepare_dockerfile( + config_hash_dict=config_hash_dict_v1, + content_generator=content_generator, + force_regenerate=False, + ) + assert generated_2 is False + + backup_dir = tmp_path / ".agentkit" / "dockerfile_backups" + assert list(backup_dir.glob("Dockerfile.backup.*")) == [] + + +def test_managed_dockerfile_not_overwritten_when_user_modified_and_config_changes( + tmp_path: Path, +) -> None: + manager = DockerfileManager(tmp_path) + + config_hash_dict_v1 = { + "language": "Python", + "language_version": "3.12", + "entry_point": "agent.py", + "dependencies_file": "requirements.txt", + "dockerfile_template": "Dockerfile.j2", + "dockerfile_template_hash": "hash_v1", + "docker_build": {"base_image": None, "build_script": None}, + } + + def content_generator() -> str: + return "FROM python:3.12-slim\n" + + manager.prepare_dockerfile( + config_hash_dict=config_hash_dict_v1, + content_generator=content_generator, + force_regenerate=False, + ) + + dockerfile_path = tmp_path / "Dockerfile" + dockerfile_path.write_text( + dockerfile_path.read_text(encoding="utf-8") + "\nRUN echo user-modified\n", + encoding="utf-8", + ) + + config_hash_dict_v2 = dict(config_hash_dict_v1) + config_hash_dict_v2["dockerfile_template_hash"] = "hash_v2" + + generated, _ = manager.prepare_dockerfile( + config_hash_dict=config_hash_dict_v2, + content_generator=content_generator, + force_regenerate=False, + ) + assert generated is False + assert "RUN echo user-modified" in dockerfile_path.read_text(encoding="utf-8")