Skip to content

Commit 8ac6e5f

Browse files
Issac-Newtonclaude
andcommitted
test(sdk): drive from_dockerfile build via test-managed builder with loopback NAT
- test_image_build.py now creates the builder sandbox itself, injects an iptables OUTPUT DNAT (127.0.0.1:port → host_ip:port) plus POSTROUTING MASQUERADE so the loopback registry URL served by the local_registry fixture is reachable from inside the builder's network namespace. Calls _ImageResolver.resolve_with_builder on the prepared builder. Three pieces are required for loopback NAT to work end-to-end: 1. enable route_localnet on lo + all (kernel default forbids routing 127.x off lo) 2. OUTPUT DNAT 127.0.0.1:port → host_ip:port (rewrite outgoing dst) 3. POSTROUTING MASQUERADE for host_ip:port (rewrite src so reply routes back) - conftest.local_registry: add comment explaining the localhost choice and the NAT requirement for sandbox callers (no behavior change). - test data Dockerfile: switch FROM to rock/example-sandbox:py311 (internal registry, reachable from CI without docker.io egress). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 13a00e9 commit 8ac6e5f

3 files changed

Lines changed: 81 additions & 16 deletions

File tree

tests/integration/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,10 @@ async def local_registry():
275275
check=True,
276276
)
277277

278-
# 4. Wait for registry to be ready
278+
# 4. Wait for registry to be ready. Use `localhost` so the registry name falls in
279+
# the `127.0.0.0/8` CIDR that both outer dockerd and inner builder dockerd trust by
280+
# default. Tests using this fixture from inside a builder sandbox must inject an
281+
# iptables NAT rule mapping 127.0.0.1:port → host_ip:port (see test_image_build.py).
279282
registry_url = f"localhost:{port}"
280283
for _ in range(30):
281284
try:

tests/integration/sdk/sandbox/test_image_build.py

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from rock.sdk.sandbox.client import Sandbox
1919
from rock.sdk.sandbox.config import SandboxConfig
2020
from rock.sdk.sandbox.image import Image
21+
from rock.sdk.sandbox.image_resolver import _ImageResolver
22+
from rock.utils import ImageUtil
2123

2224
logger = init_logger(__name__)
2325

@@ -39,9 +41,19 @@ def _create_image(env_dir, registry_info, **kwargs):
3941
)
4042

4143

42-
def _create_config(image, admin_remote_server):
44+
def _create_config(image, admin_remote_server, registry_info=None):
45+
"""Build a SandboxConfig for the just-built image.
46+
47+
`image` is the already-resolved tag string (we pre-build via _build_with_loopback_nat
48+
so the SDK's auto-resolve path inside Sandbox.start() isn't triggered here).
49+
`registry_info` carries the credentials admin needs to pull the image.
50+
"""
4351
base_url = f"{admin_remote_server.endpoint}:{admin_remote_server.port}"
44-
return SandboxConfig(image=image, memory="2g", cpus=1.0, startup_timeout=600, base_url=base_url)
52+
kwargs = dict(image=image, memory="2g", cpus=1.0, startup_timeout=600, base_url=base_url)
53+
if registry_info:
54+
kwargs["registry_username"] = registry_info["registry_username"]
55+
kwargs["registry_password"] = registry_info["registry_password"]
56+
return SandboxConfig(**kwargs)
4557

4658

4759
@asynccontextmanager
@@ -65,7 +77,56 @@ async def _assert_file_content(sandbox, expected):
6577
assert result.output.strip() == expected
6678

6779

