collin/anvil · f0c2b35b
feat: CI runner + web UI + CD webhook, CSRF hardening, ORM cleanup
Collin Richards · 2026-06-09 21:52 UTC · f0c2b35b5acc0711dabdb54ffdca6cf6297d200a · parent 2b7e7397 · browse files
modifiedCargo.lock+365 −26
| ⋯ 122 unchanged lines | |||
| 123 | 123 | name = "anvil" | |
| 124 | 124 | version = "0.0.0" | |
| 125 | 125 | dependencies = [ | |
| 126 | + | "anvil-ci", | |
| 126 | 127 | "anvil-core", | |
| 127 | 128 | "anvil-ssh", | |
| 128 | 129 | "anvil-web", | |
| ⋯ 5 unchanged lines | |||
| 134 | 135 | ] | |
| 135 | 136 | ||
| 136 | 137 | [[package]] | |
| 138 | + | name = "anvil-ci" | |
| 139 | + | version = "0.0.0" | |
| 140 | + | dependencies = [ | |
| 141 | + | "anvil-core", | |
| 142 | + | "anvil-git", | |
| 143 | + | "bollard", | |
| 144 | + | "futures-util", | |
| 145 | + | "reqwest", | |
| 146 | + | "serde_json", | |
| 147 | + | "tar", | |
| 148 | + | "tokio", | |
| 149 | + | "tracing", | |
| 150 | + | ] | |
| 151 | + | ||
| 152 | + | [[package]] | |
| 137 | 153 | name = "anvil-core" | |
| 138 | 154 | version = "0.0.0" | |
| 139 | 155 | dependencies = [ | |
| 140 | 156 | "argon2 0.5.3", | |
| 141 | 157 | "gix", | |
| 158 | + | "hmac 0.12.1", | |
| 142 | 159 | "serde", | |
| 143 | 160 | "serde_yaml", | |
| 161 | + | "sha2 0.10.9", | |
| 144 | 162 | "ssh-key", | |
| 145 | 163 | "tempfile", | |
| 146 | 164 | "thiserror", | |
| ⋯ 217 unchanged lines | |||
| 364 | 382 | dependencies = [ | |
| 365 | 383 | "blowfish", | |
| 366 | 384 | "pbkdf2", | |
| 367 | - | "sha2", | |
| 385 | + | "sha2 0.11.0", | |
| 368 | 386 | ] | |
| 369 | 387 | ||
| 370 | 388 | [[package]] | |
| ⋯ 101 unchanged lines | |||
| 472 | 490 | ] | |
| 473 | 491 | ||
| 474 | 492 | [[package]] | |
| 493 | + | name = "bollard" | |
| 494 | + | version = "0.18.1" | |
| 495 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 496 | + | checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" | |
| 497 | + | dependencies = [ | |
| 498 | + | "base64", | |
| 499 | + | "bollard-stubs", | |
| 500 | + | "bytes", | |
| 501 | + | "futures-core", | |
| 502 | + | "futures-util", | |
| 503 | + | "hex", | |
| 504 | + | "http", | |
| 505 | + | "http-body-util", | |
| 506 | + | "hyper", | |
| 507 | + | "hyper-named-pipe", | |
| 508 | + | "hyper-util", | |
| 509 | + | "hyperlocal", | |
| 510 | + | "log", | |
| 511 | + | "pin-project-lite", | |
| 512 | + | "serde", | |
| 513 | + | "serde_derive", | |
| 514 | + | "serde_json", | |
| 515 | + | "serde_repr", | |
| 516 | + | "serde_urlencoded", | |
| 517 | + | "thiserror", | |
| 518 | + | "tokio", | |
| 519 | + | "tokio-util", | |
| 520 | + | "tower-service", | |
| 521 | + | "url", | |
| 522 | + | "winapi", | |
| 523 | + | ] | |
| 524 | + | ||
| 525 | + | [[package]] | |
| 526 | + | name = "bollard-stubs" | |
| 527 | + | version = "1.47.1-rc.27.3.1" | |
| 528 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 529 | + | checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" | |
| 530 | + | dependencies = [ | |
| 531 | + | "serde", | |
| 532 | + | "serde_repr", | |
| 533 | + | "serde_with", | |
| 534 | + | ] | |
| 535 | + | ||
| 536 | + | [[package]] | |
| 537 | + | name = "bs58" | |
| 538 | + | version = "0.5.1" | |
| 539 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 540 | + | checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" | |
| 541 | + | dependencies = [ | |
| 542 | + | "tinyvec", | |
| 543 | + | ] | |
| 544 | + | ||
| 545 | + | [[package]] | |
| 475 | 546 | name = "bstr" | |
| 476 | 547 | version = "1.12.1" | |
| 477 | 548 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 81 unchanged lines | |||
| 559 | 630 | "iana-time-zone", | |
| 560 | 631 | "js-sys", | |
| 561 | 632 | "num-traits", | |
| 633 | + | "serde", | |
| 562 | 634 | "wasm-bindgen", | |
| 563 | 635 | "windows-link", | |
| 564 | 636 | ] | |
| ⋯ 359 unchanged lines | |||
| 924 | 996 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" | |
| 925 | 997 | ||
| 926 | 998 | [[package]] | |
| 999 | + | name = "dyn-clone" | |
| 1000 | + | version = "1.0.20" | |
| 1001 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1002 | + | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" | |
| 1003 | + | ||
| 1004 | + | [[package]] | |
| 927 | 1005 | name = "ecdsa" | |
| 928 | 1006 | version = "0.17.0-rc.18" | |
| 929 | 1007 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 28 unchanged lines | |||
| 958 | 1036 | "ed25519", | |
| 959 | 1037 | "rand_core 0.10.1", | |
| 960 | 1038 | "serde", | |
| 961 | - | "sha2", | |
| 1039 | + | "sha2 0.11.0", | |
| 962 | 1040 | "signature", | |
| 963 | 1041 | "subtle", | |
| 964 | 1042 | "zeroize", | |
| ⋯ 1079 unchanged lines | |||
| 2044 | 2122 | ||
| 2045 | 2123 | [[package]] | |
| 2046 | 2124 | name = "hashbrown" | |
| 2125 | + | version = "0.12.3" | |
| 2126 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2127 | + | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" | |
| 2128 | + | ||
| 2129 | + | [[package]] | |
| 2130 | + | name = "hashbrown" | |
| 2047 | 2131 | version = "0.14.5" | |
| 2048 | 2132 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2049 | 2133 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" | |
| ⋯ 78 unchanged lines | |||
| 2128 | 2212 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2129 | 2213 | checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" | |
| 2130 | 2214 | dependencies = [ | |
| 2131 | - | "hmac", | |
| 2215 | + | "hmac 0.13.0", | |
| 2216 | + | ] | |
| 2217 | + | ||
| 2218 | + | [[package]] | |
| 2219 | + | name = "hmac" | |
| 2220 | + | version = "0.12.1" | |
| 2221 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2222 | + | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" | |
| 2223 | + | dependencies = [ | |
| 2224 | + | "digest 0.10.7", | |
| 2132 | 2225 | ] | |
| 2133 | 2226 | ||
| 2134 | 2227 | [[package]] | |
| ⋯ 86 unchanged lines | |||
| 2221 | 2314 | "pin-project-lite", | |
| 2222 | 2315 | "smallvec", | |
| 2223 | 2316 | "tokio", | |
| 2317 | + | "want", | |
| 2224 | 2318 | ] | |
| 2225 | 2319 | ||
| 2226 | 2320 | [[package]] | |
| 2321 | + | name = "hyper-named-pipe" | |
| 2322 | + | version = "0.1.0" | |
| 2323 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2324 | + | checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" | |
| 2325 | + | dependencies = [ | |
| 2326 | + | "hex", | |
| 2327 | + | "hyper", | |
| 2328 | + | "hyper-util", | |
| 2329 | + | "pin-project-lite", | |
| 2330 | + | "tokio", | |
| 2331 | + | "tower-service", | |
| 2332 | + | "winapi", | |
| 2333 | + | ] | |
| 2334 | + | ||
| 2335 | + | [[package]] | |
| 2227 | 2336 | name = "hyper-util" | |
| 2228 | 2337 | version = "0.1.20" | |
| 2229 | 2338 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2230 | 2339 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | |
| 2231 | 2340 | dependencies = [ | |
| 2341 | + | "base64", | |
| 2232 | 2342 | "bytes", | |
| 2343 | + | "futures-channel", | |
| 2344 | + | "futures-util", | |
| 2233 | 2345 | "http", | |
| 2234 | 2346 | "http-body", | |
| 2235 | 2347 | "hyper", | |
| 2348 | + | "ipnet", | |
| 2349 | + | "libc", | |
| 2350 | + | "percent-encoding", | |
| 2236 | 2351 | "pin-project-lite", | |
| 2352 | + | "socket2", | |
| 2237 | 2353 | "tokio", | |
| 2238 | 2354 | "tower-service", | |
| 2355 | + | "tracing", | |
| 2239 | 2356 | ] | |
| 2240 | 2357 | ||
| 2241 | 2358 | [[package]] | |
| 2359 | + | name = "hyperlocal" | |
| 2360 | + | version = "0.9.1" | |
| 2361 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2362 | + | checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" | |
| 2363 | + | dependencies = [ | |
| 2364 | + | "hex", | |
| 2365 | + | "http-body-util", | |
| 2366 | + | "hyper", | |
| 2367 | + | "hyper-util", | |
| 2368 | + | "pin-project-lite", | |
| 2369 | + | "tokio", | |
| 2370 | + | "tower-service", | |
| 2371 | + | ] | |
| 2372 | + | ||
| 2373 | + | [[package]] | |
| 2242 | 2374 | name = "iana-time-zone" | |
| 2243 | 2375 | version = "0.1.65" | |
| 2244 | 2376 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 134 unchanged lines | |||
| 2379 | 2511 | ||
| 2380 | 2512 | [[package]] | |
| 2381 | 2513 | name = "indexmap" | |
| 2514 | + | version = "1.9.3" | |
| 2515 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2516 | + | checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" | |
| 2517 | + | dependencies = [ | |
| 2518 | + | "autocfg", | |
| 2519 | + | "hashbrown 0.12.3", | |
| 2520 | + | "serde", | |
| 2521 | + | ] | |
| 2522 | + | ||
| 2523 | + | [[package]] | |
| 2524 | + | name = "indexmap" | |
| 2382 | 2525 | version = "2.14.0" | |
| 2383 | 2526 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2384 | 2527 | checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" | |
| ⋯ 36 unchanged lines | |||
| 2421 | 2564 | ] | |
| 2422 | 2565 | ||
| 2423 | 2566 | [[package]] | |
| 2567 | + | name = "ipnet" | |
| 2568 | + | version = "2.12.0" | |
| 2569 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2570 | + | checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" | |
| 2571 | + | ||
| 2572 | + | [[package]] | |
| 2424 | 2573 | name = "is_terminal_polyfill" | |
| 2425 | 2574 | version = "1.70.2" | |
| 2426 | 2575 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 380 unchanged lines | |||
| 2807 | 2956 | "elliptic-curve", | |
| 2808 | 2957 | "primefield", | |
| 2809 | 2958 | "primeorder", | |
| 2810 | - | "sha2", | |
| 2959 | + | "sha2 0.11.0", | |
| 2811 | 2960 | ] | |
| 2812 | 2961 | ||
| 2813 | 2962 | [[package]] | |
| ⋯ 7 unchanged lines | |||
| 2821 | 2970 | "fiat-crypto", | |
| 2822 | 2971 | "primefield", | |
| 2823 | 2972 | "primeorder", | |
| 2824 | - | "sha2", | |
| 2973 | + | "sha2 0.11.0", | |
| 2825 | 2974 | ] | |
| 2826 | 2975 | ||
| 2827 | 2976 | [[package]] | |
| ⋯ 7 unchanged lines | |||
| 2835 | 2984 | "elliptic-curve", | |
| 2836 | 2985 | "primefield", | |
| 2837 | 2986 | "primeorder", | |
| 2838 | - | "sha2", | |
| 2987 | + | "sha2 0.11.0", | |
| 2839 | 2988 | ] | |
| 2840 | 2989 | ||
| 2841 | 2990 | [[package]] | |
| ⋯ 9 unchanged lines | |||
| 2851 | 3000 | "futures", | |
| 2852 | 3001 | "log", | |
| 2853 | 3002 | "rand", | |
| 2854 | - | "sha2", | |
| 3003 | + | "sha2 0.11.0", | |
| 2855 | 3004 | "thiserror", | |
| 2856 | 3005 | "tokio", | |
| 2857 | 3006 | "windows", | |
| ⋯ 50 unchanged lines | |||
| 2908 | 3057 | checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" | |
| 2909 | 3058 | dependencies = [ | |
| 2910 | 3059 | "digest 0.11.3", | |
| 2911 | - | "hmac", | |
| 3060 | + | "hmac 0.13.0", | |
| 2912 | 3061 | ] | |
| 2913 | 3062 | ||
| 2914 | 3063 | [[package]] | |
| ⋯ 49 unchanged lines | |||
| 2964 | 3113 | "pbkdf2", | |
| 2965 | 3114 | "rand_core 0.10.1", | |
| 2966 | 3115 | "scrypt", | |
| 2967 | - | "sha2", | |
| 3116 | + | "sha2 0.11.0", | |
| 2968 | 3117 | "spki", | |
| 2969 | 3118 | ] | |
| 2970 | 3119 | ||
| ⋯ 22 unchanged lines | |||
| 2993 | 3142 | checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" | |
| 2994 | 3143 | dependencies = [ | |
| 2995 | 3144 | "base64", | |
| 2996 | - | "indexmap", | |
| 3145 | + | "indexmap 2.14.0", | |
| 2997 | 3146 | "quick-xml", | |
| 2998 | 3147 | "serde", | |
| 2999 | 3148 | "time", | |
| ⋯ 184 unchanged lines | |||
| 3184 | 3333 | ] | |
| 3185 | 3334 | ||
| 3186 | 3335 | [[package]] | |
| 3336 | + | name = "ref-cast" | |
| 3337 | + | version = "1.0.25" | |
| 3338 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3339 | + | checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" | |
| 3340 | + | dependencies = [ | |
| 3341 | + | "ref-cast-impl", | |
| 3342 | + | ] | |
| 3343 | + | ||
| 3344 | + | [[package]] | |
| 3345 | + | name = "ref-cast-impl" | |
| 3346 | + | version = "1.0.25" | |
| 3347 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3348 | + | checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" | |
| 3349 | + | dependencies = [ | |
| 3350 | + | "proc-macro2", | |
| 3351 | + | "quote", | |
| 3352 | + | "syn", | |
| 3353 | + | ] | |
| 3354 | + | ||
| 3355 | + | [[package]] | |
| 3187 | 3356 | name = "regex" | |
| 3188 | 3357 | version = "1.12.4" | |
| 3189 | 3358 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 23 unchanged lines | |||
| 3213 | 3382 | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |
| 3214 | 3383 | ||
| 3215 | 3384 | [[package]] | |
| 3385 | + | name = "reqwest" | |
| 3386 | + | version = "0.12.28" | |
| 3387 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3388 | + | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" | |
| 3389 | + | dependencies = [ | |
| 3390 | + | "base64", | |
| 3391 | + | "bytes", | |
| 3392 | + | "futures-core", | |
| 3393 | + | "http", | |
| 3394 | + | "http-body", | |
| 3395 | + | "http-body-util", | |
| 3396 | + | "hyper", | |
| 3397 | + | "hyper-util", | |
| 3398 | + | "js-sys", | |
| 3399 | + | "log", | |
| 3400 | + | "percent-encoding", | |
| 3401 | + | "pin-project-lite", | |
| 3402 | + | "serde", | |
| 3403 | + | "serde_json", | |
| 3404 | + | "serde_urlencoded", | |
| 3405 | + | "sync_wrapper", | |
| 3406 | + | "tokio", | |
| 3407 | + | "tower", | |
| 3408 | + | "tower-http", | |
| 3409 | + | "tower-service", | |
| 3410 | + | "url", | |
| 3411 | + | "wasm-bindgen", | |
| 3412 | + | "wasm-bindgen-futures", | |
| 3413 | + | "web-sys", | |
| 3414 | + | ] | |
| 3415 | + | ||
| 3416 | + | [[package]] | |
| 3216 | 3417 | name = "rfc6979" | |
| 3217 | 3418 | version = "0.5.0" | |
| 3218 | 3419 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3219 | 3420 | checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3" | |
| 3220 | 3421 | dependencies = [ | |
| 3221 | - | "hmac", | |
| 3422 | + | "hmac 0.13.0", | |
| 3222 | 3423 | "subtle", | |
| 3223 | 3424 | ] | |
| 3224 | 3425 | ||
| ⋯ 24 unchanged lines | |||
| 3249 | 3450 | "pkcs1", | |
| 3250 | 3451 | "pkcs8", | |
| 3251 | 3452 | "rand_core 0.10.1", | |
| 3252 | - | "sha2", | |
| 3453 | + | "sha2 0.11.0", | |
| 3253 | 3454 | "signature", | |
| 3254 | 3455 | "spki", | |
| 3255 | 3456 | "zeroize", | |
| ⋯ 54 unchanged lines | |||
| 3310 | 3511 | "getrandom 0.4.2", | |
| 3311 | 3512 | "ghash", | |
| 3312 | 3513 | "hex-literal", | |
| 3313 | - | "hmac", | |
| 3514 | + | "hmac 0.13.0", | |
| 3314 | 3515 | "inout", | |
| 3315 | 3516 | "internal-russh-num-bigint", | |
| 3316 | 3517 | "keccak", | |
| ⋯ 21 unchanged lines | |||
| 3338 | 3539 | "scrypt", | |
| 3339 | 3540 | "sec1", | |
| 3340 | 3541 | "sha1 0.11.0", | |
| 3341 | - | "sha2", | |
| 3542 | + | "sha2 0.11.0", | |
| 3342 | 3543 | "sha3", | |
| 3343 | 3544 | "signature", | |
| 3344 | 3545 | "spki", | |
| ⋯ 85 unchanged lines | |||
| 3430 | 3631 | ] | |
| 3431 | 3632 | ||
| 3432 | 3633 | [[package]] | |
| 3634 | + | name = "schemars" | |
| 3635 | + | version = "0.9.0" | |
| 3636 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3637 | + | checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" | |
| 3638 | + | dependencies = [ | |
| 3639 | + | "dyn-clone", | |
| 3640 | + | "ref-cast", | |
| 3641 | + | "serde", | |
| 3642 | + | "serde_json", | |
| 3643 | + | ] | |
| 3644 | + | ||
| 3645 | + | [[package]] | |
| 3646 | + | name = "schemars" | |
| 3647 | + | version = "1.2.1" | |
| 3648 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3649 | + | checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" | |
| 3650 | + | dependencies = [ | |
| 3651 | + | "dyn-clone", | |
| 3652 | + | "ref-cast", | |
| 3653 | + | "serde", | |
| 3654 | + | "serde_json", | |
| 3655 | + | ] | |
| 3656 | + | ||
| 3657 | + | [[package]] | |
| 3433 | 3658 | name = "scopeguard" | |
| 3434 | 3659 | version = "1.2.0" | |
| 3435 | 3660 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 8 unchanged lines | |||
| 3444 | 3669 | "cfg-if", | |
| 3445 | 3670 | "pbkdf2", | |
| 3446 | 3671 | "salsa20", | |
| 3447 | - | "sha2", | |
| 3672 | + | "sha2 0.11.0", | |
| 3448 | 3673 | ] | |
| 3449 | 3674 | ||
| 3450 | 3675 | [[package]] | |
| ⋯ 71 unchanged lines | |||
| 3522 | 3747 | ] | |
| 3523 | 3748 | ||
| 3524 | 3749 | [[package]] | |
| 3750 | + | name = "serde_repr" | |
| 3751 | + | version = "0.1.20" | |
| 3752 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3753 | + | checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" | |
| 3754 | + | dependencies = [ | |
| 3755 | + | "proc-macro2", | |
| 3756 | + | "quote", | |
| 3757 | + | "syn", | |
| 3758 | + | ] | |
| 3759 | + | ||
| 3760 | + | [[package]] | |
| 3525 | 3761 | name = "serde_spanned" | |
| 3526 | 3762 | version = "0.6.9" | |
| 3527 | 3763 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 15 unchanged lines | |||
| 3543 | 3779 | ] | |
| 3544 | 3780 | ||
| 3545 | 3781 | [[package]] | |
| 3782 | + | name = "serde_with" | |
| 3783 | + | version = "3.21.0" | |
| 3784 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3785 | + | checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" | |
| 3786 | + | dependencies = [ | |
| 3787 | + | "base64", | |
| 3788 | + | "bs58", | |
| 3789 | + | "chrono", | |
| 3790 | + | "hex", | |
| 3791 | + | "indexmap 1.9.3", | |
| 3792 | + | "indexmap 2.14.0", | |
| 3793 | + | "schemars 0.9.0", | |
| 3794 | + | "schemars 1.2.1", | |
| 3795 | + | "serde_core", | |
| 3796 | + | "serde_json", | |
| 3797 | + | "time", | |
| 3798 | + | ] | |
| 3799 | + | ||
| 3800 | + | [[package]] | |
| 3546 | 3801 | name = "serde_yaml" | |
| 3547 | 3802 | version = "0.9.34+deprecated" | |
| 3548 | 3803 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3549 | 3804 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" | |
| 3550 | 3805 | dependencies = [ | |
| 3551 | - | "indexmap", | |
| 3806 | + | "indexmap 2.14.0", | |
| 3552 | 3807 | "itoa", | |
| 3553 | 3808 | "ryu", | |
| 3554 | 3809 | "serde", | |
| ⋯ 44 unchanged lines | |||
| 3599 | 3854 | ||
| 3600 | 3855 | [[package]] | |
| 3601 | 3856 | name = "sha2" | |
| 3857 | + | version = "0.10.9" | |
| 3858 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3859 | + | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" | |
| 3860 | + | dependencies = [ | |
| 3861 | + | "cfg-if", | |
| 3862 | + | "cpufeatures 0.2.17", | |
| 3863 | + | "digest 0.10.7", | |
| 3864 | + | ] | |
| 3865 | + | ||
| 3866 | + | [[package]] | |
| 3867 | + | name = "sha2" | |
| 3602 | 3868 | version = "0.11.0" | |
| 3603 | 3869 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3604 | 3870 | checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" | |
| ⋯ 156 unchanged lines | |||
| 3761 | 4027 | "ctutils", | |
| 3762 | 4028 | "ed25519-dalek", | |
| 3763 | 4029 | "hex", | |
| 3764 | - | "hmac", | |
| 4030 | + | "hmac 0.13.0", | |
| 3765 | 4031 | "p256", | |
| 3766 | 4032 | "p384", | |
| 3767 | 4033 | "p521", | |
| ⋯ 1 unchanged line | |||
| 3769 | 4035 | "rsa", | |
| 3770 | 4036 | "sec1", | |
| 3771 | 4037 | "sha1 0.11.0", | |
| 3772 | - | "sha2", | |
| 4038 | + | "sha2 0.11.0", | |
| 3773 | 4039 | "signature", | |
| 3774 | 4040 | "ssh-cipher", | |
| 3775 | 4041 | "ssh-encoding", | |
| ⋯ 40 unchanged lines | |||
| 3816 | 4082 | version = "1.0.2" | |
| 3817 | 4083 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3818 | 4084 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | |
| 4085 | + | dependencies = [ | |
| 4086 | + | "futures-core", | |
| 4087 | + | ] | |
| 3819 | 4088 | ||
| 3820 | 4089 | [[package]] | |
| 3821 | 4090 | name = "synstructure" | |
| ⋯ 28 unchanged lines | |||
| 3850 | 4119 | ] | |
| 3851 | 4120 | ||
| 3852 | 4121 | [[package]] | |
| 4122 | + | name = "tar" | |
| 4123 | + | version = "0.4.46" | |
| 4124 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4125 | + | checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" | |
| 4126 | + | dependencies = [ | |
| 4127 | + | "filetime", | |
| 4128 | + | "libc", | |
| 4129 | + | "xattr", | |
| 4130 | + | ] | |
| 4131 | + | ||
| 4132 | + | [[package]] | |
| 3853 | 4133 | name = "tempfile" | |
| 3854 | 4134 | version = "3.27.0" | |
| 3855 | 4135 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 103 unchanged lines | |||
| 3959 | 4239 | "deadpool", | |
| 3960 | 4240 | "hashbrown 0.17.1", | |
| 3961 | 4241 | "index_vec", | |
| 3962 | - | "indexmap", | |
| 4242 | + | "indexmap 2.14.0", | |
| 3963 | 4243 | "inventory", | |
| 3964 | 4244 | "toasty-core", | |
| 3965 | 4245 | "toasty-driver-sqlite", | |
| ⋯ 15 unchanged lines | |||
| 3981 | 4261 | "bit-set 0.10.0", | |
| 3982 | 4262 | "hashbrown 0.17.1", | |
| 3983 | 4263 | "heck", | |
| 3984 | - | "indexmap", | |
| 4264 | + | "indexmap 2.14.0", | |
| 3985 | 4265 | "pluralizer", | |
| 3986 | 4266 | "tokio-stream", | |
| 3987 | 4267 | "uuid", | |
| ⋯ 120 unchanged lines | |||
| 4108 | 4388 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4109 | 4389 | checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" | |
| 4110 | 4390 | dependencies = [ | |
| 4111 | - | "indexmap", | |
| 4391 | + | "indexmap 2.14.0", | |
| 4112 | 4392 | "serde", | |
| 4113 | 4393 | "serde_spanned", | |
| 4114 | 4394 | "toml_datetime", | |
| ⋯ 44 unchanged lines | |||
| 4159 | 4439 | "pin-project-lite", | |
| 4160 | 4440 | "tokio", | |
| 4161 | 4441 | "tokio-util", | |
| 4442 | + | "tower", | |
| 4162 | 4443 | "tower-layer", | |
| 4163 | 4444 | "tower-service", | |
| 4164 | 4445 | "tracing", | |
| 4446 | + | "url", | |
| 4165 | 4447 | ] | |
| 4166 | 4448 | ||
| 4167 | 4449 | [[package]] | |
| ⋯ 71 unchanged lines | |||
| 4239 | 4521 | ] | |
| 4240 | 4522 | ||
| 4241 | 4523 | [[package]] | |
| 4524 | + | name = "try-lock" | |
| 4525 | + | version = "0.2.5" | |
| 4526 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4527 | + | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 4528 | + | ||
| 4529 | + | [[package]] | |
| 4242 | 4530 | name = "typenum" | |
| 4243 | 4531 | version = "1.20.1" | |
| 4244 | 4532 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 128 unchanged lines | |||
| 4373 | 4661 | ] | |
| 4374 | 4662 | ||
| 4375 | 4663 | [[package]] | |
| 4664 | + | name = "want" | |
| 4665 | + | version = "0.3.1" | |
| 4666 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4667 | + | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" | |
| 4668 | + | dependencies = [ | |
| 4669 | + | "try-lock", | |
| 4670 | + | ] | |
| 4671 | + | ||
| 4672 | + | [[package]] | |
| 4376 | 4673 | name = "wasi" | |
| 4377 | 4674 | version = "0.11.1+wasi-snapshot-preview1" | |
| 4378 | 4675 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 89 unchanged lines | |||
| 4468 | 4765 | checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" | |
| 4469 | 4766 | dependencies = [ | |
| 4470 | 4767 | "anyhow", | |
| 4471 | - | "indexmap", | |
| 4768 | + | "indexmap 2.14.0", | |
| 4472 | 4769 | "wasm-encoder", | |
| 4473 | 4770 | "wasmparser", | |
| 4474 | 4771 | ] | |
| ⋯ 6 unchanged lines | |||
| 4481 | 4778 | dependencies = [ | |
| 4482 | 4779 | "bitflags", | |
| 4483 | 4780 | "hashbrown 0.15.5", | |
| 4484 | - | "indexmap", | |
| 4781 | + | "indexmap 2.14.0", | |
| 4485 | 4782 | "semver", | |
| 4486 | 4783 | ] | |
| 4487 | 4784 | ||
| 4488 | 4785 | [[package]] | |
| 4786 | + | name = "web-sys" | |
| 4787 | + | version = "0.3.100" | |
| 4788 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4789 | + | checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" | |
| 4790 | + | dependencies = [ | |
| 4791 | + | "js-sys", | |
| 4792 | + | "wasm-bindgen", | |
| 4793 | + | ] | |
| 4794 | + | ||
| 4795 | + | [[package]] | |
| 4796 | + | name = "winapi" | |
| 4797 | + | version = "0.3.9" | |
| 4798 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4799 | + | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 4800 | + | dependencies = [ | |
| 4801 | + | "winapi-i686-pc-windows-gnu", | |
| 4802 | + | "winapi-x86_64-pc-windows-gnu", | |
| 4803 | + | ] | |
| 4804 | + | ||
| 4805 | + | [[package]] | |
| 4806 | + | name = "winapi-i686-pc-windows-gnu" | |
| 4807 | + | version = "0.4.0" | |
| 4808 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4809 | + | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 4810 | + | ||
| 4811 | + | [[package]] | |
| 4489 | 4812 | name = "winapi-util" | |
| 4490 | 4813 | version = "0.1.11" | |
| 4491 | 4814 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 3 unchanged lines | |||
| 4495 | 4818 | ] | |
| 4496 | 4819 | ||
| 4497 | 4820 | [[package]] | |
| 4821 | + | name = "winapi-x86_64-pc-windows-gnu" | |
| 4822 | + | version = "0.4.0" | |
| 4823 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4824 | + | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 4825 | + | ||
| 4826 | + | [[package]] | |
| 4498 | 4827 | name = "windows" | |
| 4499 | 4828 | version = "0.62.2" | |
| 4500 | 4829 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 228 unchanged lines | |||
| 4729 | 5058 | dependencies = [ | |
| 4730 | 5059 | "anyhow", | |
| 4731 | 5060 | "heck", | |
| 4732 | - | "indexmap", | |
| 5061 | + | "indexmap 2.14.0", | |
| 4733 | 5062 | "prettyplease", | |
| 4734 | 5063 | "syn", | |
| 4735 | 5064 | "wasm-metadata", | |
| ⋯ 24 unchanged lines | |||
| 4760 | 5089 | dependencies = [ | |
| 4761 | 5090 | "anyhow", | |
| 4762 | 5091 | "bitflags", | |
| 4763 | - | "indexmap", | |
| 5092 | + | "indexmap 2.14.0", | |
| 4764 | 5093 | "log", | |
| 4765 | 5094 | "serde", | |
| 4766 | 5095 | "serde_derive", | |
| ⋯ 12 unchanged lines | |||
| 4779 | 5108 | dependencies = [ | |
| 4780 | 5109 | "anyhow", | |
| 4781 | 5110 | "id-arena", | |
| 4782 | - | "indexmap", | |
| 5111 | + | "indexmap 2.14.0", | |
| 4783 | 5112 | "log", | |
| 4784 | 5113 | "semver", | |
| 4785 | 5114 | "serde", | |
| ⋯ 10 unchanged lines | |||
| 4796 | 5125 | checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" | |
| 4797 | 5126 | ||
| 4798 | 5127 | [[package]] | |
| 5128 | + | name = "xattr" | |
| 5129 | + | version = "1.6.1" | |
| 5130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5131 | + | checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" | |
| 5132 | + | dependencies = [ | |
| 5133 | + | "libc", | |
| 5134 | + | "rustix", | |
| 5135 | + | ] | |
| 5136 | + | ||
| 5137 | + | [[package]] | |
| 4799 | 5138 | name = "yaml-rust" | |
| 4800 | 5139 | version = "0.4.5" | |
| 4801 | 5140 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 99 unchanged lines | |||
modifiedCargo.toml+11 −0
| ⋯ 14 unchanged lines | |||
| 15 | 15 | [workspace.dependencies] | |
| 16 | 16 | # Internal crates | |
| 17 | 17 | anvil-core = { path = "crates/anvil-core" } | |
| 18 | + | anvil-ci = { path = "crates/anvil-ci" } | |
| 18 | 19 | anvil-git = { path = "crates/anvil-git" } | |
| 19 | 20 | anvil-web = { path = "crates/anvil-web" } | |
| 20 | 21 | anvil-ssh = { path = "crates/anvil-ssh" } | |
| ⋯ 3 unchanged lines | |||
| 24 | 25 | axum = "0.8" | |
| 25 | 26 | axum-extra = { version = "0.10", features = ["cookie"] } | |
| 26 | 27 | base64 = "0.22" | |
| 28 | + | bollard = "0.18" | |
| 27 | 29 | clap = { version = "4", features = ["derive"] } | |
| 30 | + | futures-util = "0.3" | |
| 31 | + | hmac = "0.12" | |
| 28 | 32 | gitserver-core = { path = "vendor/gitserver-core" } | |
| 29 | 33 | gix = { version = "0.84", default-features = false, features = ["sha1", "max-performance-safe", "blob-diff", "revision"] } | |
| 30 | 34 | gix-pack = { version = "0.71", features = ["sha1"] } | |
| 31 | 35 | maud = { version = "0.27", features = ["axum"] } | |
| 32 | 36 | rand = "0.10" | |
| 37 | + | # HTTP client for the CD deploy webhook. No TLS feature on purpose: the deploy | |
| 38 | + | # receiver is host-local plaintext HTTP, and enabling rustls would drag in | |
| 39 | + | # aws-lc-rs and break the musl cross-compile (see the russh note below). | |
| 40 | + | reqwest = { version = "0.12", default-features = false, features = ["json"] } | |
| 33 | 41 | serde = { version = "1", features = ["derive"] } | |
| 42 | + | serde_json = "1" | |
| 34 | 43 | serde_yaml = "0.9" | |
| 44 | + | sha2 = "0.10" | |
| 35 | 45 | similar = "2" | |
| 36 | 46 | syntect = { version = "5", default-features = false, features = ["default-fancy"] } | |
| 47 | + | tar = "0.4" | |
| 37 | 48 | thiserror = "2" | |
| 38 | 49 | time = { version = "0.3", features = ["serde", "formatting"] } | |
| 39 | 50 | toasty = { version = "0.7", features = ["sqlite"] } | |
| ⋯ 15 unchanged lines | |||
modifiedDEPLOY.md+48 −0
| ⋯ 86 unchanged lines | |||
| 87 | 87 | docker run -d --name anvil --network hagrid --restart unless-stopped \ | |
| 88 | 88 | -p 2222:2222 \ | |
| 89 | 89 | -v anvil-data:/data \ | |
| 90 | + | -v /var/run/docker.sock:/var/run/docker.sock \ | |
| 91 | + | --group-add "$(stat -c '%g' /var/run/docker.sock)" \ | |
| 90 | 92 | anvil:latest | |
| 91 | 93 | ``` | |
| 92 | 94 | ||
| ⋯ 2 unchanged lines | |||
| 95 | 97 | - `-v anvil-data:/data` — a named volume holding the SQLite DB, the bare repos, | |
| 96 | 98 | and the persistent SSH **host key**. Use a named volume (not a host bind | |
| 97 | 99 | mount) so it's owned by the in-container `anvil` user. | |
| 100 | + | - `-v /var/run/docker.sock:/var/run/docker.sock` + `--group-add <sock gid>` — | |
| 101 | + | lets the **CI runner** drive Docker on the host. The non-root `anvil` user | |
| 102 | + | needs the socket's group to open it, hence `--group-add` with the socket's | |
| 103 | + | gid (computed at run time by `run.sh`). | |
| 104 | + | ||
| 105 | + | > ⚠️ **Security:** mounting the Docker socket grants the container | |
| 106 | + | > **root-equivalent** control of the host. This is acceptable here because anvil | |
| 107 | + | > is **single-tenant and owner-operated** — CI only ever runs code *you* push. | |
| 108 | + | > Do **not** open this instance to untrusted users while the socket is mounted. | |
| 109 | + | > If you don't want CI, drop the `-v …docker.sock…` and `--group-add` flags; | |
| 110 | + | > the forge runs fine without them (CI runs just error out). | |
| 98 | 111 | ||
| 99 | 112 | The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`). | |
| 100 | 113 | Override it by bind-mounting your own file over that path. | |
| ⋯ 38 unchanged lines | |||
| 139 | 152 | also works: `git clone https://anvil.richardscollin.com/collin/anvil.git` | |
| 140 | 153 | (pushes over HTTPS require your account password as the git password). | |
| 141 | 154 | ||
| 155 | + | ## 7. CI and the redeploy webhook (CD) | |
| 156 | + | ||
| 157 | + | Any repo with a `.anvil/ci.yml` runs CI on push (see `[ci]` requires the Docker | |
| 158 | + | socket mounted — section 4). A pipeline is just an image plus steps: | |
| 159 | + | ||
| 160 | + | ```yaml | |
| 161 | + | image: rust:1.95-bookworm | |
| 162 | + | steps: | |
| 163 | + | - name: test | |
| 164 | + | run: cargo test --workspace | |
| 165 | + | - run: cargo build --release | |
| 166 | + | ``` | |
| 167 | + | ||
| 168 | + | Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the | |
| 169 | + | commit list and a full log on each run's page. | |
| 170 | + | ||
| 171 | + | **Continuous deployment** is deliberately scoped to **one** repository. On a | |
| 172 | + | successful run of `deploy_branch` (default `main`) in the repo named by | |
| 173 | + | `[ci] deploy_repo`, anvil POSTs JSON to `[ci] deploy_webhook`: | |
| 174 | + | ||
| 175 | + | ```json | |
| 176 | + | { "repo": "collin/anvil", "ref": "main", "commit": "<oid>", "run_id": 42 } | |
| 177 | + | ``` | |
| 178 | + | ||
| 179 | + | No other repo can trigger this, even with passing CI. The webhook target is a | |
| 180 | + | **host-local plaintext** receiver (HTTPS is unsupported, to keep the build | |
| 181 | + | TLS-free) — typically a tiny script-runner on hagrid that, on a verified | |
| 182 | + | request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header | |
| 183 | + | (set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles | |
| 184 | + | (section 3), "redeploy anvil" usually means: the receiver pulls the freshly | |
| 185 | + | built image and re-runs `deploy/run.sh` — it does **not** build in-place. | |
| 186 | + | ||
| 187 | + | > The deploy receiver runs with whatever privileges you give it — keep it | |
| 188 | + | > minimal, secret-gated, and bound to localhost / the Docker host gateway only. | |
| 189 | + | ||
| 142 | 190 | ## Operations | |
| 143 | 191 | ||
| 144 | 192 | - **Update**: re-run `./deploy/run.sh` (rebuilds the image, recreates the | |
| ⋯ 11 unchanged lines | |||
addedcrates/anvil-ci/Cargo.toml+19 −0
| 1 | + | [package] | |
| 2 | + | name = "anvil-ci" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | license.workspace = true | |
| 6 | + | repository.workspace = true | |
| 7 | + | rust-version.workspace = true | |
| 8 | + | description = "CI runner for anvil: executes .anvil/ci.yml pipelines in Docker containers via the socket." | |
| 9 | + | ||
| 10 | + | [dependencies] | |
| 11 | + | anvil-core.workspace = true | |
| 12 | + | anvil-git.workspace = true | |
| 13 | + | bollard.workspace = true | |
| 14 | + | futures-util.workspace = true | |
| 15 | + | reqwest.workspace = true | |
| 16 | + | serde_json.workspace = true | |
| 17 | + | tar.workspace = true | |
| 18 | + | tokio.workspace = true | |
| 19 | + | tracing.workspace = true |
addedcrates/anvil-ci/src/lib.rs+289 −0
| 1 | + | //! CI runner: drains queued [`anvil_core::ci`] runs and executes their | |
| 2 | + | //! `.anvil/ci.yml` pipeline in a Docker container (via the socket, using | |
| 3 | + | //! bollard). | |
| 4 | + | //! | |
| 5 | + | //! For each run: resolve the repo, materialize the commit's tree, parse the | |
| 6 | + | //! pipeline, then run all steps as one `set -e` shell script inside the | |
| 7 | + | //! pipeline's image. The checkout is uploaded into the container as a tar (via | |
| 8 | + | //! the Docker API), so it works regardless of where anvil's own filesystem | |
| 9 | + | //! lives and never exposes anvil's data volume to CI. | |
| 10 | + | ||
| 11 | + | use anvil_core::ci::{self, Pipeline}; | |
| 12 | + | use anvil_core::{App, repos, storage, users}; | |
| 13 | + | use anvil_git::browse::{self, TreeFile}; | |
| 14 | + | use bollard::Docker; | |
| 15 | + | use bollard::container::{ | |
| 16 | + | Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions, | |
| 17 | + | UploadToContainerOptions, WaitContainerOptions, | |
| 18 | + | }; | |
| 19 | + | use bollard::image::CreateImageOptions; | |
| 20 | + | use futures_util::StreamExt; | |
| 21 | + | use tokio::sync::mpsc::UnboundedReceiver; | |
| 22 | + | ||
| 23 | + | const WORKDIR: &str = "/workspace"; | |
| 24 | + | ||
| 25 | + | /// Run the CI worker loop: recover interrupted runs, drain the queue, then | |
| 26 | + | /// process run ids as they arrive on `rx`. Runs one job at a time. | |
| 27 | + | pub async fn run_worker(app: App, mut rx: UnboundedReceiver<i64>) { | |
| 28 | + | match ci::requeue_interrupted(&app.db).await { | |
| 29 | + | Ok(ids) if !ids.is_empty() => { | |
| 30 | + | tracing::info!("ci: requeued {} interrupted run(s)", ids.len()) | |
| 31 | + | } | |
| 32 | + | Ok(_) => {} | |
| 33 | + | Err(e) => tracing::error!("ci: requeue failed: {e}"), | |
| 34 | + | } | |
| 35 | + | match ci::queued_ids(&app.db).await { | |
| 36 | + | Ok(ids) => { | |
| 37 | + | for id in ids { | |
| 38 | + | run_one(&app, id).await; | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | Err(e) => tracing::error!("ci: listing queued runs failed: {e}"), | |
| 42 | + | } | |
| 43 | + | tracing::info!("ci runner ready"); | |
| 44 | + | while let Some(id) = rx.recv().await { | |
| 45 | + | run_one(&app, id).await; | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | async fn run_one(app: &App, run_id: i64) { | |
| 50 | + | tracing::info!("ci: run {run_id} starting"); | |
| 51 | + | if let Err(e) = process(app, run_id).await { | |
| 52 | + | tracing::error!("ci: run {run_id} errored: {e}"); | |
| 53 | + | let _ = ci::append_log(&app.db, run_id, &format!("\n[runner error] {e}\n")).await; | |
| 54 | + | let _ = ci::finish(&app.db, run_id, ci::status::ERROR).await; | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | async fn process(app: &App, run_id: i64) -> Result<(), String> { | |
| 59 | + | let run = ci::get(&app.db, run_id) | |
| 60 | + | .await | |
| 61 | + | .map_err(|e| e.to_string())? | |
| 62 | + | .ok_or("run not found")?; | |
| 63 | + | let repo = repos::find_by_id(&app.db, run.repo_id) | |
| 64 | + | .await | |
| 65 | + | .map_err(|e| e.to_string())? | |
| 66 | + | .ok_or("repository not found")?; | |
| 67 | + | let owner = users::find_by_id(&app.db, repo.owner_id) | |
| 68 | + | .await | |
| 69 | + | .map_err(|e| e.to_string())? | |
| 70 | + | .ok_or("owner not found")?; | |
| 71 | + | let repo_path = storage::repo_path(&app.config.repositories_dir(), &owner.username, &repo.name); | |
| 72 | + | ||
| 73 | + | let yaml = browse::read_blob(&repo_path, &run.commit, ci::PIPELINE_PATH) | |
| 74 | + | .map_err(|e| e.to_string())? | |
| 75 | + | .ok_or_else(|| format!("{} missing at {}", ci::PIPELINE_PATH, run.commit))?; | |
| 76 | + | let pipeline = | |
| 77 | + | ci::parse_pipeline(&String::from_utf8_lossy(&yaml)).map_err(|e| e.to_string())?; | |
| 78 | + | ||
| 79 | + | let files = browse::read_tree_files(&repo_path, &run.commit).map_err(|e| e.to_string())?; | |
| 80 | + | let tar = build_tar(&files); | |
| 81 | + | ||
| 82 | + | ci::mark_running(&app.db, run_id).await.ok(); | |
| 83 | + | ||
| 84 | + | let short = &run.commit[..run.commit.len().min(12)]; | |
| 85 | + | let mut log = format!( | |
| 86 | + | "anvil ci · {}/{} · {} @ {short}\nimage: {}\n", | |
| 87 | + | owner.username, repo.name, run.ref_name, pipeline.image | |
| 88 | + | ); | |
| 89 | + | ||
| 90 | + | let status = match execute(&pipeline, tar, &mut log).await { | |
| 91 | + | Ok(0) => ci::status::SUCCESS, | |
| 92 | + | Ok(code) => { | |
| 93 | + | log.push_str(&format!("\n[exited with status {code}]\n")); | |
| 94 | + | ci::status::FAILURE | |
| 95 | + | } | |
| 96 | + | Err(e) => { | |
| 97 | + | log.push_str(&format!("\n[runner error] {e}\n")); | |
| 98 | + | ci::status::ERROR | |
| 99 | + | } | |
| 100 | + | }; | |
| 101 | + | ||
| 102 | + | ci::append_log(&app.db, run_id, &log).await.ok(); | |
| 103 | + | ci::finish(&app.db, run_id, status).await.ok(); | |
| 104 | + | tracing::info!("ci: run {run_id} {status}"); | |
| 105 | + | ||
| 106 | + | // Continuous deployment: on a green run of the configured deploy repo's | |
| 107 | + | // deploy branch, fire the redeploy webhook. Scoped to one repo by config — | |
| 108 | + | // no other repository can trigger it, even with passing CI. | |
| 109 | + | if status == ci::status::SUCCESS | |
| 110 | + | && app | |
| 111 | + | .config | |
| 112 | + | .ci | |
| 113 | + | .is_deploy_target(&owner.username, &repo.name, &run.ref_name) | |
| 114 | + | { | |
| 115 | + | deploy(app, &owner.username, &repo.name, &run).await; | |
| 116 | + | } | |
| 117 | + | Ok(()) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// POST the configured deploy webhook. Best-effort: logs success/failure but | |
| 121 | + | /// never fails the run (CI already passed). | |
| 122 | + | async fn deploy(app: &App, owner: &str, name: &str, run: &anvil_core::CiRun) { | |
| 123 | + | let cfg = &app.config.ci; | |
| 124 | + | let body = serde_json::json!({ | |
| 125 | + | "repo": format!("{owner}/{name}"), | |
| 126 | + | "ref": run.ref_name, | |
| 127 | + | "commit": run.commit, | |
| 128 | + | "run_id": run.id, | |
| 129 | + | }); | |
| 130 | + | let mut req = reqwest::Client::new().post(&cfg.deploy_webhook).json(&body); | |
| 131 | + | if !cfg.deploy_secret.is_empty() { | |
| 132 | + | req = req.header("X-Anvil-Deploy-Secret", &cfg.deploy_secret); | |
| 133 | + | } | |
| 134 | + | match req.send().await { | |
| 135 | + | Ok(resp) if resp.status().is_success() => { | |
| 136 | + | tracing::info!( | |
| 137 | + | "ci: deploy webhook for {owner}/{name} accepted ({})", | |
| 138 | + | resp.status() | |
| 139 | + | ) | |
| 140 | + | } | |
| 141 | + | Ok(resp) => tracing::error!( | |
| 142 | + | "ci: deploy webhook for {owner}/{name} returned {}", | |
| 143 | + | resp.status() | |
| 144 | + | ), | |
| 145 | + | Err(e) => tracing::error!("ci: deploy webhook for {owner}/{name} failed: {e}"), | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Execute the pipeline in a container, streaming output into `log`. Returns the | |
| 150 | + | /// container's exit code. | |
| 151 | + | async fn execute(pipeline: &Pipeline, tar: Vec<u8>, log: &mut String) -> Result<i64, String> { | |
| 152 | + | let docker = Docker::connect_with_socket_defaults() | |
| 153 | + | .map_err(|e| format!("docker unavailable (is the socket mounted?): {e}"))?; | |
| 154 | + | ||
| 155 | + | // Pull the image (split name:tag so we don't accidentally pull all tags). | |
| 156 | + | let (from_image, tag) = match pipeline.image.rsplit_once(':') { | |
| 157 | + | Some((name, tag)) if !tag.contains('/') => (name.to_string(), tag.to_string()), | |
| 158 | + | _ => (pipeline.image.clone(), "latest".to_string()), | |
| 159 | + | }; | |
| 160 | + | let mut pull = docker.create_image( | |
| 161 | + | Some(CreateImageOptions { | |
| 162 | + | from_image, | |
| 163 | + | tag, | |
| 164 | + | ..Default::default() | |
| 165 | + | }), | |
| 166 | + | None, | |
| 167 | + | None, | |
| 168 | + | ); | |
| 169 | + | while let Some(item) = pull.next().await { | |
| 170 | + | item.map_err(|e| format!("pull {}: {e}", pipeline.image))?; | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // Build a single `set -e` script from the steps. | |
| 174 | + | let mut script = String::from("set -e\n"); | |
| 175 | + | for step in &pipeline.steps { | |
| 176 | + | script.push_str("printf '\\n=== %s ===\\n' "); | |
| 177 | + | script.push_str(&single_quote(step.label())); | |
| 178 | + | script.push('\n'); | |
| 179 | + | script.push_str(&step.run); | |
| 180 | + | script.push('\n'); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | let config = Config { | |
| 184 | + | image: Some(pipeline.image.clone()), | |
| 185 | + | cmd: Some(vec!["sh".to_string(), "-c".to_string(), script]), | |
| 186 | + | working_dir: Some(WORKDIR.to_string()), | |
| 187 | + | ..Default::default() | |
| 188 | + | }; | |
| 189 | + | let created = docker | |
| 190 | + | .create_container(None::<CreateContainerOptions<String>>, config) | |
| 191 | + | .await | |
| 192 | + | .map_err(|e| format!("create container: {e}"))?; | |
| 193 | + | let id = created.id; | |
| 194 | + | ||
| 195 | + | // Upload the checkout (tar entries are under `workspace/`, extracted at `/`). | |
| 196 | + | docker | |
| 197 | + | .upload_to_container( | |
| 198 | + | &id, | |
| 199 | + | Some(UploadToContainerOptions { | |
| 200 | + | path: "/".to_string(), | |
| 201 | + | ..Default::default() | |
| 202 | + | }), | |
| 203 | + | tar.into(), | |
| 204 | + | ) | |
| 205 | + | .await | |
| 206 | + | .map_err(|e| format!("upload checkout: {e}"))?; | |
| 207 | + | ||
| 208 | + | docker | |
| 209 | + | .start_container(&id, None::<StartContainerOptions<String>>) | |
| 210 | + | .await | |
| 211 | + | .map_err(|e| format!("start container: {e}"))?; | |
| 212 | + | ||
| 213 | + | // Stream logs until the container stops. | |
| 214 | + | let mut logs = docker.logs( | |
| 215 | + | &id, | |
| 216 | + | Some(LogsOptions::<String> { | |
| 217 | + | follow: true, | |
| 218 | + | stdout: true, | |
| 219 | + | stderr: true, | |
| 220 | + | ..Default::default() | |
| 221 | + | }), | |
| 222 | + | ); | |
| 223 | + | while let Some(item) = logs.next().await { | |
| 224 | + | match item { | |
| 225 | + | Ok(output) => log.push_str(&String::from_utf8_lossy(&output.into_bytes())), | |
| 226 | + | Err(e) => { | |
| 227 | + | log.push_str(&format!("\n[log stream error] {e}\n")); | |
| 228 | + | break; | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Wait for the exit code (non-zero surfaces as a wait error in bollard). | |
| 234 | + | let mut code = 0i64; | |
| 235 | + | let mut wait = docker.wait_container(&id, None::<WaitContainerOptions<String>>); | |
| 236 | + | while let Some(item) = wait.next().await { | |
| 237 | + | match item { | |
| 238 | + | Ok(resp) => code = resp.status_code, | |
| 239 | + | Err(bollard::errors::Error::DockerContainerWaitError { code: c, .. }) => code = c, | |
| 240 | + | Err(e) => { | |
| 241 | + | let _ = docker | |
| 242 | + | .remove_container( | |
| 243 | + | &id, | |
| 244 | + | Some(RemoveContainerOptions { | |
| 245 | + | force: true, | |
| 246 | + | ..Default::default() | |
| 247 | + | }), | |
| 248 | + | ) | |
| 249 | + | .await; | |
| 250 | + | return Err(format!("wait: {e}")); | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | let _ = docker | |
| 256 | + | .remove_container( | |
| 257 | + | &id, | |
| 258 | + | Some(RemoveContainerOptions { | |
| 259 | + | force: true, | |
| 260 | + | ..Default::default() | |
| 261 | + | }), | |
| 262 | + | ) | |
| 263 | + | .await; | |
| 264 | + | ||
| 265 | + | Ok(code) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Build an uncompressed tar of the checkout, rooted at `workspace/` so it | |
| 269 | + | /// extracts to `/workspace` when uploaded to the container root. | |
| 270 | + | fn build_tar(files: &[TreeFile]) -> Vec<u8> { | |
| 271 | + | let mut builder = tar::Builder::new(Vec::new()); | |
| 272 | + | for f in files { | |
| 273 | + | let mut header = tar::Header::new_gnu(); | |
| 274 | + | header.set_size(f.content.len() as u64); | |
| 275 | + | header.set_mode(if f.executable { 0o755 } else { 0o644 }); | |
| 276 | + | // append_data sets the path and checksum. | |
| 277 | + | let _ = builder.append_data( | |
| 278 | + | &mut header, | |
| 279 | + | format!("workspace/{}", f.path), | |
| 280 | + | f.content.as_slice(), | |
| 281 | + | ); | |
| 282 | + | } | |
| 283 | + | builder.into_inner().unwrap_or_default() | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /// Single-quote a string for safe interpolation into a shell command. | |
| 287 | + | fn single_quote(s: &str) -> String { | |
| 288 | + | format!("'{}'", s.replace('\'', "'\\''")) | |
| 289 | + | } |
modifiedcrates/anvil-core/Cargo.toml+3 −0
| ⋯ 10 unchanged lines | |||
| 11 | 11 | gix.workspace = true | |
| 12 | 12 | toasty.workspace = true | |
| 13 | 13 | argon2.workspace = true | |
| 14 | + | hmac.workspace = true | |
| 15 | + | sha2.workspace = true | |
| 14 | 16 | ssh-key.workspace = true | |
| 15 | 17 | serde.workspace = true | |
| 16 | 18 | serde_yaml.workspace = true | |
| 17 | 19 | toml.workspace = true | |
| 18 | 20 | thiserror.workspace = true | |
| 19 | 21 | tracing.workspace = true | |
| 22 | + | tokio.workspace = true | |
| 20 | 23 | ||
| 21 | 24 | [dev-dependencies] | |
| 22 | 25 | tokio = { workspace = true } | |
| ⋯ 1 unchanged line | |||
modifiedcrates/anvil-core/src/ci.rs+46 −16
| ⋯ 50 unchanged lines | |||
| 51 | 51 | let pipeline: Pipeline = serde_yaml::from_str(yaml) | |
| 52 | 52 | .map_err(|e| Error::Invalid(format!("invalid {PIPELINE_PATH}: {e}")))?; | |
| 53 | 53 | if pipeline.image.trim().is_empty() { | |
| 54 | - | return Err(Error::Invalid(format!("{PIPELINE_PATH}: `image` is required"))); | |
| 54 | + | return Err(Error::Invalid(format!( | |
| 55 | + | "{PIPELINE_PATH}: `image` is required" | |
| 56 | + | ))); | |
| 55 | 57 | } | |
| 56 | 58 | Ok(pipeline) | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | /// Create a queued CI run for a pushed commit. | |
| 60 | - | pub async fn enqueue( | |
| 61 | - | db: &toasty::Db, | |
| 62 | - | repo_id: i64, | |
| 63 | - | commit: &str, | |
| 64 | - | ref_name: &str, | |
| 65 | - | ) -> Result<CiRun> { | |
| 62 | + | pub async fn enqueue(db: &toasty::Db, repo_id: i64, commit: &str, ref_name: &str) -> Result<CiRun> { | |
| 66 | 63 | let mut conn = db.clone(); | |
| 67 | 64 | let run = toasty::create!(CiRun { | |
| 68 | 65 | repo_id: repo_id, | |
| ⋯ 22 unchanged lines | |||
| 91 | 88 | /// List a repository's runs, newest first, up to `limit`. | |
| 92 | 89 | pub async fn list_by_repo(db: &toasty::Db, repo_id: i64, limit: usize) -> Result<Vec<CiRun>> { | |
| 93 | 90 | let mut conn = db.clone(); | |
| 94 | - | let mut runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id)) | |
| 91 | + | let runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id)) | |
| 92 | + | .order_by(CiRun::fields().id().desc()) | |
| 93 | + | .limit(limit) | |
| 95 | 94 | .exec(&mut conn) | |
| 96 | 95 | .await?; | |
| 97 | - | runs.sort_by(|a, b| b.id.cmp(&a.id)); | |
| 98 | - | runs.truncate(limit); | |
| 99 | 96 | Ok(runs) | |
| 100 | 97 | } | |
| 101 | 98 | ||
| ⋯ 4 unchanged lines | |||
| 106 | 103 | commit: &str, | |
| 107 | 104 | ) -> Result<Option<CiRun>> { | |
| 108 | 105 | let mut conn = db.clone(); | |
| 109 | - | let mut runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id)) | |
| 106 | + | let run = CiRun::filter(CiRun::fields().repo_id().eq(repo_id)) | |
| 107 | + | .filter(CiRun::fields().commit().eq(commit)) | |
| 108 | + | .order_by(CiRun::fields().id().desc()) | |
| 109 | + | .first() | |
| 110 | 110 | .exec(&mut conn) | |
| 111 | 111 | .await?; | |
| 112 | - | runs.retain(|r| r.commit == commit); | |
| 113 | - | runs.sort_by(|a, b| b.id.cmp(&a.id)); | |
| 114 | - | Ok(runs.into_iter().next()) | |
| 112 | + | Ok(run) | |
| 115 | 113 | } | |
| 116 | 114 | ||
| 117 | 115 | /// Mark a run as started (running). | |
| ⋯ 54 unchanged lines | |||
| 172 | 170 | /// List all queued run ids (oldest first) — used on startup to drain the queue. | |
| 173 | 171 | pub async fn queued_ids(db: &toasty::Db) -> Result<Vec<i64>> { | |
| 174 | 172 | let mut conn = db.clone(); | |
| 175 | - | let mut runs = CiRun::filter(CiRun::fields().status().eq(status::QUEUED)) | |
| 173 | + | let runs = CiRun::filter(CiRun::fields().status().eq(status::QUEUED)) | |
| 174 | + | .order_by(CiRun::fields().id().asc()) | |
| 176 | 175 | .exec(&mut conn) | |
| 177 | 176 | .await?; | |
| 178 | - | runs.sort_by(|a, b| a.id.cmp(&b.id)); | |
| 179 | 177 | Ok(runs.into_iter().map(|r| r.id).collect()) | |
| 180 | 178 | } | |
| 181 | 179 | ||
| ⋯ 24 unchanged lines | |||
| 206 | 204 | fn requires_an_image() { | |
| 207 | 205 | assert!(parse_pipeline("steps: []\n").is_err()); | |
| 208 | 206 | } | |
| 207 | + | ||
| 208 | + | // Exercises the run-lifecycle queries against a real SQLite database, to | |
| 209 | + | // confirm the ORM-level `order_by`/`limit`/filter actually work (Toasty is | |
| 210 | + | // pre-1.0, so we don't take that on faith). | |
| 211 | + | #[tokio::test] | |
| 212 | + | async fn ordering_and_limit_run_in_the_database() { | |
| 213 | + | let dir = tempfile::tempdir().unwrap(); | |
| 214 | + | let db = crate::db::connect(dir.path().join("t.db")).await.unwrap(); | |
| 215 | + | ||
| 216 | + | for c in ["aaa", "bbb", "ccc"] { | |
| 217 | + | enqueue(&db, 1, c, "main").await.unwrap(); | |
| 218 | + | } | |
| 219 | + | enqueue(&db, 2, "zzz", "main").await.unwrap(); // a different repo | |
| 220 | + | ||
| 221 | + | // Newest-first, limited to 2 — and scoped to repo 1. | |
| 222 | + | let runs = list_by_repo(&db, 1, 2).await.unwrap(); | |
| 223 | + | assert_eq!(runs.len(), 2); | |
| 224 | + | assert!(runs[0].id > runs[1].id, "newest first"); | |
| 225 | + | assert_eq!(runs[0].commit, "ccc"); | |
| 226 | + | assert!(runs.iter().all(|r| r.repo_id == 1)); | |
| 227 | + | ||
| 228 | + | // Commit filter pushed into the query (not an in-memory retain). | |
| 229 | + | let latest = latest_for_commit(&db, 1, "bbb").await.unwrap().unwrap(); | |
| 230 | + | assert_eq!(latest.commit, "bbb"); | |
| 231 | + | assert_eq!(latest.repo_id, 1); | |
| 232 | + | assert!(latest_for_commit(&db, 1, "nope").await.unwrap().is_none()); | |
| 233 | + | ||
| 234 | + | // All queued, ascending by id. | |
| 235 | + | let queued = queued_ids(&db).await.unwrap(); | |
| 236 | + | assert_eq!(queued.len(), 4); | |
| 237 | + | assert!(queued.windows(2).all(|w| w[0] < w[1]), "ascending"); | |
| 238 | + | } | |
| 209 | 239 | } | |
modifiedcrates/anvil-core/src/config.rs+58 −12
| 1 | 1 | //! Server configuration: loaded from a TOML file with sensible defaults. | |
| 2 | 2 | ||
| 3 | - | use std::path::{ | |
| 4 | - | Path, | |
| 5 | - | PathBuf, | |
| 6 | - | }; | |
| 3 | + | use std::path::{Path, PathBuf}; | |
| 7 | 4 | ||
| 8 | - | use serde::{ | |
| 9 | - | Deserialize, | |
| 10 | - | Serialize, | |
| 11 | - | }; | |
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 12 | 6 | ||
| 13 | - | use crate::error::{ | |
| 14 | - | Error, | |
| 15 | - | Result, | |
| 16 | - | }; | |
| 7 | + | use crate::error::{Error, Result}; | |
| 17 | 8 | ||
| 18 | 9 | /// Top-level anvil configuration. | |
| 19 | 10 | /// | |
| ⋯ 7 unchanged lines | |||
| 27 | 18 | pub http: HttpConfig, | |
| 28 | 19 | /// SSH server settings. | |
| 29 | 20 | pub ssh: SshConfig, | |
| 21 | + | /// Continuous-deployment settings (the single-repo redeploy webhook). | |
| 22 | + | pub ci: CiConfig, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// Continuous-deployment configuration. | |
| 26 | + | /// | |
| 27 | + | /// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the | |
| 28 | + | /// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil | |
| 29 | + | /// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately | |
| 30 | + | /// scoped to **one** repository — no other repo can trigger the deploy, even | |
| 31 | + | /// with its own passing CI. | |
| 32 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 33 | + | #[serde(default)] | |
| 34 | + | pub struct CiConfig { | |
| 35 | + | /// The one repository (`owner/name`) permitted to trigger the deploy | |
| 36 | + | /// webhook. Empty disables deploys entirely. | |
| 37 | + | pub deploy_repo: String, | |
| 38 | + | /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be | |
| 39 | + | /// a host-local plaintext HTTP endpoint (a small deploy-script receiver); | |
| 40 | + | /// HTTPS is intentionally unsupported to keep the build TLS-free. | |
| 41 | + | pub deploy_webhook: String, | |
| 42 | + | /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver | |
| 43 | + | /// can authenticate the call. Empty sends no header. | |
| 44 | + | pub deploy_secret: String, | |
| 45 | + | /// Branch whose successful run triggers a deploy. Defaults to `main`. | |
| 46 | + | pub deploy_branch: String, | |
| 30 | 47 | } | |
| 31 | 48 | ||
| 32 | 49 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| ⋯ 29 unchanged lines | |||
| 62 | 79 | data_dir: PathBuf::from("data"), | |
| 63 | 80 | http: HttpConfig::default(), | |
| 64 | 81 | ssh: SshConfig::default(), | |
| 82 | + | ci: CiConfig::default(), | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | impl Default for CiConfig { | |
| 88 | + | fn default() -> Self { | |
| 89 | + | Self { | |
| 90 | + | deploy_repo: String::new(), | |
| 91 | + | deploy_webhook: String::new(), | |
| 92 | + | deploy_secret: String::new(), | |
| 93 | + | deploy_branch: "main".to_string(), | |
| 65 | 94 | } | |
| 66 | 95 | } | |
| 67 | 96 | } | |
| 68 | 97 | ||
| 98 | + | impl CiConfig { | |
| 99 | + | /// Whether `owner/name` on `branch` is the configured deploy target. | |
| 100 | + | pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool { | |
| 101 | + | !self.deploy_repo.is_empty() | |
| 102 | + | && !self.deploy_webhook.is_empty() | |
| 103 | + | && self.deploy_repo == format!("{owner}/{name}") | |
| 104 | + | && self.deploy_branch == branch | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 69 | 108 | impl Default for HttpConfig { | |
| 70 | 109 | fn default() -> Self { | |
| 71 | 110 | Self { | |
| ⋯ 44 unchanged lines | |||
| 116 | 155 | self.data_dir.join("repositories") | |
| 117 | 156 | } | |
| 118 | 157 | ||
| 158 | + | /// Whether session cookies should carry the `Secure` attribute (HTTPS-only). | |
| 159 | + | /// Derived from the public base URL's scheme, so local plaintext dev still | |
| 160 | + | /// works while production behind TLS gets `Secure` automatically. | |
| 161 | + | pub fn secure_cookies(&self) -> bool { | |
| 162 | + | self.http.base_url.starts_with("https://") | |
| 163 | + | } | |
| 164 | + | ||
| 119 | 165 | /// The HTTP clone URL for `<owner>/<name>`, e.g. | |
| 120 | 166 | /// `http://localhost:3000/alice/hello.git`. | |
| 121 | 167 | pub fn http_clone_url(&self, owner: &str, name: &str) -> String { | |
| ⋯ 24 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+60 −12
| ⋯ 17 unchanged lines | |||
| 18 | 18 | pub mod users; | |
| 19 | 19 | ||
| 20 | 20 | pub use config::Config; | |
| 21 | - | pub use error::{ | |
| 22 | - | Error, | |
| 23 | - | Result, | |
| 24 | - | }; | |
| 25 | - | pub use models::{ | |
| 26 | - | CiRun, | |
| 27 | - | Repository, | |
| 28 | - | Session, | |
| 29 | - | SshKey, | |
| 30 | - | User, | |
| 31 | - | }; | |
| 21 | + | pub use error::{Error, Result}; | |
| 22 | + | pub use models::{CiRun, Repository, Session, SshKey, User}; | |
| 32 | 23 | ||
| 33 | 24 | /// Current Unix time in seconds, for `created_at` columns. | |
| 34 | 25 | pub(crate) fn now() -> i64 { | |
| ⋯ 11 unchanged lines | |||
| 46 | 37 | pub struct App { | |
| 47 | 38 | pub config: Config, | |
| 48 | 39 | pub db: toasty::Db, | |
| 40 | + | /// Notifies the CI runner of newly-enqueued run ids. `None` until the runner | |
| 41 | + | /// is started (e.g. CLI commands don't run CI). Use [`App::notify_ci`]. | |
| 42 | + | pub ci_tx: Option<tokio::sync::mpsc::UnboundedSender<i64>>, | |
| 43 | + | /// Server-wide secret keying CSRF tokens. Persisted in the data dir so | |
| 44 | + | /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap. | |
| 45 | + | csrf_secret: std::sync::Arc<[u8; 32]>, | |
| 49 | 46 | } | |
| 50 | 47 | ||
| 51 | 48 | impl App { | |
| ⋯ 4 unchanged lines | |||
| 56 | 53 | std::fs::create_dir_all(config.repositories_dir())?; | |
| 57 | 54 | ||
| 58 | 55 | let db = db::connect(config.database_path()).await?; | |
| 56 | + | let csrf_secret = std::sync::Arc::new(load_or_create_csrf_secret(&config.data_dir)?); | |
| 57 | + | ||
| 58 | + | Ok(Self { | |
| 59 | + | config, | |
| 60 | + | db, | |
| 61 | + | ci_tx: None, | |
| 62 | + | csrf_secret, | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Notify the CI runner that `run_id` is queued (no-op if no runner). | |
| 67 | + | pub fn notify_ci(&self, run_id: i64) { | |
| 68 | + | if let Some(tx) = &self.ci_tx { | |
| 69 | + | let _ = tx.send(run_id); | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// The CSRF token bound to a given session token: `HMAC-SHA256(secret, | |
| 74 | + | /// session)`, hex-encoded. Stable for a session's lifetime, unguessable | |
| 75 | + | /// without the server secret, and requires no extra storage. | |
| 76 | + | pub fn csrf_token(&self, session_token: &str) -> String { | |
| 77 | + | use hmac::{Hmac, Mac}; | |
| 78 | + | let mut mac = Hmac::<sha2::Sha256>::new_from_slice(self.csrf_secret.as_slice()) | |
| 79 | + | .expect("HMAC accepts any key length"); | |
| 80 | + | mac.update(session_token.as_bytes()); | |
| 81 | + | mac.finalize() | |
| 82 | + | .into_bytes() | |
| 83 | + | .iter() | |
| 84 | + | .map(|b| format!("{b:02x}")) | |
| 85 | + | .collect() | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Load the persistent CSRF secret, generating and saving it on first run. | |
| 90 | + | fn load_or_create_csrf_secret(data_dir: &std::path::Path) -> Result<[u8; 32]> { | |
| 91 | + | use argon2::password_hash::rand_core::{OsRng, RngCore}; | |
| 59 | 92 | ||
| 60 | - | Ok(Self { config, db }) | |
| 93 | + | let path = data_dir.join("csrf_secret"); | |
| 94 | + | if path.exists() { | |
| 95 | + | let bytes = std::fs::read(&path)?; | |
| 96 | + | if let Ok(secret) = <[u8; 32]>::try_from(bytes.as_slice()) { | |
| 97 | + | return Ok(secret); | |
| 98 | + | } | |
| 99 | + | // Malformed (truncated/extended) — regenerate rather than run weak. | |
| 100 | + | } | |
| 101 | + | let mut secret = [0u8; 32]; | |
| 102 | + | OsRng.fill_bytes(&mut secret); | |
| 103 | + | std::fs::write(&path, secret)?; | |
| 104 | + | #[cfg(unix)] | |
| 105 | + | { | |
| 106 | + | use std::os::unix::fs::PermissionsExt; | |
| 107 | + | let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); | |
| 61 | 108 | } | |
| 109 | + | Ok(secret) | |
| 62 | 110 | } | |
modifiedcrates/anvil-git/src/browse.rs+62 −8
| ⋯ 2 unchanged lines | |||
| 3 | 3 | //! These helpers back the web UI. Each opens the bare repo by path; that is | |
| 4 | 4 | //! cheap enough at our scale and keeps the API stateless. | |
| 5 | 5 | ||
| 6 | - | use std::collections::{ | |
| 7 | - | BTreeMap, | |
| 8 | - | BTreeSet, | |
| 9 | - | }; | |
| 6 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 10 | 7 | use std::fmt::Display; | |
| 11 | 8 | use std::path::Path; | |
| 12 | 9 | ||
| 13 | - | use crate::error::{ | |
| 14 | - | Error, | |
| 15 | - | Result, | |
| 16 | - | }; | |
| 10 | + | use crate::error::{Error, Result}; | |
| 17 | 11 | ||
| 18 | 12 | /// Summary of a repository's refs and state, for the repo landing page. | |
| 19 | 13 | pub struct Overview { | |
| ⋯ 256 unchanged lines | |||
| 276 | 270 | Ok(()) | |
| 277 | 271 | } | |
| 278 | 272 | ||
| 273 | + | /// A file materialized from a tree: path, executable bit, and content. | |
| 274 | + | pub struct TreeFile { | |
| 275 | + | pub path: String, | |
| 276 | + | pub executable: bool, | |
| 277 | + | pub content: Vec<u8>, | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// Read every file in the tree at `rev`, recursively — for materializing a | |
| 281 | + | /// checkout (e.g. a CI workspace). Loads all contents into memory, which is | |
| 282 | + | /// fine for the modest repos this targets. | |
| 283 | + | pub fn read_tree_files(repo_path: &Path, rev: &str) -> Result<Vec<TreeFile>> { | |
| 284 | + | let repo = gix::open(repo_path).map_err(read)?; | |
| 285 | + | let tree = repo | |
| 286 | + | .rev_parse_single(rev) | |
| 287 | + | .map_err(read)? | |
| 288 | + | .object() | |
| 289 | + | .map_err(read)? | |
| 290 | + | .peel_to_commit() | |
| 291 | + | .map_err(read)? | |
| 292 | + | .tree() | |
| 293 | + | .map_err(read)?; | |
| 294 | + | let mut out = Vec::new(); | |
| 295 | + | collect_files(&repo, &tree, "", &mut out)?; | |
| 296 | + | Ok(out) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | fn collect_files( | |
| 300 | + | repo: &gix::Repository, | |
| 301 | + | tree: &gix::Tree, | |
| 302 | + | prefix: &str, | |
| 303 | + | out: &mut Vec<TreeFile>, | |
| 304 | + | ) -> Result<()> { | |
| 305 | + | for e in tree.iter() { | |
| 306 | + | let e = e.map_err(read)?; | |
| 307 | + | let name = e.filename().to_string(); | |
| 308 | + | let path = if prefix.is_empty() { | |
| 309 | + | name | |
| 310 | + | } else { | |
| 311 | + | format!("{prefix}/{name}") | |
| 312 | + | }; | |
| 313 | + | let mode = e.mode(); | |
| 314 | + | if mode.is_tree() { | |
| 315 | + | let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?; | |
| 316 | + | collect_files(repo, &sub, &path, out)?; | |
| 317 | + | } else if mode.is_blob() { | |
| 318 | + | let content = repo | |
| 319 | + | .find_object(e.oid().to_owned()) | |
| 320 | + | .map_err(read)? | |
| 321 | + | .data | |
| 322 | + | .clone(); | |
| 323 | + | out.push(TreeFile { | |
| 324 | + | path, | |
| 325 | + | executable: mode.is_executable(), | |
| 326 | + | content, | |
| 327 | + | }); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | Ok(()) | |
| 331 | + | } | |
| 332 | + | ||
| 279 | 333 | /// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None` | |
| 280 | 334 | /// if the path does not exist or is not a blob. | |
| 281 | 335 | pub fn read_blob(repo_path: &Path, rev: &str, path: &str) -> Result<Option<Vec<u8>>> { | |
| ⋯ 19 unchanged lines | |||
modifiedcrates/anvil-ssh/src/lib.rs+19 −42
| ⋯ 6 unchanged lines | |||
| 7 | 7 | //! The SSH bind address is configurable (`[ssh] listen` in the config). | |
| 8 | 8 | ||
| 9 | 9 | use std::net::SocketAddr; | |
| 10 | - | use std::path::{ | |
| 11 | - | Path, | |
| 12 | - | PathBuf, | |
| 13 | - | }; | |
| 10 | + | use std::path::{Path, PathBuf}; | |
| 14 | 11 | use std::sync::Arc; | |
| 15 | 12 | use std::time::Duration; | |
| 16 | 13 | ||
| 17 | - | use anvil_core::{ | |
| 18 | - | App, | |
| 19 | - | Error as CoreError, | |
| 20 | - | Result as CoreResult, | |
| 21 | - | access, | |
| 22 | - | repos, | |
| 23 | - | users, | |
| 24 | - | }; | |
| 14 | + | use anvil_core::{App, Error as CoreError, Result as CoreResult, access, repos, users}; | |
| 25 | 15 | use anvil_git::ssh as git_ssh; | |
| 26 | - | use russh::keys::ssh_key::{ | |
| 27 | - | HashAlg, | |
| 28 | - | LineEnding, | |
| 29 | - | PublicKey, | |
| 30 | - | }; | |
| 31 | - | use russh::keys::{ | |
| 32 | - | Algorithm, | |
| 33 | - | PrivateKey, | |
| 34 | - | }; | |
| 35 | - | use russh::server::{ | |
| 36 | - | self, | |
| 37 | - | Auth, | |
| 38 | - | Handler, | |
| 39 | - | Msg, | |
| 40 | - | Server as _, | |
| 41 | - | Session, | |
| 42 | - | }; | |
| 43 | - | use russh::{ | |
| 44 | - | Channel, | |
| 45 | - | ChannelId, | |
| 46 | - | }; | |
| 16 | + | use russh::keys::ssh_key::{HashAlg, LineEnding, PublicKey}; | |
| 17 | + | use russh::keys::{Algorithm, PrivateKey}; | |
| 18 | + | use russh::server::{self, Auth, Handler, Msg, Server as _, Session}; | |
| 19 | + | use russh::{Channel, ChannelId}; | |
| 47 | 20 | use tokio::net::TcpListener; | |
| 48 | 21 | ||
| 49 | 22 | /// Bind to the configured SSH address and serve git over SSH until shutdown. | |
| ⋯ 131 unchanged lines | |||
| 181 | 154 | return fail(session, channel_id, "unsupported command"); | |
| 182 | 155 | }; | |
| 183 | 156 | let need_write = service == anvil_git::Service::ReceivePack; | |
| 184 | - | let (path, repo_id) = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await | |
| 185 | - | { | |
| 186 | - | Ok(resolved) => resolved, | |
| 187 | - | Err(message) => return fail(session, channel_id, message), | |
| 188 | - | }; | |
| 157 | + | let (path, repo_id) = | |
| 158 | + | match authorize_repo(&self.app, self.authed_user, &rel, need_write).await { | |
| 159 | + | Ok(resolved) => resolved, | |
| 160 | + | Err(message) => return fail(session, channel_id, message), | |
| 161 | + | }; | |
| 189 | 162 | let Some(channel) = self.channel.take() else { | |
| 190 | 163 | return fail(session, channel_id, "no session channel"); | |
| 191 | 164 | }; | |
| ⋯ 6 unchanged lines | |||
| 198 | 171 | ||
| 199 | 172 | let protocol_v2 = self.protocol_v2; | |
| 200 | 173 | let handle = session.handle(); | |
| 201 | - | let db = self.app.db.clone(); | |
| 174 | + | let app = self.app.clone(); | |
| 202 | 175 | session.channel_success(channel_id)?; | |
| 203 | 176 | ||
| 204 | 177 | tokio::spawn(async move { | |
| ⋯ 11 unchanged lines | |||
| 216 | 189 | } | |
| 217 | 190 | }; | |
| 218 | 191 | ||
| 219 | - | if code == 0 { | |
| 220 | - | if let Some(before) = before { | |
| 221 | - | anvil_git::trigger::enqueue_ci_for_push(&db, repo_id, &path, &before).await; | |
| 192 | + | if code == 0 | |
| 193 | + | && let Some(before) = before | |
| 194 | + | { | |
| 195 | + | for run_id in | |
| 196 | + | anvil_git::trigger::enqueue_ci_for_push(&app.db, repo_id, &path, &before).await | |
| 197 | + | { | |
| 198 | + | app.notify_ci(run_id); | |
| 222 | 199 | } | |
| 223 | 200 | } | |
| 224 | 201 | ||
| ⋯ 65 unchanged lines | |||
modifiedcrates/anvil-web/src/auth.rs+90 −26
| ⋯ 2 unchanged lines | |||
| 3 | 3 | ||
| 4 | 4 | use std::convert::Infallible; | |
| 5 | 5 | ||
| 6 | - | use anvil_core::{ | |
| 7 | - | App, | |
| 8 | - | User, | |
| 9 | - | sessions, | |
| 10 | - | users, | |
| 11 | - | }; | |
| 6 | + | use anvil_core::{App, User, sessions, users}; | |
| 12 | 7 | use axum::{ | |
| 13 | 8 | Form, | |
| 14 | - | extract::{ | |
| 15 | - | FromRequestParts, | |
| 16 | - | State, | |
| 17 | - | }, | |
| 18 | - | http::request::Parts, | |
| 19 | - | response::{ | |
| 20 | - | IntoResponse, | |
| 21 | - | Redirect, | |
| 22 | - | Response, | |
| 23 | - | }, | |
| 9 | + | extract::{FromRequestParts, Request, State}, | |
| 10 | + | http::{StatusCode, request::Parts}, | |
| 11 | + | middleware::Next, | |
| 12 | + | response::{IntoResponse, Redirect, Response}, | |
| 24 | 13 | }; | |
| 25 | - | use axum_extra::extract::cookie::{ | |
| 26 | - | Cookie, | |
| 27 | - | CookieJar, | |
| 28 | - | SameSite, | |
| 29 | - | }; | |
| 30 | - | use maud::{ | |
| 31 | - | Markup, | |
| 32 | - | html, | |
| 33 | - | }; | |
| 14 | + | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | |
| 15 | + | use maud::{Markup, html}; | |
| 34 | 16 | ||
| 35 | 17 | use crate::ui::layout; | |
| 36 | 18 | ||
| 37 | 19 | const SESSION_COOKIE: &str = "anvil_session"; | |
| 38 | 20 | ||
| 21 | + | /// Hidden form field (and header) name carrying the CSRF token. | |
| 22 | + | pub const CSRF_FIELD: &str = "csrf"; | |
| 23 | + | ||
| 24 | + | tokio::task_local! { | |
| 25 | + | /// Request-scoped CSRF token, set by [`csrf_context`] for the duration of | |
| 26 | + | /// each request and read by the layout to populate htmx's `hx-headers` | |
| 27 | + | /// (so JS-driven actions carry the token without a hidden field). Empty for | |
| 28 | + | /// unauthenticated requests. | |
| 29 | + | static CSRF_TOKEN: String; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// The current request's CSRF token, or empty outside a request scope. | |
| 33 | + | pub(crate) fn current_csrf() -> String { | |
| 34 | + | CSRF_TOKEN.try_with(|t| t.clone()).unwrap_or_default() | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Middleware that derives the session's CSRF token and makes it available to | |
| 38 | + | /// the layout (via [`current_csrf`]) for the rest of the request. | |
| 39 | + | pub async fn csrf_context(State(app): State<App>, req: Request, next: Next) -> Response { | |
| 40 | + | let token = CookieJar::from_headers(req.headers()) | |
| 41 | + | .get(SESSION_COOKIE) | |
| 42 | + | .map(|c| app.csrf_token(c.value())) | |
| 43 | + | .unwrap_or_default(); | |
| 44 | + | CSRF_TOKEN.scope(token, next.run(req)).await | |
| 45 | + | } | |
| 46 | + | ||
| 39 | 47 | /// Extractor yielding the logged-in user, if any, from the session cookie. | |
| 40 | 48 | /// Never fails — absence of a valid session simply yields `None`. | |
| 41 | 49 | pub struct CurrentUser(pub Option<User>); | |
| ⋯ 14 unchanged lines | |||
| 56 | 64 | } | |
| 57 | 65 | } | |
| 58 | 66 | ||
| 67 | + | /// Extractor yielding the CSRF token bound to the caller's session, or an empty | |
| 68 | + | /// string when unauthenticated. Embed it in forms via [`crate::ui::csrf_input`] | |
| 69 | + | /// and verify mutating POSTs with [`verify_csrf`]. | |
| 70 | + | pub struct Csrf(pub String); | |
| 71 | + | ||
| 72 | + | impl FromRequestParts<App> for Csrf { | |
| 73 | + | type Rejection = Infallible; | |
| 74 | + | ||
| 75 | + | async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> { | |
| 76 | + | let jar = CookieJar::from_headers(&parts.headers); | |
| 77 | + | let token = jar | |
| 78 | + | .get(SESSION_COOKIE) | |
| 79 | + | .map(|c| app.csrf_token(c.value())) | |
| 80 | + | .unwrap_or_default(); | |
| 81 | + | Ok(Csrf(token)) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Verify a submitted CSRF token against the session-bound expected value. | |
| 86 | + | /// Rejects when unauthenticated (empty expected) or on any mismatch. Comparison | |
| 87 | + | /// is constant-time to avoid leaking the token byte-by-byte. | |
| 88 | + | pub fn verify_csrf(expected: &Csrf, submitted: &str) -> Result<(), Response> { | |
| 89 | + | let ok = | |
| 90 | + | !expected.0.is_empty() && constant_time_eq(expected.0.as_bytes(), submitted.as_bytes()); | |
| 91 | + | if ok { | |
| 92 | + | Ok(()) | |
| 93 | + | } else { | |
| 94 | + | Err((StatusCode::FORBIDDEN, "invalid or missing CSRF token").into_response()) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Length-independent constant-time byte comparison. | |
| 99 | + | fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { | |
| 100 | + | if a.len() != b.len() { | |
| 101 | + | return false; | |
| 102 | + | } | |
| 103 | + | let mut diff = 0u8; | |
| 104 | + | for (x, y) in a.iter().zip(b.iter()) { | |
| 105 | + | diff |= x ^ y; | |
| 106 | + | } | |
| 107 | + | diff == 0 | |
| 108 | + | } | |
| 109 | + | ||
| 59 | 110 | #[derive(serde::Deserialize)] | |
| 60 | 111 | pub struct LoginForm { | |
| 61 | 112 | username: String, | |
| 62 | 113 | password: String, | |
| 63 | 114 | } | |
| 64 | 115 | ||
| 116 | + | /// A form body carrying only a CSRF token — for POST actions (logout, deletes) | |
| 117 | + | /// that otherwise need no fields. | |
| 118 | + | #[derive(serde::Deserialize)] | |
| 119 | + | pub struct CsrfForm { | |
| 120 | + | #[serde(default)] | |
| 121 | + | pub csrf: String, | |
| 122 | + | } | |
| 123 | + | ||
| 65 | 124 | /// `GET /login` — show the login form (or bounce home if already signed in). | |
| 66 | 125 | pub async fn login_form(CurrentUser(user): CurrentUser) -> Response { | |
| 67 | 126 | if user.is_some() { | |
| ⋯ 28 unchanged lines | |||
| 96 | 155 | let cookie = Cookie::build((SESSION_COOKIE, session.token)) | |
| 97 | 156 | .path("/") | |
| 98 | 157 | .http_only(true) | |
| 158 | + | .secure(app.config.secure_cookies()) | |
| 99 | 159 | .same_site(SameSite::Lax) | |
| 100 | 160 | .build(); | |
| 101 | 161 | (jar.add(cookie), Redirect::to("/")).into_response() | |
| ⋯ 9 unchanged lines | |||
| 111 | 171 | } | |
| 112 | 172 | } | |
| 113 | 173 | ||
| 114 | - | /// `POST /logout` — destroy the session and clear the cookie. | |
| 174 | + | /// `POST /logout` — destroy the session and clear the cookie. Not given an | |
| 175 | + | /// explicit CSRF token: `SameSite=Lax` already withholds the session cookie | |
| 176 | + | /// from cross-site POSTs (so a forced logout can't identify the session), and | |
| 177 | + | /// the impact of a forced logout is trivial. The high-value mutating forms | |
| 178 | + | /// (SSH keys, repo creation/visibility) do carry tokens via [`verify_csrf`]. | |
| 115 | 179 | pub async fn logout(State(app): State<App>, jar: CookieJar) -> Response { | |
| 116 | 180 | if let Some(cookie) = jar.get(SESSION_COOKIE) { | |
| 117 | 181 | let _ = sessions::delete(&app.db, cookie.value()).await; | |
| ⋯ 39 unchanged lines | |||
modifiedcrates/anvil-web/src/git_http.rs+12 −35
| ⋯ 10 unchanged lines | |||
| 11 | 11 | use std::collections::HashMap; | |
| 12 | 12 | use std::path::PathBuf; | |
| 13 | 13 | ||
| 14 | - | use anvil_core::{ | |
| 15 | - | App, | |
| 16 | - | Repository, | |
| 17 | - | access, | |
| 18 | - | repos, | |
| 19 | - | users, | |
| 20 | - | }; | |
| 21 | - | use anvil_git::smart_http::{ | |
| 22 | - | self, | |
| 23 | - | Service, | |
| 24 | - | UploadPack, | |
| 25 | - | }; | |
| 14 | + | use anvil_core::{App, Repository, access, repos, users}; | |
| 15 | + | use anvil_git::smart_http::{self, Service, UploadPack}; | |
| 26 | 16 | use axum::{ | |
| 27 | 17 | Router, | |
| 28 | - | body::{ | |
| 29 | - | Body, | |
| 30 | - | Bytes, | |
| 31 | - | }, | |
| 32 | - | extract::{ | |
| 33 | - | Path, | |
| 34 | - | Query, | |
| 35 | - | State, | |
| 36 | - | }, | |
| 37 | - | http::{ | |
| 38 | - | HeaderMap, | |
| 39 | - | StatusCode, | |
| 40 | - | header, | |
| 41 | - | }, | |
| 42 | - | response::{ | |
| 43 | - | IntoResponse, | |
| 44 | - | Response, | |
| 45 | - | }, | |
| 46 | - | routing::{ | |
| 47 | - | get, | |
| 48 | - | post, | |
| 49 | - | }, | |
| 18 | + | body::{Body, Bytes}, | |
| 19 | + | extract::{Path, Query, State}, | |
| 20 | + | http::{HeaderMap, StatusCode, header}, | |
| 21 | + | response::{IntoResponse, Response}, | |
| 22 | + | routing::{get, post}, | |
| 50 | 23 | }; | |
| 51 | 24 | use tokio_util::io::ReaderStream; | |
| 52 | 25 | ||
| ⋯ 165 unchanged lines | |||
| 218 | 191 | let reader = std::io::Cursor::new(body.to_vec()); | |
| 219 | 192 | match smart_http::receive_pack(&path, reader).await { | |
| 220 | 193 | Ok(b) => { | |
| 221 | - | anvil_git::trigger::enqueue_ci_for_push(&app.db, meta.id, &path, &before).await; | |
| 194 | + | for run_id in | |
| 195 | + | anvil_git::trigger::enqueue_ci_for_push(&app.db, meta.id, &path, &before).await | |
| 196 | + | { | |
| 197 | + | app.notify_ci(run_id); | |
| 198 | + | } | |
| 222 | 199 | rpc_response(Service::ReceivePack.result_content_type(), Body::from(b)) | |
| 223 | 200 | } | |
| 224 | 201 | Err(e) => internal(e), | |
| ⋯ 2 unchanged lines | |||
modifiedcrates/anvil-web/src/lib.rs+10 −9
| ⋯ 7 unchanged lines | |||
| 8 | 8 | // pattern is intentional and the responses are never hot-path allocated en masse. | |
| 9 | 9 | #![allow(clippy::result_large_err)] | |
| 10 | 10 | ||
| 11 | - | use anvil_core::{ | |
| 12 | - | App, | |
| 13 | - | Result, | |
| 14 | - | }; | |
| 11 | + | use anvil_core::{App, Result}; | |
| 15 | 12 | use axum::{ | |
| 16 | 13 | Router, | |
| 17 | - | routing::{ | |
| 18 | - | get, | |
| 19 | - | post, | |
| 20 | - | }, | |
| 14 | + | routing::{get, post}, | |
| 21 | 15 | }; | |
| 22 | 16 | ||
| 23 | 17 | pub mod auth; | |
| ⋯ 8 unchanged lines | |||
| 32 | 26 | .route("/-/logout", post(auth::logout)); | |
| 33 | 27 | router = ui::routes(router); // web UI, including `/` | |
| 34 | 28 | router = git_http::routes(router); // smart-HTTP git endpoints | |
| 35 | - | router.with_state(app) | |
| 29 | + | router | |
| 30 | + | // Derives the per-request CSRF token so the layout can attach it to | |
| 31 | + | // htmx requests (hx-headers). Runs for all routes; cheap. | |
| 32 | + | .layer(axum::middleware::from_fn_with_state( | |
| 33 | + | app.clone(), | |
| 34 | + | auth::csrf_context, | |
| 35 | + | )) | |
| 36 | + | .with_state(app) | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | 39 | /// Bind to the configured HTTP address and serve until shutdown. | |
| ⋯ 13 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+189 −62
| ⋯ 1 unchanged line | |||
| 2 | 2 | //! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based | |
| 3 | 3 | //! progressive enhancement is a follow-up. | |
| 4 | 4 | ||
| 5 | + | use std::collections::HashMap; | |
| 5 | 6 | use std::path::PathBuf; | |
| 6 | 7 | use std::sync::OnceLock; | |
| 7 | 8 | ||
| 8 | - | use anvil_core::{ | |
| 9 | - | App, | |
| 10 | - | Repository, | |
| 11 | - | SshKey, | |
| 12 | - | User, | |
| 13 | - | access, | |
| 14 | - | repos, | |
| 15 | - | ssh_keys, | |
| 16 | - | users, | |
| 17 | - | }; | |
| 18 | - | use anvil_git::browse::{ | |
| 19 | - | self, | |
| 20 | - | ChangeKind, | |
| 21 | - | FileChange, | |
| 22 | - | }; | |
| 9 | + | use anvil_core::{App, CiRun, Repository, SshKey, User, access, ci, repos, ssh_keys, users}; | |
| 10 | + | use anvil_git::browse::{self, ChangeKind, FileChange}; | |
| 23 | 11 | use axum::{ | |
| 24 | - | Form, | |
| 25 | - | Router, | |
| 26 | - | extract::{ | |
| 27 | - | Path, | |
| 28 | - | State, | |
| 29 | - | }, | |
| 30 | - | http::{ | |
| 31 | - | StatusCode, | |
| 32 | - | header, | |
| 33 | - | }, | |
| 34 | - | response::{ | |
| 35 | - | IntoResponse, | |
| 36 | - | Redirect, | |
| 37 | - | Response, | |
| 38 | - | }, | |
| 39 | - | routing::{ | |
| 40 | - | get, | |
| 41 | - | post, | |
| 42 | - | }, | |
| 43 | - | }; | |
| 44 | - | use maud::{ | |
| 45 | - | DOCTYPE, | |
| 46 | - | Markup, | |
| 47 | - | PreEscaped, | |
| 48 | - | html, | |
| 49 | - | }; | |
| 50 | - | use similar::{ | |
| 51 | - | ChangeTag, | |
| 52 | - | TextDiff, | |
| 12 | + | Form, Router, | |
| 13 | + | extract::{Path, State}, | |
| 14 | + | http::{StatusCode, header}, | |
| 15 | + | response::{IntoResponse, Redirect, Response}, | |
| 16 | + | routing::{get, post}, | |
| 53 | 17 | }; | |
| 18 | + | use maud::{DOCTYPE, Markup, PreEscaped, html}; | |
| 19 | + | use similar::{ChangeTag, TextDiff}; | |
| 54 | 20 | use syntect::easy::HighlightLines; | |
| 55 | - | use syntect::highlighting::{ | |
| 56 | - | Theme, | |
| 57 | - | ThemeSet, | |
| 58 | - | }; | |
| 59 | - | use syntect::html::{ | |
| 60 | - | IncludeBackground, | |
| 61 | - | styled_line_to_highlighted_html, | |
| 62 | - | }; | |
| 21 | + | use syntect::highlighting::{Theme, ThemeSet}; | |
| 22 | + | use syntect::html::{IncludeBackground, styled_line_to_highlighted_html}; | |
| 63 | 23 | use syntect::parsing::SyntaxSet; | |
| 64 | 24 | use time::OffsetDateTime; | |
| 65 | 25 | ||
| 66 | - | use crate::auth::CurrentUser; | |
| 26 | + | use crate::auth::{CSRF_FIELD, Csrf, CurrentUser, verify_csrf}; | |
| 67 | 27 | ||
| 68 | 28 | const STYLE: &str = r#" | |
| 69 | 29 | :root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; } | |
| ⋯ 51 unchanged lines | |||
| 121 | 81 | table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; } | |
| 122 | 82 | .badge { font-size:11px; border-radius:3px; padding:1px 6px; } | |
| 123 | 83 | .badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; } | |
| 84 | + | .st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; } | |
| 85 | + | .st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; } | |
| 86 | + | .st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; } | |
| 87 | + | .log { background:#0d1117; color:#e6edf3; border-radius:6px; padding:14px 16px; overflow-x:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; margin:0; } | |
| 124 | 88 | footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; } | |
| 125 | 89 | "#; | |
| 126 | 90 | ||
| ⋯ 50 unchanged lines | |||
| 177 | 141 | .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob)) | |
| 178 | 142 | .route("/{owner}/{repo}/commits/{rev}", get(commits)) | |
| 179 | 143 | .route("/{owner}/{repo}/commit/{id}", get(commit)) | |
| 144 | + | .route("/{owner}/{repo}/ci", get(ci_runs)) | |
| 145 | + | .route("/{owner}/{repo}/ci/{id}", get(ci_run)) | |
| 180 | 146 | .route("/-/static/htmx.min.js", get(htmx_js)) | |
| 181 | 147 | } | |
| 182 | 148 | ||
| ⋯ 10 unchanged lines | |||
| 193 | 159 | } | |
| 194 | 160 | ||
| 195 | 161 | pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup { | |
| 162 | + | // Attach the session's CSRF token to every htmx request as a header, so any | |
| 163 | + | // JS-driven action carries it without a hidden field. Omitted (no attribute) | |
| 164 | + | // when unauthenticated. The token is hex, so it needs no JSON escaping. | |
| 165 | + | let csrf = crate::auth::current_csrf(); | |
| 166 | + | let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#)); | |
| 196 | 167 | html! { | |
| 197 | 168 | (DOCTYPE) | |
| 198 | 169 | html lang="en" { | |
| ⋯ 3 unchanged lines | |||
| 202 | 173 | title { (title) " · anvil" } | |
| 203 | 174 | style { (PreEscaped(STYLE)) } | |
| 204 | 175 | } | |
| 205 | - | body hx-boost="true" { | |
| 176 | + | body hx-boost="true" hx-headers=[hx_headers] { | |
| 206 | 177 | header.top { div.container { | |
| 207 | 178 | a.brand href="/" { "anvil" } | |
| 208 | 179 | span style="margin-left:auto" { | |
| ⋯ 18 unchanged lines | |||
| 227 | 198 | } | |
| 228 | 199 | } | |
| 229 | 200 | ||
| 201 | + | /// Hidden CSRF token field for embedding inside a mutating `<form>`. | |
| 202 | + | pub(crate) fn csrf_input(token: &str) -> Markup { | |
| 203 | + | html! { input type="hidden" name=(CSRF_FIELD) value=(token); } | |
| 204 | + | } | |
| 205 | + | ||
| 230 | 206 | fn not_found(message: &str) -> Response { | |
| 231 | 207 | ( | |
| 232 | 208 | StatusCode::NOT_FOUND, | |
| ⋯ 141 unchanged lines | |||
| 374 | 350 | #[serde(default)] | |
| 375 | 351 | title: String, | |
| 376 | 352 | key: String, | |
| 353 | + | #[serde(default)] | |
| 354 | + | csrf: String, | |
| 377 | 355 | } | |
| 378 | 356 | ||
| 379 | 357 | /// `GET /settings` — account settings: profile + SSH keys. | |
| 380 | - | async fn account_settings(State(app): State<App>, CurrentUser(user): CurrentUser) -> Response { | |
| 358 | + | async fn account_settings( | |
| 359 | + | State(app): State<App>, | |
| 360 | + | CurrentUser(user): CurrentUser, | |
| 361 | + | csrf: Csrf, | |
| 362 | + | ) -> Response { | |
| 381 | 363 | let Some(user) = user else { | |
| 382 | 364 | return Redirect::to("/-/login").into_response(); | |
| 383 | 365 | }; | |
| ⋯ 1 unchanged line | |||
| 385 | 367 | Ok(keys) => keys, | |
| 386 | 368 | Err(e) => return server_error(e), | |
| 387 | 369 | }; | |
| 388 | - | account_page(&user, &keys, None).into_response() | |
| 370 | + | account_page(&user, &keys, None, &csrf.0).into_response() | |
| 389 | 371 | } | |
| 390 | 372 | ||
| 391 | 373 | /// `POST /settings/keys` — register an SSH public key for the current user. | |
| 392 | 374 | async fn add_ssh_key( | |
| 393 | 375 | State(app): State<App>, | |
| 394 | 376 | CurrentUser(user): CurrentUser, | |
| 377 | + | csrf: Csrf, | |
| 395 | 378 | Form(form): Form<AddKeyForm>, | |
| 396 | 379 | ) -> Response { | |
| 397 | 380 | let Some(user) = user else { | |
| 398 | 381 | return Redirect::to("/-/login").into_response(); | |
| 399 | 382 | }; | |
| 383 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 384 | + | return resp; | |
| 385 | + | } | |
| 400 | 386 | let result = match ssh_keys::parse_public_key(&form.key) { | |
| 401 | 387 | Ok((fingerprint, content)) => { | |
| 402 | 388 | ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content) | |
| ⋯ 10 unchanged lines | |||
| 413 | 399 | .unwrap_or_default(); | |
| 414 | 400 | ( | |
| 415 | 401 | StatusCode::BAD_REQUEST, | |
| 416 | - | account_page(&user, &keys, Some(&e.to_string())), | |
| 402 | + | account_page(&user, &keys, Some(&e.to_string()), &csrf.0), | |
| 417 | 403 | ) | |
| 418 | 404 | .into_response() | |
| 419 | 405 | } | |
| ⋯ 4 unchanged lines | |||
| 424 | 410 | async fn delete_ssh_key( | |
| 425 | 411 | State(app): State<App>, | |
| 426 | 412 | CurrentUser(user): CurrentUser, | |
| 413 | + | csrf: Csrf, | |
| 427 | 414 | Path(id): Path<i64>, | |
| 415 | + | Form(form): Form<crate::auth::CsrfForm>, | |
| 428 | 416 | ) -> Response { | |
| 429 | 417 | let Some(user) = user else { | |
| 430 | 418 | return Redirect::to("/-/login").into_response(); | |
| 431 | 419 | }; | |
| 420 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 421 | + | return resp; | |
| 422 | + | } | |
| 432 | 423 | if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await { | |
| 433 | 424 | return server_error(e); | |
| 434 | 425 | } | |
| 435 | 426 | Redirect::to("/-/settings").into_response() | |
| 436 | 427 | } | |
| 437 | 428 | ||
| 438 | - | fn account_page(user: &User, keys: &[SshKey], error: Option<&str>) -> Markup { | |
| 429 | + | fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup { | |
| 439 | 430 | layout( | |
| 440 | 431 | "Account settings", | |
| 441 | 432 | Some(user), | |
| ⋯ 19 unchanged lines | |||
| 461 | 452 | div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) } | |
| 462 | 453 | } | |
| 463 | 454 | form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) { | |
| 455 | + | (csrf_input(csrf)) | |
| 464 | 456 | button.linkbtn type="submit" { "delete" } | |
| 465 | 457 | } | |
| 466 | 458 | } | |
| ⋯ 2 unchanged lines | |||
| 469 | 461 | } | |
| 470 | 462 | ||
| 471 | 463 | form.stack method="post" action="/-/settings/keys" style="margin-top:16px" { | |
| 464 | + | (csrf_input(csrf)) | |
| 472 | 465 | p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } } | |
| 473 | 466 | p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } } | |
| 474 | 467 | p { button.btn type="submit" { "Add SSH key" } } | |
| ⋯ 20 unchanged lines | |||
| 495 | 488 | #[serde(default)] | |
| 496 | 489 | description: String, | |
| 497 | 490 | private: Option<String>, | |
| 491 | + | #[serde(default)] | |
| 492 | + | csrf: String, | |
| 498 | 493 | } | |
| 499 | 494 | ||
| 500 | 495 | #[derive(serde::Deserialize)] | |
| ⋯ 1 unchanged line | |||
| 502 | 497 | #[serde(default)] | |
| 503 | 498 | description: String, | |
| 504 | 499 | private: Option<String>, | |
| 500 | + | #[serde(default)] | |
| 501 | + | csrf: String, | |
| 505 | 502 | } | |
| 506 | 503 | ||
| 507 | 504 | /// `GET /new` — new-repository form (requires login). | |
| 508 | - | async fn new_repo_form(CurrentUser(user): CurrentUser) -> Response { | |
| 505 | + | async fn new_repo_form(CurrentUser(user): CurrentUser, csrf: Csrf) -> Response { | |
| 509 | 506 | let Some(user) = user else { | |
| 510 | 507 | return Redirect::to("/-/login").into_response(); | |
| 511 | 508 | }; | |
| 512 | - | new_repo_page(&user, None, "", "", false).into_response() | |
| 509 | + | new_repo_page(&user, None, "", "", false, &csrf.0).into_response() | |
| 513 | 510 | } | |
| 514 | 511 | ||
| 515 | 512 | /// `POST /new` — create a repository owned by the current user. | |
| 516 | 513 | async fn new_repo_submit( | |
| 517 | 514 | State(app): State<App>, | |
| 518 | 515 | CurrentUser(user): CurrentUser, | |
| 516 | + | csrf: Csrf, | |
| 519 | 517 | Form(form): Form<NewRepoForm>, | |
| 520 | 518 | ) -> Response { | |
| 521 | 519 | let Some(user) = user else { | |
| 522 | 520 | return Redirect::to("/-/login").into_response(); | |
| 523 | 521 | }; | |
| 522 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 523 | + | return resp; | |
| 524 | + | } | |
| 524 | 525 | let private = form.private.is_some(); | |
| 525 | 526 | match repos::create( | |
| 526 | 527 | &app.db, | |
| ⋯ 14 unchanged lines | |||
| 541 | 542 | &form.name, | |
| 542 | 543 | &form.description, | |
| 543 | 544 | private, | |
| 545 | + | &csrf.0, | |
| 544 | 546 | ), | |
| 545 | 547 | ) | |
| 546 | 548 | .into_response(), | |
| ⋯ 6 unchanged lines | |||
| 553 | 555 | name: &str, | |
| 554 | 556 | description: &str, | |
| 555 | 557 | private: bool, | |
| 558 | + | csrf: &str, | |
| 556 | 559 | ) -> Markup { | |
| 557 | 560 | layout( | |
| 558 | 561 | "New repository", | |
| ⋯ 2 unchanged lines | |||
| 561 | 564 | h1 { "New repository" } | |
| 562 | 565 | @if let Some(error) = error { p style="color:#cf222e" { (error) } } | |
| 563 | 566 | form.stack method="post" action="/-/new" { | |
| 567 | + | (csrf_input(csrf)) | |
| 564 | 568 | p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } } | |
| 565 | 569 | p { label { "Description" br; input type="text" name="description" value=(description); } } | |
| 566 | 570 | p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } } | |
| ⋯ 32 unchanged lines | |||
| 599 | 603 | async fn repo_settings( | |
| 600 | 604 | State(app): State<App>, | |
| 601 | 605 | CurrentUser(user): CurrentUser, | |
| 606 | + | csrf: Csrf, | |
| 602 | 607 | Path((owner, repo)): Path<(String, String)>, | |
| 603 | 608 | ) -> Response { | |
| 604 | 609 | let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await { | |
| 605 | 610 | Ok(m) => m, | |
| 606 | 611 | Err(resp) => return resp, | |
| 607 | 612 | }; | |
| 608 | - | settings_page(user.as_ref(), &owner, &repo, &meta, None).into_response() | |
| 613 | + | settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response() | |
| 609 | 614 | } | |
| 610 | 615 | ||
| 611 | 616 | /// `POST /{owner}/{repo}/settings` — update description / visibility. | |
| 612 | 617 | async fn repo_settings_submit( | |
| 613 | 618 | State(app): State<App>, | |
| 614 | 619 | CurrentUser(user): CurrentUser, | |
| 620 | + | csrf: Csrf, | |
| 615 | 621 | Path((owner, repo)): Path<(String, String)>, | |
| 616 | 622 | Form(form): Form<SettingsForm>, | |
| 617 | 623 | ) -> Response { | |
| ⋯ 1 unchanged line | |||
| 619 | 625 | Ok(m) => m, | |
| 620 | 626 | Err(resp) => return resp, | |
| 621 | 627 | }; | |
| 628 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 629 | + | return resp; | |
| 630 | + | } | |
| 622 | 631 | if let Err(e) = | |
| 623 | 632 | repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await | |
| 624 | 633 | { | |
| ⋯ 8 unchanged lines | |||
| 633 | 642 | repo: &str, | |
| 634 | 643 | meta: &Repository, | |
| 635 | 644 | error: Option<&str>, | |
| 645 | + | csrf: &str, | |
| 636 | 646 | ) -> Markup { | |
| 637 | 647 | layout( | |
| 638 | 648 | &format!("{owner}/{repo}: settings"), | |
| ⋯ 2 unchanged lines | |||
| 641 | 651 | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" } | |
| 642 | 652 | @if let Some(error) = error { p style="color:#cf222e" { (error) } } | |
| 643 | 653 | form.stack method="post" action=(format!("/{owner}/{repo}/settings")) { | |
| 654 | + | (csrf_input(csrf)) | |
| 644 | 655 | p { label { "Description" br; input type="text" name="description" value=(meta.description); } } | |
| 645 | 656 | p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } } | |
| 646 | 657 | p { button.btn type="submit" { "Save changes" } } | |
| ⋯ 48 unchanged lines | |||
| 695 | 706 | a href=(format!("/{owner}")) { (owner) } " / " (repo) | |
| 696 | 707 | @if meta.is_private { " " span.pill { "private" } } | |
| 697 | 708 | } | |
| 709 | + | a.btn href=(format!("/{owner}/{repo}/ci")) { "CI" } | |
| 698 | 710 | @if can_write { | |
| 699 | 711 | a.btn href=(format!("/{owner}/{repo}/settings")) { "Settings" } | |
| 700 | 712 | } | |
| ⋯ 191 unchanged lines | |||
| 892 | 904 | CurrentUser(user): CurrentUser, | |
| 893 | 905 | Path((owner, repo, rev)): Path<(String, String, String)>, | |
| 894 | 906 | ) -> Result<Markup, Response> { | |
| 895 | - | let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 907 | + | let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 896 | 908 | let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?; | |
| 909 | + | ||
| 910 | + | // Map each commit oid to its latest run status, for inline badges. One query | |
| 911 | + | // for the repo's recent runs; first match wins (list is newest-first). | |
| 912 | + | let runs = ci::list_by_repo(&app.db, meta.id, 200) | |
| 913 | + | .await | |
| 914 | + | .unwrap_or_default(); | |
| 915 | + | let mut status_of: HashMap<&str, &str> = HashMap::new(); | |
| 916 | + | for r in &runs { | |
| 917 | + | status_of | |
| 918 | + | .entry(r.commit.as_str()) | |
| 919 | + | .or_insert(r.status.as_str()); | |
| 920 | + | } | |
| 921 | + | ||
| 897 | 922 | Ok(layout( | |
| 898 | 923 | &format!("{owner}/{repo}: commits"), | |
| 899 | 924 | user.as_ref(), | |
| ⋯ 3 unchanged lines | |||
| 903 | 928 | @for c in &log { | |
| 904 | 929 | li { | |
| 905 | 930 | a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) } | |
| 931 | + | @if let Some(st) = status_of.get(c.id.as_str()) { | |
| 932 | + | a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) } | |
| 933 | + | } | |
| 906 | 934 | span { (c.summary) } | |
| 907 | 935 | span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) } | |
| 908 | 936 | } | |
| ⋯ 34 unchanged lines | |||
| 943 | 971 | )) | |
| 944 | 972 | } | |
| 945 | 973 | ||
| 974 | + | /// `GET /{owner}/{repo}/ci` — recent CI runs for the repository. | |
| 975 | + | async fn ci_runs( | |
| 976 | + | State(app): State<App>, | |
| 977 | + | CurrentUser(user): CurrentUser, | |
| 978 | + | Path((owner, repo)): Path<(String, String)>, | |
| 979 | + | ) -> Result<Markup, Response> { | |
| 980 | + | let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 981 | + | let runs = ci::list_by_repo(&app.db, meta.id, 100) | |
| 982 | + | .await | |
| 983 | + | .map_err(server_error)?; | |
| 984 | + | Ok(layout( | |
| 985 | + | &format!("{owner}/{repo}: CI"), | |
| 986 | + | user.as_ref(), | |
| 987 | + | html! { | |
| 988 | + | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" } | |
| 989 | + | @if runs.is_empty() { | |
| 990 | + | p.muted { | |
| 991 | + | "No CI runs yet. Add a " code { ".anvil/ci.yml" } | |
| 992 | + | " pipeline and push to trigger one." | |
| 993 | + | } | |
| 994 | + | } @else { | |
| 995 | + | div.box { | |
| 996 | + | @for r in &runs { | |
| 997 | + | div.row { | |
| 998 | + | a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) { | |
| 999 | + | (status_badge(&r.status)) | |
| 1000 | + | span.sha { (short_commit(&r.commit)) } | |
| 1001 | + | span { (r.ref_name) } | |
| 1002 | + | } | |
| 1003 | + | span.muted { (fmt_time(r.created_at)) } | |
| 1004 | + | } | |
| 1005 | + | } | |
| 1006 | + | } | |
| 1007 | + | } | |
| 1008 | + | }, | |
| 1009 | + | )) | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | /// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output. | |
| 1013 | + | async fn ci_run( | |
| 1014 | + | State(app): State<App>, | |
| 1015 | + | CurrentUser(user): CurrentUser, | |
| 1016 | + | Path((owner, repo, id)): Path<(String, String, i64)>, | |
| 1017 | + | ) -> Result<Markup, Response> { | |
| 1018 | + | let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 1019 | + | let run = ci::get(&app.db, id) | |
| 1020 | + | .await | |
| 1021 | + | .map_err(server_error)? | |
| 1022 | + | .filter(|r| r.repo_id == meta.id) | |
| 1023 | + | .ok_or_else(|| not_found("no such CI run"))?; | |
| 1024 | + | Ok(layout( | |
| 1025 | + | &format!("{owner}/{repo}: CI #{}", run.id), | |
| 1026 | + | user.as_ref(), | |
| 1027 | + | html! { | |
| 1028 | + | h1 { | |
| 1029 | + | a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } | |
| 1030 | + | " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" } | |
| 1031 | + | " · #" (run.id) | |
| 1032 | + | } | |
| 1033 | + | p { | |
| 1034 | + | (status_badge(&run.status)) | |
| 1035 | + | " " | |
| 1036 | + | a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) } | |
| 1037 | + | " " span.muted { (run.ref_name) } | |
| 1038 | + | } | |
| 1039 | + | p.muted { | |
| 1040 | + | "queued " (fmt_time(run.created_at)) | |
| 1041 | + | @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) } | |
| 1042 | + | @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) } | |
| 1043 | + | @if let Some(d) = run_duration(&run) { " · took " (d) } | |
| 1044 | + | } | |
| 1045 | + | @if run.log.is_empty() { | |
| 1046 | + | p.muted { "No output yet." } | |
| 1047 | + | } @else { | |
| 1048 | + | pre.log { (run.log) } | |
| 1049 | + | } | |
| 1050 | + | }, | |
| 1051 | + | )) | |
| 1052 | + | } | |
| 1053 | + | ||
| 1054 | + | /// A coloured status pill for a CI run status string. | |
| 1055 | + | fn status_badge(status: &str) -> Markup { | |
| 1056 | + | html! { span class=(format!("st {status}")) { (status) } } | |
| 1057 | + | } | |
| 1058 | + | ||
| 1059 | + | /// First 8 hex chars of a commit oid (for compact display). | |
| 1060 | + | fn short_commit(commit: &str) -> &str { | |
| 1061 | + | &commit[..commit.len().min(8)] | |
| 1062 | + | } | |
| 1063 | + | ||
| 1064 | + | /// Wall-clock run duration (`started`→`finished`) as a short string, if known. | |
| 1065 | + | fn run_duration(run: &CiRun) -> Option<String> { | |
| 1066 | + | if run.started_at > 0 && run.finished_at >= run.started_at { | |
| 1067 | + | Some(format!("{}s", run.finished_at - run.started_at)) | |
| 1068 | + | } else { | |
| 1069 | + | None | |
| 1070 | + | } | |
| 1071 | + | } | |
| 1072 | + | ||
| 946 | 1073 | /// Render one file's diff (added/deleted/modified) as a unified line diff. | |
| 947 | 1074 | fn render_file_diff(change: &FileChange) -> Markup { | |
| 948 | 1075 | let (badge_cls, badge) = match change.kind { | |
| ⋯ 107 unchanged lines | |||
modifiedcrates/anvil/Cargo.toml+1 −0
| ⋯ 15 unchanged lines | |||
| 16 | 16 | anvil-core.workspace = true | |
| 17 | 17 | anvil-web.workspace = true | |
| 18 | 18 | anvil-ssh.workspace = true | |
| 19 | + | anvil-ci.workspace = true | |
| 19 | 20 | tokio.workspace = true | |
| 20 | 21 | clap.workspace = true | |
| 21 | 22 | anyhow.workspace = true | |
| ⋯ 2 unchanged lines | |||
modifiedcrates/anvil/src/main.rs+11 −16
| 1 | 1 | //! `anvild` — the anvil git forge daemon and admin CLI. | |
| 2 | 2 | ||
| 3 | - | use anvil_core::{ | |
| 4 | - | App, | |
| 5 | - | Config, | |
| 6 | - | repos, | |
| 7 | - | ssh_keys, | |
| 8 | - | users, | |
| 9 | - | }; | |
| 10 | - | use anyhow::{ | |
| 11 | - | Context, | |
| 12 | - | Result, | |
| 13 | - | }; | |
| 14 | - | use clap::{ | |
| 15 | - | Parser, | |
| 16 | - | Subcommand, | |
| 17 | - | }; | |
| 3 | + | use anvil_core::{App, Config, repos, ssh_keys, users}; | |
| 4 | + | use anyhow::{Context, Result}; | |
| 5 | + | use clap::{Parser, Subcommand}; | |
| 18 | 6 | ||
| 19 | 7 | #[derive(Parser)] | |
| 20 | 8 | #[command(name = "anvild", version, about = "anvil git forge")] | |
| ⋯ 92 unchanged lines | |||
| 113 | 101 | } | |
| 114 | 102 | ||
| 115 | 103 | async fn serve(config: Config) -> Result<()> { | |
| 116 | - | let app = App::bootstrap(config).await?; | |
| 104 | + | let mut app = App::bootstrap(config).await?; | |
| 105 | + | ||
| 106 | + | // Start the CI runner: it drains queued runs and processes new ones pushed | |
| 107 | + | // through `app.ci_tx` (set here so handlers can notify it). | |
| 108 | + | let (ci_tx, ci_rx) = tokio::sync::mpsc::unbounded_channel(); | |
| 109 | + | app.ci_tx = Some(ci_tx); | |
| 110 | + | tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx)); | |
| 111 | + | ||
| 117 | 112 | if app.config.ssh.enabled { | |
| 118 | 113 | // Run the HTTP and SSH servers concurrently; if either exits, stop. | |
| 119 | 114 | tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?; | |
| ⋯ 95 unchanged lines | |||
modifieddeploy/run.sh+11 −1
| ⋯ 8 unchanged lines | |||
| 9 | 9 | IMAGE="${ANVIL_IMAGE:-anvil:latest}" | |
| 10 | 10 | NETWORK="${ANVIL_NETWORK:-hagrid}" | |
| 11 | 11 | SSH_PORT="${ANVIL_SSH_PORT:-2222}" | |
| 12 | + | DOCKER_SOCK="${ANVIL_DOCKER_SOCK:-/var/run/docker.sock}" | |
| 13 | + | ||
| 14 | + | # The CI runner drives Docker via the host socket. Mount it in, and add the | |
| 15 | + | # socket's group to the non-root `anvil` user so it can actually open it. | |
| 16 | + | # NOTE: socket access = root-equivalent on the host. We accept this because | |
| 17 | + | # anvil is a single-tenant, owner-operated forge; CI only runs code the owner | |
| 18 | + | # pushed. Do not expose this instance to untrusted users. | |
| 19 | + | SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK")" | |
| 12 | 20 | ||
| 13 | 21 | docker rm -f anvil 2>/dev/null || true | |
| 14 | 22 | docker run -d \ | |
| ⋯ 2 unchanged lines | |||
| 17 | 25 | --restart unless-stopped \ | |
| 18 | 26 | -p "${SSH_PORT}:2222" \ | |
| 19 | 27 | -v anvil-data:/data \ | |
| 28 | + | -v "${DOCKER_SOCK}:/var/run/docker.sock" \ | |
| 29 | + | --group-add "$SOCK_GID" \ | |
| 20 | 30 | "$IMAGE" | |
| 21 | 31 | ||
| 22 | - | echo "==> anvil (re)started from $IMAGE (web: anvil:3000 via Caddy, ssh: host :${SSH_PORT})" | |
| 32 | + | echo "==> anvil (re)started from $IMAGE (web: anvil:3000 via Caddy, ssh: host :${SSH_PORT}, docker.sock gid ${SOCK_GID})" | |