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>5693bd5 parent: e20f5ab modified
.gitlab-ci.yml +61 -1 | @@ -5,7 +5,7 @@ | ||
| 5 | 5 | # push. There used to be a second, scheduled job that re-resolved the |
| 6 | 6 | # jolt-native flake input; there is no jolt half any more and no input to |
| 7 | 7 | # follow, so there is nothing for a schedule to do. |
| 8 | -stages: [check] | |
| 8 | +stages: [check, image, deploy] | |
| 9 | 9 | |
| 10 | 10 | # Two toolchains and one artifact between them. |
| 11 | 11 | # |
| @@ -61,3 +61,63 @@ dart-test: | ||
| 61 | 61 | - apt-get update -qq && apt-get install -y -qq libssl3 |
| 62 | 62 | script: |
| 63 | 63 | - 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 the | 5 | # 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 to | 6 | # 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 libssl3 | 61 | - 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 expanded | 63 | - 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: | ||
| 40 | 40 | self.command = run.get("command", "") |
| 41 | 41 | self.env = dict(run.get("env", {})) |
| 42 | 42 | |
| 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. | |
| 45 | 47 | self.runtime = spec.get("container", {}).get("runtime", "function") |
| 46 | 48 | |
| 47 | 49 | # name -> mount path. Modal Volumes, mounted while the container runs |
| @@ -57,9 +59,10 @@ class Container: | ||
| 57 | 59 | # plain HTTP to your process" -- so the thing listening inside is an |
| 58 | 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, | |
| 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. | |
| 63 | 66 | self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])] |
| 64 | 67 | |
| 65 | 68 | # Modal re-imports this module inside the container, so everything |
| @@ -95,17 +98,22 @@ class Container: | ||
| 95 | 98 | |
| 96 | 99 | def _validate(self): |
| 97 | 100 | c = self.spec.get("container", {}) |
| 98 | - if self.runtime not in ("function", "sandbox"): | |
| 101 | + if self.runtime not in ("function", "sandbox", "web"): | |
| 99 | 102 | raise SpecError( |
| 100 | - f'[container] runtime must be "function" or "sandbox",' | |
| 103 | + f'[container] runtime must be "function", "sandbox" or "web",' | |
| 101 | 104 | f" not {self.runtime!r}" |
| 102 | 105 | ) |
| 103 | 106 | if bool(c.get("base")) == bool(c.get("registry")): |
| 104 | 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 | 109 | 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" | |
| 109 | 117 | ) |
| 110 | 118 | for name, mount in self.volume_spec.items(): |
| 111 | 119 | if not isinstance(mount, str) or not mount.startswith("/"): |
| @@ -135,7 +143,22 @@ class Container: | ||
| 135 | 143 | if c.get("base"): |
| 136 | 144 | image = modal.Image.from_name(c["base"]) |
| 137 | 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 | 163 | # copy=True throughout: later run_commands need these files present. |
| 141 | 164 | # `context` is what include paths are relative to, and it may sit above |
| @@ -168,7 +191,8 @@ class Container: | ||
| 168 | 191 | # builds into a volume of its own. Patterns are relative to the copied |
| 169 | 192 | # directory, as in .dockerignore. |
| 170 | 193 | ignore = list(build.get("ignore", [])) |
| 171 | - for rel in build.get("include", ["."]): | |
| 194 | + includes = build.get("include", ["."]) | |
| 195 | + for rel in includes: | |
| 172 | 196 | src = os.path.normpath(os.path.join(context, rel)) |
| 173 | 197 | dest = self.workdir if rel == "." else f"{self.workdir}/{rel}" |
| 174 | 198 | if os.path.isdir(src): |
| @@ -181,7 +205,12 @@ class Container: | ||
| 181 | 205 | # which points at nothing out here, so any tool that follows it fails |
| 182 | 206 | # in a way that has nothing to do with what it was asked to do. |
| 183 | 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 | 215 | if commands := build.get("commands", []): |
| 187 | 216 | # Volumes mounted for the build too, not just the run, so a step |
| @@ -233,10 +262,15 @@ class Container: | ||
| 233 | 262 | kwargs["memory"] = int(r["memory"]) |
| 234 | 263 | if r.get("gpu"): |
| 235 | 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 | 270 | # Deliberately NOT self.experimental_options: that fills in the |
| 237 | 271 | # sandbox default, and vm_runtime on a Function is refused by the |
| 238 | 272 | # server. A sandbox container's @app.function is vestigial anyway. |
| 239 | - if self.runtime == "function": | |
| 273 | + if self.runtime != "sandbox": | |
| 240 | 274 | if experimental := dict(self.spec.get("experimental", {})): |
| 241 | 275 | kwargs["experimental_options"] = experimental |
| 242 | 276 | if volumes := self.volumes: |
| @@ -283,6 +317,49 @@ class Container: | ||
| 283 | 317 | raise SpecError("container.toml has no [run] command") |
| 284 | 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 | 363 | def run_sandbox(self, override: str = "") -> str: |
| 287 | 364 | """Run one command in a Sandbox that dies when the command does. |
| 288 | 365 | |
| @@ -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 real | 43 | + # "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 runs | 49 | # 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 an | 59 | # 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 that | 63 | + # 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 everything | 68 | # 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 above | 164 | # `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 copied | 191 | # 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 fails | 205 | # 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 step | 216 | # 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 the | 270 | # Deliberately NOT self.experimental_options: that fills in the |
| 237 | # sandbox default, and vm_runtime on a Function is refused by the | 271 | # 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"] = experimental | 275 | 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 | ||
| 28 | 28 | nothing until the Sandbox starts — the image build streams to the client and |
| 29 | 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 | 38 | The containers run as Modal **Sandboxes on a real VM** rather than under |
| 32 | 39 | gVisor: a real kernel, a working pty, and memory that is exactly what |
| 33 | 40 | `[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 and | 28 | 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 under | 38 | 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 what | 39 | 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 @@ | ||
| 14 | 14 | # just run TARGET apk desktop web ui app |
| 15 | 15 | # just test SUITE all nim dart common live |
| 16 | 16 | # just modal CONTAINER dev |
| 17 | +# just deploy web the CI-built image, to a Modal URL | |
| 17 | 18 | # just serve [PORT] the Modal-built web bundle, on localhost |
| 18 | 19 | # just tools ... the toolchain itself |
| 19 | 20 | |
| @@ -137,6 +138,27 @@ modal container="dev" *args: | ||
| 137 | 138 | shift || true |
| 138 | 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 | 162 | # The same core, compiled to JavaScript. |
| 141 | 163 | # |
| 142 | 164 | # `--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 app | 14 | # just run TARGET apk desktop web ui app |
| 15 | # just test SUITE all nim dart common live | 15 | # just test SUITE all nim dart common live |
| 16 | # just modal CONTAINER dev | 16 | # 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 localhost | 18 | # just serve [PORT] the Modal-built web bundle, on localhost |
| 18 | # just tools ... the toolchain itself | 19 | # just tools ... the toolchain itself |
| 19 | 20 | ||
| @@ -137,6 +138,27 @@ modal container="dev" *args: | |||
| 137 | shift || true | 138 | 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: every | 164 | # `--path:src --path:web`, in that order, because the later path wins: every |