| A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago | 1 | [container] |
| 2 | name = "frq-flutter-web" |
| 3 | description = "the Flutter web build, incremental, in a nix devShell" |
| 4 | base = "arch-nix" |
| 5 | # A Sandbox, not a Function: it runs on a real VM, the command is the |
| 6 | # sandbox's own process so it dies when the command does, and only a Sandbox |
| 7 | # can hold open a tunnel -- which is the whole of `serve`. |
| 8 | runtime = "sandbox" |
| 9 | |
| 10 | [build] |
| 11 | # The container lives inside the repo it builds, so the copy is rooted two |
| 12 | # levels up and `.` is the whole tree. |
| 13 | context = "../.." |
| 14 | include = ["."] |
| 15 | # The devShell, baked in rather than entered -- `flutter-dev`'s trick and for |
| 16 | # its reasons. `print-dev-env` writes the whole environment out as shell and |
| 17 | # realises its inputs on the way, so the closure becomes an image layer |
| 18 | # instead of a fetch every container pays for; sourcing it from .bashrc means |
| 19 | # a shell attached to this container *is* the devShell. |
| 20 | # |
| 21 | # `dev` stays for the case where the baked env is stale against a flake edit. |
| 22 | # The toolchain layer, and what it is allowed to depend on. |
| 23 | # |
| 24 | # `warm` is copied before `setup` runs and is deliberately six files: the |
| 25 | # flake and its lock, plus the four `cljd-deps` actually reads -- it does |
| 26 | # `cp ${./common/deps.edn}`, `${./flutter/deps.edn}`, `${./flutter/pubspec.yaml}` |
| 27 | # and `${./flutter/pubspec.lock}` and nothing else. That is the whole of what |
| 28 | # `nix develop .#flutter-web` needs to evaluate, so this layer moves when a |
| 29 | # dependency moves and not when a line of ClojureDart does. |
| 30 | # |
| 31 | # The point of the split: `[build] commands` run after the full source copy, |
| 32 | # so editing `flutter/src/frq/net/web.cljd` used to invalidate them and spend |
| 33 | # minutes re-warming a devShell that had not changed. Here the image is built |
| 34 | # from the toolchain and the program is built in the sandbox, which is where |
| 35 | # it was always going to happen anyway. |
| 36 | warm = [ |
| 37 | "flake.nix", "flake.lock", |
| 38 | "common/deps.edn", |
| 39 | "flutter/deps.edn", "flutter/pubspec.yaml", "flutter/pubspec.lock", |
| 40 | ] |
| 41 | # The devShell, baked in rather than entered. `print-dev-env` writes the whole |
| 42 | # environment out as shell and realises its inputs on the way, so the closure |
| 43 | # becomes an image layer instead of a fetch every container pays for; sourcing |
| 44 | # it from .bashrc means a shell attached to this container *is* the devShell. |
| 45 | # |
| 46 | # Best-effort, and the reason is gVisor. An image build is a Function |
| 47 | # underneath, so it cannot *build* a derivation -- and `print-dev-env` realises |
| 48 | # the shell's inputs, which here includes `flutter-wrapped-…-sdk-links.drv`, |
| 49 | # in no binary cache and therefore built. Under gVisor that dies with |
| 50 | # `unexpected EOF reading a line`, and the ptyshim that papered over it is |
| 51 | # deprecated. Nothing here is load-bearing: [run] enters `nix develop` itself, |
| 52 | # on the VM, where a real pty makes the same build work. |
| 53 | setup = [ |
| 54 | "nix print-dev-env /app#flutter-web --accept-flake-config --extra-substituters file:///nix-cache > /etc/devshell.sh || rm -f /etc/devshell.sh", |
| 55 | "echo '[ -s /etc/devshell.sh ] && . /etc/devshell.sh' >> /root/.bashrc", |
| 56 | "printf '#!/bin/sh\\nexec nix develop /app#flutter-web \"$@\"\\n' > /usr/local/bin/dev && chmod +x /usr/local/bin/dev", |
| 57 | ] |
| 58 | # The build state a local checkout carries, wanted by nothing out here: this |
| 59 | # container builds into a volume of its own, and the jolt and clojure caches |
| 60 | # are the laptop's. |
| 61 | ignore = [ |
| 62 | "flutter/build", "flutter/.home", "flutter/.dart_tool", |
| 63 | "flutter/.clojuredart", "flutter/.cpcache", |
| 64 | ".jolt", ".cpcache", "result", "build", ".git", |
| 65 | ] |
| 66 | |
| 67 | # `nix-cache` is the binary cache every container here reads from and writes |
| 68 | # back to. `devshell` is the working state of a `nix develop` loop, shared by |
| 69 | # every container that has one -- each gets its own directory under it, named |
| 70 | # for the devShell it belongs to, so `flutter-web` and `flutter-desktop` never |
| 71 | # write the same tree even though they share a `.home`-shaped cache layout. |
| 72 | # Modal Volumes have no locking, so those directory names are the only thing |
| 73 | # keeping them apart, and two runs of the *same* devshell must not overlap. |
| 74 | [volumes] |
| 75 | nix-cache = "/nix-cache" |
| 76 | devshell = "/devshell" |
| 77 | |
| 78 | [resources] |
| 79 | cpu = 8 |
| 80 | memory = 16384 |
| 81 | timeout = 3600 |
| 82 | |
| 83 | # The port `just flutter-web serve` listens on, tunnelled out. Nothing is |
| 84 | # served unless the command asks for it -- a plain build exits and the tunnel |
| 85 | # closes with the sandbox -- but the port has to be declared at create time, |
| 86 | # so it is declared once here and `modal run --command` decides whether |
| 87 | # anything ever binds it. |
| 88 | [network] |
| 89 | ports = [8080] |
| 90 | |
| 91 | [run] |
| 92 | workdir = "/app" |
| 93 | # Nix for the dependencies, the ordinary toolchain for the build -- the |
| 94 | # `flutter-dev` argument, unchanged: a derivation is all-or-nothing, so any |
| 95 | # edit under `nix build` is a fresh sandbox and a fresh compile of everything. |
| 96 | # Here the devShell supplies dart2js and the engine artifacts and `flutter |
| 97 | # build web` decides what is stale. |
| 98 | command = """ |
| 99 | set -e |
| 100 | # This container's own directory on the shared devshell volume, named for the |
| 101 | # devShell whose state it keeps. `flutter-desktop` has its own beside it. |
| 102 | SHELL_DIR=/devshell/frq-flutter-web |
| 103 | mkdir -p "$SHELL_DIR" "$SHELL_DIR/.cache" |
| 104 | |
| 105 | # A worktree's `.git` is a *file* naming a gitdir back on the machine that |
| 106 | # copied it in, and nix believes it and goes looking for a path that is not |
| 107 | # here. It has to go before any flake reference to /app. |
| 108 | rm -rf /app/.git |
| 109 | |
| 110 | echo "sync: /app -> $SHELL_DIR" |
| 111 | # `nix shell --command` and not `nix profile install`: a profile install puts |
| 112 | # rsync in ~/.nix-profile/bin, which is not on the PATH of the shell already |
| 113 | # running. |
| 114 | # |
| 115 | # rsync and not cp, with --checksum and not mtimes: Modal copies the source in |
| 116 | # with fresh timestamps every run, so a plain copy looks entirely new to |
| 117 | # Flutter and rebuilds the lot. --checksum compares content and leaves the |
| 118 | # unchanged files' timestamps alone, which is the whole basis of the |
| 119 | # incremental build. |
| 120 | # |
| 121 | # The excludes are the state we are here to keep -- overwriting them from /app |
| 122 | # would defeat the volume. `flutter/web/` is NOT on the list: it is committed |
| 123 | # now, because the OAuth client keeps a script there, so it has to arrive from |
| 124 | # /app like any other source. |
| 125 | nix shell nixpkgs#rsync --accept-flake-config \ |
| 126 | --extra-substituters file:///nix-cache --command \ |
| 127 | rsync -a --checksum --delete \ |
| 128 | --exclude 'flutter/.home/' \ |
| 129 | --exclude 'flutter/.clojuredart/' \ |
| 130 | --exclude 'flutter/build/' \ |
| 131 | --exclude 'flutter/.dart_tool/' \ |
| 132 | --exclude '.git' \ |
| 133 | /app/ "$SHELL_DIR/" |
| 134 | |
| 135 | cd "$SHELL_DIR" |
| 136 | echo "state carried over:" |
| 137 | du -sh flutter/.home flutter/.clojuredart flutter/build 2>/dev/null \ |
| 138 | || echo " (none yet -- first run)" |
| 139 | |
| 140 | # Evaluated from /app and built in the volume, as `flutter-dev` does and for |
| 141 | # the same two reasons: /app is the pristine copy, so nix stores a source tree |
| 142 | # of the repo rather than one carrying gigabytes of flutter/build, while the |
| 143 | # recipe still runs where the state it reuses lives. `just -f` is what puts it |
| 144 | # there, since the recipe cds to its own justfile's directory. |
| 145 | # The baked devShell if there is one, and `nix develop` if there is not. |
| 146 | # |
| 147 | # This is the difference between a two-minute rebuild and a three-minute one. |
| 148 | # `nix develop /app#flutter-web` re-copies the whole repo into the nix store |
| 149 | # and re-evaluates the flake on every run -- the flake's source is the tree, |
| 150 | # so any edit makes it a new source -- and all of that to arrive at an |
| 151 | # environment the image already computed with `print-dev-env` and wrote to |
| 152 | # /etc/devshell.sh. Sourcing it is the same PATH and the same variables with |
| 153 | # no evaluation at all. |
| 154 | # |
| 155 | # The fallback is not decoration: that setup step is best-effort, because |
| 156 | # realising the devShell under gVisor can fail (see [build] setup), and a |
| 157 | # container whose bake did not happen still has to build. |
| 158 | # The devShell environment, computed once and kept on the volume. |
| 159 | # |
| 160 | # `nix develop /app#flutter-web` re-copies the whole repo into the nix store |
| 161 | # and re-evaluates the flake on every run -- the flake's source IS the tree, |
| 162 | # so any edit makes it a new source -- to arrive at an environment that has |
| 163 | # not changed. `print-dev-env` writes that environment out as shell, and |
| 164 | # sourcing it is the same PATH and the same variables with no evaluation. |
| 165 | # |
| 166 | # Here and not in [build] setup, which is where it used to be: an image build |
| 167 | # is a Function under gVisor, where nix cannot realise a derivation, and this |
| 168 | # devShell's closure contains one no cache can answer for |
| 169 | # (flutter-wrapped-...-sdk-links). That bake failed every time and was made |
| 170 | # non-fatal, which meant it silently never happened. On the VM it works, and |
| 171 | # the volume is what makes it worth doing once. |
| 172 | # |
| 173 | # Regenerated when flake.lock's CONTENT changes -- by hash and never by mtime. |
| 174 | # Modal copies /app in with fresh timestamps on every run, so `-nt` says the |
| 175 | # lock is newer every single time and the cache never hits. That is the same |
| 176 | # trap the rsync above documents and works around with --checksum; it catches |
| 177 | # anything here that asks a file when it changed. |
| 178 | # Beside the working tree and NOT inside it: the rsync above runs with |
| 179 | # --delete, so anything under $SHELL_DIR that is not in /app is removed on |
| 180 | # every run. Kept in there, this cache was deleted moments before it was |
| 181 | # consulted, which is why it recomputed every time while claiming to be a |
| 182 | # cache. |
| 183 | ENV_DIR="$SHELL_DIR.env" |
| 184 | mkdir -p "$ENV_DIR" |
| 185 | ENV_SH="$ENV_DIR/devshell.sh" |
| 186 | STAMP="$ENV_DIR/devshell.lock" |
| 187 | WANT="$(sha256sum /app/flake.lock | cut -d' ' -f1)" |
| 188 | if [ ! -s "$ENV_SH" ] || [ "$(cat "$STAMP" 2>/dev/null)" != "$WANT" ]; then |
| 189 | echo "devshell: computing (first run, or flake.lock changed)" |
| 190 | nix print-dev-env /app#flutter-web --accept-flake-config \ |
| 191 | --extra-substituters file:///nix-cache > "$ENV_SH.tmp" |
| 192 | mv "$ENV_SH.tmp" "$ENV_SH" |
| 193 | echo "$WANT" > "$STAMP" |
| 194 | else |
| 195 | echo "devshell: reusing the computed env" |
| 196 | fi |
| 197 | |
| 198 | # A subshell, so what the env sets does not leak into the `nix copy` below -- |
| 199 | # that wants the container's own nix, not the shell's. `set +u` because a |
| 200 | # printed dev env references variables that need not be set. |
| 201 | ( set +u; . "$ENV_SH"; set -u |
| 202 | just -f "$SHELL_DIR/justfile" flutter-web ) |
| 203 | |
| 204 | echo "built:" |
| 205 | du -sh flutter/build/web |
| 206 | |
| 207 | # The devShell's closure is gigabytes of Flutter and Dart, and the store it |
| 208 | # landed in belongs to the image rather than to a volume -- so without this |
| 209 | # every run re-fetches it from upstream. Written back, the next run |
| 210 | # substitutes it from file:///nix-cache instead. |
| 211 | if [ -f /nix-cache/nix-cache-info ]; then |
| 212 | echo "cache: writing the devShell closure back" |
| 213 | nix copy --no-check-sigs --all --to file:///nix-cache |
| 214 | fi |
| 215 | """ |
| 216 | # Nix's own cache, on the volume rather than in the container. Without it |
| 217 | # every Sandbox starts empty and `nix develop` re-clones the flake's git |
| 218 | # inputs, because flake.lock pins which revision to fetch and not whether it |
| 219 | # is already on disk. Set here rather than in the command so an interactive |
| 220 | # shell into this container gets it too. |
| 221 | env = { XDG_CACHE_HOME = "/devshell/frq-flutter-web/.cache" } |
| 222 | |
| 223 | [nix] |
| 224 | # Every nix command in the container reads the mounted cache, including one |
| 225 | # typed by hand in a shell. |
| 226 | substituters = ["file:///nix-cache"] |
| 227 | # No devShell warming at image build time: this enters `nix develop` at run |
| 228 | # time, on the VM, where the cache answers for its closure. The ptyshim that |
| 229 | # warming would need under gVisor is deprecated and does not come back. |
| 230 | flake = false |
| 231 | shim = false |
| 232 | |
| 233 | # [experimental] overrides the sandbox default of vm_runtime = true. |