nandi/frqpublic Fork 0
2e24e647a7766c822d13d253777204c3bf92fb7a
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 · 362 lines · 15.9 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
Six verbs, and the last of the nix 2e24e64 nandi 15h ago5a stub `container.py`. The keys are documented by the comments below, which
6is the whole of the spec. Nothing here is Modal-specific configuration in its
7own right -- each
Build the window somewhere with room for it 1451151 nandi 5d ago8spec key maps onto a documented Modal argument, and the mapping is meant to
9stay boring enough to read straight through.
10"""
11
12import os
13import subprocess
14import tomllib
15
16import modal
17
18
19class SpecError(Exception):
20 """The container.toml says something that cannot be built."""
21
22
23def _running_in_modal() -> bool:
24 """True inside a Modal container, false on the machine that launched it."""
25 return bool(os.environ.get("MODAL_TASK_ID"))
26
27
28class Container:
29 """One container: its spec, its image, its app, and how to run it."""
30
31 def __init__(self, spec: dict, directory: str):
32 self.is_remote = _running_in_modal()
33 self.dir = directory
34 self.spec = spec
35 self.name = self._require("container", "name")
36 self.description = spec.get("container", {}).get("description", "")
37
38 run = spec.get("run", {})
39 self.workdir = run.get("workdir", "/app")
40 self.command = run.get("command", "")
41 self.env = dict(run.get("env", {}))
42
43 # "function" (the default) or "sandbox". Sandboxes can run on a real
44 # VM, which Functions cannot -- see ../README.md.
45 self.runtime = spec.get("container", {}).get("runtime", "function")
46
47 # name -> mount path. Modal Volumes, mounted while the container runs
48 # and NOT while its image is built: a volume mount is not part of the
49 # resulting image, so anything written to one during a build step is
50 # gone by the time the container starts. Persist across runs is the
Six verbs, and the last of the nix 2e24e64 nandi 15h ago51 # whole point -- a toolchain too big to fetch every run, a build
52 # tree an incremental compile reuses, a dataset too big to bake in.
Build the window somewhere with room for it 1451151 nandi 5d ago53 self.volume_spec = dict(spec.get("volumes", {}))
54
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago55 # Ports to tunnel out of a Sandbox, from [network] ports. Encrypted,
56 # which is Modal's own word for "the tunnel terminates TLS and speaks
57 # plain HTTP to your process" -- so the thing listening inside is an
58 # ordinary http.server and not something holding a certificate.
59 #
60 # Sandbox-only: a Function has no long-lived process to tunnel into,
61 # and `modal run` on one would hand back a URL for a container that
62 # has already exited.
63 self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])]
64
Build the window somewhere with room for it 1451151 nandi 5d ago65 # Modal re-imports this module inside the container, so everything
66 # below runs twice: once here, once out there. Out there the local
Six verbs, and the last of the nix 2e24e64 nandi 15h ago67 # tree does not exist -- no repo to copy from -- and
Build the window somewhere with room for it 1451151 nandi 5d ago68 # the image is already built, so validating and rebuilding it would
69 # only fail. The App still has to exist for the decorators to bind.
70 if self.is_remote:
71 self.image = None
72 self.app = modal.App(self.name)
73 else:
74 self._validate()
75 self.image = self._build_image()
76 self.app = modal.App(self.name, image=self.image)
77
78 # -- spec reading ----------------------------------------------------
79
80 @classmethod
81 def from_toml(cls, container_py: str) -> "Container":
82 """Load the container.toml sitting next to the given container.py."""
83 directory = os.path.dirname(os.path.abspath(container_py))
84 path = os.path.join(directory, "container.toml")
85 if not os.path.exists(path):
86 raise SpecError(f"no container.toml in {directory}")
87 with open(path, "rb") as f:
88 return cls(tomllib.load(f), directory)
89
90 def _require(self, table: str, key: str):
91 try:
92 return self.spec[table][key]
93 except KeyError:
94 raise SpecError(f"container.toml needs [{table}] {key}") from None
95
96 def _validate(self):
97 c = self.spec.get("container", {})
98 if self.runtime not in ("function", "sandbox"):
99 raise SpecError(
100 f'[container] runtime must be "function" or "sandbox",'
101 f" not {self.runtime!r}"
102 )
103 if bool(c.get("base")) == bool(c.get("registry")):
104 raise SpecError("set exactly one of [container] base or registry")
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago105 if self.ports and self.runtime != "sandbox":
106 raise SpecError(
107 "[network] ports needs [container] runtime = \"sandbox\" --"
108 " a Function has no process to tunnel into"
109 )
Build the window somewhere with room for it 1451151 nandi 5d ago110 for name, mount in self.volume_spec.items():
111 if not isinstance(mount, str) or not mount.startswith("/"):
112 raise SpecError(
113 f"[volumes] {name} must be an absolute path, not {mount!r}"
114 )
115 # Mounting over one of these hides what the image already has
Six verbs, and the last of the nix 2e24e64 nandi 15h ago116 # there: an empty volume would shadow what the image's own build
117 # put in its place.
118 if mount.rstrip("/") in ("", "/usr", "/etc", "/bin", "/lib"):
Build the window somewhere with room for it 1451151 nandi 5d ago119 raise SpecError(
120 f"[volumes] {name} may not mount over {mount} --"
121 " it would hide what the image has there"
122 )
123 if mount.rstrip("/") == self.workdir.rstrip("/"):
124 raise SpecError(
125 f"[volumes] {name} may not mount over the workdir"
126 f" ({mount}) -- [build] include copies land there"
127 )
128
129 # -- image -----------------------------------------------------------
130
131 def _build_image(self) -> modal.Image:
132 c = self.spec["container"]
133 build = self.spec.get("build", {})
134
135 if c.get("base"):
136 image = modal.Image.from_name(c["base"])
137 else:
138 image = modal.Image.from_registry(c["registry"])
139
140 # copy=True throughout: later run_commands need these files present.
141 # `context` is what include paths are relative to, and it may sit above
142 # the container directory -- a container that builds the repo it lives
143 # in sets context = "../..", so include = ["."] means the whole repo.
144 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 ago145
146 # Image building and program building, kept apart.
147 #
Six verbs, and the last of the nix 2e24e64 nandi 15h ago148 # `[build] commands` run AFTER the source copy, so any edit anywhere
149 # in the tree invalidates them. `warm` + `setup` are the same step
150 # moved in front of the source: `warm` names only the files the step
151 # actually reads, and `setup` runs against those alone, so editing
152 # `flutter/src` invalidates nothing above the final copy. A container
153 # whose setup is an `apt-get` needs no `warm` at all -- it reads
154 # nothing out of the tree, so nothing in the tree can invalidate it.
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago155 for rel in build.get("warm", []):
156 src = os.path.normpath(os.path.join(context, rel))
157 dest = f"{self.workdir}/{rel}"
158 if os.path.isdir(src):
159 image = image.add_local_dir(src, dest, copy=True)
160 else:
161 image = image.add_local_file(src, dest, copy=True)
162 if setup := build.get("setup", []):
163 image = image.run_commands(*setup, volumes=self.volumes)
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago164 # `ignore` is what keeps a build tree out of the image. A checkout that
165 # has been built in locally carries its output -- flutter/build and the
166 # caches beside it were 395MB of a 441MB repo -- and all of it would be
167 # uploaded on every start only to be thrown away, since the container
168 # builds into a volume of its own. Patterns are relative to the copied
169 # directory, as in .dockerignore.
170 ignore = list(build.get("ignore", []))
Build the window somewhere with room for it 1451151 nandi 5d ago171 for rel in build.get("include", ["."]):
172 src = os.path.normpath(os.path.join(context, rel))
173 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
174 if os.path.isdir(src):
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago175 image = image.add_local_dir(src, dest, copy=True, ignore=ignore)
Build the window somewhere with room for it 1451151 nandi 5d ago176 else:
177 image = image.add_local_file(src, dest, copy=True)
178
Six verbs, and the last of the nix 2e24e64 nandi 15h ago179 # A repo copied in brings its `.git` along, and in a worktree that is
180 # a *file* holding `gitdir: <path on the machine that copied it>` --
181 # which points at nothing out here, so any tool that follows it fails
182 # in a way that has nothing to do with what it was asked to do.
183 # Nothing in a container wants the git metadata, so it goes.
Carry the loader's .git fix over 85852e8 nandi 5d ago184 image = image.run_commands(f"rm -rf {self.workdir}/.git")
185
Build the window somewhere with room for it 1451151 nandi 5d ago186 if commands := build.get("commands", []):
Six verbs, and the last of the nix 2e24e64 nandi 15h ago187 # Volumes mounted for the build too, not just the run, so a step
188 # can read a cache the last run filled. The mount is not part of
189 # the resulting image; only what the step writes outside it is.
A shell that is already the devShell, and two recipes to reach it e853593 nandi 5d ago190 image = image.run_commands(*commands, volumes=self.volumes)
Build the window somewhere with room for it 1451151 nandi 5d ago191
192 # container.py does `from _loader import Container`, and Modal mounts
193 # the entrypoint file alone -- so without this the import that works
194 # locally fails in the container. copy=False adds it at startup rather
195 # than baking a layer, so it invalidates nothing above it, and it must
196 # therefore come after every build step.
197 image = image.add_local_python_source("_loader")
198 # ...and container.py reads its spec at import time, so the spec has to
199 # be there too. Modal re-imports the entrypoint at /root, which is the
200 # one directory that gets none of the workdir copies above.
201 image = image.add_local_file(
202 os.path.join(self.dir, "container.toml"), "/root/container.toml"
203 )
204
205 return image
206
207 # -- volumes ---------------------------------------------------------
208
209 @property
210 def volumes(self) -> dict:
211 """{mount path: Volume}, as both Sandbox.create and @app.function want.
212
213 `from_name` is lazy, so this is safe to evaluate on the re-import
214 inside the container as well as out here. create_if_missing means a
215 spec naming a volume that does not exist yet makes it rather than
216 failing -- the first run of a cache is the one that fills it.
217 """
218 return {
219 mount: modal.Volume.from_name(name, create_if_missing=True)
220 for name, mount in self.volume_spec.items()
221 }
222
223 # -- function --------------------------------------------------------
224
225 @property
226 def function_kwargs(self) -> dict:
227 """Everything `@app.function` should be given, from [resources]."""
228 r = self.spec.get("resources", {})
229 kwargs: dict = {"timeout": int(r.get("timeout", 900))}
230 if r.get("cpu"):
231 kwargs["cpu"] = float(r["cpu"])
232 if r.get("memory"):
233 kwargs["memory"] = int(r["memory"])
234 if r.get("gpu"):
235 kwargs["gpu"] = r["gpu"]
236 # Deliberately NOT self.experimental_options: that fills in the
237 # sandbox default, and vm_runtime on a Function is refused by the
238 # server. A sandbox container's @app.function is vestigial anyway.
239 if self.runtime == "function":
240 if experimental := dict(self.spec.get("experimental", {})):
241 kwargs["experimental_options"] = experimental
242 if volumes := self.volumes:
243 kwargs["volumes"] = volumes
244 return kwargs
245
246 @property
247 def experimental_options(self) -> dict:
248 """Experimental options, with the sandbox default filled in.
249
250 `vm_runtime` is Sandbox-only: the server rejects it on a Function
251 outright. So it is defaulted on for sandboxes and never for functions,
252 and an explicit [experimental] table always wins.
253 """
254 explicit = dict(self.spec.get("experimental", {}))
255 if explicit:
256 return explicit
257 if self.runtime == "sandbox":
258 return {"vm_runtime": True}
259 return {}
260
261 @property
262 def sandbox_kwargs(self) -> dict:
263 """Everything `Sandbox.create` should be given, from [resources]."""
264 r = self.spec.get("resources", {})
265 kwargs: dict = {"timeout": int(r.get("timeout", 900))}
266 if r.get("cpu"):
267 kwargs["cpu"] = float(r["cpu"])
268 if r.get("memory"):
269 # A VM sandbox gets exactly this much and cannot grow into more.
270 kwargs["memory"] = int(r["memory"])
271 if opts := self.experimental_options:
272 kwargs["experimental_options"] = dict(opts)
273 if volumes := self.volumes:
274 kwargs["volumes"] = volumes
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago275 if self.ports:
276 kwargs["encrypted_ports"] = list(self.ports)
Build the window somewhere with room for it 1451151 nandi 5d ago277 return kwargs
278
279 def shell_command(self, override: str = "") -> str:
Six verbs, and the last of the nix 2e24e64 nandi 15h ago280 """The full shell line the container runs."""
Build the window somewhere with room for it 1451151 nandi 5d ago281 command = override or self.command
282 if not command:
283 raise SpecError("container.toml has no [run] command")
284 return f"cd {self.workdir} && {command}"
285
286 def run_sandbox(self, override: str = "") -> str:
287 """Run one command in a Sandbox that dies when the command does.
288
289 The command IS the sandbox's process, rather than something exec'd
290 into a `sleep infinity` box that then has to be torn down. There is no
291 idle window to pay for and nothing to leak if this script is killed;
292 the [resources] timeout is a backstop, not the mechanism.
293
294 Modal streams a Sandbox's output into the app log as it runs -- which
295 is what you want for a build -- so this returns "" rather than handing
296 back a copy for the caller to print underneath it.
297 """
298 line = self.shell_command(override)
299 sb = modal.Sandbox.create(
300 "sh", "-c", line,
301 app=self.app,
302 image=self.image,
303 workdir=self.workdir,
304 env={k: str(v) for k, v in self.env.items()},
305 **self.sandbox_kwargs,
306 )
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago307 self._print_tunnels(sb)
Build the window somewhere with room for it 1451151 nandi 5d ago308 sb.wait()
309 if sb.returncode != 0:
310 raise RuntimeError(
311 f"{self.name}: command failed ({sb.returncode})\n"
312 f"$ {line}\n{sb.stderr.read()}"
313 )
314 return ""
315
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago316 def _print_tunnels(self, sb: "modal.Sandbox") -> None:
317 """Say where a tunnelled port can be reached, once the sandbox is up.
318
319 `tunnels()` blocks until the Sandbox is scheduled, which is why this
320 is called after create and not folded into it. Nothing prints when
321 [network] ports is empty, which is every container but the serving
322 ones.
323 """
324 if not self.ports:
325 return
326 for port, tunnel in sb.tunnels().items():
327 print(f" :{port} -> {tunnel.url}")
328
Build the window somewhere with room for it 1451151 nandi 5d ago329 def open_sandbox(self) -> "modal.Sandbox":
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago330 """Start a Sandbox and leave it running, for `scripts/shell`.
331
332 Same workdir and the same [run] env as the real thing: a shell opened
333 to debug a container that does not have the container's environment is
334 a shell that reproduces something else. `command` is the one part left
335 out, because not running it is the point.
336 """
Build the window somewhere with room for it 1451151 nandi 5d ago337 return modal.Sandbox.create(
338 "sleep",
339 "infinity",
340 app=self.app,
341 image=self.image,
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 5d ago342 workdir=self.workdir,
343 env={k: str(v) for k, v in self.env.items()},
Build the window somewhere with room for it 1451151 nandi 5d ago344 **self.sandbox_kwargs,
345 )
346
347 def execute(self, override: str = "") -> str:
348 """Run the command in this container. Called remotely, not locally."""
349 line = self.shell_command(override)
350 result = subprocess.run(
351 line,
352 shell=True,
353 capture_output=True,
354 text=True,
355 env={**os.environ, **{k: str(v) for k, v in self.env.items()}},
356 )
357 if result.returncode != 0:
358 raise RuntimeError(
359 f"{self.name}: command failed ({result.returncode})\n"
360 f"$ {line}\n{result.stderr}"
361 )
362 return result.stdout