68-
# ── Fixtures ──
80+
# ── Fixtures / helpers ──
81+
82+
83+
async def _inject_loopback_nat(builder, port: int) -> None:
84+
"""NAT 127.0.0.1:port → builder.host_ip:port inside the builder.
85+
86+
The local_registry fixture serves on the host's loopback (`localhost:port`, i.e.
87+
127.0.0.1:port). That address falls in 127.0.0.0/8 which dockerd trusts as insecure
88+
by default, but from inside the builder (its own netns) 127.0.0.1 is the builder's
89+
own loopback with no listener. Three things make the loopback URL actually reach
90+
the host's docker-proxy:
91+
1. enable route_localnet (kernel default forbids routing 127.x off lo)
92+
2. OUTPUT DNAT 127.0.0.1:port → host_ip:port (rewrite outgoing dst)
93+
3. POSTROUTING MASQUERADE for host_ip:port (rewrite src so reply routes back)
94+
"""
95+
host_ip = builder.host_ip
96+
cmd = (
97+
"echo 1 | tee /proc/sys/net/ipv4/conf/all/route_localnet "
98+
"/proc/sys/net/ipv4/conf/lo/route_localnet > /dev/null && "
99+
f"iptables -t nat -A OUTPUT -p tcp -d 127.0.0.1 --dport {port} "
100+
f"-j DNAT --to-destination {host_ip}:{port} && "
101+
f"iptables -t nat -A POSTROUTING -p tcp -d {host_ip} --dport {port} -j MASQUERADE"
102+
)
103+
logger.info("Injecting builder loopback NAT: 127.0.0.1:%s -> %s:%s", port, host_ip, port)
104+
obs = await builder.arun(cmd=cmd, session=_ImageResolver.BUILD_SESSION, mode="normal")
105+
if obs.exit_code != 0:
106+
raise RuntimeError(f"NAT setup failed (exit_code={obs.exit_code}): {obs.failure_reason or obs.output}")
107+
108+
109+
async def _build_with_loopback_nat(image: Image, admin_remote_server) -> str:
110+
"""Drive the build using a builder we own so we can inject test-only NAT.
111+
112+
Returns the resolved image name (string) once build+push completes.
113+
"""
114+
base_url = f"{admin_remote_server.endpoint}:{admin_remote_server.port}"
115+
resolver = _ImageResolver(base_url=base_url, cluster="default")
116+
builder = resolver.create_builder()
117+
await builder.start()
118+
try:
119+
await builder.create_session(CreateBashSessionRequest(session=_ImageResolver.BUILD_SESSION))
120+
registry, _ = ImageUtil.parse_registry_and_others(image.image_name)
121+
host_part, _, port_part = (registry or "").partition(":")
122+
if (host_part.startswith("127.") or host_part == "localhost") and port_part:
123+
await _inject_loopback_nat(builder, int(port_part))
124+
return await resolver.resolve_with_builder(image, builder)
125+
finally:
126+
try:
127+
await builder.stop()
128+
except Exception:
129+
logger.warning("Failed to stop builder sandbox: %s", builder.sandbox_id, exc_info=True)
69130

70131

71132
@pytest.fixture
@@ -93,29 +154,29 @@ def modified_env_dir(tmp_path):
93154
@pytest.mark.need_admin
94155
@pytest.mark.asyncio
95156
async def test_from_dockerfile_build_and_start(local_registry_info, admin_remote_server):
96-
"""Image.from_dockerfile() → Sandbox.start() → verify COPY file accessible."""
157+
"""Image.from_dockerfile() → build/push (via test-managed builder) → Sandbox.start()."""
97158
image = _create_image(TEST_DATA_DIR, local_registry_info)
98-
config = _create_config(image, admin_remote_server)
159+
resolved = await _build_with_loopback_nat(image, admin_remote_server)
160+
config = _create_config(resolved, admin_remote_server, local_registry_info)
99161
async with _run_sandbox(config) as sandbox:
100162
await _assert_file_content(sandbox, EXPECTED_FILE_CONTENT)
101163

102164

103165
@pytest.mark.need_admin
104166
@pytest.mark.asyncio
105167
async def test_from_dockerfile_cache_skip(local_registry_info, admin_remote_server):
106-
"""Second start with same Image should skip build (cache hit)."""
168+
"""Second build of the same Image should hit cache (CACHE_HIT) and skip push."""
107169
image = _create_image(TEST_DATA_DIR, local_registry_info)
108-
config = _create_config(image, admin_remote_server)
109170

110-
async with _run_sandbox(config):
111-
first_duration = time.monotonic()
112-
first_duration = time.monotonic() - first_duration
171+
t0 = time.monotonic()
172+
resolved = await _build_with_loopback_nat(image, admin_remote_server)
173+
first_duration = time.monotonic() - t0
113174

114175
t0 = time.monotonic()
115-
async with _run_sandbox(config) as sandbox:
116-
second_duration = time.monotonic() - t0
117-
await _assert_file_content(sandbox, EXPECTED_FILE_CONTENT)
176+
resolved2 = await _build_with_loopback_nat(image, admin_remote_server)
177+
second_duration = time.monotonic() - t0
118178

179+
assert resolved == resolved2
119180
logger.info("First build: %.1fs, second build: %.1fs", first_duration, second_duration)
120181
assert second_duration < first_duration
121182

@@ -125,6 +186,7 @@ async def test_from_dockerfile_cache_skip(local_registry_info, admin_remote_serv
125186
async def test_from_dockerfile_rebuilds_on_content_change(local_registry_info, admin_remote_server, modified_env_dir):
126187
"""Content change in env_dir triggers rebuild, new file content is picked up."""
127188
image = _create_image(modified_env_dir, local_registry_info)
128-
config = _create_config(image, admin_remote_server)
189+
resolved = await _build_with_loopback_nat(image, admin_remote_server)
190+
config = _create_config(resolved, admin_remote_server, local_registry_info)
129191
async with _run_sandbox(config) as sandbox:
130192
await _assert_file_content(sandbox, MODIFIED_CONTENT)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
FROM python:3.11
1+
FROM rock-n-roll-registry.cn-hangzhou.cr.aliyuncs.com/rock/example-sandbox:py311
22
COPY hello.txt /opt/hello.txt

0 commit comments

Comments
 (0)