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