1818from rock .sdk .sandbox .client import Sandbox
1919from rock .sdk .sandbox .config import SandboxConfig
2020from rock .sdk .sandbox .image import Image
21+ from rock .sdk .sandbox .image_resolver import _ImageResolver
22+ from rock .utils import ImageUtil
2123
2224logger = 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
95156async 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
105167async 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
125186async 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 )
0 commit comments