nandi/frqpublic Fork 0
claude/ci-image-modal-deploy-f0f1d9
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 · 439 lines · 19.7 KBPython Blame HistoryRaw
Build the window somewhere with room for it 1451151 nandi 6d ago1"""Turn a `container.toml` into a `modal.Image` and a `modal.App`.
2
Carry the loader's .git fix over 85852e8 nandi 6d 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 yesterday5a 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 6d 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
CI builds the image, Modal serves it 5693bd5 nandi 5h ago43 # "function" (the default), "sandbox" or "web". Sandboxes can run on
44 # a real VM, which Functions cannot -- see ../README.md. A "web"
45 # container is a Function that serves [network] ports at a URL and
46 # stays deployed, rather than a run that ends.
Build the window somewhere with room for it 1451151 nandi 6d ago47 self.runtime = spec.get("container", {}).get("runtime", "function")
48
49 # name -> mount path. Modal Volumes, mounted while the container runs
50 # and NOT while its image is built: a volume mount is not part of the
51 # resulting image, so anything written to one during a build step is
52 # gone by the time the container starts. Persist across runs is the
Six verbs, and the last of the nix 2e24e64 nandi yesterday53 # whole point -- a toolchain too big to fetch every run, a build
54 # tree an incremental compile reuses, a dataset too big to bake in.
Build the window somewhere with room for it 1451151 nandi 6d ago55 self.volume_spec = dict(spec.get("volumes", {}))
56
A third target, and the seam that was already waiting for it f54ca45 nandi 3d ago57 # Ports to tunnel out of a Sandbox, from [network] ports. Encrypted,
58 # which is Modal's own word for "the tunnel terminates TLS and speaks
59 # plain HTTP to your process" -- so the thing listening inside is an
60 # ordinary http.server and not something holding a certificate.
61 #
CI builds the image, Modal serves it 5693bd5 nandi 5h ago62 # Not for a plain Function: it has no long-lived process to tunnel
63 # into, and `modal run` on one would hand back a URL for a container
64 # that has already exited. A "web" container has exactly one, and
65 # Modal fronts it with a stable https:// URL instead of a tunnel.
A third target, and the seam that was already waiting for it f54ca45 nandi 3d ago66 self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])]
67
Build the window somewhere with room for it 1451151 nandi 6d ago68 # Modal re-imports this module inside the container, so everything
69 # below runs twice: once here, once out there. Out there the local
Six verbs, and the last of the nix 2e24e64 nandi yesterday70 # tree does not exist -- no repo to copy from -- and
Build the window somewhere with room for it 1451151 nandi 6d ago71 # the image is already built, so validating and rebuilding it would
72 # only fail. The App still has to exist for the decorators to bind.
73 if self.is_remote:
74 self.image = None
75 self.app = modal.App(self.name)
76 else:
77 self._validate()
78 self.image = self._build_image()
79 self.app = modal.App(self.name, image=self.image)
80
81 # -- spec reading ----------------------------------------------------
82
83 @classmethod
84 def from_toml(cls, container_py: str) -> "Container":
85 """Load the container.toml sitting next to the given container.py."""
86 directory = os.path.dirname(os.path.abspath(container_py))
87 path = os.path.join(directory, "container.toml")
88 if not os.path.exists(path):
89 raise SpecError(f"no container.toml in {directory}")
90 with open(path, "rb") as f:
91 return cls(tomllib.load(f), directory)
92
93 def _require(self, table: str, key: str):
94 try:
95 return self.spec[table][key]
96 except KeyError:
97 raise SpecError(f"container.toml needs [{table}] {key}") from None
98
99 def _validate(self):
100 c = self.spec.get("container", {})
CI builds the image, Modal serves it 5693bd5 nandi 5h ago101 if self.runtime not in ("function", "sandbox", "web"):
Build the window somewhere with room for it 1451151 nandi 6d ago102 raise SpecError(
CI builds the image, Modal serves it 5693bd5 nandi 5h ago103 f'[container] runtime must be "function", "sandbox" or "web",'
Build the window somewhere with room for it 1451151 nandi 6d ago104 f" not {self.runtime!r}"
105 )
106 if bool(c.get("base")) == bool(c.get("registry")):
107 raise SpecError("set exactly one of [container] base or registry")
CI builds the image, Modal serves it 5693bd5 nandi 5h ago108 if self.ports and self.runtime == "function":
A third target, and the seam that was already waiting for it f54ca45 nandi 3d ago109 raise SpecError(
CI builds the image, Modal serves it 5693bd5 nandi 5h ago110 "[network] ports needs [container] runtime = \"sandbox\""
111 " or \"web\" -- a Function has no process to tunnel into"
112 )
113 if self.runtime == "web" and len(self.ports) != 1:
114 raise SpecError(
115 "[container] runtime = \"web\" needs exactly one"
116 " [network] ports entry -- a URL fronts one listener"
A third target, and the seam that was already waiting for it f54ca45 nandi 3d ago117 )
Build the window somewhere with room for it 1451151 nandi 6d ago118 for name, mount in self.volume_spec.items():
119 if not isinstance(mount, str) or not mount.startswith("/"):
120 raise SpecError(
121 f"[volumes] {name} must be an absolute path, not {mount!r}"
122 )
123 # Mounting over one of these hides what the image already has
Six verbs, and the last of the nix 2e24e64 nandi yesterday124 # there: an empty volume would shadow what the image's own build
125 # put in its place.
126 if mount.rstrip("/") in ("", "/usr", "/etc", "/bin", "/lib"):
Build the window somewhere with room for it 1451151 nandi 6d ago127 raise SpecError(
128 f"[volumes] {name} may not mount over {mount} --"
129 " it would hide what the image has there"
130 )
131 if mount.rstrip("/") == self.workdir.rstrip("/"):
132 raise SpecError(
133 f"[volumes] {name} may not mount over the workdir"
134 f" ({mount}) -- [build] include copies land there"
135 )
136
137 # -- image -----------------------------------------------------------
138
139 def _build_image(self) -> modal.Image:
140 c = self.spec["container"]
141 build = self.spec.get("build", {})
142
143 if c.get("base"):
144 image = modal.Image.from_name(c["base"])
145 else:
CI builds the image, Modal serves it 5693bd5 nandi 5h ago146 # `${VAR}` in a registry reference is expanded here, so a tag that
147 # CI computes -- a commit sha -- can be passed in rather than
148 # committed. A private registry needs credentials, and Modal wants
149 # them as a Secret holding REGISTRY_USERNAME / REGISTRY_PASSWORD:
150 # name it with [container] registry_secret.
151 ref = os.path.expandvars(c["registry"])
152 if "$" in ref:
153 raise SpecError(
154 f"[container] registry has an unset variable: {ref}"
155 )
156 if secret := c.get("registry_secret"):
157 image = modal.Image.from_registry(
158 ref, secret=modal.Secret.from_name(secret)
159 )
160 else:
161 image = modal.Image.from_registry(ref)
Build the window somewhere with room for it 1451151 nandi 6d ago162
163 # copy=True throughout: later run_commands need these files present.
164 # `context` is what include paths are relative to, and it may sit above
165 # the container directory -- a container that builds the repo it lives
166 # in sets context = "../..", so include = ["."] means the whole repo.
167 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 3d ago168
169 # Image building and program building, kept apart.
170 #
Six verbs, and the last of the nix 2e24e64 nandi yesterday171 # `[build] commands` run AFTER the source copy, so any edit anywhere
172 # in the tree invalidates them. `warm` + `setup` are the same step
173 # moved in front of the source: `warm` names only the files the step
174 # actually reads, and `setup` runs against those alone, so editing
175 # `flutter/src` invalidates nothing above the final copy. A container
176 # whose setup is an `apt-get` needs no `warm` at all -- it reads
177 # 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 3d ago178 for rel in build.get("warm", []):
179 src = os.path.normpath(os.path.join(context, rel))
180 dest = f"{self.workdir}/{rel}"
181 if os.path.isdir(src):
182 image = image.add_local_dir(src, dest, copy=True)
183 else:
184 image = image.add_local_file(src, dest, copy=True)
185 if setup := build.get("setup", []):
186 image = image.run_commands(*setup, volumes=self.volumes)
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 6d ago187 # `ignore` is what keeps a build tree out of the image. A checkout that
188 # has been built in locally carries its output -- flutter/build and the
189 # caches beside it were 395MB of a 441MB repo -- and all of it would be
190 # uploaded on every start only to be thrown away, since the container
191 # builds into a volume of its own. Patterns are relative to the copied
192 # directory, as in .dockerignore.
193 ignore = list(build.get("ignore", []))
CI builds the image, Modal serves it 5693bd5 nandi 5h ago194 includes = build.get("include", ["."])
195 for rel in includes:
Build the window somewhere with room for it 1451151 nandi 6d ago196 src = os.path.normpath(os.path.join(context, rel))
197 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
198 if os.path.isdir(src):
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 6d ago199 image = image.add_local_dir(src, dest, copy=True, ignore=ignore)
Build the window somewhere with room for it 1451151 nandi 6d ago200 else:
201 image = image.add_local_file(src, dest, copy=True)
202
Six verbs, and the last of the nix 2e24e64 nandi yesterday203 # A repo copied in brings its `.git` along, and in a worktree that is
204 # a *file* holding `gitdir: <path on the machine that copied it>` --
205 # which points at nothing out here, so any tool that follows it fails
206 # in a way that has nothing to do with what it was asked to do.
207 # Nothing in a container wants the git metadata, so it goes.
CI builds the image, Modal serves it 5693bd5 nandi 5h ago208 # ...but only if a copy happened. `include = []` is how a container
209 # built elsewhere -- by CI, into a registry -- says the image is
210 # already what it should be, and adding a layer to it would rebuild
211 # and re-push something nobody asked to change.
212 if includes:
213 image = image.run_commands(f"rm -rf {self.workdir}/.git")
Carry the loader's .git fix over 85852e8 nandi 6d ago214
Build the window somewhere with room for it 1451151 nandi 6d ago215 if commands := build.get("commands", []):
Six verbs, and the last of the nix 2e24e64 nandi yesterday216 # Volumes mounted for the build too, not just the run, so a step
217 # can read a cache the last run filled. The mount is not part of
218 # 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 6d ago219 image = image.run_commands(*commands, volumes=self.volumes)
Build the window somewhere with room for it 1451151 nandi 6d ago220
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"]
CI builds the image, Modal serves it 5693bd5 nandi 5h ago265 # Containers kept warm. Zero -- the default -- means a served URL
266 # pays a cold start on the first request after it goes quiet, which
267 # for a static bundle is a second or two.
268 if r.get("min_containers"):
269 kwargs["min_containers"] = int(r["min_containers"])
Build the window somewhere with room for it 1451151 nandi 6d ago270 # Deliberately NOT self.experimental_options: that fills in the
271 # sandbox default, and vm_runtime on a Function is refused by the
272 # server. A sandbox container's @app.function is vestigial anyway.
CI builds the image, Modal serves it 5693bd5 nandi 5h ago273 if self.runtime != "sandbox":
Build the window somewhere with room for it 1451151 nandi 6d ago274 if experimental := dict(self.spec.get("experimental", {})):
275 kwargs["experimental_options"] = experimental
276 if volumes := self.volumes:
277 kwargs["volumes"] = volumes
278 return kwargs
279
280 @property
281 def experimental_options(self) -> dict:
282 """Experimental options, with the sandbox default filled in.
283
284 `vm_runtime` is Sandbox-only: the server rejects it on a Function
285 outright. So it is defaulted on for sandboxes and never for functions,
286 and an explicit [experimental] table always wins.
287 """
288 explicit = dict(self.spec.get("experimental", {}))
289 if explicit:
290 return explicit
291 if self.runtime == "sandbox":
292 return {"vm_runtime": True}
293 return {}
294
295 @property
296 def sandbox_kwargs(self) -> dict:
297 """Everything `Sandbox.create` should be given, from [resources]."""
298 r = self.spec.get("resources", {})
299 kwargs: dict = {"timeout": int(r.get("timeout", 900))}
300 if r.get("cpu"):
301 kwargs["cpu"] = float(r["cpu"])
302 if r.get("memory"):
303 # A VM sandbox gets exactly this much and cannot grow into more.
304 kwargs["memory"] = int(r["memory"])
305 if opts := self.experimental_options:
306 kwargs["experimental_options"] = dict(opts)
307 if volumes := self.volumes:
308 kwargs["volumes"] = volumes
A third target, and the seam that was already waiting for it f54ca45 nandi 3d ago309 if self.ports:
310 kwargs["encrypted_ports"] = list(self.ports)
Build the window somewhere with room for it 1451151 nandi 6d ago311 return kwargs
312
313 def shell_command(self, override: str = "") -> str:
Six verbs, and the last of the nix 2e24e64 nandi yesterday314 """The full shell line the container runs."""
Build the window somewhere with room for it 1451151 nandi 6d ago315 command = override or self.command
316 if not command:
317 raise SpecError("container.toml has no [run] command")
318 return f"cd {self.workdir} && {command}"
319
CI builds the image, Modal serves it 5693bd5 nandi 5h ago320 # -- web -------------------------------------------------------------
321
322 def register_web(self):
323 """Serve the [run] command's process at a URL, for `modal deploy`.
324
325 A web container's command is a server, not a build: it listens on the
326 one [network] port and Modal fronts it with https. `web_server` waits
327 for that port to accept a connection and then proxies to it, so the
328 command has to keep running -- Popen and return, rather than the
329 `subprocess.run` that `execute` uses for a job that ends.
330
331 Registered by calling this at import time, which happens twice: once
332 here, to define the Function, and once inside the container, where
333 `self.image` is None and the App has no image of its own.
334 """
335 kwargs = dict(self.function_kwargs)
336 if self.image is not None:
337 kwargs["image"] = self.image
338 line = self.shell_command()
339 env = {k: str(v) for k, v in self.env.items()}
340 port = self.ports[0]
341
342 # One container answering many requests. A static bundle costs
343 # nothing per request, so scaling out on concurrency would only buy
344 # cold starts.
345 #
346 # serialized=True because this body is defined in here rather than at
347 # a module's top level: Modal pickles it instead of importing it by
348 # name, which is also why a web container needs nothing of its own
349 # beyond the stub that calls this.
350 @self.app.function(name="serve", serialized=True, **kwargs)
351 @modal.concurrent(max_inputs=100)
352 @modal.web_server(port=port, startup_timeout=self.startup_timeout)
353 def serve():
354 subprocess.Popen(line, shell=True, env={**os.environ, **env})
355
356 return serve
357
358 @property
359 def startup_timeout(self) -> int:
360 """How long Modal waits for the port to answer, from [network]."""
361 return int(self.spec.get("network", {}).get("startup_timeout", 60))
362
Build the window somewhere with room for it 1451151 nandi 6d ago363 def run_sandbox(self, override: str = "") -> str:
364 """Run one command in a Sandbox that dies when the command does.
365
366 The command IS the sandbox's process, rather than something exec'd
367 into a `sleep infinity` box that then has to be torn down. There is no
368 idle window to pay for and nothing to leak if this script is killed;
369 the [resources] timeout is a backstop, not the mechanism.
370
371 Modal streams a Sandbox's output into the app log as it runs -- which
372 is what you want for a build -- so this returns "" rather than handing
373 back a copy for the caller to print underneath it.
374 """
375 line = self.shell_command(override)
376 sb = modal.Sandbox.create(
377 "sh", "-c", line,
378 app=self.app,
379 image=self.image,
380 workdir=self.workdir,
381 env={k: str(v) for k, v in self.env.items()},
382 **self.sandbox_kwargs,
383 )
A third target, and the seam that was already waiting for it f54ca45 nandi 3d ago384 self._print_tunnels(sb)
Build the window somewhere with room for it 1451151 nandi 6d ago385 sb.wait()
386 if sb.returncode != 0:
387 raise RuntimeError(
388 f"{self.name}: command failed ({sb.returncode})\n"
389 f"$ {line}\n{sb.stderr.read()}"
390 )
391 return ""
392
A third target, and the seam that was already waiting for it f54ca45 nandi 3d ago393 def _print_tunnels(self, sb: "modal.Sandbox") -> None:
394 """Say where a tunnelled port can be reached, once the sandbox is up.
395
396 `tunnels()` blocks until the Sandbox is scheduled, which is why this
397 is called after create and not folded into it. Nothing prints when
398 [network] ports is empty, which is every container but the serving
399 ones.
400 """
401 if not self.ports:
402 return
403 for port, tunnel in sb.tunnels().items():
404 print(f" :{port} -> {tunnel.url}")
405
Build the window somewhere with room for it 1451151 nandi 6d ago406 def open_sandbox(self) -> "modal.Sandbox":
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 6d ago407 """Start a Sandbox and leave it running, for `scripts/shell`.
408
409 Same workdir and the same [run] env as the real thing: a shell opened
410 to debug a container that does not have the container's environment is
411 a shell that reproduces something else. `command` is the one part left
412 out, because not running it is the point.
413 """
Build the window somewhere with room for it 1451151 nandi 6d ago414 return modal.Sandbox.create(
415 "sleep",
416 "infinity",
417 app=self.app,
418 image=self.image,
Stop shipping the build tree, and let a shell be the devShell 013945f nandi 6d ago419 workdir=self.workdir,
420 env={k: str(v) for k, v in self.env.items()},
Build the window somewhere with room for it 1451151 nandi 6d ago421 **self.sandbox_kwargs,
422 )
423
424 def execute(self, override: str = "") -> str:
425 """Run the command in this container. Called remotely, not locally."""
426 line = self.shell_command(override)
427 result = subprocess.run(
428 line,
429 shell=True,
430 capture_output=True,
431 text=True,
432 env={**os.environ, **{k: str(v) for k, v in self.env.items()}},
433 )
434 if result.returncode != 0:
435 raise RuntimeError(
436 f"{self.name}: command failed ({result.returncode})\n"
437 f"$ {line}\n{result.stderr}"
438 )
439 return result.stdout