max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
16 files changed,
+654 insertions,
-166 deletions
| @@ -203,15 +203,27 @@ | |||
| 203 | 203 | ||
| 204 | 204 | ## Deployment | |
| 205 | 205 | ||
| 206 | - | MT builds natively on Astra (aarch64). Deploy from `multithreaded/`: | |
| 206 | + | Full procedure: `deploy/README.md`. | |
| 207 | + | ||
| 208 | + | Production is not deployed from here. MT ships as a Sando companion of the MNW | |
| 209 | + | server: same worktree, same sha, same release bundle, installed on testnot-1 and | |
| 210 | + | prod-1 after the server's swap. A normal MNW deploy carries it, and it inherits | |
| 211 | + | the whole gate ladder (native build, migration dry run against mt's own dump, | |
| 212 | + | tests, burn-in, node health). `deploy/deploy-hetzner.sh`, which cross-compiled on | |
| 213 | + | macOS and scp'd to `root@`, is gone. | |
| 214 | + | ||
| 215 | + | The astra instance (aarch64) still has its own script, because Sando builds only | |
| 216 | + | on x86_64 fw13: | |
| 207 | 217 | ||
| 208 | 218 | ```bash | |
| 209 | 219 | ./deploy/deploy.sh # rsync source + shared deps, build on Astra, deploy binary + assets | |
| 210 | 220 | ``` | |
| 211 | 221 | ||
| 212 | - | The deploy script: | |
| 213 | 222 | 1. Rsyncs source to `~/src/multithreaded/` on Astra | |
| 214 | - | 2. Rsyncs shared deps (docengine, tagtree, s3-storage) to `~/src/shared/` | |
| 223 | + | 2. Rsyncs shared deps (tagtree, s3-storage) to `~/src/shared/` | |
| 215 | 224 | 3. Builds release binary on Astra | |
| 216 | - | 4. Copies binary + static + migrations to `/opt/multithreaded/` | |
| 225 | + | 4. Copies binary + migrations to `/opt/multithreaded/` | |
| 217 | 226 | 5. Restarts the systemd service | |
| 227 | + | ||
| 228 | + | Static assets are not copied by either path — they are compiled into the binary | |
| 229 | + | (`src/static_assets.rs`), so editing CSS or TypeScript needs a rebuild. |
| @@ -1910,6 +1910,25 @@ | |||
| 1910 | 1910 | "icu_properties", | |
| 1911 | 1911 | ] | |
| 1912 | 1912 | ||
| 1913 | + | [[package]] | |
| 1914 | + | name = "include_dir" | |
| 1915 | + | version = "0.7.4" | |
| 1916 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1917 | + | checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" | |
| 1918 | + | dependencies = [ | |
| 1919 | + | "include_dir_macros", | |
| 1920 | + | ] | |
| 1921 | + | ||
| 1922 | + | [[package]] | |
| 1923 | + | name = "include_dir_macros" | |
| 1924 | + | version = "0.7.4" | |
| 1925 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1926 | + | checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" | |
| 1927 | + | dependencies = [ | |
| 1928 | + | "proc-macro2", | |
| 1929 | + | "quote", | |
| 1930 | + | ] | |
| 1931 | + | ||
| 1913 | 1932 | [[package]] | |
| 1914 | 1933 | name = "indexmap" | |
| 1915 | 1934 | version = "2.13.0" | |
| @@ -2236,6 +2255,7 @@ | |||
| 2236 | 2255 | "hex", | |
| 2237 | 2256 | "hmac 0.13.0", | |
| 2238 | 2257 | "http-body-util", | |
| 2258 | + | "include_dir", | |
| 2239 | 2259 | "mt-core", | |
| 2240 | 2260 | "mt-db", | |
| 2241 | 2261 | "pom-contract", |
| @@ -100,6 +100,7 @@ | |||
| 100 | 100 | regex-lite = "0.1" | |
| 101 | 101 | urlencoding = "2" | |
| 102 | 102 | time = "0.3" | |
| 103 | + | include_dir = "0.7.4" | |
| 103 | 104 | ||
| 104 | 105 | [dev-dependencies] | |
| 105 | 106 | http-body-util = "0.1" |
| @@ -47,6 +47,20 @@ | |||
| 47 | 47 | /// The generated partials are gitignored build output. They are written only | |
| 48 | 48 | /// when their content changes, so a no-op build does not retrigger Askama. | |
| 49 | 49 | fn fingerprint_assets() { | |
| 50 | + | // These watches now do double duty. They have always decided when the `?v=` | |
| 51 | + | // hash is recomputed; since `static/` is compiled into the binary | |
| 52 | + | // (src/static_assets.rs), they also decide when the embed is refreshed. An | |
| 53 | + | // asset outside this set is an asset that can go stale in the binary. | |
| 54 | + | // | |
| 55 | + | // `static/fonts` is watched as a directory — cargo walks it — and is safe to | |
| 56 | + | // watch because nothing in the build writes there. | |
| 57 | + | // | |
| 58 | + | // `static/dist` is deliberately NOT watched: `build_frontend` rewrites it on | |
| 59 | + | // every run, so watching it would rerun this script (and npm) on every | |
| 60 | + | // build. It is covered transitively instead, by the `frontend/src` watch in | |
| 61 | + | // `build_frontend` — a build-script rerun recompiles the crate, which is | |
| 62 | + | // when `include_dir!` re-reads the emitted bundles. | |
| 63 | + | println!("cargo::rerun-if-changed=static/fonts"); | |
| 50 | 64 | let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.js"]; | |
| 51 | 65 | ||
| 52 | 66 | let mut hasher = DefaultHasher::new(); |
| @@ -145,6 +145,16 @@ | |||
| 145 | 145 | # binary can satisfy between restarts. The node binds HOST=127.0.0.1 PORT=8080 | |
| 146 | 146 | # (/etc/mnw/makenotwork.env); verified serving 200 on 2026-07-21. | |
| 147 | 147 | health_url = "http://127.0.0.1:8080/health" | |
| 148 | + | # Companion: multithreaded (the forum) is built from this same sha and installed | |
| 149 | + | # after the server is up. It is on testnot FIRST, unlike mnw-cli, because this is | |
| 150 | + | # the tier where the 48h burn-in runs — a forum that fails to boot on the new | |
| 151 | + | # binary should fail here, not on prod. Needs the node-side wrapper + sudoers | |
| 152 | + | # grant (deploy/install-companion.sh, deploy/mnw-companion.sudoers) and an | |
| 153 | + | # existing multithreaded.service; see multithreaded/deploy/README.md. | |
| 154 | + | [[tier.node.companion]] | |
| 155 | + | name = "multithreaded" | |
| 156 | + | install_path = "/opt/multithreaded/multithreaded" | |
| 157 | + | service_name = "multithreaded.service" | |
| 148 | 158 | ||
| 149 | 159 | # ---- B: prod-1 ---- | |
| 150 | 160 | [[tier]] | |
| @@ -183,6 +193,16 @@ | |||
| 183 | 193 | name = "mnw-cli" | |
| 184 | 194 | install_path = "/opt/mnw-cli/mnw-cli" | |
| 185 | 195 | service_name = "mnw-cli.service" | |
| 196 | + | # Companion: multithreaded, same sha as the server, same host. This is what | |
| 197 | + | # retires multithreaded/deploy/deploy-hetzner.sh (cross-compiled on macOS, scp'd | |
| 198 | + | # to root@, 41 commits behind when found). mt now rides the whole ladder — native | |
| 199 | + | # build on fw13, the migration_dry_run gate against its own dump, burn-in on | |
| 200 | + | # testnot, node_health — instead of a laptop and an scp. Design: | |
| 201 | + | # wiki sando-mt-pom-pipelines. | |
| 202 | + | [[tier.node.companion]] | |
| 203 | + | name = "multithreaded" | |
| 204 | + | install_path = "/opt/multithreaded/multithreaded" | |
| 205 | + | service_name = "multithreaded.service" | |
| 186 | 206 | ||
| 187 | 207 | # ---- C: prod-2 (declared, not yet provisioned) ---- | |
| 188 | 208 | [[tier]] |
| @@ -56,13 +56,15 @@ | |||
| 56 | 56 | echo "[build] Done" | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | # static/ is deliberately not copied: it is compiled into the binary | |
| 60 | + | # (src/static_assets.rs), so the binary IS the assets. migrations/ is copied for | |
| 61 | + | # operator convenience only — sqlx::migrate!() embeds them at compile time too. | |
| 59 | 62 | deploy_files() { | |
| 60 | - | echo "[deploy] Stopping service + copying binary + assets to $REMOTE_DIR..." | |
| 63 | + | echo "[deploy] Stopping service + copying binary to $REMOTE_DIR..." | |
| 61 | 64 | ssh $SERVER " | |
| 62 | 65 | sudo systemctl stop multithreaded || true | |
| 63 | 66 | sudo cp ~/$SRC_DIR/target/release/$BINARY_NAME $REMOTE_DIR/$BINARY_NAME | |
| 64 | 67 | sudo chmod +x $REMOTE_DIR/$BINARY_NAME | |
| 65 | - | sudo rsync -a --delete ~/$SRC_DIR/static/ $REMOTE_DIR/static/ | |
| 66 | 68 | sudo rsync -a --delete ~/$SRC_DIR/migrations/ $REMOTE_DIR/migrations/ | |
| 67 | 69 | sudo chown -R multithreaded:multithreaded $REMOTE_DIR | |
| 68 | 70 | " |
| @@ -205,4 +205,4 @@ | |||
| 205 | 205 | - `src/csrf.rs`: CSRF token generation, middleware, constant-time comparison | |
| 206 | 206 | - `src/internal_auth.rs`: HMAC-SHA256 auth for MNW-to-MT internal API (separate from OAuth) | |
| 207 | 207 | - `src/main.rs`: session store setup, session layer config, middleware stack | |
| 208 | - | - `deploy/deploy-hetzner.sh`, `deploy/deploy.sh`: deploy scripts (production env vars are provisioned on-server, not committed to the repo) | |
| 208 | + | - `deploy/README.md`, `deploy/deploy.sh`: deploy procedure (production env vars are provisioned on-server, not committed to the repo) |
| @@ -15,6 +15,7 @@ | |||
| 15 | 15 | pub mod maintenance; | |
| 16 | 16 | pub mod routes; | |
| 17 | 17 | pub mod seed; | |
| 18 | + | pub mod static_assets; | |
| 18 | 19 | pub mod storage; | |
| 19 | 20 | pub mod templates; | |
| 20 | 21 | pub mod trusted_proxy; |
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | use multithreaded::{AppState, config::Config, csrf}; | |
| 2 | 2 | use sqlx::postgres::PgPoolOptions; | |
| 3 | 3 | use tokio::net::TcpListener; | |
| 4 | - | use tower_http::services::ServeDir; | |
| 5 | 4 | use tower_sessions::ExpiredDeletion; | |
| 6 | 5 | use tower_sessions::SessionManagerLayer; | |
| 7 | 6 | use tower_sessions::cookie::SameSite; | |
| @@ -140,7 +139,14 @@ | |||
| 140 | 139 | let app = forum | |
| 141 | 140 | // Internal API routes, HMAC auth only, no CSRF/session middleware | |
| 142 | 141 | .merge(multithreaded::routes::internal::internal_routes(state)) | |
| 143 | - | .nest_service("/static", ServeDir::new("static")) | |
| 142 | + | // Assets are compiled into the binary, not read from a `static/` dir | |
| 143 | + | // beside it, so mt ships as the single file Sando's companion mechanism | |
| 144 | + | // installs and its markup can never disagree with its stylesheet. See | |
| 145 | + | // multithreaded::static_assets. | |
| 146 | + | .route( | |
| 147 | + | "/static/{*path}", | |
| 148 | + | axum::routing::get(multithreaded::static_assets::serve), | |
| 149 | + | ) | |
| 144 | 150 | .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( | |
| 145 | 151 | axum::http::header::CONTENT_SECURITY_POLICY, | |
| 146 | 152 | axum::http::HeaderValue::from_static( |
| @@ -70,6 +70,25 @@ | |||
| 70 | 70 | manifest_dir = "mnw-cli" | |
| 71 | 71 | bin = "mnw-cli" | |
| 72 | 72 | ||
| 73 | + | # multithreaded (forums.makenot.work) runs on alpha-west-1, the same host as the | |
| 74 | + | # server, x86_64, so the build host can compile it and the never-cross-compile | |
| 75 | + | # rule is satisfied. Its own deploy path was multithreaded/deploy/deploy-hetzner.sh, | |
| 76 | + | # which cross-compiled on macOS via cargo zigbuild and scp'd to root@ — against | |
| 77 | + | # that rule, and 41 commits behind by the time it was found (2026-07-30). | |
| 78 | + | # | |
| 79 | + | # mt is a single file to install because it embeds its own `static/` tree | |
| 80 | + | # (multithreaded/src/static_assets.rs). It used to serve those assets off disk | |
| 81 | + | # beside the binary, which the one-file companion mechanism cannot ship. | |
| 82 | + | # | |
| 83 | + | # Note this is a companion and NOT an entry in `bin_names`: bin_names lists the | |
| 84 | + | # bin targets of the SERVER crate, resolved under its own target/release, so | |
| 85 | + | # naming multithreaded there would fail the build looking for a binary the | |
| 86 | + | # server never produces. Wiki sando-mt-pom-pipelines had it wrong. | |
| 87 | + | [[companion]] | |
| 88 | + | name = "multithreaded" | |
| 89 | + | manifest_dir = "multithreaded" | |
| 90 | + | bin = "multithreaded" | |
| 91 | + | ||
| 73 | 92 | # Crates the cargo_test gate runs, in order. This list used to be hardcoded to | |
| 74 | 93 | # `server`, so every other crate in the repo shipped ungated — including | |
| 75 | 94 | # mnw-cli, which is BUILT as a companion and installed onto prod-1 in the same |
| @@ -71,6 +71,104 @@ | |||
| 71 | 71 | [[frontend_build]] | |
| 72 | 72 | dir = "multithreaded/frontend" | |
| 73 | 73 | ||
| 74 | + | # Crates built from the same worktree/sha as the server and staged into the | |
| 75 | + | # release bundle as `companions/<name>`, so a service that shares the server's | |
| 76 | + | # contract cannot drift out of lockstep. This list only says what to BUILD and | |
| 77 | + | # stage; which nodes install which companion is per-node in the topology (see | |
| 78 | + | # the [[tier.node.companion]] blocks in sando.toml). | |
| 79 | + | # | |
| 80 | + | # mnw-cli is the public git-SSH server that proxies to /api/internal/* — a | |
| 81 | + | # two-month drift there is what broke git hosting during the 0.10.14 deploy. | |
| 82 | + | [[companion]] | |
| 83 | + | name = "mnw-cli" | |
| 84 | + | manifest_dir = "mnw-cli" | |
| 85 | + | bin = "mnw-cli" | |
| 86 | + | ||
| 87 | + | # multithreaded (forums.makenot.work) runs on alpha-west-1, the same host as the | |
| 88 | + | # server and the same architecture, so the build host can compile it. Replaces | |
| 89 | + | # multithreaded/deploy/deploy-hetzner.sh, which cross-compiled on macOS via | |
| 90 | + | # cargo zigbuild and scp'd to root@ — against the native-build rule, and the | |
| 91 | + | # real deploy path until 2026-08-02. | |
| 92 | + | # | |
| 93 | + | # One file to install: mt embeds its own static/ tree | |
| 94 | + | # (multithreaded/src/static_assets.rs) rather than serving it from a directory | |
| 95 | + | # beside the binary, which is what makes it fit a mechanism that ships exactly | |
| 96 | + | # one file per companion. | |
| 97 | + | # | |
| 98 | + | # NOT a `bin_names` entry — that list is the SERVER crate's bin targets. | |
| 99 | + | [[companion]] | |
| 100 | + | name = "multithreaded" | |
| 101 | + | manifest_dir = "multithreaded" | |
| 102 | + | bin = "multithreaded" | |
| 103 | + | ||
| 104 | + | # Crates the cargo_test gate runs, in order; the gate stops at the first red one. | |
| 105 | + | # This list used to be absent here, which means the default applied — a single | |
| 106 | + | # `server` entry — so everything else in the repo shipped ungated, the companions | |
| 107 | + | # installed onto prod-1 included. A target with no Cargo.toml in the sha being | |
| 108 | + | # built is skipped with a warning rather than failing, so this list can describe | |
| 109 | + | # the tip and still bisect. | |
| 110 | + | # | |
| 111 | + | # scratch_db exports DATABASE_URL/TEST_DATABASE_URL. Only crates whose sqlx | |
| 112 | + | # macros type-check against a live DB need it; the rest ship offline `.sqlx` data | |
| 113 | + | # and are left in offline mode. | |
| 114 | + | [[test_target]] | |
| 115 | + | dir = "server" | |
| 116 | + | features = ["fast-tests"] | |
| 117 | + | scratch_db = true | |
| 118 | + | ||
| 119 | + | # Companion: ships to prod-1, so it is gated first among the rest. | |
| 120 | + | [[test_target]] | |
| 121 | + | dir = "mnw-cli" | |
| 122 | + | ||
| 123 | + | # Companion: ships to testnot-1 and prod-1 (see sando.toml). | |
| 124 | + | [[test_target]] | |
| 125 | + | dir = "multithreaded" | |
| 126 | + | scratch_db = true | |
| 127 | + | ||
| 128 | + | [[test_target]] | |
| 129 | + | dir = "pom" | |
| 130 | + | ||
| 131 | + | [[test_target]] | |
| 132 | + | dir = "wam" | |
| 133 | + | ||
| 134 | + | # shared/* — every app and service in the repo links these. | |
| 135 | + | # | |
| 136 | + | # No `shared/docengine` entry: docengine left the repo for Libraries/docengine on | |
| 137 | + | # 2026-07-30 and is consumed as a path dep resolved through the [[aux_repo]] | |
| 138 | + | # checkout, which is beside the worktree rather than under it. test_target dirs | |
| 139 | + | # are worktree-relative, so it is not addressable from here. (The dev config at | |
| 140 | + | # daemon/sando-daemon.toml still lists it; that entry is a warn-and-skip no-op.) | |
| 141 | + | [[test_target]] | |
| 142 | + | dir = "shared/egui-updater" | |
| 143 | + | ||
| 144 | + | [[test_target]] | |
| 145 | + | dir = "shared/kberg" | |
| 146 | + | all_features = true | |
| 147 | + | ||
| 148 | + | [[test_target]] | |
| 149 | + | dir = "shared/livechat" | |
| 150 | + | all_features = true | |
| 151 | + | ||
| 152 | + | [[test_target]] | |
| 153 | + | dir = "shared/ops-core" | |
| 154 | + | ||
| 155 | + | [[test_target]] | |
| 156 | + | dir = "shared/ops-exec" | |
| 157 | + | all_features = true | |
| 158 | + | ||
| 159 | + | [[test_target]] | |
| 160 | + | dir = "shared/pom-contract" | |
| 161 | + | ||
| 162 | + | [[test_target]] | |
| 163 | + | dir = "shared/s3-storage" | |
| 164 | + | ||
| 165 | + | [[test_target]] | |
| 166 | + | dir = "shared/synckit-client" | |
| 167 | + | all_features = true | |
| 168 | + | ||
| 169 | + | [[test_target]] | |
| 170 | + | dir = "shared/tagtree" | |
| 171 | + | ||
| 74 | 172 | # Non-binary content shipped as part of each release. Multiple entries can | |
| 75 | 173 | # target the same `dst` (additive merge — used to build `docs/` from three | |
| 76 | 174 | # worktree sources). Sources are relative to the worktree root; dsts are |
| @@ -50,6 +50,7 @@ | |||
| 50 | 50 | let topo = topology::Topology::load(&cfg.topology_path)?; | |
| 51 | 51 | topo.ensure_build_host_not_serving(&cfg.build_host)?; | |
| 52 | 52 | topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?; | |
| 53 | + | topo.ensure_node_companions_are_built(&cfg.companions)?; | |
| 53 | 54 | Ok(topo) | |
| 54 | 55 | } | |
| 55 | 56 | ||
| @@ -73,6 +74,7 @@ | |||
| 73 | 74 | let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?); | |
| 74 | 75 | topo.ensure_build_host_not_serving(&cfg.build_host)?; | |
| 75 | 76 | topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?; | |
| 77 | + | topo.ensure_node_companions_are_built(&cfg.companions)?; | |
| 76 | 78 | tokio::fs::create_dir_all(&cfg.workdir).await?; | |
| 77 | 79 | tokio::fs::create_dir_all(&cfg.release_root).await?; | |
| 78 | 80 | git::ensure_bare_repo(Path::new(&topo.repo.bare_path)).await?; | |
| @@ -180,18 +182,22 @@ | |||
| 180 | 182 | // binary must pass against the topology this build ships with — the | |
| 181 | 183 | // regression guard for the 2026-07-09 brick, where a binary that could | |
| 182 | 184 | // not parse the live node_health config was installed and crash-looped. | |
| 183 | - | // (`Config::for_tests` is lib-test-only and unavailable here, so build | |
| 184 | - | // the config by deserializing, pointing it at the shipped sando.toml.) | |
| 185 | - | let topo_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sando.toml"); | |
| 186 | - | let raw = format!( | |
| 187 | - | "listen = \"127.0.0.1:7766\"\n\ | |
| 188 | - | db_path = \"./sando.db\"\n\ | |
| 189 | - | topology_path = {topo_path:?}\n\ | |
| 190 | - | build_host = \"fw13\"\n\ | |
| 191 | - | workdir = \"./work\"\n\ | |
| 192 | - | release_root = \"./releases\"\n" | |
| 193 | - | ); | |
| 194 | - | let cfg: super::config::Config = toml::from_str(&raw).expect("build fixture daemon config"); | |
| 185 | + | // Both halves of the pair are the shipped ones. This used to hand-roll a | |
| 186 | + | // minimal daemon config, which meant the production daemon config was | |
| 187 | + | // never validated by anything and the cross-file checks had nothing real | |
| 188 | + | // to check: the fixture declared no companions while the shipped | |
| 189 | + | // topology installs two, so `ensure_node_companions_are_built` would | |
| 190 | + | // have passed on a fixture and failed on the box. | |
| 191 | + | // | |
| 192 | + | // Only `topology_path` is overridden, because the shipped value is the | |
| 193 | + | // installed location (/etc/sando/sando.toml) and the repo copy is what | |
| 194 | + | // this build ships. | |
| 195 | + | let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); | |
| 196 | + | let raw = std::fs::read_to_string(root.join("../deploy/sando-daemon.toml.example")) | |
| 197 | + | .expect("reading the shipped production daemon config"); | |
| 198 | + | let mut cfg: super::config::Config = | |
| 199 | + | toml::from_str(&raw).expect("shipped daemon config must parse"); | |
| 200 | + | cfg.topology_path = root.join("../sando.toml"); | |
| 195 | 201 | validate_loaded(&cfg).expect("shipped config must pass --check-config"); | |
| 196 | 202 | } | |
| 197 | 203 |