nandi/frqpublic Fork 0
85852e854ee8b81416c1b67f4ae526b8d4e48a07
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 · 372 lines · 15.7 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", {})
165
166 if c.get("base"):
167 image = modal.Image.from_name(c["base"])
168 else:
169 image = modal.Image.from_registry(c["registry"])
170
171 if self.use_shim:
172 image = image.add_local_file(
173 PTYSHIM_C, "/opt/ptyshim.c", copy=True
174 ).run_commands(
175 f"gcc -shared -fPIC -O2 -o {SHIM_SO} /opt/ptyshim.c -ldl"
176 )
177
178 # Warm the devShell BEFORE the source is copied in. Everything the
179 # shell needs is binary-cached, so the download happens once -- but
180 # only if this layer survives. Copy the source first and any edit to
181 # any file invalidates the warm, and the whole closure is fetched
182 # again on every build. Only flake.nix and flake.lock go in here, so
183 # the layer is invalidated by a dependency change and nothing else.
184 if self.use_flake:
185 for f in ("flake.nix", "flake.lock"):
186 path = os.path.join(self.dir, f)
187 if os.path.exists(path):
188 image = image.add_local_file(
189 path, f"{self.workdir}/{f}", copy=True
190 )
191 image = image.run_commands(
192 f"cd {self.workdir} && {self.build_nix} develop"
193 " --accept-flake-config --command true",
194 f'echo "store paths after warming:'
195 f' $({self.build_nix} path-info --all | wc -l)"',
196 )
197
198 # copy=True throughout: later run_commands need these files present.
199 # `context` is what include paths are relative to, and it may sit above
200 # the container directory -- a container that builds the repo it lives
201 # in sets context = "../..", so include = ["."] means the whole repo.
202 context = os.path.normpath(os.path.join(self.dir, build.get("context", ".")))
203 for rel in build.get("include", ["."]):
204 src = os.path.normpath(os.path.join(context, rel))
205 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
206 if os.path.isdir(src):
207 image = image.add_local_dir(src, dest, copy=True)
208 else:
209 image = image.add_local_file(src, dest, copy=True)
210
Carry the loader's .git fix over 85852e8 nandi 5d ago211 # A repo copied in brings its `.git` along, and in a worktree that is a
212 # *file* holding `gitdir: <path on the machine that copied it>`. Nix
213 # believes it and goes looking for a checkout that is not there --
214 # `nix develop` and `nix build .#x` both die before evaluating
215 # anything. Nothing in a container wants the git metadata, so it goes.
216 image = image.run_commands(f"rm -rf {self.workdir}/.git")
217
Build the window somewhere with room for it 1451151 nandi 5d ago218 if commands := build.get("commands", []):
219 image = image.run_commands(*commands)
220
221 # container.py does `from _loader import Container`, and Modal mounts
222 # the entrypoint file alone -- so without this the import that works
223 # locally fails in the container. copy=False adds it at startup rather
224 # than baking a layer, so it invalidates nothing above it, and it must
225 # therefore come after every build step.
226 image = image.add_local_python_source("_loader")
227 # ...and container.py reads its spec at import time, so the spec has to
228 # be there too. Modal re-imports the entrypoint at /root, which is the
229 # one directory that gets none of the workdir copies above.
230 image = image.add_local_file(
231 os.path.join(self.dir, "container.toml"), "/root/container.toml"
232 )
233
234 return image
235
236 # -- volumes ---------------------------------------------------------
237
238 @property
239 def volumes(self) -> dict:
240 """{mount path: Volume}, as both Sandbox.create and @app.function want.
241
242 `from_name` is lazy, so this is safe to evaluate on the re-import
243 inside the container as well as out here. create_if_missing means a
244 spec naming a volume that does not exist yet makes it rather than
245 failing -- the first run of a cache is the one that fills it.
246 """
247 return {
248 mount: modal.Volume.from_name(name, create_if_missing=True)
249 for name, mount in self.volume_spec.items()
250 }
251
252 # -- function --------------------------------------------------------
253
254 @property
255 def function_kwargs(self) -> dict:
256 """Everything `@app.function` should be given, from [resources]."""
257 r = self.spec.get("resources", {})
258 kwargs: dict = {"timeout": int(r.get("timeout", 900))}
259 if r.get("cpu"):
260 kwargs["cpu"] = float(r["cpu"])
261 if r.get("memory"):
262 kwargs["memory"] = int(r["memory"])
263 if r.get("gpu"):
264 kwargs["gpu"] = r["gpu"]
265 # Deliberately NOT self.experimental_options: that fills in the
266 # sandbox default, and vm_runtime on a Function is refused by the
267 # server. A sandbox container's @app.function is vestigial anyway.
268 if self.runtime == "function":
269 if experimental := dict(self.spec.get("experimental", {})):
270 kwargs["experimental_options"] = experimental
271 if volumes := self.volumes:
272 kwargs["volumes"] = volumes
273 return kwargs
274
275 @property
276 def experimental_options(self) -> dict:
277 """Experimental options, with the sandbox default filled in.
278
279 `vm_runtime` is Sandbox-only: the server rejects it on a Function
280 outright. So it is defaulted on for sandboxes and never for functions,
281 and an explicit [experimental] table always wins.
282 """
283 explicit = dict(self.spec.get("experimental", {}))
284 if explicit:
285 return explicit
286 if self.runtime == "sandbox":
287 return {"vm_runtime": True}
288 return {}
289
290 @property
291 def sandbox_kwargs(self) -> dict:
292 """Everything `Sandbox.create` should be given, from [resources]."""
293 r = self.spec.get("resources", {})
294 kwargs: dict = {"timeout": int(r.get("timeout", 900))}
295 if r.get("cpu"):
296 kwargs["cpu"] = float(r["cpu"])
297 if r.get("memory"):
298 # A VM sandbox gets exactly this much and cannot grow into more.
299 kwargs["memory"] = int(r["memory"])
300 if opts := self.experimental_options:
301 kwargs["experimental_options"] = dict(opts)
302 if volumes := self.volumes:
303 kwargs["volumes"] = volumes
304 return kwargs
305
306 def shell_command(self, override: str = "") -> str:
307 """The full shell line the container runs, devShell wrapper included."""
308 command = override or self.command
309 if not command:
310 raise SpecError("container.toml has no [run] command")
311 if self.use_flake:
312 return (
313 f"cd {self.workdir} && {self.nix} develop --accept-flake-config"
314 f" --command sh -c {shlex.quote(command)}"
315 )
316 return f"cd {self.workdir} && {command}"
317
318 def run_sandbox(self, override: str = "") -> str:
319 """Run one command in a Sandbox that dies when the command does.
320
321 The command IS the sandbox's process, rather than something exec'd
322 into a `sleep infinity` box that then has to be torn down. There is no
323 idle window to pay for and nothing to leak if this script is killed;
324 the [resources] timeout is a backstop, not the mechanism.
325
326 Modal streams a Sandbox's output into the app log as it runs -- which
327 is what you want for a build -- so this returns "" rather than handing
328 back a copy for the caller to print underneath it.
329 """
330 line = self.shell_command(override)
331 sb = modal.Sandbox.create(
332 "sh", "-c", line,
333 app=self.app,
334 image=self.image,
335 workdir=self.workdir,
336 env={k: str(v) for k, v in self.env.items()},
337 **self.sandbox_kwargs,
338 )
339 sb.wait()
340 if sb.returncode != 0:
341 raise RuntimeError(
342 f"{self.name}: command failed ({sb.returncode})\n"
343 f"$ {line}\n{sb.stderr.read()}"
344 )
345 return ""
346
347 def open_sandbox(self) -> "modal.Sandbox":
348 """Start a Sandbox and leave it running, for `scripts/shell`."""
349 return modal.Sandbox.create(
350 "sleep",
351 "infinity",
352 app=self.app,
353 image=self.image,
354 **self.sandbox_kwargs,
355 )
356
357 def execute(self, override: str = "") -> str:
358 """Run the command in this container. Called remotely, not locally."""
359 line = self.shell_command(override)
360 result = subprocess.run(
361 line,
362 shell=True,
363 capture_output=True,
364 text=True,
365 env={**os.environ, **{k: str(v) for k, v in self.env.items()}},
366 )
367 if result.returncode != 0:
368 raise RuntimeError(
369 f"{self.name}: command failed ({result.returncode})\n"
370 f"$ {line}\n{result.stderr}"
371 )
372 return result.stdout