main.py
  1  # Copyright 2025 Alibaba Group Holding Ltd.
  2  #
  3  # Licensed under the Apache License, Version 2.0 (the "License");
  4  # you may not use this file except in compliance with the License.
  5  # You may obtain a copy of the License at
  6  #
  7  #     http://www.apache.org/licenses/LICENSE-2.0
  8  #
  9  # Unless required by applicable law or agreed to in writing, software
 10  # distributed under the License is distributed on an "AS IS" BASIS,
 11  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12  # See the License for the specific language governing permissions and
 13  # limitations under the License.
 14  
 15  """
 16  Docker PVC (Named Volume) Mount Example
 17  ========================================
 18  
 19  Demonstrates how to mount Docker named volumes into sandbox containers using
 20  the OpenSandbox ``pvc`` backend.  In Docker runtime the ``pvc`` backend maps
 21  ``claimName`` to a Docker named volume -- providing a more convenient and
 22  secure alternative to host-path bind mounts for sharing data across sandboxes.
 23  
 24  Four scenarios are demonstrated:
 25  
 26  1. **Read-write mount**        - Mount a named volume for bidirectional file I/O.
 27  2. **Read-only mount**         - Mount a named volume as read-only.
 28  3. **Cross-sandbox sharing**   - Two sandboxes share data through the same named
 29     volume without exposing any host path.
 30  4. **SubPath mount**           - Mount only a subdirectory of a named volume,
 31     keeping the same API as Kubernetes PVC subPath.
 32  
 33  Prerequisites:
 34  - OpenSandbox server running with Docker runtime
 35  - Docker named volume created before running this script (see README.md)
 36  """
 37  
 38  import asyncio
 39  import os
 40  import subprocess
 41  from datetime import timedelta
 42  
 43  from opensandbox import Sandbox
 44  from opensandbox.config import ConnectionConfig
 45  
 46  try:
 47      from opensandbox.models.sandboxes import PVC, Volume
 48  except ImportError:
 49      print(
 50          "ERROR: Your installed opensandbox SDK does not include Volume/PVC models.\n"
 51          "       Volume support requires the latest SDK from source.\n"
 52          "       Please install from the local repository:\n"
 53          "\n"
 54          "           pip install -e sdks/sandbox/python\n"
 55          "\n"
 56          "       See README.md for details."
 57      )
 58      raise SystemExit(1)
 59  
 60  
 61  VOLUME_NAME = "opensandbox-pvc-demo"
 62  
 63  
 64  async def print_exec(sandbox: Sandbox, command: str) -> str | None:
 65      """Run a command in the sandbox and print/return stdout."""
 66      result = await sandbox.commands.run(command)
 67      if result.error:
 68          print(f"  [error] {result.error.name}: {result.error.value}")
 69          return None
 70      text = "\n".join(msg.text for msg in result.logs.stdout)
 71      if text:
 72          print(f"  {text}")
 73      return text
 74  
 75  
 76  def ensure_named_volume() -> None:
 77      """Create the Docker named volume and seed it with test data."""
 78      print(f"  Ensuring Docker named volume '{VOLUME_NAME}' exists...")
 79      subprocess.run(
 80          ["docker", "volume", "rm", VOLUME_NAME],
 81          capture_output=True,
 82      )
 83      subprocess.run(
 84          ["docker", "volume", "create", VOLUME_NAME],
 85          check=True,
 86          capture_output=True,
 87      )
 88      # Seed the volume with a marker file and subpath test data
 89      subprocess.run(
 90          [
 91              "docker", "run", "--rm",
 92              "-v", f"{VOLUME_NAME}:/data",
 93              "alpine",
 94              "sh", "-c",
 95              "echo 'hello-from-named-volume' > /data/marker.txt && "
 96              "mkdir -p /data/datasets/train && "
 97              "echo 'id,value' > /data/datasets/train/data.csv && "
 98              "echo '1,100' >> /data/datasets/train/data.csv && "
 99              "echo '2,200' >> /data/datasets/train/data.csv",
100          ],
101          check=True,
102          capture_output=True,
103      )
104      print(f"  Created volume '{VOLUME_NAME}' with marker.txt and datasets/train/")
105  
106  
107  async def demo_readwrite_mount(config: ConnectionConfig, image: str) -> None:
108      """
109      Scenario 1: Read-write named volume mount.
110  
111      Mount a Docker named volume into the sandbox at /mnt/data.
112      Write a file inside the sandbox, then read it back to verify.
113      """
114      print("\n" + "=" * 60)
115      print("Scenario 1: Read-Write PVC (Named Volume) Mount")
116      print("=" * 60)
117      print(f"  Volume name: {VOLUME_NAME}")
118      print(f"  Mount path : /mnt/data")
119  
120      sandbox = await Sandbox.create(
121          image=image,
122          connection_config=config,
123          timeout=timedelta(minutes=2),
124          volumes=[
125              Volume(
126                  name="demo-data",
127                  pvc=PVC(claimName=VOLUME_NAME),
128                  mountPath="/mnt/data",
129                  readOnly=False,
130              ),
131          ],
132      )
133  
134      async with sandbox:
135          try:
136              # Read the seeded marker file
137              print("\n  [1] Reading marker file from named volume:")
138              await print_exec(sandbox, "cat /mnt/data/marker.txt")
139  
140              # Write a new file
141              print("\n  [2] Writing a file from inside the sandbox:")
142              await print_exec(
143                  sandbox,
144                  "echo 'written-by-sandbox' > /mnt/data/sandbox-output.txt",
145              )
146              print("  -> Written: /mnt/data/sandbox-output.txt")
147  
148              # Read it back
149              print("\n  [3] Reading back the written file:")
150              await print_exec(sandbox, "cat /mnt/data/sandbox-output.txt")
151  
152              # List all files
153              print("\n  [4] Listing volume contents:")
154              await print_exec(sandbox, "ls -la /mnt/data/")
155  
156          finally:
157              await sandbox.kill()
158  
159      print("\n  Scenario 1 completed.")
160  
161  
162  async def demo_readonly_mount(config: ConnectionConfig, image: str) -> None:
163      """
164      Scenario 2: Read-only named volume mount.
165  
166      Mount the same named volume as read-only.  Verify reads succeed but
167      writes are rejected by the container runtime.
168      """
169      print("\n" + "=" * 60)
170      print("Scenario 2: Read-Only PVC (Named Volume) Mount")
171      print("=" * 60)
172      print(f"  Volume name: {VOLUME_NAME}")
173      print(f"  Mount path : /mnt/readonly")
174  
175      sandbox = await Sandbox.create(
176          image=image,
177          connection_config=config,
178          timeout=timedelta(minutes=2),
179          volumes=[
180              Volume(
181                  name="readonly-vol",
182                  pvc=PVC(claimName=VOLUME_NAME),
183                  mountPath="/mnt/readonly",
184                  readOnly=True,
185              ),
186          ],
187      )
188  
189      async with sandbox:
190          try:
191              # Read the marker file
192              print("\n  [1] Reading marker.txt from read-only mount:")
193              await print_exec(sandbox, "cat /mnt/readonly/marker.txt")
194  
195              # Attempt to write (should fail)
196              print("\n  [2] Attempting to write (should fail):")
197              result = await sandbox.commands.run(
198                  "touch /mnt/readonly/should-fail.txt 2>&1 || echo 'Write denied (expected)'"
199              )
200              for msg in result.logs.stdout:
201                  print(f"  {msg.text}")
202              for msg in result.logs.stderr:
203                  print(f"  {msg.text}")
204  
205          finally:
206              await sandbox.kill()
207  
208      print("\n  Scenario 2 completed.")
209  
210  
211  async def demo_cross_sandbox_sharing(config: ConnectionConfig, image: str) -> None:
212      """
213      Scenario 3: Cross-sandbox data sharing via named volume.
214  
215      Two sandboxes mount the same named volume.  Sandbox A writes a file,
216      then Sandbox B reads it -- demonstrating data sharing without any host
217      path exposure.
218      """
219      print("\n" + "=" * 60)
220      print("Scenario 3: Cross-Sandbox Sharing via PVC (Named Volume)")
221      print("=" * 60)
222      print(f"  Volume name: {VOLUME_NAME}")
223  
224      volume_spec = Volume(
225          name="shared-vol",
226          pvc=PVC(claimName=VOLUME_NAME),
227          mountPath="/mnt/shared",
228          readOnly=False,
229      )
230  
231      # --- Sandbox A: write ---
232      print("\n  [Sandbox A] Creating sandbox and writing data...")
233      sandbox_a = await Sandbox.create(
234          image=image,
235          connection_config=config,
236          timeout=timedelta(minutes=2),
237          volumes=[volume_spec],
238      )
239      async with sandbox_a:
240          try:
241              await print_exec(
242                  sandbox_a,
243                  "echo 'message-from-sandbox-a' > /mnt/shared/cross-sandbox.txt",
244              )
245              print("  [Sandbox A] Wrote /mnt/shared/cross-sandbox.txt")
246          finally:
247              await sandbox_a.kill()
248  
249      # --- Sandbox B: read ---
250      print("\n  [Sandbox B] Creating sandbox and reading data...")
251      sandbox_b = await Sandbox.create(
252          image=image,
253          connection_config=config,
254          timeout=timedelta(minutes=2),
255          volumes=[volume_spec],
256      )
257      async with sandbox_b:
258          try:
259              print("  [Sandbox B] Reading file written by Sandbox A:")
260              text = await print_exec(sandbox_b, "cat /mnt/shared/cross-sandbox.txt")
261              if text and "message-from-sandbox-a" in text:
262                  print("\n  Cross-sandbox data sharing verified!")
263          finally:
264              await sandbox_b.kill()
265  
266      print("\n  Scenario 3 completed.")
267  
268  
269  async def demo_subpath_mount(config: ConnectionConfig, image: str) -> None:
270      """
271      Scenario 4: SubPath mount on a named volume.
272  
273      Mount only a subdirectory (datasets/train) of the named volume.  The server
274      resolves the volume's host-side Mountpoint via ``docker volume inspect`` and
275      appends the subPath, producing a standard bind mount.  This keeps the API
276      consistent with Kubernetes PVC subPath semantics.
277      """
278      print("\n" + "=" * 60)
279      print("Scenario 4: SubPath PVC (Named Volume) Mount")
280      print("=" * 60)
281      print(f"  Volume name: {VOLUME_NAME}")
282      print(f"  SubPath    : datasets/train")
283      print(f"  Mount path : /mnt/training-data")
284  
285      sandbox = await Sandbox.create(
286          image=image,
287          connection_config=config,
288          timeout=timedelta(minutes=2),
289          volumes=[
290              Volume(
291                  name="train-data",
292                  pvc=PVC(claimName=VOLUME_NAME),
293                  mountPath="/mnt/training-data",
294                  readOnly=True,
295                  subPath="datasets/train",
296              ),
297          ],
298      )
299  
300      async with sandbox:
301          try:
302              # List contents -- should only show the subpath
303              print("\n  [1] Listing mounted subpath content:")
304              await print_exec(sandbox, "ls -la /mnt/training-data/")
305  
306              # Read the CSV data
307              print("\n  [2] Reading data.csv:")
308              await print_exec(sandbox, "cat /mnt/training-data/data.csv")
309  
310              # Verify the root marker.txt is NOT visible (we're inside datasets/train)
311              print("\n  [3] Verifying volume root is NOT visible:")
312              result = await sandbox.commands.run("test -f /mnt/training-data/marker.txt && echo FOUND || echo NOT-FOUND")
313              text = "\n".join(msg.text for msg in result.logs.stdout)
314              print(f"  marker.txt at mount root: {text}")
315              if "NOT-FOUND" in text:
316                  print("  -> Confirmed: subPath isolation is working correctly")
317  
318          finally:
319              await sandbox.kill()
320  
321      print("\n  Scenario 4 completed.")
322  
323  
324  async def main() -> None:
325      domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080")
326      api_key = os.getenv("SANDBOX_API_KEY")
327      image = os.getenv("SANDBOX_IMAGE", "ubuntu")
328  
329      config = ConnectionConfig(
330          domain=domain,
331          api_key=api_key,
332          request_timeout=timedelta(minutes=3),
333      )
334  
335      print(f"OpenSandbox server : {config.domain}")
336      print(f"Sandbox image      : {image}")
337      print(f"Docker volume      : {VOLUME_NAME}")
338  
339      # Ensure the named volume exists with seed data
340      ensure_named_volume()
341  
342      await demo_readwrite_mount(config, image)
343      await demo_readonly_mount(config, image)
344      await demo_cross_sandbox_sharing(config, image)
345      await demo_subpath_mount(config, image)
346  
347      print("\n" + "=" * 60)
348      print("All scenarios completed successfully!")
349      print("=" * 60)
350  
351  
352  if __name__ == "__main__":
353      asyncio.run(main())