nandi/frqpublic Fork 0
e8535935c5353b1c27744bf7e94b7bb549f7ae62
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 · 420 lines · 18.6 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
77 # Modal re-imports this module inside the container, so everything
78 # below runs twice: once here, once out there. Out there the local
79 # tree does not exist -- no flake.nix, no ptyshim.c, no repo -- and
80 # the image is already built, so validating and rebuilding it would
81 # only fail. The App still has to exist for the decorators to bind.
82 if self.is_remote:
83 self.image = None
84 self.app = modal.App(self.name)
85 else:
86 self._validate()
87 self.image = self._build_image()
88 self.app = modal.App(self.name, image=self.image)
89
90 # -- spec reading ----------------------------------------------------
91
92 @classmethod
93 def from_toml(cls, container_py: str) -> "Container":
94 """Load the container.toml sitting next to the given container.py."""
95 directory = os.path.dirname(os.path.abspath(container_py))
96 path = os.path.join(directory, "container.toml")
97 if not os.path.exists(path):
98 raise SpecError(f"no container.toml in {directory}")
99 with open(path, "rb") as f:
100 return cls(tomllib.load(f), directory)
101
102 def _require(self, table: str, key: str):
103 try:
104 return self.spec[table][key]
105 except KeyError:
106 raise SpecError(f"container.toml needs [{table}] {key}") from None
107
108 def _validate(self):
109 c = self.spec.get("container", {})
110 if self.runtime not in ("function", "sandbox"):
111 raise SpecError(
112 f'[container] runtime must be "function" or "sandbox",'
113 f" not {self.runtime!r}"
114 )
115 if bool(c.get("base")) == bool(c.get("registry")):
116 raise SpecError("set exactly one of [container] base or registry")
117 if self.use_flake:
118 if not os.path.exists(os.path.join(self.dir, "flake.nix")):
119 raise SpecError("[nix] flake = true but there is no flake.nix")
120 if not self.use_shim:
121 # Warming the shell builds nix-shell-env, which is the gVisor
122 # pty bug. Failing here beats failing ten minutes into a build.
123 raise SpecError(
124 "[nix] flake = true needs shim = true -- see ../README.md"
125 )
126 if self.use_shim and not os.path.exists(PTYSHIM_C):
127 raise SpecError(f"[nix] shim = true but {PTYSHIM_C} is missing")
128 for name, mount in self.volume_spec.items():
129 if not isinstance(mount, str) or not mount.startswith("/"):
130 raise SpecError(
131 f"[volumes] {name} must be an absolute path, not {mount!r}"
132 )
133 # Mounting over one of these hides what the image already has
134 # there -- /nix in particular, where the empty volume would shadow
135 # the store the base image spent its build populating.
136 if mount.rstrip("/") in ("", "/nix", "/nix/store", "/usr", "/etc"):
137 raise SpecError(
138 f"[volumes] {name} may not mount over {mount} --"
139 " it would hide what the image has there"
140 )
141 if mount.rstrip("/") == self.workdir.rstrip("/"):
142 raise SpecError(
143 f"[volumes] {name} may not mount over the workdir"
144 f" ({mount}) -- [build] include copies land there"
145 )
146
147 # -- image -----------------------------------------------------------
148
149 @property
150 def nix(self) -> str:
151 """The `nix` command as the container runs it.
152
153 A sandbox on a real VM has a working pty, so the shim buys nothing
154 there and is left off even when the image was built with it.
155 """
156 if self.use_shim and not self.vm_at_runtime:
157 return f"LD_PRELOAD={SHIM_SO} nix"
158 return "nix"
159
160 @property
161 def vm_at_runtime(self) -> bool:
162 """True when this container actually runs on a VM rather than gVisor."""
163 return (self.runtime == "sandbox"
164 and bool(self.experimental_options.get("vm_runtime")))
165
166 @property
167 def build_nix(self) -> str:
168 """The `nix` command for BUILD steps, which always run under gVisor.
169
170 Image builds are Functions underneath, so a sandbox container still
171 needs the shim while its image is being built -- only its run time
172 gets the VM.
173 """
174 return f"LD_PRELOAD={SHIM_SO} nix" if self.use_shim else "nix"
175
176 def _build_image(self) -> modal.Image:
177 c = self.spec["container"]
178 build = self.spec.get("build", {})
179
180 if c.get("base"):
181 image = modal.Image.from_name(c["base"])
182 else:
183 image = modal.Image.from_registry(c["registry"])
184
A shell that is already the devShell, and two recipes to reach it e853593 nandi 5d ago185 # `nix profile install` puts things in ~/.nix-profile/bin, which is on
186 # nobody's PATH here -- so the install succeeds and the very next line
187 # says `command not found`. Set on the image rather than in a .bashrc,
188 # because a container's command runs under `sh -c` and reads neither.
189 # Spelled out rather than prefixed onto $PATH: an image env is a value,
190 # not a shell expression, so "$PATH" here would be four literal
191 # characters.
192 image = image.env({
193 "PATH": "/root/.nix-profile/bin:/usr/local/sbin:/usr/local/bin"
194 ":/usr/sbin:/usr/bin:/sbin:/bin",
195 })
196
Build the window somewhere with room for it 1451151 nandi 5d ago197 if self.use_shim:
198 image = image.add_local_file(
199 PTYSHIM_C, "/opt/ptyshim.c", copy=True
200 ).run_commands(
201 f"gcc -shared -fPIC -O2 -o {SHIM_SO} /opt/ptyshim.c -ldl"
202 )
203
204 # Warm the devShell BEFORE the source is copied in. Everything the
205 # shell needs is binary-cached, so the download happens once -- but
206 # only if this layer survives. Copy the source first and any edit to
207 # any file invalidates the warm, and the whole closure is fetched
208 # again on every build. Only flake.nix and flake.lock go in here, so
209 # the layer is invalidated by a dependency change and nothing else.
210 if self.use_flake:
211 for f in ("flake.nix", "flake.lock"):
212 path = os.path.join(self.dir, f)
213 if os.path.exists(path):
214 image = image.add_local_file(
215 path, f"{self.workdir}/{f}", copy=True
216 )
217 image = image.run_commands(
218 f"cd {self.workdir} && {self.build_nix} develop"
219 " --accept-flake-config --command true",
220 f'echo "store paths after warming:'
221 f' $({self.build_nix} path-info --all | wc -l)"',
222 )
223
224 # copy=True throughout: later run_commands need these files present.
225 # `context` is what include paths are relative to, and it may sit above
226 # the container directory -- a container that builds the repo it lives
227 # in sets context = "../..", so include = ["."] means the whole repo.
228 context = os.path.normpath(os.path.join(self.dir, build.get("context", ".")))
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago229 # `ignore` is what keeps a build tree out of the image. A checkout that
230 # has been built in locally carries its output -- flutter/build and the
231 # caches beside it were 395MB of a 441MB repo -- and all of it would be
232 # uploaded on every start only to be thrown away, since the container
233 # builds into a volume of its own. Patterns are relative to the copied
234 # directory, as in .dockerignore.
235 ignore = list(build.get("ignore", []))
Build the window somewhere with room for it 1451151 nandi 5d ago236 for rel in build.get("include", ["."]):
237 src = os.path.normpath(os.path.join(context, rel))
238 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
239 if os.path.isdir(src):
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago240 image = image.add_local_dir(src, dest, copy=True, ignore=ignore)
Build the window somewhere with room for it 1451151 nandi 5d ago241 else:
242 image = image.add_local_file(src, dest, copy=True)
243
Carry the loader's .git fix over 85852e8 nandi 5d ago244 # A repo copied in brings its `.git` along, and in a worktree that is a
245 # *file* holding `gitdir: <path on the machine that copied it>`. Nix
246 # believes it and goes looking for a checkout that is not there --
247 # `nix develop` and `nix build .#x` both die before evaluating
248 # anything. Nothing in a container wants the git metadata, so it goes.
249 image = image.run_commands(f"rm -rf {self.workdir}/.git")
250
Build the window somewhere with room for it 1451151 nandi 5d ago251 if commands := build.get("commands", []):
A shell that is already the devShell, and two recipes to reach it e853593 nandi 5d ago252 # Volumes mounted for the build too, not just the run. A build step
253 # that wants nix to *build* something is the one thing gVisor will
254 # not do -- image builds are Functions underneath, and a derivation
255 # there dies on `unexpected EOF reading a line`. Substituting is
256 # fine, so a step that can reach the cache never has to build, and
257 # the shim stays retired. The mount is not part of the resulting
258 # image; only what the step writes outside it is.
259 image = image.run_commands(*commands, volumes=self.volumes)
Build the window somewhere with room for it 1451151 nandi 5d ago260
261 # container.py does `from _loader import Container`, and Modal mounts
262 # the entrypoint file alone -- so without this the import that works
263 # locally fails in the container. copy=False adds it at startup rather
264 # than baking a layer, so it invalidates nothing above it, and it must
265 # therefore come after every build step.
266 image = image.add_local_python_source("_loader")
267 # ...and container.py reads its spec at import time, so the spec has to
268 # be there too. Modal re-imports the entrypoint at /root, which is the
269 # one directory that gets none of the workdir copies above.
270 image = image.add_local_file(
271 os.path.join(self.dir, "container.toml"), "/root/container.toml"
272 )
273
274 return image
275
276 # -- volumes ---------------------------------------------------------
277
278 @property
279 def volumes(self) -> dict:
280 """{mount path: Volume}, as both Sandbox.create and @app.function want.
281
282 `from_name` is lazy, so this is safe to evaluate on the re-import
283 inside the container as well as out here. create_if_missing means a
284 spec naming a volume that does not exist yet makes it rather than
285 failing -- the first run of a cache is the one that fills it.
286 """
287 return {
288 mount: modal.Volume.from_name(name, create_if_missing=True)
289 for name, mount in self.volume_spec.items()
290 }
291
292 # -- function --------------------------------------------------------
293
294 @property
295 def function_kwargs(self) -> dict:
296 """Everything `@app.function` should be given, from [resources]."""
297 r = self.spec.get("resources", {})
298 kwargs: dict = {"timeout": int(r.get("timeout", 900))}
299 if r.get("cpu"):
300 kwargs["cpu"] = float(r["cpu"])
301 if r.get("memory"):
302 kwargs["memory"] = int(r["memory"])
303 if r.get("gpu"):
304 kwargs["gpu"] = r["gpu"]
305 # Deliberately NOT self.experimental_options: that fills in the
306 # sandbox default, and vm_runtime on a Function is refused by the
307 # server. A sandbox container's @app.function is vestigial anyway.
308 if self.runtime == "function":
309 if experimental := dict(self.spec.get("experimental", {})):
310 kwargs["experimental_options"] = experimental
311 if volumes := self.volumes:
312 kwargs["volumes"] = volumes
313 return kwargs
314
315 @property
316 def experimental_options(self) -> dict:
317 """Experimental options, with the sandbox default filled in.
318
319 `vm_runtime` is Sandbox-only: the server rejects it on a Function
320 outright. So it is defaulted on for sandboxes and never for functions,
321 and an explicit [experimental] table always wins.
322 """
323 explicit = dict(self.spec.get("experimental", {}))
324 if explicit:
325 return explicit
326 if self.runtime == "sandbox":
327 return {"vm_runtime": True}
328 return {}
329
330 @property
331 def sandbox_kwargs(self) -> dict:
332 """Everything `Sandbox.create` should be given, from [resources]."""
333 r = self.spec.get("resources", {})
334 kwargs: dict = {"timeout": int(r.get("timeout", 900))}
335 if r.get("cpu"):
336 kwargs["cpu"] = float(r["cpu"])
337 if r.get("memory"):
338 # A VM sandbox gets exactly this much and cannot grow into more.
339 kwargs["memory"] = int(r["memory"])
340 if opts := self.experimental_options:
341 kwargs["experimental_options"] = dict(opts)
342 if volumes := self.volumes:
343 kwargs["volumes"] = volumes
344 return kwargs
345
346 def shell_command(self, override: str = "") -> str:
347 """The full shell line the container runs, devShell wrapper included."""
348 command = override or self.command
349 if not command:
350 raise SpecError("container.toml has no [run] command")
351 if self.use_flake:
352 return (
353 f"cd {self.workdir} && {self.nix} develop --accept-flake-config"
354 f" --command sh -c {shlex.quote(command)}"
355 )
356 return f"cd {self.workdir} && {command}"
357
358 def run_sandbox(self, override: str = "") -> str:
359 """Run one command in a Sandbox that dies when the command does.
360
361 The command IS the sandbox's process, rather than something exec'd
362 into a `sleep infinity` box that then has to be torn down. There is no
363 idle window to pay for and nothing to leak if this script is killed;
364 the [resources] timeout is a backstop, not the mechanism.
365
366 Modal streams a Sandbox's output into the app log as it runs -- which
367 is what you want for a build -- so this returns "" rather than handing
368 back a copy for the caller to print underneath it.
369 """
370 line = self.shell_command(override)
371 sb = modal.Sandbox.create(
372 "sh", "-c", line,
373 app=self.app,
374 image=self.image,
375 workdir=self.workdir,
376 env={k: str(v) for k, v in self.env.items()},
377 **self.sandbox_kwargs,
378 )
379 sb.wait()
380 if sb.returncode != 0:
381 raise RuntimeError(
382 f"{self.name}: command failed ({sb.returncode})\n"
383 f"$ {line}\n{sb.stderr.read()}"
384 )
385 return ""
386
387 def open_sandbox(self) -> "modal.Sandbox":
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago388 """Start a Sandbox and leave it running, for `scripts/shell`.
389
390 Same workdir and the same [run] env as the real thing: a shell opened
391 to debug a container that does not have the container's environment is
392 a shell that reproduces something else. `command` is the one part left
393 out, because not running it is the point.
394 """
Build the window somewhere with room for it 1451151 nandi 5d ago395 return modal.Sandbox.create(
396 "sleep",
397 "infinity",
398 app=self.app,
399 image=self.image,
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago400 workdir=self.workdir,
401 env={k: str(v) for k, v in self.env.items()},
Build the window somewhere with room for it 1451151 nandi 5d ago402 **self.sandbox_kwargs,
403 )
404
405 def execute(self, override: str = "") -> str:
406 """Run the command in this container. Called remotely, not locally."""
407 line = self.shell_command(override)
408 result = subprocess.run(
409 line,
410 shell=True,
411 capture_output=True,
412 text=True,
413 env={**os.environ, **{k: str(v) for k, v in self.env.items()}},
414 )
415 if result.returncode != 0:
416 raise RuntimeError(
417 f"{self.name}: command failed ({result.returncode})\n"
418 f"$ {line}\n{result.stderr}"
419 )
420 return result.stdout