| Build the window somewhere with room for it 1451151 nandi 5d ago | 1 | """Turn a `container.toml` into a `modal.Image` and a `modal.App`. |
| 2 | |
| Carry the loader's .git fix over 85852e8 nandi 4d ago | 3 | Every container -- under `containers/` here, `.modal/` in a repo that merely |
| 4 | builds itself on Modal -- is a directory with a `container.toml` and |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 5 | a stub `container.py`. See `spec.md` for the keys; this file is what reads |
| 6 | them. Nothing here is Modal-specific configuration in its own right -- each |
| 7 | spec key maps onto a documented Modal argument, and the mapping is meant to |
| 8 | stay boring enough to read straight through. |
| 9 | """ |
| 10 | |
| 11 | import os |
| 12 | import shlex |
| 13 | import subprocess |
| 14 | import tomllib |
| 15 | |
| 16 | import modal |
| 17 | |
| 18 | REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 19 | PTYSHIM_C = os.path.join(REPO, "ptyshim.c") |
| 20 | SHIM_SO = "/opt/ptyshim.so" |
| 21 | |
| 22 | |
| 23 | class SpecError(Exception): |
| 24 | """The container.toml says something that cannot be built.""" |
| 25 | |
| 26 | |
| 27 | def _running_in_modal() -> bool: |
| 28 | """True inside a Modal container, false on the machine that launched it.""" |
| 29 | return bool(os.environ.get("MODAL_TASK_ID")) |
| 30 | |
| 31 | |
| 32 | class Container: |
| 33 | """One container: its spec, its image, its app, and how to run it.""" |
| 34 | |
| 35 | def __init__(self, spec: dict, directory: str): |
| 36 | self.is_remote = _running_in_modal() |
| 37 | self.dir = directory |
| 38 | self.spec = spec |
| 39 | self.name = self._require("container", "name") |
| 40 | self.description = spec.get("container", {}).get("description", "") |
| 41 | |
| 42 | run = spec.get("run", {}) |
| 43 | self.workdir = run.get("workdir", "/app") |
| 44 | self.command = run.get("command", "") |
| 45 | self.env = dict(run.get("env", {})) |
| 46 | |
| A shell that is already the devShell, and two recipes to reach it e853593 nandi 4d ago | 47 | # Substituters reach nix through NIX_CONFIG rather than nix.conf, and |
| 48 | # at run time rather than build time. Written into the image's nix.conf |
| 49 | # instead, nix touches the path while the image is being built and |
| 50 | # creates it -- and Modal will not mount a volume over a non-empty |
| 51 | # directory, so the container that wanted the cache cannot start. As |
| 52 | # env it covers every nix command that runs in the container, the ones |
| 53 | # typed by hand in a shell included, and leaves the mount point empty. |
| 54 | if subs := list(spec.get("nix", {}).get("substituters", [])): |
| 55 | line = "extra-substituters = " + " ".join(subs) |
| 56 | self.env["NIX_CONFIG"] = ( |
| 57 | f"{self.env['NIX_CONFIG']}\n{line}" if "NIX_CONFIG" in self.env |
| 58 | else line |
| 59 | ) |
| 60 | |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 61 | # "function" (the default) or "sandbox". Sandboxes can run on a real |
| 62 | # VM, which Functions cannot -- see ../README.md. |
| 63 | self.runtime = spec.get("container", {}).get("runtime", "function") |
| 64 | |
| 65 | nix = spec.get("nix", {}) |
| 66 | self.use_flake = bool(nix.get("flake", False)) |
| 67 | self.use_shim = bool(nix.get("shim", False)) |
| 68 | |
| 69 | # name -> mount path. Modal Volumes, mounted while the container runs |
| 70 | # and NOT while its image is built: a volume mount is not part of the |
| 71 | # resulting image, so anything written to one during a build step is |
| 72 | # gone by the time the container starts. Persist across runs is the |
| 73 | # whole point -- a nix store to substitute from, a cargo target |
| 74 | # directory, a dataset too big to bake in. |
| 75 | self.volume_spec = dict(spec.get("volumes", {})) |
| 76 | |
| A third target, and the seam that was already waiting for it f54ca45 nandi yesterday | 77 | # Ports to tunnel out of a Sandbox, from [network] ports. Encrypted, |
| 78 | # which is Modal's own word for "the tunnel terminates TLS and speaks |
| 79 | # plain HTTP to your process" -- so the thing listening inside is an |
| 80 | # ordinary http.server and not something holding a certificate. |
| 81 | # |
| 82 | # Sandbox-only: a Function has no long-lived process to tunnel into, |
| 83 | # and `modal run` on one would hand back a URL for a container that |
| 84 | # has already exited. |
| 85 | self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])] |
| 86 | |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 87 | # Modal re-imports this module inside the container, so everything |
| 88 | # below runs twice: once here, once out there. Out there the local |
| 89 | # tree does not exist -- no flake.nix, no ptyshim.c, no repo -- and |
| 90 | # the image is already built, so validating and rebuilding it would |
| 91 | # only fail. The App still has to exist for the decorators to bind. |
| 92 | if self.is_remote: |
| 93 | self.image = None |
| 94 | self.app = modal.App(self.name) |
| 95 | else: |
| 96 | self._validate() |
| 97 | self.image = self._build_image() |
| 98 | self.app = modal.App(self.name, image=self.image) |
| 99 | |
| 100 | # -- spec reading ---------------------------------------------------- |
| 101 | |
| 102 | @classmethod |
| 103 | def from_toml(cls, container_py: str) -> "Container": |
| 104 | """Load the container.toml sitting next to the given container.py.""" |
| 105 | directory = os.path.dirname(os.path.abspath(container_py)) |
| 106 | path = os.path.join(directory, "container.toml") |
| 107 | if not os.path.exists(path): |
| 108 | raise SpecError(f"no container.toml in {directory}") |
| 109 | with open(path, "rb") as f: |
| 110 | return cls(tomllib.load(f), directory) |
| 111 | |
| 112 | def _require(self, table: str, key: str): |
| 113 | try: |
| 114 | return self.spec[table][key] |
| 115 | except KeyError: |
| 116 | raise SpecError(f"container.toml needs [{table}] {key}") from None |
| 117 | |
| 118 | def _validate(self): |
| 119 | c = self.spec.get("container", {}) |
| 120 | if self.runtime not in ("function", "sandbox"): |
| 121 | raise SpecError( |
| 122 | f'[container] runtime must be "function" or "sandbox",' |
| 123 | f" not {self.runtime!r}" |
| 124 | ) |
| 125 | if bool(c.get("base")) == bool(c.get("registry")): |
| 126 | raise SpecError("set exactly one of [container] base or registry") |
| 127 | if self.use_flake: |
| 128 | if not os.path.exists(os.path.join(self.dir, "flake.nix")): |
| 129 | raise SpecError("[nix] flake = true but there is no flake.nix") |
| 130 | if not self.use_shim: |
| 131 | # Warming the shell builds nix-shell-env, which is the gVisor |
| 132 | # pty bug. Failing here beats failing ten minutes into a build. |
| 133 | raise SpecError( |
| 134 | "[nix] flake = true needs shim = true -- see ../README.md" |
| 135 | ) |
| 136 | if self.use_shim and not os.path.exists(PTYSHIM_C): |
| 137 | raise SpecError(f"[nix] shim = true but {PTYSHIM_C} is missing") |
| A third target, and the seam that was already waiting for it f54ca45 nandi yesterday | 138 | if self.ports and self.runtime != "sandbox": |
| 139 | raise SpecError( |
| 140 | "[network] ports needs [container] runtime = \"sandbox\" --" |
| 141 | " a Function has no process to tunnel into" |
| 142 | ) |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 143 | for name, mount in self.volume_spec.items(): |
| 144 | if not isinstance(mount, str) or not mount.startswith("/"): |
| 145 | raise SpecError( |
| 146 | f"[volumes] {name} must be an absolute path, not {mount!r}" |
| 147 | ) |
| 148 | # Mounting over one of these hides what the image already has |
| 149 | # there -- /nix in particular, where the empty volume would shadow |
| 150 | # the store the base image spent its build populating. |
| 151 | if mount.rstrip("/") in ("", "/nix", "/nix/store", "/usr", "/etc"): |
| 152 | raise SpecError( |
| 153 | f"[volumes] {name} may not mount over {mount} --" |
| 154 | " it would hide what the image has there" |
| 155 | ) |
| 156 | if mount.rstrip("/") == self.workdir.rstrip("/"): |
| 157 | raise SpecError( |
| 158 | f"[volumes] {name} may not mount over the workdir" |
| 159 | f" ({mount}) -- [build] include copies land there" |
| 160 | ) |
| 161 | |
| 162 | # -- image ----------------------------------------------------------- |
| 163 | |
| 164 | @property |
| 165 | def nix(self) -> str: |
| 166 | """The `nix` command as the container runs it. |
| 167 | |
| 168 | A sandbox on a real VM has a working pty, so the shim buys nothing |
| 169 | there and is left off even when the image was built with it. |
| 170 | """ |
| 171 | if self.use_shim and not self.vm_at_runtime: |
| 172 | return f"LD_PRELOAD={SHIM_SO} nix" |
| 173 | return "nix" |
| 174 | |
| 175 | @property |
| 176 | def vm_at_runtime(self) -> bool: |
| 177 | """True when this container actually runs on a VM rather than gVisor.""" |
| 178 | return (self.runtime == "sandbox" |
| 179 | and bool(self.experimental_options.get("vm_runtime"))) |
| 180 | |
| 181 | @property |
| 182 | def build_nix(self) -> str: |
| 183 | """The `nix` command for BUILD steps, which always run under gVisor. |
| 184 | |
| 185 | Image builds are Functions underneath, so a sandbox container still |
| 186 | needs the shim while its image is being built -- only its run time |
| 187 | gets the VM. |
| 188 | """ |
| 189 | return f"LD_PRELOAD={SHIM_SO} nix" if self.use_shim else "nix" |
| 190 | |
| 191 | def _build_image(self) -> modal.Image: |
| 192 | c = self.spec["container"] |
| 193 | build = self.spec.get("build", {}) |
| 194 | |
| 195 | if c.get("base"): |
| 196 | image = modal.Image.from_name(c["base"]) |
| 197 | else: |
| 198 | image = modal.Image.from_registry(c["registry"]) |
| 199 | |
| A shell that is already the devShell, and two recipes to reach it e853593 nandi 4d ago | 200 | # `nix profile install` puts things in ~/.nix-profile/bin, which is on |
| 201 | # nobody's PATH here -- so the install succeeds and the very next line |
| 202 | # says `command not found`. Set on the image rather than in a .bashrc, |
| 203 | # because a container's command runs under `sh -c` and reads neither. |
| 204 | # Spelled out rather than prefixed onto $PATH: an image env is a value, |
| 205 | # not a shell expression, so "$PATH" here would be four literal |
| 206 | # characters. |
| 207 | image = image.env({ |
| 208 | "PATH": "/root/.nix-profile/bin:/usr/local/sbin:/usr/local/bin" |
| 209 | ":/usr/sbin:/usr/bin:/sbin:/bin", |
| 210 | }) |
| 211 | |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 212 | if self.use_shim: |
| 213 | image = image.add_local_file( |
| 214 | PTYSHIM_C, "/opt/ptyshim.c", copy=True |
| 215 | ).run_commands( |
| 216 | f"gcc -shared -fPIC -O2 -o {SHIM_SO} /opt/ptyshim.c -ldl" |
| 217 | ) |
| 218 | |
| 219 | # Warm the devShell BEFORE the source is copied in. Everything the |
| 220 | # shell needs is binary-cached, so the download happens once -- but |
| 221 | # only if this layer survives. Copy the source first and any edit to |
| 222 | # any file invalidates the warm, and the whole closure is fetched |
| 223 | # again on every build. Only flake.nix and flake.lock go in here, so |
| 224 | # the layer is invalidated by a dependency change and nothing else. |
| 225 | if self.use_flake: |
| 226 | for f in ("flake.nix", "flake.lock"): |
| 227 | path = os.path.join(self.dir, f) |
| 228 | if os.path.exists(path): |
| 229 | image = image.add_local_file( |
| 230 | path, f"{self.workdir}/{f}", copy=True |
| 231 | ) |
| 232 | image = image.run_commands( |
| 233 | f"cd {self.workdir} && {self.build_nix} develop" |
| 234 | " --accept-flake-config --command true", |
| 235 | f'echo "store paths after warming:' |
| 236 | f' $({self.build_nix} path-info --all | wc -l)"', |
| 237 | ) |
| 238 | |
| 239 | # copy=True throughout: later run_commands need these files present. |
| 240 | # `context` is what include paths are relative to, and it may sit above |
| 241 | # the container directory -- a container that builds the repo it lives |
| 242 | # in sets context = "../..", so include = ["."] means the whole repo. |
| 243 | context = os.path.normpath(os.path.join(self.dir, build.get("context", "."))) |
| A third target, and the seam that was already waiting for it f54ca45 nandi yesterday | 244 | |
| 245 | # Image building and program building, kept apart. |
| 246 | # |
| 247 | # `[build] commands` run AFTER the source copy, so any edit anywhere in |
| 248 | # the tree invalidates them -- and for a container whose commands warm |
| 249 | # a devShell, that means minutes of nix on every iteration of a |
| 250 | # one-line change. `warm` + `setup` are the same two steps moved in |
| 251 | # front of the source: `warm` names only the files the flake actually |
| 252 | # evaluates (its own, and whatever the devShell's derivations read), |
| 253 | # and `setup` runs against those alone. Editing `flutter/src` then |
| 254 | # invalidates nothing above the final copy, and the toolchain layer is |
| 255 | # reused until a dependency moves. |
| 256 | # |
| 257 | # Volumes are mounted here for `commands`' reason: a step that can |
| 258 | # reach the binary cache never has to build, which is the one thing |
| 259 | # gVisor will not do. |
| 260 | for rel in build.get("warm", []): |
| 261 | src = os.path.normpath(os.path.join(context, rel)) |
| 262 | dest = f"{self.workdir}/{rel}" |
| 263 | if os.path.isdir(src): |
| 264 | image = image.add_local_dir(src, dest, copy=True) |
| 265 | else: |
| 266 | image = image.add_local_file(src, dest, copy=True) |
| 267 | if setup := build.get("setup", []): |
| 268 | image = image.run_commands(*setup, volumes=self.volumes) |
| Stop shipping the build tree, and let a shell be the devShell 013945f nandi 4d ago | 269 | # `ignore` is what keeps a build tree out of the image. A checkout that |
| 270 | # has been built in locally carries its output -- flutter/build and the |
| 271 | # caches beside it were 395MB of a 441MB repo -- and all of it would be |
| 272 | # uploaded on every start only to be thrown away, since the container |
| 273 | # builds into a volume of its own. Patterns are relative to the copied |
| 274 | # directory, as in .dockerignore. |
| 275 | ignore = list(build.get("ignore", [])) |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 276 | for rel in build.get("include", ["."]): |
| 277 | src = os.path.normpath(os.path.join(context, rel)) |
| 278 | dest = self.workdir if rel == "." else f"{self.workdir}/{rel}" |
| 279 | if os.path.isdir(src): |
| Stop shipping the build tree, and let a shell be the devShell 013945f nandi 4d ago | 280 | image = image.add_local_dir(src, dest, copy=True, ignore=ignore) |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 281 | else: |
| 282 | image = image.add_local_file(src, dest, copy=True) |
| 283 | |
| Carry the loader's .git fix over 85852e8 nandi 4d ago | 284 | # A repo copied in brings its `.git` along, and in a worktree that is a |
| 285 | # *file* holding `gitdir: <path on the machine that copied it>`. Nix |
| 286 | # believes it and goes looking for a checkout that is not there -- |
| 287 | # `nix develop` and `nix build .#x` both die before evaluating |
| 288 | # anything. Nothing in a container wants the git metadata, so it goes. |
| 289 | image = image.run_commands(f"rm -rf {self.workdir}/.git") |
| 290 | |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 291 | if commands := build.get("commands", []): |
| A shell that is already the devShell, and two recipes to reach it e853593 nandi 4d ago | 292 | # Volumes mounted for the build too, not just the run. A build step |
| 293 | # that wants nix to *build* something is the one thing gVisor will |
| 294 | # not do -- image builds are Functions underneath, and a derivation |
| 295 | # there dies on `unexpected EOF reading a line`. Substituting is |
| 296 | # fine, so a step that can reach the cache never has to build, and |
| 297 | # the shim stays retired. The mount is not part of the resulting |
| 298 | # image; only what the step writes outside it is. |
| 299 | image = image.run_commands(*commands, volumes=self.volumes) |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 300 | |
| 301 | # container.py does `from _loader import Container`, and Modal mounts |
| 302 | # the entrypoint file alone -- so without this the import that works |
| 303 | # locally fails in the container. copy=False adds it at startup rather |
| 304 | # than baking a layer, so it invalidates nothing above it, and it must |
| 305 | # therefore come after every build step. |
| 306 | image = image.add_local_python_source("_loader") |
| 307 | # ...and container.py reads its spec at import time, so the spec has to |
| 308 | # be there too. Modal re-imports the entrypoint at /root, which is the |
| 309 | # one directory that gets none of the workdir copies above. |
| 310 | image = image.add_local_file( |
| 311 | os.path.join(self.dir, "container.toml"), "/root/container.toml" |
| 312 | ) |
| 313 | |
| 314 | return image |
| 315 | |
| 316 | # -- volumes --------------------------------------------------------- |
| 317 | |
| 318 | @property |
| 319 | def volumes(self) -> dict: |
| 320 | """{mount path: Volume}, as both Sandbox.create and @app.function want. |
| 321 | |
| 322 | `from_name` is lazy, so this is safe to evaluate on the re-import |
| 323 | inside the container as well as out here. create_if_missing means a |
| 324 | spec naming a volume that does not exist yet makes it rather than |
| 325 | failing -- the first run of a cache is the one that fills it. |
| 326 | """ |
| 327 | return { |
| 328 | mount: modal.Volume.from_name(name, create_if_missing=True) |
| 329 | for name, mount in self.volume_spec.items() |
| 330 | } |
| 331 | |
| 332 | # -- function -------------------------------------------------------- |
| 333 | |
| 334 | @property |
| 335 | def function_kwargs(self) -> dict: |
| 336 | """Everything `@app.function` should be given, from [resources].""" |
| 337 | r = self.spec.get("resources", {}) |
| 338 | kwargs: dict = {"timeout": int(r.get("timeout", 900))} |
| 339 | if r.get("cpu"): |
| 340 | kwargs["cpu"] = float(r["cpu"]) |
| 341 | if r.get("memory"): |
| 342 | kwargs["memory"] = int(r["memory"]) |
| 343 | if r.get("gpu"): |
| 344 | kwargs["gpu"] = r["gpu"] |
| 345 | # Deliberately NOT self.experimental_options: that fills in the |
| 346 | # sandbox default, and vm_runtime on a Function is refused by the |
| 347 | # server. A sandbox container's @app.function is vestigial anyway. |
| 348 | if self.runtime == "function": |
| 349 | if experimental := dict(self.spec.get("experimental", {})): |
| 350 | kwargs["experimental_options"] = experimental |
| 351 | if volumes := self.volumes: |
| 352 | kwargs["volumes"] = volumes |
| 353 | return kwargs |
| 354 | |
| 355 | @property |
| 356 | def experimental_options(self) -> dict: |
| 357 | """Experimental options, with the sandbox default filled in. |
| 358 | |
| 359 | `vm_runtime` is Sandbox-only: the server rejects it on a Function |
| 360 | outright. So it is defaulted on for sandboxes and never for functions, |
| 361 | and an explicit [experimental] table always wins. |
| 362 | """ |
| 363 | explicit = dict(self.spec.get("experimental", {})) |
| 364 | if explicit: |
| 365 | return explicit |
| 366 | if self.runtime == "sandbox": |
| 367 | return {"vm_runtime": True} |
| 368 | return {} |
| 369 | |
| 370 | @property |
| 371 | def sandbox_kwargs(self) -> dict: |
| 372 | """Everything `Sandbox.create` should be given, from [resources].""" |
| 373 | r = self.spec.get("resources", {}) |
| 374 | kwargs: dict = {"timeout": int(r.get("timeout", 900))} |
| 375 | if r.get("cpu"): |
| 376 | kwargs["cpu"] = float(r["cpu"]) |
| 377 | if r.get("memory"): |
| 378 | # A VM sandbox gets exactly this much and cannot grow into more. |
| 379 | kwargs["memory"] = int(r["memory"]) |
| 380 | if opts := self.experimental_options: |
| 381 | kwargs["experimental_options"] = dict(opts) |
| 382 | if volumes := self.volumes: |
| 383 | kwargs["volumes"] = volumes |
| A third target, and the seam that was already waiting for it f54ca45 nandi yesterday | 384 | if self.ports: |
| 385 | kwargs["encrypted_ports"] = list(self.ports) |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 386 | return kwargs |
| 387 | |
| 388 | def shell_command(self, override: str = "") -> str: |
| 389 | """The full shell line the container runs, devShell wrapper included.""" |
| 390 | command = override or self.command |
| 391 | if not command: |
| 392 | raise SpecError("container.toml has no [run] command") |
| 393 | if self.use_flake: |
| 394 | return ( |
| 395 | f"cd {self.workdir} && {self.nix} develop --accept-flake-config" |
| 396 | f" --command sh -c {shlex.quote(command)}" |
| 397 | ) |
| 398 | return f"cd {self.workdir} && {command}" |
| 399 | |
| 400 | def run_sandbox(self, override: str = "") -> str: |
| 401 | """Run one command in a Sandbox that dies when the command does. |
| 402 | |
| 403 | The command IS the sandbox's process, rather than something exec'd |
| 404 | into a `sleep infinity` box that then has to be torn down. There is no |
| 405 | idle window to pay for and nothing to leak if this script is killed; |
| 406 | the [resources] timeout is a backstop, not the mechanism. |
| 407 | |
| 408 | Modal streams a Sandbox's output into the app log as it runs -- which |
| 409 | is what you want for a build -- so this returns "" rather than handing |
| 410 | back a copy for the caller to print underneath it. |
| 411 | """ |
| 412 | line = self.shell_command(override) |
| 413 | sb = modal.Sandbox.create( |
| 414 | "sh", "-c", line, |
| 415 | app=self.app, |
| 416 | image=self.image, |
| 417 | workdir=self.workdir, |
| 418 | env={k: str(v) for k, v in self.env.items()}, |
| 419 | **self.sandbox_kwargs, |
| 420 | ) |
| A third target, and the seam that was already waiting for it f54ca45 nandi yesterday | 421 | self._print_tunnels(sb) |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 422 | sb.wait() |
| 423 | if sb.returncode != 0: |
| 424 | raise RuntimeError( |
| 425 | f"{self.name}: command failed ({sb.returncode})\n" |
| 426 | f"$ {line}\n{sb.stderr.read()}" |
| 427 | ) |
| 428 | return "" |
| 429 | |
| A third target, and the seam that was already waiting for it f54ca45 nandi yesterday | 430 | def _print_tunnels(self, sb: "modal.Sandbox") -> None: |
| 431 | """Say where a tunnelled port can be reached, once the sandbox is up. |
| 432 | |
| 433 | `tunnels()` blocks until the Sandbox is scheduled, which is why this |
| 434 | is called after create and not folded into it. Nothing prints when |
| 435 | [network] ports is empty, which is every container but the serving |
| 436 | ones. |
| 437 | """ |
| 438 | if not self.ports: |
| 439 | return |
| 440 | for port, tunnel in sb.tunnels().items(): |
| 441 | print(f" :{port} -> {tunnel.url}") |
| 442 | |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 443 | def open_sandbox(self) -> "modal.Sandbox": |
| Stop shipping the build tree, and let a shell be the devShell 013945f nandi 4d ago | 444 | """Start a Sandbox and leave it running, for `scripts/shell`. |
| 445 | |
| 446 | Same workdir and the same [run] env as the real thing: a shell opened |
| 447 | to debug a container that does not have the container's environment is |
| 448 | a shell that reproduces something else. `command` is the one part left |
| 449 | out, because not running it is the point. |
| 450 | """ |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 451 | return modal.Sandbox.create( |
| 452 | "sleep", |
| 453 | "infinity", |
| 454 | app=self.app, |
| 455 | image=self.image, |
| Stop shipping the build tree, and let a shell be the devShell 013945f nandi 4d ago | 456 | workdir=self.workdir, |
| 457 | env={k: str(v) for k, v in self.env.items()}, |
| Build the window somewhere with room for it 1451151 nandi 5d ago | 458 | **self.sandbox_kwargs, |
| 459 | ) |
| 460 | |
| 461 | def execute(self, override: str = "") -> str: |
| 462 | """Run the command in this container. Called remotely, not locally.""" |
| 463 | line = self.shell_command(override) |
| 464 | result = subprocess.run( |
| 465 | line, |
| 466 | shell=True, |
| 467 | capture_output=True, |
| 468 | text=True, |
| 469 | env={**os.environ, **{k: str(v) for k, v in self.env.items()}}, |
| 470 | ) |
| 471 | if result.returncode != 0: |
| 472 | raise RuntimeError( |
| 473 | f"{self.name}: command failed ({result.returncode})\n" |
| 474 | f"$ {line}\n{result.stderr}" |
| 475 | ) |
| 476 | return result.stdout |