nandi/frqpublic Fork 0
1403956859648c3d39892e3994df8fee863ec653
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

_loader.py · 476 lines · 21.3 KBPython Blame HistoryRaw
Build the window somewhere with room for it 1451151 nandi 5d ago1"""Turn a `container.toml` into a `modal.Image` and a `modal.App`.
2
Carry the loader's .git fix over 85852e8 nandi 5d ago3Every container -- under `containers/` here, `.modal/` in a repo that merely
4builds itself on Modal -- is a directory with a `container.toml` and
Build the window somewhere with room for it 1451151 nandi 5d ago5a stub `container.py`. See `spec.md` for the keys; this file is what reads
6them. Nothing here is Modal-specific configuration in its own right -- each
7spec key maps onto a documented Modal argument, and the mapping is meant to
8stay boring enough to read straight through.
9"""
10
11import os
12import shlex
13import subprocess
14import tomllib
15
16import modal
17
18REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19PTYSHIM_C = os.path.join(REPO, "ptyshim.c")
20SHIM_SO = "/opt/ptyshim.so"
21
22
23class SpecError(Exception):
24 """The container.toml says something that cannot be built."""
25
26
27def _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
32class 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 5d ago47 # 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 ago61 # "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 2d ago77 # 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 ago87 # 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 2d ago138 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 ago143 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 5d ago200 # `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 ago212 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 2d ago244
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 5d ago269 # `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 ago276 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 5d ago280 image = image.add_local_dir(src, dest, copy=True, ignore=ignore)
Build the window somewhere with room for it 1451151 nandi 5d ago281 else:
282 image = image.add_local_file(src, dest, copy=True)
283
Carry the loader's .git fix over 85852e8 nandi 5d ago284 # 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 ago291 if commands := build.get("commands", []):
A shell that is already the devShell, and two recipes to reach it e853593 nandi 5d ago292 # 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 ago300
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 2d ago384 if self.ports:
385 kwargs["encrypted_ports"] = list(self.ports)
Build the window somewhere with room for it 1451151 nandi 5d ago386 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 2d ago421 self._print_tunnels(sb)
Build the window somewhere with room for it 1451151 nandi 5d ago422 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 2d ago430 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 ago443 def open_sandbox(self) -> "modal.Sandbox":
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago444 """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 ago451 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 5d ago456 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 ago458 **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