nandi/frqpublic Fork 0
5693bd5
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.

CI builds the image, Modal serves it

The web bundle had no way out of a laptop. `just build web` produced
`build/web` and `just run web` put it on localhost, and that was the end
of the road: nothing published it, and the only Modal container was a
build that ends.

So the split is by what each side has. CI has a registry, so it builds
`.modal/web/Dockerfile` -- two stages, the first a gigabyte of pinned
Flutter and Nim running the same commands as `just build web`, the second
that output and a python to serve it -- and pushes the small half. Modal
has somewhere to run it, so it deploys that exact tag and compiles
nothing. What is served is what was built and tested, by construction
rather than by care.

The loader learns one runtime for it. `web` is a Function whose [run]
command listens on the one [network] port, fronted by a stable URL and
left up by `modal deploy` rather than torn down like a Sandbox. Two spec
keys came with it: `${VAR}` expansion in `registry`, so CI passes the
commit's tag in and an unset one fails loudly; and `include = []`, which
is how a container says its image is already right -- a copy there would
add a layer to CI's image and rebuild it at deploy time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T22:39:36-07:00 Browse files
5693bd5 parent: e20f5ab
modified .gitlab-ci.yml +61 -1
@@ -5,7 +5,7 @@
55 # push. There used to be a second, scheduled job that re-resolved the
66 # jolt-native flake input; there is no jolt half any more and no input to
77 # follow, so there is nothing for a schedule to do.
8-stages: [check]
8+stages: [check, image, deploy]
99
1010 # Two toolchains and one artifact between them.
1111 #
@@ -61,3 +61,63 @@ dart-test:
6161 - apt-get update -qq && apt-get install -y -qq libssl3
6262 script:
6363 - cd dart/frq_core && dart pub get && dart test -r expanded
64+
65+# The web bundle, as an image, built by CI.
66+#
67+# Kaniko rather than docker: a GitLab runner has no docker daemon to lend and
68+# this needs no privileged mode. The context is the repo and the Dockerfile
69+# is `.modal/web/Dockerfile`, whose first stage is the same pinned Flutter and
70+# Nim that `just build web` uses -- so this is not a second way to build the
71+# web target, it is the first one inside an image.
72+#
73+# `--cache=true` makes the toolchain layer a pull rather than a fetch on every
74+# push: the apt line and the COPY above it change rarely, and the gigabyte
75+# behind them is the slow half.
76+build-web-image:
77+ stage: image
78+ needs: [nim-test]
79+ image:
80+ name: gcr.io/kaniko-project/executor:v1.23.2-debug
81+ entrypoint: [""]
82+ script:
83+ - mkdir -p /kaniko/.docker
84+ - |
85+ cat > /kaniko/.docker/config.json <<EOF
86+ {"auths":{"$CI_REGISTRY":{"username":"$CI_REGISTRY_USER","password":"$CI_REGISTRY_PASSWORD"}}}
87+ EOF
88+ - /kaniko/executor
89+ --context "$CI_PROJECT_DIR"
90+ --dockerfile "$CI_PROJECT_DIR/.modal/web/Dockerfile"
91+ --destination "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
92+ --destination "$CI_REGISTRY_IMAGE/web:latest"
93+ --cache=true
94+ --cache-repo "$CI_REGISTRY_IMAGE/web-cache"
95+ rules:
96+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
97+
98+# ...and Modal deploys that image, without rebuilding it.
99+#
100+# `modal deploy` and not `modal run`: a run is a job that ends, and this is a
101+# URL that should still be there on the next push. The app is named by
102+# `[container] name` in `.modal/web/container.toml`, so deploying again
103+# replaces the running one rather than starting a second.
104+#
105+# Two things have to exist outside this file. MODAL_TOKEN_ID and
106+# MODAL_TOKEN_SECRET are CI variables (masked, protected); and a Modal Secret
107+# named `gitlab-registry` holds REGISTRY_USERNAME / REGISTRY_PASSWORD for a
108+# GitLab deploy token with `read_registry`, because the image above is private
109+# and Modal pulls it on every cold start rather than once here.
110+deploy-web:
111+ stage: deploy
112+ needs: [build-web-image]
113+ image: python:3.13-slim
114+ variables:
115+ # The tag, not `latest`: `${FRQ_WEB_IMAGE}` in container.toml expands to
116+ # this, so what is deployed is the commit that was built and no other.
117+ FRQ_WEB_IMAGE: "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
118+ before_script:
119+ - pip install --quiet --no-cache-dir modal
120+ script:
121+ - modal deploy .modal/web/container.py
122+ rules:
123+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
@@ -5,7 +5,7 @@
5 # push. There used to be a second, scheduled job that re-resolved the5 # push. There used to be a second, scheduled job that re-resolved the
6 # jolt-native flake input; there is no jolt half any more and no input to6 # jolt-native flake input; there is no jolt half any more and no input to
7 # follow, so there is nothing for a schedule to do.7 # follow, so there is nothing for a schedule to do.
8-stages: [check]8+stages: [check, image, deploy]
9 9
10 # Two toolchains and one artifact between them.10 # Two toolchains and one artifact between them.
11 #11 #
@@ -61,3 +61,63 @@ dart-test:
61 - apt-get update -qq && apt-get install -y -qq libssl361 - apt-get update -qq && apt-get install -y -qq libssl3
62 script:62 script:
63 - cd dart/frq_core && dart pub get && dart test -r expanded63 - cd dart/frq_core && dart pub get && dart test -r expanded
64+
65+# The web bundle, as an image, built by CI.
66+#
67+# Kaniko rather than docker: a GitLab runner has no docker daemon to lend and
68+# this needs no privileged mode. The context is the repo and the Dockerfile
69+# is `.modal/web/Dockerfile`, whose first stage is the same pinned Flutter and
70+# Nim that `just build web` uses -- so this is not a second way to build the
71+# web target, it is the first one inside an image.
72+#
73+# `--cache=true` makes the toolchain layer a pull rather than a fetch on every
74+# push: the apt line and the COPY above it change rarely, and the gigabyte
75+# behind them is the slow half.
76+build-web-image:
77+ stage: image
78+ needs: [nim-test]
79+ image:
80+ name: gcr.io/kaniko-project/executor:v1.23.2-debug
81+ entrypoint: [""]
82+ script:
83+ - mkdir -p /kaniko/.docker
84+ - |
85+ cat > /kaniko/.docker/config.json <<EOF
86+ {"auths":{"$CI_REGISTRY":{"username":"$CI_REGISTRY_USER","password":"$CI_REGISTRY_PASSWORD"}}}
87+ EOF
88+ - /kaniko/executor
89+ --context "$CI_PROJECT_DIR"
90+ --dockerfile "$CI_PROJECT_DIR/.modal/web/Dockerfile"
91+ --destination "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
92+ --destination "$CI_REGISTRY_IMAGE/web:latest"
93+ --cache=true
94+ --cache-repo "$CI_REGISTRY_IMAGE/web-cache"
95+ rules:
96+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
97+
98+# ...and Modal deploys that image, without rebuilding it.
99+#
100+# `modal deploy` and not `modal run`: a run is a job that ends, and this is a
101+# URL that should still be there on the next push. The app is named by
102+# `[container] name` in `.modal/web/container.toml`, so deploying again
103+# replaces the running one rather than starting a second.
104+#
105+# Two things have to exist outside this file. MODAL_TOKEN_ID and
106+# MODAL_TOKEN_SECRET are CI variables (masked, protected); and a Modal Secret
107+# named `gitlab-registry` holds REGISTRY_USERNAME / REGISTRY_PASSWORD for a
108+# GitLab deploy token with `read_registry`, because the image above is private
109+# and Modal pulls it on every cold start rather than once here.
110+deploy-web:
111+ stage: deploy
112+ needs: [build-web-image]
113+ image: python:3.13-slim
114+ variables:
115+ # The tag, not `latest`: `${FRQ_WEB_IMAGE}` in container.toml expands to
116+ # this, so what is deployed is the commit that was built and no other.
117+ FRQ_WEB_IMAGE: "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
118+ before_script:
119+ - pip install --quiet --no-cache-dir modal
120+ script:
121+ - modal deploy .modal/web/container.py
122+ rules:
123+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
modified .modal/_loader.py +91 -14
@@ -40,8 +40,10 @@ class Container:
4040 self.command = run.get("command", "")
4141 self.env = dict(run.get("env", {}))
4242
43- # "function" (the default) or "sandbox". Sandboxes can run on a real
44- # VM, which Functions cannot -- see ../README.md.
43+ # "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.
4547 self.runtime = spec.get("container", {}).get("runtime", "function")
4648
4749 # name -> mount path. Modal Volumes, mounted while the container runs
@@ -57,9 +59,10 @@ class Container:
5759 # plain HTTP to your process" -- so the thing listening inside is an
5860 # ordinary http.server and not something holding a certificate.
5961 #
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.
62+ # 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.
6366 self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])]
6467
6568 # Modal re-imports this module inside the container, so everything
@@ -95,17 +98,22 @@ class Container:
9598
9699 def _validate(self):
97100 c = self.spec.get("container", {})
98- if self.runtime not in ("function", "sandbox"):
101+ if self.runtime not in ("function", "sandbox", "web"):
99102 raise SpecError(
100- f'[container] runtime must be "function" or "sandbox",'
103+ f'[container] runtime must be "function", "sandbox" or "web",'
101104 f" not {self.runtime!r}"
102105 )
103106 if bool(c.get("base")) == bool(c.get("registry")):
104107 raise SpecError("set exactly one of [container] base or registry")
105- if self.ports and self.runtime != "sandbox":
108+ if self.ports and self.runtime == "function":
106109 raise SpecError(
107- "[network] ports needs [container] runtime = \"sandbox\" --"
108- " a Function has no process to tunnel into"
110+ "[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"
109117 )
110118 for name, mount in self.volume_spec.items():
111119 if not isinstance(mount, str) or not mount.startswith("/"):
@@ -135,7 +143,22 @@ class Container:
135143 if c.get("base"):
136144 image = modal.Image.from_name(c["base"])
137145 else:
138- image = modal.Image.from_registry(c["registry"])
146+ # `${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)
139162
140163 # copy=True throughout: later run_commands need these files present.
141164 # `context` is what include paths are relative to, and it may sit above
@@ -168,7 +191,8 @@ class Container:
168191 # builds into a volume of its own. Patterns are relative to the copied
169192 # directory, as in .dockerignore.
170193 ignore = list(build.get("ignore", []))
171- for rel in build.get("include", ["."]):
194+ includes = build.get("include", ["."])
195+ for rel in includes:
172196 src = os.path.normpath(os.path.join(context, rel))
173197 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
174198 if os.path.isdir(src):
@@ -181,7 +205,12 @@ class Container:
181205 # which points at nothing out here, so any tool that follows it fails
182206 # in a way that has nothing to do with what it was asked to do.
183207 # Nothing in a container wants the git metadata, so it goes.
184- image = image.run_commands(f"rm -rf {self.workdir}/.git")
208+ # ...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")
185214
186215 if commands := build.get("commands", []):
187216 # Volumes mounted for the build too, not just the run, so a step
@@ -233,10 +262,15 @@ class Container:
233262 kwargs["memory"] = int(r["memory"])
234263 if r.get("gpu"):
235264 kwargs["gpu"] = r["gpu"]
265+ # 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"])
236270 # Deliberately NOT self.experimental_options: that fills in the
237271 # sandbox default, and vm_runtime on a Function is refused by the
238272 # server. A sandbox container's @app.function is vestigial anyway.
239- if self.runtime == "function":
273+ if self.runtime != "sandbox":
240274 if experimental := dict(self.spec.get("experimental", {})):
241275 kwargs["experimental_options"] = experimental
242276 if volumes := self.volumes:
@@ -283,6 +317,49 @@ class Container:
283317 raise SpecError("container.toml has no [run] command")
284318 return f"cd {self.workdir} && {command}"
285319
320+ # -- 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+
286363 def run_sandbox(self, override: str = "") -> str:
287364 """Run one command in a Sandbox that dies when the command does.
288365
@@ -40,8 +40,10 @@ class Container:
40 self.command = run.get("command", "")40 self.command = run.get("command", "")
41 self.env = dict(run.get("env", {}))41 self.env = dict(run.get("env", {}))
42 42
43- # "function" (the default) or "sandbox". Sandboxes can run on a real43+ # "function" (the default), "sandbox" or "web". Sandboxes can run on
44- # VM, which Functions cannot -- see ../README.md.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.
45 self.runtime = spec.get("container", {}).get("runtime", "function")47 self.runtime = spec.get("container", {}).get("runtime", "function")
46 48
47 # name -> mount path. Modal Volumes, mounted while the container runs49 # name -> mount path. Modal Volumes, mounted while the container runs
@@ -57,9 +59,10 @@ class Container:
57 # plain HTTP to your process" -- so the thing listening inside is an59 # plain HTTP to your process" -- so the thing listening inside is an
58 # ordinary http.server and not something holding a certificate.60 # ordinary http.server and not something holding a certificate.
59 #61 #
60- # Sandbox-only: a Function has no long-lived process to tunnel into,62+ # Not for a plain Function: it has no long-lived process to tunnel
61- # and `modal run` on one would hand back a URL for a container that63+ # into, and `modal run` on one would hand back a URL for a container
62- # has already exited.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.
63 self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])]66 self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])]
64 67
65 # Modal re-imports this module inside the container, so everything68 # Modal re-imports this module inside the container, so everything
@@ -95,17 +98,22 @@ class Container:
95 98
96 def _validate(self):99 def _validate(self):
97 c = self.spec.get("container", {})100 c = self.spec.get("container", {})
98- if self.runtime not in ("function", "sandbox"):101+ if self.runtime not in ("function", "sandbox", "web"):
99 raise SpecError(102 raise SpecError(
100- f'[container] runtime must be "function" or "sandbox",'103+ f'[container] runtime must be "function", "sandbox" or "web",'
101 f" not {self.runtime!r}"104 f" not {self.runtime!r}"
102 )105 )
103 if bool(c.get("base")) == bool(c.get("registry")):106 if bool(c.get("base")) == bool(c.get("registry")):
104 raise SpecError("set exactly one of [container] base or registry")107 raise SpecError("set exactly one of [container] base or registry")
105- if self.ports and self.runtime != "sandbox":108+ if self.ports and self.runtime == "function":
106 raise SpecError(109 raise SpecError(
107- "[network] ports needs [container] runtime = \"sandbox\" --"110+ "[network] ports needs [container] runtime = \"sandbox\""
108- " a Function has no process to tunnel into"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"
109 )117 )
110 for name, mount in self.volume_spec.items():118 for name, mount in self.volume_spec.items():
111 if not isinstance(mount, str) or not mount.startswith("/"):119 if not isinstance(mount, str) or not mount.startswith("/"):
@@ -135,7 +143,22 @@ class Container:
135 if c.get("base"):143 if c.get("base"):
136 image = modal.Image.from_name(c["base"])144 image = modal.Image.from_name(c["base"])
137 else:145 else:
138- image = modal.Image.from_registry(c["registry"])146+ # `${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)
139 162
140 # copy=True throughout: later run_commands need these files present.163 # copy=True throughout: later run_commands need these files present.
141 # `context` is what include paths are relative to, and it may sit above164 # `context` is what include paths are relative to, and it may sit above
@@ -168,7 +191,8 @@ class Container:
168 # builds into a volume of its own. Patterns are relative to the copied191 # builds into a volume of its own. Patterns are relative to the copied
169 # directory, as in .dockerignore.192 # directory, as in .dockerignore.
170 ignore = list(build.get("ignore", []))193 ignore = list(build.get("ignore", []))
171- for rel in build.get("include", ["."]):194+ includes = build.get("include", ["."])
195+ for rel in includes:
172 src = os.path.normpath(os.path.join(context, rel))196 src = os.path.normpath(os.path.join(context, rel))
173 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"197 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
174 if os.path.isdir(src):198 if os.path.isdir(src):
@@ -181,7 +205,12 @@ class Container:
181 # which points at nothing out here, so any tool that follows it fails205 # 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.206 # 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.207 # Nothing in a container wants the git metadata, so it goes.
184- image = image.run_commands(f"rm -rf {self.workdir}/.git")208+ # ...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")
185 214
186 if commands := build.get("commands", []):215 if commands := build.get("commands", []):
187 # Volumes mounted for the build too, not just the run, so a step216 # Volumes mounted for the build too, not just the run, so a step
@@ -233,10 +262,15 @@ class Container:
233 kwargs["memory"] = int(r["memory"])262 kwargs["memory"] = int(r["memory"])
234 if r.get("gpu"):263 if r.get("gpu"):
235 kwargs["gpu"] = r["gpu"]264 kwargs["gpu"] = r["gpu"]
265+ # 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"])
236 # Deliberately NOT self.experimental_options: that fills in the270 # Deliberately NOT self.experimental_options: that fills in the
237 # sandbox default, and vm_runtime on a Function is refused by the271 # sandbox default, and vm_runtime on a Function is refused by the
238 # server. A sandbox container's @app.function is vestigial anyway.272 # server. A sandbox container's @app.function is vestigial anyway.
239- if self.runtime == "function":273+ if self.runtime != "sandbox":
240 if experimental := dict(self.spec.get("experimental", {})):274 if experimental := dict(self.spec.get("experimental", {})):
241 kwargs["experimental_options"] = experimental275 kwargs["experimental_options"] = experimental
242 if volumes := self.volumes:276 if volumes := self.volumes:
@@ -283,6 +317,49 @@ class Container:
283 raise SpecError("container.toml has no [run] command")317 raise SpecError("container.toml has no [run] command")
284 return f"cd {self.workdir} && {command}"318 return f"cd {self.workdir} && {command}"
285 319
320+ # -- 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+
286 def run_sandbox(self, override: str = "") -> str:363 def run_sandbox(self, override: str = "") -> str:
287 """Run one command in a Sandbox that dies when the command does.364 """Run one command in a Sandbox that dies when the command does.
288 365
added .modal/web/Dockerfile +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+# The web bundle, built once and carried as an image.
2+#
3+# Two stages, and the seam between them is the point: the first is a whole
4+# Flutter SDK and a Nim compiler, about a gigabyte of toolchain that exists
5+# only to produce `flutter/build/web`; the second is that directory and a
6+# python to serve it. What gets pushed to the registry, and what Modal pulls
7+# on every cold start, is the small half.
8+#
9+# This is the one place in the tree where a build happens inside a Dockerfile
10+# rather than in `just` or a Modal sandbox. It is deliberate: CI is what has
11+# a registry to push to, and an image is what Modal can deploy without
12+# rebuilding anything. The build itself is still `tools/toolchain.sh` -- the
13+# same pinned Flutter and Nim as `just build web` -- so nothing about the
14+# output depends on being in a container.
15+
16+FROM debian:13-slim AS build
17+
18+# The toolchain's own needs (git, because Flutter shells out to it against
19+# its SDK checkout; the unpackers; ca-certificates for curl) and Nim's one
20+# host dependency, a C compiler. No GTK here and no CMake: the Linux desktop
21+# target wants those, and this is the web one.
22+RUN apt-get update && apt-get install -y --no-install-recommends \
23+ build-essential ca-certificates curl git tar unzip xz-utils \
24+ && rm -rf /var/lib/apt/lists/*
25+
26+WORKDIR /src
27+COPY . .
28+
29+# The same two steps as `just build web`, inlined because `just` is not here
30+# and is not worth an install for one call. The core is compiled to
31+# JavaScript and copied into `flutter/web/` rather than into the output,
32+# because `flutter build web` copies that directory into the bundle -- which
33+# is what makes the page's `<script src="frq_core.js">` resolve the same
34+# either way.
35+RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\
36+ mkdir -p /src/build/web && cd /src/nim && \
37+ nim js -d:release --hints:off --path:src --path:web \
38+ --out:/src/build/web/frq_core.js web/frq_web.nim && \
39+ cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \
40+ cd /src/flutter && flutter pub get && flutter build web'
41+
42+# python:*-slim and not debian:*-slim, for two reasons that happen to agree:
43+# Modal runs its own client inside the container, so the image needs a Python
44+# it can use, and the server is `http.server` -- the same one `just run web`
45+# starts on localhost, so what is served here is served the same way there.
46+FROM python:3.13-slim
47+
48+COPY --from=build /src/flutter/build/web /srv/web
49+
50+EXPOSE 8000
51+CMD ["python3", "-m", "http.server", "8000", "--directory", "/srv/web"]
new file mode 100644
@@ -0,0 +1,51 @@
1+# The web bundle, built once and carried as an image.
2+#
3+# Two stages, and the seam between them is the point: the first is a whole
4+# Flutter SDK and a Nim compiler, about a gigabyte of toolchain that exists
5+# only to produce `flutter/build/web`; the second is that directory and a
6+# python to serve it. What gets pushed to the registry, and what Modal pulls
7+# on every cold start, is the small half.
8+#
9+# This is the one place in the tree where a build happens inside a Dockerfile
10+# rather than in `just` or a Modal sandbox. It is deliberate: CI is what has
11+# a registry to push to, and an image is what Modal can deploy without
12+# rebuilding anything. The build itself is still `tools/toolchain.sh` -- the
13+# same pinned Flutter and Nim as `just build web` -- so nothing about the
14+# output depends on being in a container.
15+
16+FROM debian:13-slim AS build
17+
18+# The toolchain's own needs (git, because Flutter shells out to it against
19+# its SDK checkout; the unpackers; ca-certificates for curl) and Nim's one
20+# host dependency, a C compiler. No GTK here and no CMake: the Linux desktop
21+# target wants those, and this is the web one.
22+RUN apt-get update && apt-get install -y --no-install-recommends \
23+ build-essential ca-certificates curl git tar unzip xz-utils \
24+ && rm -rf /var/lib/apt/lists/*
25+
26+WORKDIR /src
27+COPY . .
28+
29+# The same two steps as `just build web`, inlined because `just` is not here
30+# and is not worth an install for one call. The core is compiled to
31+# JavaScript and copied into `flutter/web/` rather than into the output,
32+# because `flutter build web` copies that directory into the bundle -- which
33+# is what makes the page's `<script src="frq_core.js">` resolve the same
34+# either way.
35+RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\
36+ mkdir -p /src/build/web && cd /src/nim && \
37+ nim js -d:release --hints:off --path:src --path:web \
38+ --out:/src/build/web/frq_core.js web/frq_web.nim && \
39+ cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \
40+ cd /src/flutter && flutter pub get && flutter build web'
41+
42+# python:*-slim and not debian:*-slim, for two reasons that happen to agree:
43+# Modal runs its own client inside the container, so the image needs a Python
44+# it can use, and the server is `http.server` -- the same one `just run web`
45+# starts on localhost, so what is served here is served the same way there.
46+FROM python:3.13-slim
47+
48+COPY --from=build /src/flutter/build/web /srv/web
49+
50+EXPOSE 8000
51+CMD ["python3", "-m", "http.server", "8000", "--directory", "/srv/web"]
added .modal/web/README.md +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+# `web`
2+
3+ FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> \
4+ modal deploy .modal/web/container.py
5+
6+Defined by `container.toml`; `../_loader.py` is what reads it, and
7+its comments are the spec.
8+
9+Unlike `dev`, this container builds nothing. CI builds the image --
10+`Dockerfile` here, two stages, the second one just the bundle and a
11+python -- and pushes it to the GitLab registry; this deploys that
12+exact tag. So the thing served is the thing that was built and
13+tested, and a deploy is a pull rather than a compile.
14+
15+`runtime = "web"`: a Function whose [run] command listens on the one
16+[network] port, fronted by a stable https URL. `modal deploy` leaves
17+it up, and deploying again replaces it in place because the app is
18+named by `[container] name`.
19+
20+Two credentials live outside the repo:
21+
22+* `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, as protected CI variables.
23+* A Modal Secret named `gitlab-registry`, holding `REGISTRY_USERNAME`
24+ and `REGISTRY_PASSWORD` -- a GitLab deploy token with
25+ `read_registry`. Modal pulls the private image on every cold start,
26+ not once at deploy time, so this has to be Modal's to keep.
new file mode 100644
@@ -0,0 +1,26 @@
1+# `web`
2+
3+ FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> \
4+ modal deploy .modal/web/container.py
5+
6+Defined by `container.toml`; `../_loader.py` is what reads it, and
7+its comments are the spec.
8+
9+Unlike `dev`, this container builds nothing. CI builds the image --
10+`Dockerfile` here, two stages, the second one just the bundle and a
11+python -- and pushes it to the GitLab registry; this deploys that
12+exact tag. So the thing served is the thing that was built and
13+tested, and a deploy is a pull rather than a compile.
14+
15+`runtime = "web"`: a Function whose [run] command listens on the one
16+[network] port, fronted by a stable https URL. `modal deploy` leaves
17+it up, and deploying again replaces it in place because the app is
18+named by `[container] name`.
19+
20+Two credentials live outside the repo:
21+
22+* `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, as protected CI variables.
23+* A Modal Secret named `gitlab-registry`, holding `REGISTRY_USERNAME`
24+ and `REGISTRY_PASSWORD` -- a GitLab deploy token with
25+ `read_registry`. Modal pulls the private image on every cold start,
26+ not once at deploy time, so this has to be Modal's to keep.
added .modal/web/container.py +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+"""Generated stub -- the container is defined by container.toml.
2+
3+Edit container.toml, not this file.
4+"""
5+
6+import os
7+import sys
8+
9+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10+
11+from _loader import Container # noqa: E402
12+
13+c = Container.from_toml(__file__)
14+image, app = c.image, c.app
15+
16+# A web container's Function is not vestigial the way a sandbox's is: it *is*
17+# the container. Registered at import time so that `modal deploy` finds it,
18+# and so that the re-import inside the container binds the same body.
19+serve = c.register_web()
new file mode 100644
@@ -0,0 +1,19 @@
1+"""Generated stub -- the container is defined by container.toml.
2+
3+Edit container.toml, not this file.
4+"""
5+
6+import os
7+import sys
8+
9+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10+
11+from _loader import Container # noqa: E402
12+
13+c = Container.from_toml(__file__)
14+image, app = c.image, c.app
15+
16+# A web container's Function is not vestigial the way a sandbox's is: it *is*
17+# the container. Registered at import time so that `modal deploy` finds it,
18+# and so that the re-import inside the container binds the same body.
19+serve = c.register_web()
added .modal/web/container.toml +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+[container]
2+name = "frq-web"
3+description = "the web bundle CI built, served at a URL"
4+# Nothing is built here. `registry` is an image CI already made -- the
5+# Dockerfile beside this file -- and `${FRQ_WEB_IMAGE}` is expanded when this
6+# spec is read, so the tag is whatever commit the deploy job is deploying.
7+# Unset, it fails loudly rather than deploying something stale.
8+registry = "${FRQ_WEB_IMAGE}"
9+# The GitLab container registry is private, so Modal needs credentials to
10+# pull. A Modal Secret of this name holding REGISTRY_USERNAME and
11+# REGISTRY_PASSWORD -- a GitLab deploy token with `read_registry` is enough.
12+registry_secret = "gitlab-registry"
13+# Not a Sandbox: a Sandbox is a run that ends, and this is meant to stay up
14+# between pushes. `web` is a Function that Modal fronts with a stable https
15+# URL and `modal deploy` leaves running.
16+runtime = "web"
17+
18+[build]
19+# Empty, and that is the whole point of this container: the image is already
20+# what it should be. A copy here would add a layer to CI's image -- rebuilt
21+# and re-pushed at deploy time -- and the tree it copied would be the
22+# deploying machine's rather than the one that was built and tested.
23+include = []
24+
25+[network]
26+ports = [8000]
27+
28+[resources]
29+# A static bundle over http.server. The cost is the pull, not the serving.
30+cpu = 1
31+memory = 1024
32+timeout = 3600
33+# One container kept warm, so the URL answers without a cold pull after a
34+# quiet spell. Set it to 0 to pay that second or two instead.
35+min_containers = 1
36+
37+[run]
38+workdir = "/srv"
39+# The image's own CMD, said again here because `web` starts the [run] command
40+# and not the CMD: one place says what runs, and `--shell` into this
41+# container gets the same line.
42+command = "python3 -m http.server 8000 --directory /srv/web"
new file mode 100644
@@ -0,0 +1,42 @@
1+[container]
2+name = "frq-web"
3+description = "the web bundle CI built, served at a URL"
4+# Nothing is built here. `registry` is an image CI already made -- the
5+# Dockerfile beside this file -- and `${FRQ_WEB_IMAGE}` is expanded when this
6+# spec is read, so the tag is whatever commit the deploy job is deploying.
7+# Unset, it fails loudly rather than deploying something stale.
8+registry = "${FRQ_WEB_IMAGE}"
9+# The GitLab container registry is private, so Modal needs credentials to
10+# pull. A Modal Secret of this name holding REGISTRY_USERNAME and
11+# REGISTRY_PASSWORD -- a GitLab deploy token with `read_registry` is enough.
12+registry_secret = "gitlab-registry"
13+# Not a Sandbox: a Sandbox is a run that ends, and this is meant to stay up
14+# between pushes. `web` is a Function that Modal fronts with a stable https
15+# URL and `modal deploy` leaves running.
16+runtime = "web"
17+
18+[build]
19+# Empty, and that is the whole point of this container: the image is already
20+# what it should be. A copy here would add a layer to CI's image -- rebuilt
21+# and re-pushed at deploy time -- and the tree it copied would be the
22+# deploying machine's rather than the one that was built and tested.
23+include = []
24+
25+[network]
26+ports = [8000]
27+
28+[resources]
29+# A static bundle over http.server. The cost is the pull, not the serving.
30+cpu = 1
31+memory = 1024
32+timeout = 3600
33+# One container kept warm, so the URL answers without a cold pull after a
34+# quiet spell. Set it to 0 to pay that second or two instead.
35+min_containers = 1
36+
37+[run]
38+workdir = "/srv"
39+# The image's own CMD, said again here because `web` starts the [run] command
40+# and not the CMD: one place says what runs, and `--shell` into this
41+# container gets the same line.
42+command = "python3 -m http.server 8000 --directory /srv/web"
modified CLAUDE.md +7 -0
@@ -28,6 +28,13 @@ deployed apps by name, not the ephemeral one a `modal run` creates, and carries
2828 nothing until the Sandbox starts — the image build streams to the client and
2929 nowhere else.
3030
31+Two containers, and they are not the same kind of thing. `dev` is a build
32+that ends. `web` is a deploy: CI builds `.modal/web/Dockerfile` into the
33+GitLab registry, and `modal deploy .modal/web/container.py` serves that exact
34+tag at a URL, building nothing. So `just build web` on a laptop and the thing
35+on the internet come from the same two commands, run in different places —
36+and a deploy is a pull rather than a compile.
37+
3138 The containers run as Modal **Sandboxes on a real VM** rather than under
3239 gVisor: a real kernel, a working pty, and memory that is exactly what
3340 `[resources] memory` asks for.
@@ -28,6 +28,13 @@ deployed apps by name, not the ephemeral one a `modal run` creates, and carries
28 nothing until the Sandbox starts — the image build streams to the client and28 nothing until the Sandbox starts — the image build streams to the client and
29 nowhere else.29 nowhere else.
30 30
31+Two containers, and they are not the same kind of thing. `dev` is a build
32+that ends. `web` is a deploy: CI builds `.modal/web/Dockerfile` into the
33+GitLab registry, and `modal deploy .modal/web/container.py` serves that exact
34+tag at a URL, building nothing. So `just build web` on a laptop and the thing
35+on the internet come from the same two commands, run in different places —
36+and a deploy is a pull rather than a compile.
37+
31 The containers run as Modal **Sandboxes on a real VM** rather than under38 The containers run as Modal **Sandboxes on a real VM** rather than under
32 gVisor: a real kernel, a working pty, and memory that is exactly what39 gVisor: a real kernel, a working pty, and memory that is exactly what
33 `[resources] memory` asks for.40 `[resources] memory` asks for.
modified justfile +22 -0
@@ -14,6 +14,7 @@
1414 # just run TARGET apk desktop web ui app
1515 # just test SUITE all nim dart common live
1616 # just modal CONTAINER dev
17+# just deploy web the CI-built image, to a Modal URL
1718 # just serve [PORT] the Modal-built web bundle, on localhost
1819 # just tools ... the toolchain itself
1920
@@ -137,6 +138,27 @@ modal container="dev" *args:
137138 shift || true
138139 exec modal run ".modal/{{container}}/container.py" "$@"
139140
141+# Deploy, rather than run: a URL that stays up between pushes.
142+#
143+# Nothing is built here. `.modal/web/` points at an image CI already made and
144+# pushed, and FRQ_WEB_IMAGE is which tag of it — so this is the same command
145+# the `deploy-web` job runs, with the tag named by hand instead of by the
146+# commit. Normally you want the job; this is for deploying an older tag, or a
147+# first deploy before CI has one.
148+#
149+# FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> just deploy web
150+[doc('deploy a .modal/ container as a URL (needs FRQ_WEB_IMAGE)')]
151+deploy container="web":
152+ #!/usr/bin/env bash
153+ set -euo pipefail
154+ cd "{{root}}"
155+ if [ -z "${FRQ_WEB_IMAGE:-}" ]; then
156+ echo "deploy: set FRQ_WEB_IMAGE to the image tag CI pushed" >&2
157+ echo " e.g. registry.gitlab.com/<ns>/frq/web:\$(git rev-parse HEAD)" >&2
158+ exit 1
159+ fi
160+ exec modal deploy ".modal/{{container}}/container.py"
161+
140162 # The same core, compiled to JavaScript.
141163 #
142164 # `--path:src --path:web`, in that order, because the later path wins: every
@@ -14,6 +14,7 @@
14 # just run TARGET apk desktop web ui app14 # just run TARGET apk desktop web ui app
15 # just test SUITE all nim dart common live15 # just test SUITE all nim dart common live
16 # just modal CONTAINER dev16 # just modal CONTAINER dev
17+# just deploy web the CI-built image, to a Modal URL
17 # just serve [PORT] the Modal-built web bundle, on localhost18 # just serve [PORT] the Modal-built web bundle, on localhost
18 # just tools ... the toolchain itself19 # just tools ... the toolchain itself
19 20
@@ -137,6 +138,27 @@ modal container="dev" *args:
137 shift || true138 shift || true
138 exec modal run ".modal/{{container}}/container.py" "$@"139 exec modal run ".modal/{{container}}/container.py" "$@"
139 140
141+# Deploy, rather than run: a URL that stays up between pushes.
142+#
143+# Nothing is built here. `.modal/web/` points at an image CI already made and
144+# pushed, and FRQ_WEB_IMAGE is which tag of it — so this is the same command
145+# the `deploy-web` job runs, with the tag named by hand instead of by the
146+# commit. Normally you want the job; this is for deploying an older tag, or a
147+# first deploy before CI has one.
148+#
149+# FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> just deploy web
150+[doc('deploy a .modal/ container as a URL (needs FRQ_WEB_IMAGE)')]
151+deploy container="web":
152+ #!/usr/bin/env bash
153+ set -euo pipefail
154+ cd "{{root}}"
155+ if [ -z "${FRQ_WEB_IMAGE:-}" ]; then
156+ echo "deploy: set FRQ_WEB_IMAGE to the image tag CI pushed" >&2
157+ echo " e.g. registry.gitlab.com/<ns>/frq/web:\$(git rev-parse HEAD)" >&2
158+ exit 1
159+ fi
160+ exec modal deploy ".modal/{{container}}/container.py"
161+
140 # The same core, compiled to JavaScript.162 # The same core, compiled to JavaScript.
141 #163 #
142 # `--path:src --path:web`, in that order, because the later path wins: every164 # `--path:src --path:web`, in that order, because the later path wins: every