File size: 7,279 Bytes
9792ea7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# -*- coding: utf-8 -*-
"""Dockerfile generation + build-context preparation for DockerWorkspace.

The image is keyed by content hash of the Dockerfile text plus all
files COPYed into it. If the tag already exists locally the build is
skipped; otherwise the caller builds with the prepared context.

The container installs the same ``agentscope`` version as the host via
``uv pip install --no-deps`` inside the gateway venv.

Public functions:

* :func:`render_dockerfile` β€” substitute placeholders in
  ``Dockerfile.template`` and return the rendered text.
* :func:`compute_image_tag` β€” sha256 of Dockerfile + COPY files;
  returns ``agentscope-workspace:<12hex>``.
* :func:`prepare_build_context` β€” assemble a temp directory holding
  the Dockerfile, ``requirements.txt``, and helper scripts. Returns
  ``(ctx_dir, tag, copy_files)``.
"""

import hashlib
import importlib.resources as _res
import tempfile
from pathlib import Path

from .._utils import (
    _GATEWAY_BASE_REQUIREMENTS,
    _read_gateway_script_bytes,
    _read_glob_helper_bytes,
)

# ── shared constants (also imported by _docker_workspace) ──────────

DEFAULT_BASE_IMAGE = "python:3.11-slim"
DEFAULT_GATEWAY_PORT = 5600

CONTAINER_WORKDIR = "/workspace"

GATEWAY_HOME = "/root/.agentscope"

IMAGE_REPO = "agentscope-workspace"

# ── template loading ───────────────────────────────────────────────

_TEMPLATE_PKG = "agentscope.workspace._docker"
_DOCKERFILE_TEMPLATE = "Dockerfile.template"
_DOCKERFILE_NODE_FROM_TEMPLATE = "Dockerfile.node_from.template"
_DOCKERFILE_NODE_COPY_TEMPLATE = "Dockerfile.node_copy.template"


def _read_template(name: str) -> str:
    """Read a packaged template file as text."""
    return _res.files(_TEMPLATE_PKG).joinpath(name).read_text(encoding="utf-8")


# ── public API ─────────────────────────────────────────────────────


def render_dockerfile(
    *,
    base_image: str = DEFAULT_BASE_IMAGE,
    gateway_home: str = GATEWAY_HOME,
    container_workdir: str = CONTAINER_WORKDIR,
    node_version: str | None = None,
    install_agentscope_block: str = "",
) -> str:
    """Render the Dockerfile by substituting into the template files.

    Args:
        base_image: Base image (must already provide ``python3``).
        gateway_home: In-container directory for the gateway venv,
            script and config.
        container_workdir: Container-side workdir; bind-mounted from
            the host when the workspace's ``workdir`` is set, else
            an empty in-image directory.
        node_version: When given (e.g. ``"20"``) a ``node`` and ``npm``
            of that version are copied from the official Node slim
            image. ``None`` skips Node installation.
        install_agentscope_block: Pre-rendered block (no surrounding
            blank lines) that installs ``agentscope`` into the gateway
            venv. Built by :func:`prepare_build_context`.

    Returns:
        The full Dockerfile text.
    """
    if node_version:
        # Normalise trailing whitespace so the main template's surrounding
        # newlines fully control inter-section spacing β€” the template files
        # themselves are not relied on for exact terminal newlines.
        nf_raw = _read_template(_DOCKERFILE_NODE_FROM_TEMPLATE).format(
            node_version=node_version,
        )
        nc_raw = _read_template(_DOCKERFILE_NODE_COPY_TEMPLATE)
        node_from_block = nf_raw.rstrip() + "\n"
        node_copy_block = nc_raw.rstrip() + "\n\n"
    else:
        node_from_block = ""
        node_copy_block = ""

    return _read_template(_DOCKERFILE_TEMPLATE).format(
        base_image=base_image,
        gateway_home=gateway_home,
        container_workdir=container_workdir,
        node_from_block=node_from_block,
        node_copy_block=node_copy_block,
        install_agentscope_block=install_agentscope_block.rstrip() + "\n",
    )


def _render_requirements(extra_pip: list[str]) -> str:
    """Render ``requirements.txt`` content for the gateway venv."""
    pinned = list(_GATEWAY_BASE_REQUIREMENTS) + list(extra_pip or [])
    return "\n".join(pinned) + "\n"


def compute_image_tag(
    dockerfile_text: str,
    copy_files: dict[str, bytes],
) -> str:
    """Hash the Dockerfile and COPY payloads into a deterministic tag.

    Args:
        dockerfile_text: Full Dockerfile text.
        copy_files: Mapping of context-relative filename β†’ bytes for
            every file referenced by a ``COPY`` instruction.

    Returns:
        Tag of the form ``agentscope-workspace:<12 hex chars>``.
    """
    h = hashlib.sha256()
    h.update(b"DOCKERFILE\x00")
    h.update(dockerfile_text.encode("utf-8"))
    for name in sorted(copy_files):
        h.update(b"\x00FILE\x00")
        h.update(name.encode("utf-8"))
        h.update(b"\x00")
        h.update(copy_files[name])
    return f"{IMAGE_REPO}:{h.hexdigest()[:12]}"


def prepare_build_context(
    *,
    base_image: str = DEFAULT_BASE_IMAGE,
    gateway_home: str = GATEWAY_HOME,
    container_workdir: str = CONTAINER_WORKDIR,
    node_version: str | None = None,
    extra_pip: list[str] | None = None,
) -> tuple[Path, str, dict[str, bytes]]:
    """Assemble a temporary build context directory.

    Writes Dockerfile, ``requirements.txt`` and helper scripts into a
    fresh temp dir. The caller is responsible for removing the directory
    after the build completes.

    Returns:
        ``(ctx_dir, tag, copy_files)`` β€” ``ctx_dir`` holds the
        materialised files; ``tag`` is the deterministic image tag;
        ``copy_files`` is the same mapping that was hashed into the
        tag (handy for callers that want to recompute / verify).
    """
    extra_pip_list = list(extra_pip or [])

    install_block = 'RUN uv pip install "agentscope"'

    dockerfile_text = render_dockerfile(
        base_image=base_image,
        gateway_home=gateway_home,
        container_workdir=container_workdir,
        node_version=node_version,
        install_agentscope_block=install_block,
    )
    requirements_text = _render_requirements(extra_pip_list)

    # Read helper scripts once β€” we both hash them into the image tag
    # (so edits invalidate the image cache) and write them into the
    # build context so the Dockerfile can ``COPY`` them.
    gateway_script_bytes = _read_gateway_script_bytes()
    glob_helper_bytes = _read_glob_helper_bytes()

    copy_files: dict[str, bytes] = {
        "requirements.txt": requirements_text.encode("utf-8"),
        "_mcp_gateway_app.py": gateway_script_bytes,
        "_glob_helper.py": glob_helper_bytes,
    }
    tag = compute_image_tag(dockerfile_text, copy_files)

    ctx_dir = Path(tempfile.mkdtemp(prefix="as-ws-build-"))
    (ctx_dir / "Dockerfile").write_text(dockerfile_text, encoding="utf-8")
    (ctx_dir / "requirements.txt").write_bytes(
        copy_files["requirements.txt"],
    )
    (ctx_dir / "_mcp_gateway_app.py").write_bytes(gateway_script_bytes)
    (ctx_dir / "_glob_helper.py").write_bytes(glob_helper_bytes)

    return ctx_dir, tag, copy_files