diff --git a/.github/workflows/docker-windows.yaml b/.github/workflows/docker-windows.yaml index 8514b872..813e494e 100644 --- a/.github/workflows/docker-windows.yaml +++ b/.github/workflows/docker-windows.yaml @@ -155,12 +155,32 @@ jobs: # shellcheck disable=SC2086 docker build $args . + # Report the runner's disk once the test image is built, as the space the compile below has left to fit in. + - name: Report disk before cargo-test + if: matrix.base == 'servercore' + run: | + df -h /c + docker system df + # Run the Rust tests, excluding et-ws-web-runner on the gnullvm target. - # CARGO_INCREMENTAL=0 has to be passed into the container, not just set on the job: this compiles from - # scratch inside `docker run` (Dockerfile.windows's precompile stage deletes `target` after building the - # modules), so the incremental caches land on the runner's C: drive. They are pure waste on a - # single-shot CI compile and this lane has no disk to spare -- servercore ran out mid-link with - # `There is not enough space on the disk. (os error 112)` even after the disk-free step left 43 GB. + # This compiles from scratch inside `docker run` (Dockerfile.windows's precompile stage deletes `target` after + # building the modules), so the whole target dir lands in the container, on the runner's C: drive beside the + # test image. This lane has no disk to spare, so two savings are passed into the container rather than set on + # the job. CARGO_INCREMENTAL=0 drops the incremental caches, pure waste on a single-shot compile. Debug info is + # cut to line tables, which keeps file and line in a backtrace and sheds the bulk of every statically linked test + # binary; no test reads the rest. The container is named rather than run with `--rm`, so the disk report after + # this step still counts what it wrote. Servercore first ran out mid-link with 43 GB left by the disk-free step: + # There is not enough space on the disk. (os error 112) + # and again with CARGO_INCREMENTAL=0 alone once the suite reached 171 test binaries, on the windows-2025 runner + # only: its 150 GB C: drive has 76 GB free after the disk-free step, to windows-2022's 153 GB. There the error + # also came out with its message lookup failing, and rustc then died paging in its own code: + # OS Error 112 (FormatMessageW() returned error 433) (os error 112) + # process didn't exit successfully: ... (exit code: 0xc0000006, STATUS_IN_PAGE_ERROR) + # Observed twice on commit + # https://github.com/edge-toolkit/core/commit/90ab81a9baa47bae3683cacc8dd8a3450edd7e75, at + # https://github.com/edge-toolkit/core/actions/runs/42530061047/job/128882052073 and + # https://github.com/edge-toolkit/core/actions/runs/42530061047/job/128910749803. + # # The k3s generator tests are skipped here, and only here. # `not test(k3s)` names the two cases in et-cli::scenario_generation whose names carry it, leaving the # deployment-type test that also writes a k3s.yaml in place, so the generator still runs on this lane. The @@ -193,9 +213,17 @@ jobs: - name: Run cargo-test if: matrix.base == 'servercore' run: | - args="--rm --pull=never -e CARGO_INCREMENTAL=0" + args="--pull=never --name et-windows-cargo-test -e CARGO_INCREMENTAL=0" + args="$args -e CARGO_PROFILE_DEV_DEBUG=line-tables-only -e CARGO_PROFILE_TEST_DEBUG=line-tables-only" scenarios='not test(pyo3_math1_scenario) and not test(wasi_math1_scenario)' skip="not test(k3s) and $scenarios" # $args is a word-split flag list by design; do not quote it. # shellcheck disable=SC2086 docker run $args et-windows-test mise run cargo-test -- -E "$skip" + + # The same report after the tests, on a failure too, since running out of disk is the failure it exists to show. + - name: Report disk after cargo-test + if: always() && matrix.base == 'servercore' + run: | + df -h /c + docker system df diff --git a/.mise/config.toml b/.mise/config.toml index 79becfbd..092ac9a2 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -184,6 +184,8 @@ pipx = { version = "latest", os = ["linux", "macos"] } # sys.path (see `edge_toolkit::config::mise_python_site_packages`). gh = "latest" "pipx:cowsay" = "latest" +# Lints the Jinja templates et-cli renders its deployment files from. +"pipx:j2lint" = "latest" # semgrep 1.182.0 is the last release with a macOS x86_64 wheel, so second-tier macos/x64 stays on it. # Upstream's 1.183.0 notes: "Semgrep no longer ships binaries for Intel Macs; 1.182 is the last release that # supports them." @@ -938,6 +940,7 @@ depends = [ "gen-specs-check", "gitleaks-check", "hadolint-check", + "j2lint-check", "jscpd-check", "kubeconform-check", "link-check", @@ -1132,6 +1135,12 @@ git ls-files '*Dockerfile' '*Dockerfile.*' | rg -v '\\.dockerignore$' | """ shell = "{{ vars.task_shell }}" +[tasks.j2lint-check] +description = "Lint the Jinja templates with j2lint" +# Every rule is on: whitespace inside a tag never reaches a rendered file, so its indentation rules cost nothing. +run = "git ls-files '*.j2' '*.jinja' '*.jinja2' | xargs j2lint" +shell = "{{ vars.task_shell }}" + # Duplication is gated as a fingerprint ratchet, not as a percentage. # config/jscpd-baseline.json records the fingerprint of every clone that already existed, and only a clone absent # from it fails, so nothing is permanently silenced and the ledger shrinks as duplication is removed -- diff --git a/.mise/locks/pipx-j2lint/1.3.0/pyproject.toml b/.mise/locks/pipx-j2lint/1.3.0/pyproject.toml new file mode 100644 index 00000000..ab1b859f --- /dev/null +++ b/.mise/locks/pipx-j2lint/1.3.0/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "mise-pypi-tool-environment" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = ["j2lint==1.3.0"] diff --git a/.mise/locks/pipx-j2lint/1.3.0/uv.lock b/.mise/locks/pipx-j2lint/1.3.0/uv.lock new file mode 100644 index 00000000..031dc306 --- /dev/null +++ b/.mise/locks/pipx-j2lint/1.3.0/uv.lock @@ -0,0 +1,228 @@ +version = 1 +revision = 5 +requires-python = ">=3.10" + +[options] +exclude-newer = "2026-11-07T19:17:09.891024Z" + +[[package]] +name = "j2lint" +version = "1.3.0" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "jinja2" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9d/b92e24a651013e8862ca6d3571d4f63bc98b870d40c965434735ff04eb35/j2lint-1.3.0.tar.gz", hash = "sha256:d6dd95fd4045efc71b1586a321163ae5988b6b0724ca51a1e2e7eeefa8cc9638", size = 34334, upload-time = "2026-07-30T12:24:01.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/be/4532ef928bf8df39813e38395598d24a1b0d276750156819d719a2e972e1/j2lint-1.3.0-py3-none-any.whl", hash = "sha256:fe552eb1184083f28acc72cf070c57caa7db67029f4869b4e096a9f4e253649f", size = 35721, upload-time = "2026-07-30T12:24:00.106Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/aa/9a65962e364bf19745f6bec7bde398fb1f5ca53ad2e734258e6198cc32ba/markupsafe-3.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889", size = 11521, upload-time = "2026-10-02T23:04:20.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/36/927999a34b7d1def6957de89153d327fedc4030061940b5a468584e6c5a8/markupsafe-3.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2", size = 12066, upload-time = "2026-10-02T23:04:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/69e7b0db9bd687bfd83451eed5666384d67cfed3be062bfc59a06a15474e/markupsafe-3.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a", size = 22081, upload-time = "2026-10-02T23:04:22.221Z" }, + { url = "https://files.pythonhosted.org/packages/bd/14/f6f5c97903f7d2db76bbfaced31509a47d360a103b3aaf4849f6536591ec/markupsafe-3.0.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc", size = 26079, upload-time = "2026-10-02T23:04:23.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/04/c3cc9b75f94f8b54d7e503c44cebd4b4a115ec1d6f1b996b999807daba99/markupsafe-3.0.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8", size = 22611, upload-time = "2026-10-02T23:04:24.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/26/c4708ed3b0f08e8e6d7cbce3314ac130cb5352d1f62951b6fef7878f2b1e/markupsafe-3.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9", size = 20712, upload-time = "2026-10-02T23:04:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/f3894d6aae3d7a52c9363317f4baf2fcadc052163d6dbc871266e32639ed/markupsafe-3.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a", size = 20742, upload-time = "2026-10-02T23:04:26.403Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/8e248ddbfe286ab6bddb462bdac0851cbb1510d2cbbb815a415fcb0511af/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36", size = 21560, upload-time = "2026-10-02T23:04:27.522Z" }, + { url = "https://files.pythonhosted.org/packages/df/26/2353fef7d4bcf2b18e16bad81979fcecff2915156ca6882447917205b8e2/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be", size = 20973, upload-time = "2026-10-02T23:04:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/67/6f/a9561d98d9a6ee3494b0b970a1c766e58bab128cc84841d56ec009456dbd/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa", size = 22036, upload-time = "2026-10-02T23:04:29.568Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/6217df9192eca95af3ff0cad955c9854a93fa43b288cf7411ae24713eb52/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9", size = 20314, upload-time = "2026-10-02T23:04:30.881Z" }, + { url = "https://files.pythonhosted.org/packages/35/7c/9cd8081dae70e17fdab3558121fa618d459c7950c8abd8fc10fc024486d7/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a", size = 20780, upload-time = "2026-10-02T23:04:31.975Z" }, + { url = "https://files.pythonhosted.org/packages/44/37/c5f2f45f4d8f0c24e4af7b9dc711640c888044cbf5f7e2ba2c0bb74cbb55/markupsafe-3.0.4-cp310-cp310-win32.whl", hash = "sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278", size = 14096, upload-time = "2026-10-02T23:04:33.129Z" }, + { url = "https://files.pythonhosted.org/packages/50/50/394a1c61c9b9972cb4f76875c332af7109bd39bdad09d3a0ec5327a339dc/markupsafe-3.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7", size = 14329, upload-time = "2026-10-02T23:04:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9a/86d03d2f32ba41a57baa124a7bcb76d0a6d39b1622e9bd4d550074e8b61a/markupsafe-3.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf", size = 14196, upload-time = "2026-10-02T23:04:35.201Z" }, + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mise-pypi-tool-environment" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "j2lint" }, +] + +[package.metadata] +requires-dist = [{ name = "j2lint", specifier = "==1.3.0" }] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] diff --git a/.mise/mise.lock b/.mise/mise.lock index f8755994..eecd0c41 100644 --- a/.mise/mise.lock +++ b/.mise/mise.lock @@ -1893,6 +1893,12 @@ backend = "pipx:cowsay" specifiers = ["latest"] uv = { path = "locks/pipx-cowsay/6.1", digest = "sha256:2a29956e6d4f94b328bced749020ca46f5cc66385a4b99198c3656e4b15e78e8" } +[[tools."pipx:j2lint"]] +version = "1.3.0" +backend = "pipx:j2lint" +specifiers = ["latest"] +uv = { path = "locks/pipx-j2lint/1.3.0", digest = "sha256:c0597cf2238deab07acc530190fa10f3b0fc51a9572b6af7ece5914bb8763059" } + [[tools."pipx:semgrep"]] version = "1.183.0" backend = "pipx:semgrep" diff --git a/CHECKS.md b/CHECKS.md index ffa3befc..eebbfae8 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -1,11 +1,11 @@ # Checks - **Generated:** 2026-11-09 -- **Checks:** 83 -- **Tools:** 48 +- **Checks:** 84 +- **Tools:** 49 - **Shell-script checks:** 10 - **Checks with a repair pass:** 22 -- **Rules:** 376 custom local rules, or non-default strict linter settings +- **Rules:** 377 custom local rules, or non-default strict linter settings - **Meta rules:** 21 of those rules are rules about rules Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config @@ -815,6 +815,10 @@ Rules, from `config/conftest/policy`: - `hadolint-check` (`default` env; run by `check:rust`) -- Lint the Dockerfiles with hadolint. Config: `config/hadolint.yaml`. +## `j2lint` + +- `j2lint-check` (`default` env; run by `check:rust`) -- Lint the Jinja templates with j2lint. + ## `jscpd` - `jscpd-check` (`default` env; run by `check:rust`) -- Detect copy/pasted code across every language (jscpd). Config: @@ -1034,6 +1038,8 @@ Rules, from `config/semgrep`: - `no-hadolint-mentions` -- Do not name the Dockerfile linter outside its config, its `hadolint ignore=` pragmas, and the code that emits them. Say what the instruction does and why it is written that way; a pragma already carries the code it suppresses. +- `no-j2lint-mentions` -- Do not name the Jinja linter outside the files that install and run it. Say what a template + renders and why it is written that way, rather than which linter asked for its layout. - `no-jscpd-mentions` -- Do not name the duplication checker outside its config, its baseline, and the policy that ratchets them. A reported clone is a request to remove duplication: factor the shared part out so it exists once. Where a clone is genuinely irreducible -- the same few lines every caller must write verbatim -- leave the code plain diff --git a/Cargo.lock b/Cargo.lock index f013c530..3f806776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5749,12 +5749,14 @@ dependencies = [ "fs-err", "jiff", "k8s-openapi", + "minijinja", "pretty_yaml", "rand 0.10.3", "rand_chacha 0.10.0", "reqwest", "serde", "serde-env", + "serde-saphyr", "serde_json", "serde_yaml", "sha2 0.11.1", @@ -9140,6 +9142,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "minijinja" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509eeb2603a84eccabb4d75f6ec8ba23b46bbf441aaeb863d6b83a5887fd51e4" +dependencies = [ + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -9428,6 +9439,12 @@ dependencies = [ "url", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -12206,6 +12223,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-saphyr" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43fb82d550b07e6f929a58d7d0935182602f48ddead875f5ee313c41c8f07fe" +dependencies = [ + "base64 0.23.1", + "nohash-hasher", + "num-traits", + "serde_core", + "zmij", +] + [[package]] name = "serde_bytes" version = "0.11.19" diff --git a/Cargo.toml b/Cargo.toml index 625fa893..1455b26c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -196,6 +196,16 @@ log = "0.4" # `initializeValueProfRuntimeRecord`. Bump both together, never one alone; the `=` lifts once wasm-bindgen-test # accepts a newer minicov. minicov = "=0.3.8" +# Renders et-cli's deployment templates. +# `serde` renders them from the generators' own structs, `multi_template` lets one include another, and `debug` +# points a failed render at the template line it failed on. +minijinja = { version = "3.1", default-features = false, features = [ + "builtins", + "debug", + "macros", + "multi_template", + "serde", +] } # Third-party OTLP mock collector, wrapped by et-test-otlp for the workspace's integration tests. # Serves the spec paths (/v1/traces, ...), so a consumer points `collector_url` at the mock's own `/v1` base # for `et-otlp`'s `{collector_url}/traces` shape to land on them. @@ -269,6 +279,8 @@ semver = "1" serde = { version = "1.0", default-features = false, features = ["derive"] } serde-env = "0.3" serde-inline-default = "1.0" +# Writes YAML only for now, so the parser behind its `deserialize` feature stays out of the build. +serde-saphyr = { version = "2.1", default-features = false, features = ["serialize"] } serde_default = "0.2" serde_json = { version = "1", default-features = false } serde_path_to_error = "0.1" @@ -566,6 +578,10 @@ ignore = [ # ndk-sys unmaintained upstream; Android-only, reached via wgpu-hal (deno_webgpu -> wgpu). # Never compiled on our target platforms. "ndk-sys", + # Archived upstream at paritytech/nohash-hasher, untouched since 2020: a pass-through `Hasher` for integer keys. + # Pulled only by serde-saphyr's `serialize` feature, which et-cli writes compose.yaml with. serde-saphyr 2.1.1 is + # the newest release and its main branch still requires it, so no upgrade drops it. + "nohash-hasher", # Upstream dignifiedquire/num-bigint reached a year without a commit on 2026-11-06; 0.8.6 still requires rand ^0.8. # Pulled by rsa 0.9 (deno_crypto, deno_node_crypto), dsa and deno_node_crypto, all under et-ws-web-runner's Deno. # rsa 0.10, still a release candidate, drops it for crypto-bigint; this goes once deno_crypto moves to rsa 0.10. diff --git a/Dockerfile.nanoserver b/Dockerfile.nanoserver index 8df5f180..4f2ab7b7 100644 --- a/Dockerfile.nanoserver +++ b/Dockerfile.nanoserver @@ -418,7 +418,7 @@ RUN (if exist C:\token\gh_token set /p GITHUB_TOKEN=- + Do not name the Jinja linter outside the files that install and run it. Say what a template renders and why + it is written that way, rather than which linter asked for its layout. + severity: ERROR + - id: no-jscpd-mentions languages: [generic] paths: @@ -644,7 +658,7 @@ rules: - "/Dockerfile.nanoserver" # Same, plus the tool's own MISE_DISABLE_TOOLS entry. - "/Dockerfile.windows" # Same pragmas. - "/config/hadolint.yaml" # Ignores a code raised by the copy embedded in the Dockerfile linter. - - "/utilities/cli/src/deployment_types/scenario_image.rs" # Emits those pragmas into generated images. + - "/utilities/cli/templates/scenario-image.j2" # Emits those pragmas into generated images. - "/verification/**" # The generated images holding them. - "/CHECKS.md" # The generated catalogue of every check, which names each tool by design. - "/config/semgrep/no-*-mentions.yaml" # The mention bans, which cannot help but name what they ban. diff --git a/config/semgrep/no-task-name-mentions.yaml b/config/semgrep/no-task-name-mentions.yaml index e5edec0c..ed5b81f5 100644 --- a/config/semgrep/no-task-name-mentions.yaml +++ b/config/semgrep/no-task-name-mentions.yaml @@ -127,7 +127,9 @@ rules: - "/config/otelcol-winperf.yaml" # Likewise. - "/config/conftest/policy/mise/mise.rego" # Names the task each os-scoped exporter tool exists for. - "/services/ws-wasi-runner/tests/o2_ingest_search.rs" # Its subject is the server those tasks run. - - "/utilities/cli/src/lib.rs" # Emits those task names into the deployments it generates. + - "/utilities/cli/templates/readme/dekit.md.j2" # Emits those task names into the deployments it generates. + - "/utilities/cli/templates/readme/mise.md.j2" # Likewise. + - "/utilities/cli/templates/readme/process-compose.md.j2" # Likewise. - "/utilities/cli/src/deployment_types/mise.rs" # Likewise. - "/utilities/cli/src/deployment_types/dekit.rs" # Likewise. - "/CHECKS.md" # The generated catalogue of every check, which names each task by design. diff --git a/utilities/cli/Cargo.toml b/utilities/cli/Cargo.toml index 930c25fa..84d891da 100644 --- a/utilities/cli/Cargo.toml +++ b/utilities/cli/Cargo.toml @@ -28,11 +28,13 @@ fake.workspace = true fs-err.workspace = true jiff.workspace = true k8s-openapi.workspace = true +minijinja.workspace = true pretty_yaml.workspace = true rand.workspace = true rand_chacha.workspace = true reqwest = { workspace = true, features = ["json", "rustls"] } serde = { workspace = true, features = ["std"] } +serde-saphyr.workspace = true serde_json = { workspace = true, features = ["std"] } serde_yaml.workspace = true sha2.workspace = true diff --git a/utilities/cli/src/deployment_types/docker_compose.rs b/utilities/cli/src/deployment_types/docker_compose.rs index 1ffa8b63..dd9c74d7 100644 --- a/utilities/cli/src/deployment_types/docker_compose.rs +++ b/utilities/cli/src/deployment_types/docker_compose.rs @@ -1,8 +1,12 @@ +use std::collections::BTreeMap; +use std::num::NonZeroU64; use std::path::{Path, PathBuf}; use edge_toolkit::ports::Services; use et_path::{absolute_from, relative_path_from}; use fs_err as fs; +use serde::ser::SerializeMap as _; +use serde::{Serialize, Serializer}; use super::hub_sharing::{ BACKPLANE_HEALTH_PATH, BACKPLANE_IMAGE, BACKPLANE_TASK, DATA_DIR, SHARED_STORAGE_HEALTH_PATH, SHARED_STORAGE_IMAGE, @@ -38,23 +42,6 @@ const OFELIA_IMAGE: &str = "ghcr.io/netresearch/ofelia:1.0.1"; /// host would each schedule the other's jobs. const SCENARIO_LABEL: &str = concat!(et_org::org!(), "/scenario"); -/// Characters YAML reserves as indicators, which a plain scalar may not open with. -/// -/// `@` and a backtick are reserved for future use and are a scanner error outright; the rest introduce anchors, tags, -/// flow collections, block scalars and the like. A value starting with any of them has to be quoted to be read back as -/// the string it is. -const YAML_INDICATORS: [char; 18] = [ - '@', '`', '-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', '\'', '"', -]; - -/// Whether YAML reads `value`, written bare, as a boolean, a null or a number rather than the string it is. -/// -/// The YAML 1.1 spellings are included, since compose's parser still honours `yes`, `no`, `on` and `off`. -fn reads_as_non_string(value: &str) -> bool { - const RESERVED: [&str; 10] = ["true", "false", "yes", "no", "on", "off", "y", "n", "null", "~"]; - RESERVED.iter().any(|reserved| value.eq_ignore_ascii_case(reserved)) || value.parse::().is_ok() -} - /// The scheduler service for the machine `machines` names, or for the single host when it names none. /// /// One per machine with a scheduled runner, since Ofelia can only start a container on the Docker daemon it runs on. @@ -164,13 +151,13 @@ fn scenario_hub_service( Vec::new() }; let shared = shares_hubs(cluster); - let mut environment = hub_environment(module_paths, &workspace_root, serves_a_page, shared); + let mut environment = hub_environment(&module_paths, &workspace_root, serves_a_page, shared); let mut depends_on = vec![healthy(COLLECTOR_TASK)]; if shared { environment.extend( shared_hub_env(&hub.server_host, &hub.server_host) .into_iter() - .map(|(name, value)| (name.to_string(), ComposeValue::Plain(value))), + .map(|(name, value)| (name.to_string(), value)), ); environment.sort_by(|(left, _), (right, _)| left.cmp(right)); depends_on.extend([healthy(BACKPLANE_TASK), healthy(SHARED_STORAGE_TASK)]); @@ -252,7 +239,7 @@ fn shared_services(cluster: &ClusterInput, profiles: &[String]) -> [(String, Com env_file: vec![SECRETS_ENV_FILE.to_string()], environment: shared_storage_env() .into_iter() - .map(|(name, value)| (name.to_string(), ComposeValue::Plain(value))) + .map(|(name, value)| (name.to_string(), value)) .collect(), volumes: vec![format!("{SHARED_STORAGE_TASK}-data:{DATA_DIR}")], limits: cluster.services.storage.limits.resolve(), @@ -369,8 +356,7 @@ pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &P volumes: volumes.into_iter().map(|name| (name, ComposeVolume)).collect(), secrets, }; - let content = render_compose_yaml(&compose); - fs::write(&output_path, content)?; + fs::write(&output_path, render_compose_yaml(&compose)?)?; Ok(()) } @@ -510,30 +496,22 @@ fn job_labels(runner: &RunnerInstance, cluster_name: &str) -> [(String, String); /// What wires a runner to its module and its hub, plus whatever the scenario declared for that agent. /// /// The scenario's entries cannot collide with the derived two: the resolver rejects an `env:` naming either. -fn runner_environment(runner: &RunnerInstance) -> Vec<(String, ComposeValue)> { +fn runner_environment(runner: &RunnerInstance) -> Vec<(String, String)> { let mut environment = vec![ - ("RUNNER_MODULE".to_string(), ComposeValue::Plain(runner.module.clone())), - ( - "WS_SERVER_URL".to_string(), - ComposeValue::Plain(runner.site.hub_ws_url()), - ), + ("RUNNER_MODULE".to_string(), runner.module.clone()), + ("WS_SERVER_URL".to_string(), runner.site.hub_ws_url()), ]; - environment.extend( - runner - .env - .iter() - .map(|(name, value)| (name.clone(), ComposeValue::Plain(value.clone()))), - ); + environment.extend(runner.env.iter().map(|(name, value)| (name.clone(), value.clone()))); environment } /// The collector's non-secret settings, plus the data directory the named volume below makes meaningful. -fn collector_environment() -> Vec<(String, ComposeValue)> { - let mut environment: Vec<(String, ComposeValue)> = COLLECTOR_SETTINGS +fn collector_environment() -> Vec<(String, String)> { + let mut environment: Vec<(String, String)> = COLLECTOR_SETTINGS .iter() - .map(|(name, value)| ((*name).to_string(), ComposeValue::Plain((*value).to_string()))) + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) .collect(); - environment.push(("ZO_DATA_DIR".to_string(), ComposeValue::Plain("/data".to_string()))); + environment.push(("ZO_DATA_DIR".to_string(), "/data".to_string())); environment } @@ -560,45 +538,38 @@ fn openobserve_service() -> ComposeService { /// `MODULES_ROOT` is stated even when it is empty, which is what a headless cluster needs rather than the entry /// being left out. The hub image names the page it bundles, and a container inherits an image's environment for every /// variable the compose file does not set -- so omitting it would serve a page this cluster was not given the modules -/// for, which the hub rejects at startup. Empty is read as unset. +/// for, which the hub rejects at startup. Empty is read as unset, and is quoted as YAML's empty string, since written +/// bare it would be a null, which compose reads as "take this from the host environment". +/// +/// `MODULES_PATHS` is the comma-and-space list the server's per-segment trim expects, and what the `mise` deployment +/// writes too. #[expect( clippy::single_call_fn, reason = "distinct step of the hub service; separate so the root's two spellings are not inlined mid-literal" )] fn hub_environment( - module_paths: Vec, + module_paths: &[String], workspace_root: &Path, serves_a_page: bool, shared: bool, -) -> Vec<(String, ComposeValue)> { +) -> Vec<(String, String)> { let root_module = if serves_a_page { super::hub_root_module(&workspace_root.join("services/ws-server")) } else { String::default() }; let mut environment = vec![ - ( - "MODULES_PATHS".to_string(), - ComposeValue::WrappedDoubleQuoted(module_paths), - ), - ("MODULES_ROOT".to_string(), ComposeValue::Plain(root_module)), - ]; - environment.extend([ - ( - "OTLP_AUTH_USERNAME".to_string(), - ComposeValue::Plain(COLLECTOR_USERNAME.to_string()), - ), + ("MODULES_PATHS".to_string(), module_paths.join(", ")), + ("MODULES_ROOT".to_string(), root_module), + ("OTLP_AUTH_USERNAME".to_string(), COLLECTOR_USERNAME.to_string()), ( "OTLP_COLLECTOR_URL".to_string(), - ComposeValue::Plain("http://127.0.0.1:5080/api/default/v1".to_string()), + "http://127.0.0.1:5080/api/default/v1".to_string(), ), - ]); + ]; // Hubs that share storage are pointed at it by the caller instead. if !shared { - environment.push(( - "STORAGE_URL".to_string(), - ComposeValue::Plain("file:///app/storage".to_string()), - )); + environment.push(("STORAGE_URL".to_string(), "file:///app/storage".to_string())); } environment } @@ -623,37 +594,59 @@ pub fn docker_image_module_paths( /// One service of the file, under the name it is written as. type NamedService = (String, ComposeService); -#[derive(Debug, Default)] +/// The whole compose file, each field a top-level key. +#[derive(Debug, Default, Serialize)] struct ComposeFile { + #[serde(serialize_with = "map_of")] services: Vec, + #[serde(serialize_with = "map_of")] volumes: Vec<(String, ComposeVolume)>, /// Build secrets, by name; each is the registry token, read from the host's environment. + #[serde(serialize_with = "registry_token_secrets", skip_serializing_if = "Vec::is_empty")] secrets: Vec, } -#[derive(Debug, Default)] +/// One service, its fields declared in the order the file writes them. +#[derive(Debug, Default, Serialize)] struct ComposeService { + #[serde(skip_serializing_if = "Vec::is_empty")] profiles: Vec, - build: Option, - restart: Option, + #[serde(skip_serializing_if = "Option::is_none")] image: Option, /// A fixed container name, for a container something outside compose has to find by name. + #[serde(skip_serializing_if = "Option::is_none")] container_name: Option, /// The command replacing the image's `ENTRYPOINT`. + #[serde(skip_serializing_if = "Vec::is_empty")] entrypoint: Vec, /// Arguments replacing the image's `CMD`. + #[serde(skip_serializing_if = "Vec::is_empty")] command: Vec, - /// Container labels, in the order they are written; every value is quoted. + /// Container labels, in the order they are written. + #[serde(serialize_with = "map_of", skip_serializing_if = "Vec::is_empty")] labels: Vec<(String, String)>, + #[serde(skip_serializing_if = "Option::is_none")] healthcheck: Option, - network_mode: Option, - scale: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] ports: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] env_file: Vec, - environment: Vec<(String, ComposeValue)>, + #[serde(skip_serializing_if = "Option::is_none")] + build: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + network_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + restart: Option, + #[serde(serialize_with = "map_of", skip_serializing_if = "Vec::is_empty")] + environment: Vec<(String, String)>, + #[serde(skip_serializing_if = "Vec::is_empty")] volumes: Vec, + #[serde(serialize_with = "map_of", skip_serializing_if = "Vec::is_empty")] depends_on: Vec<(String, ComposeDependsOnCondition)>, - /// The resource limits the service runs under. + /// The resource limits the service runs under, written as the keys compose spells them. + #[serde(flatten, serialize_with = "compose_limits")] limits: ResolvedLimits, } @@ -676,7 +669,47 @@ enum BuildSource { Within { context: String, dockerfile: String }, } -#[derive(Debug)] +impl Serialize for ComposeBuild { + /// Write a service's `build:` key, as the shorthand `build: .` when nothing would sit beneath it. + /// + /// A bare `build:` parses as null rather than as compose's defaults of this directory and its Dockerfile. + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + /// A build written out in full. + #[derive(Serialize)] + struct Spec<'build> { + #[serde(skip_serializing_if = "Option::is_none")] + context: Option<&'build str>, + #[serde(skip_serializing_if = "Option::is_none")] + dockerfile: Option<&'build str>, + #[serde(serialize_with = "map_of", skip_serializing_if = "<[_]>::is_empty")] + additional_contexts: &'build [(String, String)], + #[serde(skip_serializing_if = "<[_]>::is_empty")] + secrets: &'build [String], + } + let (context, dockerfile) = match &self.source { + BuildSource::Here => (None, None), + BuildSource::Within { context, dockerfile } => (Some(context.as_str()), Some(dockerfile.as_str())), + }; + if context.is_none() && self.additional_contexts.is_empty() && self.secrets.is_empty() { + // Compose's own spelling of the directory holding the file, written out for it rather than resolved here. + // nosemgrep: no-semgrep-mentions, no-ast-grep-mentions -- Required to waive the rule for this value. + // ast-grep-ignore: no-relative-path-literal + return serializer.serialize_str("."); + } + Spec { + additional_contexts: &self.additional_contexts, + context, + dockerfile, + secrets: &self.secrets, + } + .serialize(serializer) + } +} + +#[derive(Debug, Serialize)] struct ComposeHealthcheck { test: Vec, interval: String, @@ -685,255 +718,182 @@ struct ComposeHealthcheck { start_period: String, } -#[derive(Debug)] +#[derive(Debug, Serialize)] struct ComposeDependsOnCondition { condition: String, /// Whether compose refuses to start without the dependency; written out only when it does not. + #[serde(skip_serializing_if = "is_true")] required: bool, } +/// Whether `value` is set, which leaves a field that only ever says `false` out of the file. +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "serde's skip_serializing_if passes the field by reference" +)] +const fn is_true(value: &bool) -> bool { + *value +} + +/// A named volume, which needs nothing set, so it is written as an empty mapping. #[derive(Debug, Default)] struct ComposeVolume; -#[derive(Debug)] -enum ComposeValue { - Plain(String), - WrappedDoubleQuoted(Vec), -} - -fn render_compose_yaml(compose: &ComposeFile) -> String { - let mut renderer = ComposeRenderer::default(); - renderer.push_line(0, "services:"); - for (name, service) in &compose.services { - renderer.render_service(name, service); - } - renderer.push_line(0, "volumes:"); - for (name, _) in &compose.volumes { - renderer.push_line(1, &format!("{name}: {{}}")); +impl Serialize for ComposeVolume { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_map(Some(0))?.end() } - if !compose.secrets.is_empty() { - renderer.push_line(0, "secrets:"); - for name in &compose.secrets { - renderer.push_line(1, &format!("{name}:")); - renderer.push_line(2, &format!("environment: {REGISTRY_TOKEN_ENV}")); - } - } - renderer.finish() } -#[derive(Default)] -struct ComposeRenderer { - output: String, +/// A service's resource limits as compose spells them, every default applied. +/// +/// Applying the defaults is what makes the service run as the same process does under k3s. The CPU weight is +/// written as `cpu_shares` even when it comes from the reservation, because compose outside swarm ignores +/// `deploy.resources.reservations.cpus`: a container started from it keeps the default weight, where a Kubernetes +/// container requesting the same CPUs is weighted by that request. The memory reservation, by contrast, compose does +/// apply, as the cgroup's `memory.low`. +#[derive(Serialize)] +struct ComposeLimits { + deploy: ComposeDeploy, + #[serde(skip_serializing_if = "Option::is_none")] + blkio_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cpu_period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cpu_quota: Option, + cpu_shares: u64, + #[serde(skip_serializing_if = "Option::is_none")] + cpuset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + memswap_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + shm_size: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + ulimits: BTreeMap, } -impl ComposeRenderer { - fn finish(self) -> String { - self.output - } +#[derive(Serialize)] +struct ComposeDeploy { + resources: ComposeResources, +} - fn push_line(&mut self, indent: usize, line: &str) { - self.output.push_str(&" ".repeat(indent)); - self.output.push_str(line); - self.output.push('\n'); - } +#[derive(Serialize)] +struct ComposeResources { + limits: ComposeResourceLimits, + #[serde(skip_serializing_if = "Option::is_none")] + reservations: Option, +} - /// Write `key:` and then each of `items` as a `- item` line one level deeper, or nothing when there are none. - /// - /// An item YAML would read as something other than a string is quoted, since compose wants strings here. - fn push_list(&mut self, indent: usize, key: &str, items: &[String]) { - if items.is_empty() { - return; - } - self.push_line(indent, &format!("{key}:")); - for item in items { - let rendered = if reads_as_non_string(item) { - format!("\"{item}\"") - } else { - item.clone() - }; - self.push_line(indent.saturating_add(1), &format!("- {rendered}")); - } - } +#[derive(Serialize)] +struct ComposeResourceLimits { + #[serde(skip_serializing_if = "Option::is_none")] + cpus: Option, + memory: String, + #[serde(skip_serializing_if = "Option::is_none")] + pids: Option, +} - fn render_healthcheck(&mut self, healthcheck: &ComposeHealthcheck) { - self.push_line(2, "healthcheck:"); - self.push_list(3, "test", &healthcheck.test); - self.push_line(3, &format!("interval: {}", healthcheck.interval)); - self.push_line(3, &format!("timeout: {}", healthcheck.timeout)); - self.push_line(3, &format!("retries: {}", healthcheck.retries)); - self.push_line(3, &format!("start_period: {}", healthcheck.start_period)); - } +#[derive(Serialize)] +struct ComposeReservation { + memory: String, +} - /// Write a service's `build:` key, as the shorthand `build: .` when nothing would sit beneath it. - /// - /// A bare `build:` parses as null rather than as compose's defaults of this directory and its Dockerfile. - fn render_build(&mut self, build: &ComposeBuild) { - if matches!(build.source, BuildSource::Here) && build.additional_contexts.is_empty() && build.secrets.is_empty() - { - self.push_line(2, "build: ."); - return; - } - self.push_line(2, "build:"); - if let BuildSource::Within { context, dockerfile } = &build.source { - self.push_line(3, &format!("context: {context}")); - self.push_line(3, &format!("dockerfile: {dockerfile}")); - } - if !build.additional_contexts.is_empty() { - self.push_line(3, "additional_contexts:"); - for (name, source) in &build.additional_contexts { - self.push_line(4, &format!("{name}: {source}")); - } - } - self.push_list(3, "secrets", &build.secrets); - } +#[derive(Serialize)] +struct ComposeBlkio { + weight: u16, +} - fn render_service(&mut self, name: &str, service: &ComposeService) { - self.push_line(1, &format!("{name}:")); - self.push_list(2, "profiles", &service.profiles); - if let Some(image) = &service.image { - self.push_line(2, &format!("image: {image}")); - } - if let Some(container_name) = &service.container_name { - self.push_line(2, &format!("container_name: {container_name}")); - } - self.push_list(2, "entrypoint", &service.entrypoint); - self.push_list(2, "command", &service.command); - if !service.labels.is_empty() { - self.push_line(2, "labels:"); - for (key, value) in &service.labels { - self.push_line(3, &format!("{key}: \"{value}\"")); - } - } - if let Some(healthcheck) = &service.healthcheck { - self.render_healthcheck(healthcheck); - } - self.push_list(2, "ports", &service.ports); - self.push_list(2, "env_file", &service.env_file); - if let Some(build) = &service.build { - self.render_build(build); - } - if let Some(scale) = service.scale { - self.push_line(2, &format!("scale: {scale}")); - } - if let Some(network_mode) = &service.network_mode { - self.push_line(2, &format!("network_mode: {network_mode}")); - } - if let Some(restart) = &service.restart { - self.push_line(2, &format!("restart: {restart}")); - } - if !service.environment.is_empty() { - self.push_line(2, "environment:"); - for (key, value) in &service.environment { - self.render_environment_value(key, value); - } - } - self.push_list(2, "volumes", &service.volumes); - if !service.depends_on.is_empty() { - self.push_line(2, "depends_on:"); - for (name, condition) in &service.depends_on { - self.push_line(3, &format!("{name}:")); - self.push_line(4, &format!("condition: {}", condition.condition)); - if !condition.required { - self.push_line(4, "required: false"); - } - } - } - self.render_limits(&service.limits); - } +#[derive(Serialize)] +struct ComposeUlimit { + soft: u64, + hard: u64, +} - /// Write a service's resource limits, every default applied, so it runs as the same process does under k3s. - /// - /// The CPU weight is written as `cpu_shares` even when it comes from the reservation, because compose outside swarm - /// ignores `deploy.resources.reservations.cpus`: a container started from it keeps the default weight, where a - /// Kubernetes container requesting the same CPUs is weighted by that request. The memory reservation, by contrast, - /// compose does apply, as the cgroup's `memory.low`. - fn render_limits(&mut self, limits: &ResolvedLimits) { +impl From<&ResolvedLimits> for ComposeLimits { + fn from(limits: &ResolvedLimits) -> Self { let rest = &limits.rest; - self.push_line(2, "deploy:"); - self.push_line(3, "resources:"); - self.push_line(4, "limits:"); - if let CpuCap::Cpus(cpus) = limits.cpu_cap { - self.push_line(5, &format!("cpus: \"{cpus}\"")); - } - self.push_line(5, &format!("memory: {}", compose_bytes(limits.memory))); - if let Some(pids) = rest.pids { - self.push_line(5, &format!("pids: {pids}")); - } - if let Some(reservation) = limits.memory_low { - self.push_line(4, "reservations:"); - self.push_line(5, &format!("memory: {}", compose_bytes(reservation))); - } - if let Some(weight) = rest.blkio_weight { - self.push_line(2, "blkio_config:"); - self.push_line(3, &format!("weight: {weight}")); - } - let (cpu_quota, cpu_period) = match limits.cpu_cap { - CpuCap::Quota { quota, period } => (Some(quota.to_string()), Some(period.to_string())), - CpuCap::Cpus(_) => (None, None), + let (cpus, cpu_quota, cpu_period) = match limits.cpu_cap { + CpuCap::Cpus(cpus) => (Some(cpus.to_string()), None, None), + CpuCap::Quota { quota, period } => (None, Some(quota), Some(period)), }; - let scalars = [ - ("cpu_period", cpu_period), - ("cpu_quota", cpu_quota), - ("cpu_shares", Some(limits.cpu_shares.to_string())), - ("cpuset", rest.cpuset.as_ref().map(|cpuset| format!("\"{cpuset}\""))), - ("memswap_limit", rest.memswap_limit.map(compose_bytes)), - ("shm_size", rest.shm_size.map(compose_bytes)), - ]; - for (key, value) in scalars { - if let Some(value) = value { - self.push_line(2, &format!("{key}: {value}")); - } - } - if !rest.ulimits.is_empty() { - self.push_line(2, "ulimits:"); - for (name, ulimit) in &rest.ulimits { - let (soft, hard) = ulimit.soft_hard(); - self.push_line(3, &format!("{name}:")); - self.push_line(4, &format!("soft: {soft}")); - self.push_line(4, &format!("hard: {hard}")); - } + let resources = ComposeResources { + limits: ComposeResourceLimits { + cpus, + memory: compose_bytes(limits.memory), + pids: rest.pids, + }, + reservations: limits.memory_low.map(|memory| ComposeReservation { + memory: compose_bytes(memory), + }), + }; + Self { + blkio_config: rest.blkio_weight.map(|weight| ComposeBlkio { weight }), + cpu_period, + cpu_quota, + cpu_shares: limits.cpu_shares, + cpuset: rest.cpuset.clone(), + deploy: ComposeDeploy { resources }, + memswap_limit: rest.memswap_limit.map(compose_bytes), + shm_size: rest.shm_size.map(compose_bytes), + ulimits: rest + .ulimits + .iter() + .map(|(name, ulimit)| { + let (soft, hard) = ulimit.soft_hard(); + (name.clone(), ComposeUlimit { soft, hard }) + }) + .collect(), } } +} - fn render_environment_value(&mut self, key: &str, value: &ComposeValue) { - match value { - // Quoted when the value opens with a character YAML reserves as an indicator. A module name carries - // its owner scope, so it begins with `@` -- which unquoted is a scanner error rather than a string, and - // produced a compose file nothing could parse. - // - // An empty value is quoted for a different reason: written bare it is YAML's null, and compose reads a null - // entry as "take this from the host environment" rather than as a value. The variable would then be absent - // from the container and the image's own default would stand -- the opposite of what stating it empty is - // for. - // - // So is one YAML would read as a boolean or a number, which compose rejects as an environment value. - ComposeValue::Plain(value) => { - let rendered = if value.is_empty() || value.starts_with(YAML_INDICATORS) || reads_as_non_string(value) { - format!("\"{value}\"") - } else { - value.clone() - }; - self.push_line(3, &format!("{key}: {rendered}")); - } - // Wrapped by YAML's own line folding rather than by escaping the breaks away with a trailing `\`. Both keep - // the value one scalar; folding differs only in leaving a space where the break was, so the list arrives - // comma-and-space separated -- which is what the `mise` deployment has always written and what the server's - // per-segment trim expects. The backslash form is banned repo-wide, and this was the one generator still - // emitting it. - ComposeValue::WrappedDoubleQuoted(parts) => { - if let [only] = parts.as_slice() { - self.push_line(3, &format!("{key}: \"{only}\"")); - } else if let Some((first, rest)) = parts.split_first() { - self.push_line(3, &format!("{key}: \"{first},")); - let last_index = rest.len().saturating_sub(1); - for (index, part) in rest.iter().enumerate() { - let suffix = if index == last_index { "\"" } else { "," }; - self.push_line(4, &format!("{part}{suffix}")); - } - } else { - self.push_line(3, &format!("{key}: \"\"")); - } - } - } +/// Serialise the compose file. +/// +/// With serde-saphyr rather than the `serde_yaml` the other YAML files are written with, for two things compose needs. +/// It quotes a string YAML 1.1 reads as a boolean, `yes` or `off` among them, which compose's parser still honours and +/// rejects as an environment value -- and a scenario's own `env:` can hold one. And it writes a long string as a folded +/// block scalar, broken only at spaces, so the hub's module list fits the line length and still reads back as the one +/// comma-and-space list. Sequences are indented under their key, as in every other YAML file here, and the result is +/// restyled as the others are, which settles the quotes it picks. +fn render_compose_yaml(compose: &ComposeFile) -> Result { + let mut options = serde_saphyr::SerializerOptions::default(); + options.compact_list_indent = false; + let yaml = serde_saphyr::to_string_with_options(compose, options)?; + Ok(super::yaml::formatted(&yaml)) +} + +/// Serialise `pairs` as a mapping, in the order they are listed. +fn map_of(pairs: &[(String, V)], serializer: S) -> Result +where + S: Serializer, + V: Serialize, +{ + serializer.collect_map(pairs.iter().map(|pair| (&pair.0, &pair.1))) +} + +/// Serialise the build secrets by name, each read from the host variable the registry token is in. +fn registry_token_secrets(names: &[String], serializer: S) -> Result +where + S: Serializer, +{ + /// A secret compose reads from the host's environment. + #[derive(Serialize)] + struct FromEnvironment { + environment: &'static str, } + let secret = FromEnvironment { + environment: REGISTRY_TOKEN_ENV, + }; + serializer.collect_map(names.iter().map(|name| (name, &secret))) +} + +/// Serialise a service's resource limits as the keys compose spells them. +fn compose_limits(limits: &ResolvedLimits, serializer: S) -> Result +where + S: Serializer, +{ + ComposeLimits::from(limits).serialize(serializer) } diff --git a/utilities/cli/src/deployment_types/k3s.rs b/utilities/cli/src/deployment_types/k3s.rs index fb4bdab2..6fe8d1f8 100644 --- a/utilities/cli/src/deployment_types/k3s.rs +++ b/utilities/cli/src/deployment_types/k3s.rs @@ -175,7 +175,7 @@ const REQUIRED_PLUGINS_ANNOTATION: &str = "required-plugins.noderesource.dev/con pub(crate) const CONTAINERD_TEMPLATE_FILE: &str = "containerd-config-v3.toml.tmpl"; /// Where k3s reads a containerd configuration template from, on every machine of the cluster. -const CONTAINERD_TEMPLATE_PATH: &str = "/var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.tmpl"; +pub(crate) const CONTAINERD_TEMPLATE_PATH: &str = "/var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.tmpl"; /// k3s's own containerd configuration with NRI's default validator turned on. /// @@ -772,36 +772,9 @@ fn cluster_plugins(cluster: &ClusterInput) -> Vec<&'static RuntimePlugin> { needed } -/// Render the README step that readies every machine for the plugins, or nothing when the scenario needs none. -pub(crate) fn node_preparation_note(cluster: &ClusterInput) -> String { - let plugins = cluster_plugins(cluster); - if plugins.is_empty() { - return String::default(); - } - let names = plugins - .iter() - .map(|plugin| format!("`{}`", plugin.name)) - .collect::>() - .join(" and "); - format!( - concat!( - "### Prepare The Nodes\n\n", - "Some of this scenario's limits have no field in the Pod API, so `k3s.yaml` runs these container\n", - "runtime plugins on every node to write them into each container's cgroup:\n", - "{names}.\n\n", - "Each limited pod names the plugins it needs, and containerd refuses it on a node where one of them\n", - "did not adjust it -- once NRI's validator is on, which k3s leaves off. Before applying `k3s.yaml`,\n", - "copy the template below from this directory to every machine of the cluster and restart k3s there\n", - "(`k3s-agent` on a machine that joined as an agent):\n\n", - "```bash\n", - "sudo install -D -m 0644 {file} {path}\n", - "sudo systemctl restart k3s\n", - "```\n\n" - ), - file = CONTAINERD_TEMPLATE_FILE, - names = names, - path = CONTAINERD_TEMPLATE_PATH, - ) +/// The names of the plugins every machine has to be readied for, in name order; none when the scenario needs none. +pub(crate) fn plugin_names(cluster: &ClusterInput) -> Vec<&'static str> { + cluster_plugins(cluster).iter().map(|plugin| plugin.name).collect() } /// The documents that run each plugin any process of the scenario relies on, or none when no process relies on one. diff --git a/utilities/cli/src/deployment_types/mise.rs b/utilities/cli/src/deployment_types/mise.rs index bb82b7df..a9a32148 100644 --- a/utilities/cli/src/deployment_types/mise.rs +++ b/utilities/cli/src/deployment_types/mise.rs @@ -16,6 +16,7 @@ use super::process_compose::PROCESS_COMPOSE_TOOL; use crate::error::CliError; use crate::input::{ArtifactSource, ClusterInput, Delivery}; use crate::placement::{hubs, server_delivery, shares_hubs}; +use crate::templates::render; use crate::{ COLLECTOR_SETTINGS, COLLECTOR_USERNAME, MODULE_SCOPE, ModuleRegistryEntry, ModuleSource, RunnerInstance, cluster_module_names, module_registry, resolve_cluster_modules, resolve_module_paths, runner_crate, task_runners, @@ -36,8 +37,7 @@ const STAGED_PREFIX: &str = "npm:"; /// /// A file beside `mise.toml` rather than a setting, because a registry and its credential are npm's own config /// rather than mise's, and mise reads user-level npm config while deliberately ignoring a project `.npmrc` -- -/// `NPM_CONFIG_USERCONFIG` is the one handle that reaches it. The credential is written as a `${GITHUB_TOKEN}` -/// reference that npm expands when it reads the file, so the deployment carries no secret. +/// `NPM_CONFIG_USERCONFIG` is the one handle that reaches it. const NPMRC_FILE: &str = "npmrc"; /// Directory, beside `mise.toml`, a published deployment's hub keeps agent storage in. @@ -230,7 +230,7 @@ pub fn generate_mise_deployment( fs::write(&output_path, toml::to_string(&Value::Table(root))?)?; if matches!(artifacts, ArtifactSource::Published) { - fs::write(output_dir.join(NPMRC_FILE), npmrc_contents())?; + fs::write(output_dir.join(NPMRC_FILE), npmrc_contents()?)?; } Ok(()) @@ -678,10 +678,17 @@ fn mise_env(artifacts: ArtifactSource) -> Table { } /// The npm config a published deployment carries, pointing the owner scope at GitHub Packages. -pub(crate) fn npmrc_contents() -> String { - format!( - "{scope}:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=${{GITHUB_TOKEN}}\n", - scope = MODULE_SCOPE.trim_end_matches('/'), +pub(crate) fn npmrc_contents() -> Result { + /// What the npm config is rendered from. + #[derive(serde::Serialize)] + struct Npmrc { + scope: &'static str, + } + render( + "npmrc.j2", + &Npmrc { + scope: MODULE_SCOPE.trim_end_matches('/'), + }, ) } diff --git a/utilities/cli/src/deployment_types/mod.rs b/utilities/cli/src/deployment_types/mod.rs index 49b68a86..0e1e020b 100644 --- a/utilities/cli/src/deployment_types/mod.rs +++ b/utilities/cli/src/deployment_types/mod.rs @@ -22,8 +22,8 @@ pub(crate) use self::hub::{hub_root_module, serves_a_page}; pub(crate) use self::hub_sharing::{BACKPLANE_TASK, SHARED_STORAGE_TASK, STORAGE_SECRET_ENVS}; pub use self::k3s::generate_k3s_deployment; pub(crate) use self::k3s::{ - CONTAINERD_TEMPLATE_FILE, LB_POOL_LABEL as K3S_LB_POOL_LABEL, PLUGIN_NAMESPACE as K3S_PLUGIN_NAMESPACE, - needs_containerd_template, node_preparation_note as k3s_node_preparation_note, + CONTAINERD_TEMPLATE_FILE, CONTAINERD_TEMPLATE_PATH, LB_POOL_LABEL as K3S_LB_POOL_LABEL, + PLUGIN_NAMESPACE as K3S_PLUGIN_NAMESPACE, needs_containerd_template, plugin_names as k3s_plugin_names, }; pub(crate) use self::mise::{COLLECTOR_TASK, HUB_TASK, OPENER_TASK, STORAGE_DIR, machine_task}; pub use self::mise::{ScenarioModules, generate_mise_deployment, scenario_module_paths}; diff --git a/utilities/cli/src/deployment_types/scenario_image.rs b/utilities/cli/src/deployment_types/scenario_image.rs index 086ea82d..a2e05fc7 100644 --- a/utilities/cli/src/deployment_types/scenario_image.rs +++ b/utilities/cli/src/deployment_types/scenario_image.rs @@ -1,11 +1,12 @@ use std::collections::BTreeSet; -use std::fmt::Write as _; use std::path::Path; use fs_err as fs; +use serde::Serialize; use crate::error::CliError; use crate::input::{ArtifactSource, ClusterInput}; +use crate::templates::render; use crate::{IMAGE_REGISTRY, ModuleSource, cluster_module_names, module_registry, resolve_cluster_modules}; /// Build context path of the hub image's Dockerfile, which the scenario image layers on top of. @@ -24,98 +25,6 @@ const HUB_IMAGE_MODULES: [&str; 2] = ["/app/services/ws-server/static", "/app/se /// costs nothing -- only the tools named on the `mise install` line are actually fetched. const DEPS_MISE_ENV: &str = "dart,dotnet,java,js,kotlin,python,r,rust,zig"; -/// The fixed head of the deps stage: the base image, the packages it installs, and a verified mise. -/// -/// Held as a const rather than inlined so the rendering function below stays a short composer of named parts. Nothing -/// in this block varies with the cluster, so there is nothing here to parameterise. -const DEPS_BASE_STAGE: &str = concat!( - "# Stage the packages that live outside the repository.\n", - "# These are published packages the modules load at runtime, provisioned by mise rather than built\n", - "# here, so this stage installs them from the repo's own pins and copies each one to the `/app` path\n", - "# the hub serves it from.\n", - "FROM docker.io/library/debian:bookworm-slim AS deps\n", - "\n", - "# The package list is named rather than inlined so a policy can hold it against the repo-root Dockerfile.\n", - "# Every name here is drawn from that file's own COMMON_PACKAGES: the shell, the archive tools mise unpacks\n", - "# downloads with, plus git and curl. Its compiler packages are deliberately absent -- nothing is compiled\n", - "# in this stage -- so the two lists are checked as subset rather than equality.\n", - "# Promoted to ENV because the quoted heredoc below is expanded by bash at run time, which an ARG alone\n", - "# does not reach.\n", - "ARG COMMON_PACKAGES=\"bash bzip2 ca-certificates curl git gzip tar unzip\"\n", - "ENV COMMON_PACKAGES=${COMMON_PACKAGES}\n", - "RUN bash <<'EOF'\n", - "set -euo pipefail\n", - "apt-get update\n", - "# Word-splitting $COMMON_PACKAGES into separate arguments is the intent, so it is deliberately unquoted.\n", - "apt-get install -y --no-install-recommends $COMMON_PACKAGES\n", - "apt-get clean\n", - "rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb\n", - "EOF\n", - "\n", - "# mise is fetched to a file and checksum-verified, never piped from mise.run into a shell.\n", - "# Piping a remote script into an interpreter runs unreviewed code straight off the network, which the\n", - "# repo's no-curl-pipe rule bans. The release's SHASUMS256.txt covers every asset, so `--ignore-missing`\n", - "# checks exactly the archive downloaded here. Pinned rather than installed from mise's apt repository,\n", - "# which carries only the newest version: a floating mise writes lockfile entries an older CI mise\n", - "# then refuses to install.\n", - "ARG MISE_VERSION=v2026.10.15\n", - "ARG TARGETARCH\n", - "ENV MISE_VERSION=${MISE_VERSION}\n", - "ENV TARGETARCH=${TARGETARCH}\n", - "RUN bash <<'EOF'\n", - "set -euo pipefail\n", - "case \"${TARGETARCH}\" in\n", - " amd64) arch=x64 ;;\n", - " arm64) arch=arm64 ;;\n", - " *) echo \"unsupported TARGETARCH ${TARGETARCH}\" >&2; exit 1 ;;\n", - "esac\n", - "asset=\"mise-${MISE_VERSION}-linux-${arch}.tar.gz\"\n", - "base=\"https://github.com/jdx/mise/releases/download/${MISE_VERSION}\"\n", - "cd /tmp\n", - "curl -fsSL -o \"${asset}\" \"${base}/${asset}\"\n", - "curl -fsSL -o SHASUMS256.txt \"${base}/SHASUMS256.txt\"\n", - "sha256sum -c --ignore-missing SHASUMS256.txt\n", - "tar -xzf \"${asset}\"\n", - "install -m 0755 /tmp/mise/bin/mise /usr/local/bin/mise\n", - "rm -rf /tmp/mise \"/tmp/${asset}\" /tmp/SHASUMS256.txt\n", - "EOF\n", - "\n", - "ENV HOME=/root\n", - "ENV PATH=\"/root/.local/share/mise/shims:${PATH}\"\n", - "WORKDIR /workspace\n", - "COPY .miserc.toml .miserc.toml\n", - "COPY .mise/ .mise/\n", - "RUN mise trust\n", -); - -/// The per-tool copy step, driven entirely by the `STAGE_*` variables the loop emits ahead of it. -/// -/// Locates the package rather than assuming where the backend put it. The npm backend nests it under one of several -/// layouts (`lib/node_modules/`, `node_modules/`, or an aube virtual store below a content-hashed directory) that vary -/// by platform, so this searches for the named directory instead. The named directory is tried FIRST and the install -/// root only as a fallback: the npm backend drops its own wrapper manifest (`"name": "mise-npm-install"`) at that root, -/// so a root-first probe stages the wrapper and the module is then served under the wrapper's name. The fallback is -/// what covers an archive-backed `http:` tool, which extracts flat so the root really is the package. `-L` and `cp -L` -/// resolve the symlinks the aube store is built from, so real files land in the image. -const DEPS_STAGE_TOOL_COPY: &str = concat!( - "RUN bash <<'EOF'\n", - "set -euo pipefail\n", - "root=\"$(mise where \"${STAGE_TOOL}\")\"\n", - "src=\"$(find -L \"${root}\" -type d -name \"${STAGE_PACKAGE}\" ", - "-exec test -f '{}/package.json' ';' -print -quit)\"\n", - "if [ -z \"${src}\" ] && [ -f \"${root}/package.json\" ]; then\n", - " src=\"${root}\"\n", - "fi\n", - "if [ -z \"${src}\" ]; then\n", - " echo \"no ${STAGE_PACKAGE} package dir under the ${STAGE_TOOL} install at ${root}\" >&2\n", - " exit 1\n", - "fi\n", - "mkdir -p \"$(dirname \"${STAGE_DEST}\")\"\n", - "cp -rL \"${src}\" \"${STAGE_DEST}\"\n", - "EOF\n", - "\n", -); - /// Image the released-modules stage runs `npm` from, on the Node major the repository's own `node` tool is pinned to. const NODE_IMAGE: &str = "docker.io/library/node:22-bookworm-slim"; @@ -131,14 +40,87 @@ pub(crate) const REGISTRY_TOKEN_ENV: &str = "GITHUB_TOKEN"; /// every list the image is rendered from stays put in a committed file: `SCENARIO_TOOLS`, the staging steps, the COPYs. #[derive(Default)] struct ImageSources { - /// This repository's modules, copied from the checkout: `(repo path, served path)`. - repo_paths: Vec<(String, String)>, - /// This repository's modules, fetched as the releases a published scenario consumes: `(package, served path)`. - released: Vec<(String, String)>, - /// Packages a mise tool provisions, staged from the repository's own pins: `(tool, package, served path)`. - mise_tools: Vec<(String, String, String)>, - /// The scenario's own `module_paths:` directories: `(build context, path in it, served path)`. - scenario_dirs: Vec<(String, String, String)>, + /// This repository's modules, copied from the checkout. + repo_paths: Vec, + /// This repository's modules, fetched as the releases a published scenario consumes. + released: Vec, + /// Packages a mise tool provisions, staged from the repository's own pins. + mise_tools: Vec, + /// The scenario's own `module_paths:` directories. + scenario_dirs: Vec, +} + +/// One of this repository's modules, copied from the checkout. +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct RepoModule { + repo_path: String, + docker_path: String, +} + +/// One of this repository's modules, fetched as the release a published scenario consumes. +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct ReleasedModule { + package: String, + docker_path: String, +} + +/// A package a mise tool provisions, located by `package` below the install of `tool`. +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct StagedTool { + tool: String, + package: String, + docker_path: String, +} + +/// One of the scenario's own `module_paths:` directories, at `context_path` within the build context `context`. +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ScenarioDir { + context: String, + context_path: String, + docker_path: String, +} + +/// A [`ScenarioDir`] as its `COPY` names it, each path already one operand of the JSON-array form. +#[derive(Serialize)] +struct ScenarioCopy<'sources> { + context: &'sources str, + source: String, + destination: String, +} + +/// What the scenario Dockerfile is rendered from. +#[derive(Serialize)] +struct DockerfileContext<'sources> { + name: &'sources str, + published: bool, + /// Whether a staged tool is an `npm:` one, which needs `node` installed first. + installs_node: bool, + repo_paths: &'sources [RepoModule], + released: &'sources [ReleasedModule], + mise_tools: &'sources [StagedTool], + scenario_dirs: Vec>, + /// The npm config the released stage fetches with, as a published deployment carries it. + npmrc: String, + deps_mise_env: &'static str, + hub_dockerfile: &'static str, + hub_image_build: &'static str, + node_image: &'static str, + registry: &'static str, + repository_url: &'static str, + token_env: &'static str, + token_secret: &'static str, +} + +/// What the scenario Dockerfile's ignore file is rendered from. +#[derive(Serialize)] +struct DockerignoreContext<'sources> { + name: &'sources str, + /// Whether the image builds from the repository, rather than from the deployment directory with nothing sent. + repository: bool, + /// The repository's own ignore file, which an image built from the repository starts from. + root_dockerignore: String, + /// The `pkg/` tree of each module copied from the repository, as a pattern; sorted and without repeats. + built_artifacts: BTreeSet, } /// What building a scenario's image asks of whoever runs the build. @@ -167,14 +149,23 @@ fn image_sources(cluster: &ClusterInput) -> Result { let docker_path = entry.docker_path; match entry.source { ModuleSource::Repo(_) if HUB_IMAGE_MODULES.contains(&docker_path.as_str()) => {} - ModuleSource::Repo(_) if published => sources - .released - .push((entry.package_name.unwrap_or_default(), docker_path)), - ModuleSource::Repo(repo_path) => sources.repo_paths.push((repo_path, docker_path)), - ModuleSource::MiseTool { tool, package } => sources.mise_tools.push((tool, package, docker_path)), + ModuleSource::Repo(_) if published => sources.released.push(ReleasedModule { + docker_path, + package: entry.package_name.unwrap_or_default(), + }), + ModuleSource::Repo(repo_path) => sources.repo_paths.push(RepoModule { repo_path, docker_path }), + ModuleSource::MiseTool { tool, package } => sources.mise_tools.push(StagedTool { + tool, + package, + docker_path, + }), ModuleSource::Scenario { context, context_path, .. - } => sources.scenario_dirs.push((context, context_path, docker_path)), + } => sources.scenario_dirs.push(ScenarioDir { + context, + context_path, + docker_path, + }), } } sources.repo_paths.sort(); @@ -201,40 +192,16 @@ const fn needs_of(cluster: &ClusterInput, sources: &ImageSources) -> ImageNeeds } } -/// The ignore file for an image built from its own deployment directory, which copies nothing out of its context. -/// -/// Everything is excluded, so the build is sent nothing at all -- the directory's `secrets.env` included. -fn render_empty_context_dockerignore(cluster_name: &str) -> String { - format!( - concat!( - "# AUTO-GENERATED by `et-cli` for the `{name}` scenario -- do not edit.\n", - "# Regenerate with `mise run regen-verification`.\n", - "#\n", - "# The Dockerfile beside this file copies nothing from its build context, so the context is empty.\n", - "*\n", - ), - name = cluster_name, - ) -} - pub fn generate_scenario_image(cluster: &ClusterInput, output_dir: &Path) -> Result<(), CliError> { - let project_root = edge_toolkit::config::get_project_root(); let sources = image_sources(cluster)?; - - // Trimmed to exactly one trailing newline, because each section appends its own separating blank line and whichever - // section ends up last therefore leaves one behind. A scenario with no modules at all has no `COPY` sections, so - // the separator after the label is the end of the file. - let dockerfile = render_dockerfile( + let published = matches!(cluster.artifact_source, ArtifactSource::Published); + let dockerfile = render_dockerfile(&cluster.cluster_name, published, &sources)?; + fs::write(output_dir.join("Dockerfile"), dockerfile)?; + let dockerignore = render_dockerignore( &cluster.cluster_name, - matches!(cluster.artifact_source, ArtifactSource::Published), - &sources, - ); - fs::write(output_dir.join("Dockerfile"), format!("{}\n", dockerfile.trim_end()))?; - let dockerignore = if needs_of(cluster, &sources).repository { - render_dockerignore(&project_root, &cluster.cluster_name, &sources.repo_paths)? - } else { - render_empty_context_dockerignore(&cluster.cluster_name) - }; + needs_of(cluster, &sources).repository, + &sources.repo_paths, + )?; fs::write(output_dir.join("Dockerfile.dockerignore"), dockerignore)?; Ok(()) @@ -249,283 +216,57 @@ fn dockerfile_word(path: &str) -> String { serde_json::Value::String(path.replace('$', "\\$")).to_string() } -/// Render the scenario Dockerfile's opening comment, which says what the image is and where its modules come from. -/// -/// A published image copies nothing out of a checkout, so the ignore file that re-admits built artifacts has no part in -/// it; what it says instead is where its modules come from. -fn render_dockerfile_header(cluster_name: &str, published: bool) -> String { - let mut out = String::default(); - out.push_str("# syntax=docker/dockerfile:1\n\n"); - let sourcing = if published { - concat!( - "# This scenario consumes the project's releases, so its modules are fetched from the registry they are\n", - "# published to rather than copied from a checkout, and the image builds from the deployment directory\n", - "# alone.\n", - ) - } else { - concat!( - "# The sibling `Dockerfile.dockerignore` is what makes the COPYs below possible. The repository's root\n", - "# `.dockerignore` drops every `*.wasm` and `*.onnx` so the CI images built from `./Dockerfile` stay\n", - "# small; BuildKit uses this Dockerfile's own ignore file instead, which re-admits the built `pkg/`\n", - "# directories and nothing else.\n", - ) - }; - let _write_result = writeln!( - out, - concat!( - "# AUTO-GENERATED by `et-cli` for the `{name}` scenario -- do not edit.\n", - "# Regenerate with `mise run regen-verification`.\n", - "#\n", - "# The hub image ({hub}) carries no workflow modules at all, because which\n", - "# modules a deployment serves is a per-cluster decision. This image is that decision made concrete: it\n", - "# stages exactly the modules this cluster's agents declare, plus the packages those modules depend on,\n", - "# into the `/app` paths the hub already scans.\n", - "#\n", - "{sourcing}", - ), - name = cluster_name, - hub = HUB_DOCKERFILE, - sourcing = sourcing, - ); - out -} - -/// Render the stage the scenario image is built on: the released hub when published, the one built beside it otherwise. -#[expect( - clippy::single_call_fn, - reason = "the base half of render_dockerfile, split out to keep it within its length budget" -)] -fn render_hub_base(published: bool) -> String { - if published { - return format!( - concat!( - "# The released hub image, which this scenario layers its modules onto.\n", - "# `latest` because that is the one tag the hub is published under: it is assembled from each\n", - "# architecture's build, so a node of either architecture resolves it. That is why the rule against\n", - "# a `latest` base is suppressed on the next line.\n", - "# hadolint ignore=DL3007\n", - "FROM {registry}/et-ws-server:latest\n", - "\n", - ), - registry = IMAGE_REGISTRY, - ); - } - format!( - concat!( - "# `hub` is a named build context, not a stage defined here.\n", - "# The generated compose file wires it to its `{service}` service, so compose builds the hub image\n", - "# first and hands the result over under this name. That is also why the version-tag rule is\n", - "# suppressed on the next line: hadolint reads `hub` as an untagged image, but there is no tag to give a\n", - "# build context, and the image it resolves to is pinned by the hub Dockerfile rather than here.\n", - "# hadolint ignore=DL3006\n", - "FROM hub\n", - "\n", - ), - service = super::HUB_IMAGE_BUILD, - ) -} - /// Render the scenario image, which layers this cluster's modules onto the hub image. -fn render_dockerfile(cluster_name: &str, published: bool, sources: &ImageSources) -> String { - let mut out = render_dockerfile_header(cluster_name, published); - - if !sources.mise_tools.is_empty() { - out.push_str(&render_deps_stage(&sources.mise_tools)); - } - if !sources.released.is_empty() { - out.push_str(&render_released_stage(&sources.released)); - } - - // Each section above ends with its own blank line, so none of them start with one. Doing it the other way round - // doubles the separator wherever two sections meet. - out.push_str(&render_hub_base(published)); - out.push_str(concat!( - "LABEL org.opencontainers.image.source=\"", - et_org::repository_url!(), - "\"\n", - "\n", - )); - - // `pkg/` unconditionally, never probed for on disk. Every module serves out of `pkg/`, whether that directory is - // committed (the JS shims) or produced by a build (wasm-pack output, wheels). Probing for it makes this file depend - // on what happens to be built in the working tree: CI checks out unbuilt Rust modules, found no `services/ws- - // modules/har1/pkg`, and emitted the module root instead -- so the committed output and the CI regeneration - // disagreed, and the drift check failed. A module that genuinely has no `pkg/` now fails loudly at image build - // rather than silently generating a different Dockerfile per machine. - for (repo_path, docker_path) in &sources.repo_paths { - let _write_result = writeln!(out, "COPY --chown=10001:10001 {repo_path}/pkg {docker_path}/pkg"); - } - // One copy of the whole staged tree, which mirrors `/app` path for path. - if !sources.released.is_empty() { - out.push_str("COPY --from=released --chown=10001:10001 /staged/app /app\n"); - } - for (.., docker_path) in &sources.mise_tools { - let _write_result = writeln!( - out, - "COPY --from=deps --chown=10001:10001 /staged{docker_path} {docker_path}" - ); - } - // Each from the named build context its `module_paths:` entry is handed to the build as, and whole rather than - // its `pkg/`: whether the module serves from `pkg/` or its root is the scenario author's layout, and the hub finds - // either once the directory is there. hadolint reads a context name as an undefined stage, as with `hub` above. - for (context, context_path, docker_path) in &sources.scenario_dirs { - let operands = format!("[{}, {}]", dockerfile_word(context_path), dockerfile_word(docker_path)); - let _write_result = writeln!( - out, - "# hadolint ignore=DL3022\nCOPY --from={context} --chown=10001:10001 {operands}" - ); - } - // The user the hub image already runs as, restated so the image says so itself rather than only through a base - // a scanner cannot see into; every COPY above hands its files to the same id. An image with no COPY already ends - // in the blank line that closes its LABEL. - if !out.ends_with("\n\n") { - out.push('\n'); - } - out.push_str("USER 10001\n"); - - out -} - -/// Render the stage that fetches this repository's modules from the registry a published scenario consumes them from. -/// -/// `npm pack` rather than a download by URL, because it checks each tarball against the integrity the registry records -/// for it before writing it. The tarball holds the module's `pkg/` exactly as it was published, under npm's `package/` -/// prefix, which the extraction strips. The token reaches the step as a secret mount, never as a build argument or a -/// file in a layer, and the npm config only names it: npm expands `${GITHUB_TOKEN}` when it reads the file. -fn render_released_stage(released: &[(String, String)]) -> String { - // One `printf` per line, appending, so no line of the step outgrows the file's width. - let npmrc = super::mise::npmrc_contents() - .lines() - .fold(String::default(), |mut steps, line| { - let _write_result = writeln!(steps, "printf '%s\\n' '{line}' >> /released.npmrc"); - steps - }); - let mut stage = format!( - concat!( - "# Fetch the released modules this scenario serves.\n", - "# GitHub Packages refuses an anonymous read even of a public package, so the build takes a `gh_token`\n", - "# secret, which is only ever mounted into the step that needs it.\n", - "FROM {image} AS released\n", - "\n", - "ENV NPM_CONFIG_USERCONFIG=/released.npmrc\n", - "RUN bash <<'EOF'\n", - "set -euo pipefail\n", - "{npmrc}", - "EOF\n", - "\n", - ), - image = NODE_IMAGE, - npmrc = npmrc, - ); - for (package, docker_path) in released { - let _write_result = writeln!(stage, "ENV STAGE_PACKAGE=\"{package}\""); - let _write_result = writeln!(stage, "ENV STAGE_DEST=\"/staged{docker_path}/pkg\""); - let _write_result = writeln!( - stage, - "RUN --mount=type=secret,id={REGISTRY_TOKEN_SECRET},env={REGISTRY_TOKEN_ENV} bash <<'EOF'" - ); - stage.push_str(concat!( - "set -euo pipefail\n", - "cd /tmp\n", - "tarball=\"$(npm pack --silent \"${STAGE_PACKAGE}@latest\")\"\n", - "mkdir -p \"${STAGE_DEST}\"\n", - "tar -xzf \"${tarball}\" -C \"${STAGE_DEST}\" --strip-components=1\n", - "rm \"${tarball}\"\n", - "EOF\n", - "\n", - )); - } - stage -} - -/// Render the stage that installs the mise-staged packages the cluster's modules depend on. -/// -/// The repo's own `.mise/` configs and lockfiles are the version source, so nothing here pins a version that could -/// drift from what a workstation resolves; only the tool ids come from the module registry. -fn render_deps_stage(mise_tools: &[(String, String, String)]) -> String { - let tools = mise_tools - .iter() - .map(|(tool, ..)| tool.as_str()) - .collect::>() - .join(" "); - let mut stage = String::default(); - stage.push_str(DEPS_BASE_STAGE); - let _write_result = writeln!(stage, "ENV MISE_ENV={DEPS_MISE_ENV}"); - if mise_tools.iter().any(|(tool, ..)| tool.starts_with("npm:")) { - // mise's npm backend shells out to npm, and refuses an `npm:` tool whose configured `node` dependency is - // not installed yet ("requires configured install dependency 'node@22', but its selected version is not - // installed"). Its version comes from the repo's own [tools] pin, like every other tool staged here. The - // blank line after `EOF` is load-bearing, not formatting. hadolint's parser reads the next instruction as a - // continuation of the heredoc without it and fails with `unexpected 'E' expecting a new line followed by the - // next instruction`. - stage.push_str(concat!( - "RUN bash <<'EOF'\n", - "set -euo pipefail\n", - "mise install node\n", - "EOF\n", - "\n", - )); - } - let _write_result = writeln!(stage, "ENV SCENARIO_TOOLS=\"{tools}\""); - stage.push_str(concat!( - "RUN bash <<'EOF'\n", - "set -euo pipefail\n", - "# Word-splitting SCENARIO_TOOLS is intentional; it is a space-separated tool list.\n", - "# shellcheck disable=SC2086\n", - "mise install ${SCENARIO_TOOLS}\n", - "EOF\n", - "\n", - )); - for (tool, package, docker_path) in mise_tools { - let _write_result = writeln!(stage, "ENV STAGE_TOOL=\"{tool}\""); - let _write_result = writeln!(stage, "ENV STAGE_PACKAGE=\"{package}\""); - let _write_result = writeln!(stage, "ENV STAGE_DEST=\"/staged{docker_path}\""); - stage.push_str(DEPS_STAGE_TOOL_COPY); - } - stage -} - -/// Render the ignore file `BuildKit` applies to this Dockerfile in place of the repository root one. -/// -/// It is the root file verbatim plus a re-include block, rather than a hand-picked subset, so a rule added to -/// `.gitignore` reaches this build too. Docker's matcher is last-match-wins and, unlike git's, still descends into an -/// excluded directory to honour a later negation, so the trailing block is enough to bring back the built artifacts -/// while every other exclusion keeps applying. -fn render_dockerignore( - project_root: &Path, - cluster_name: &str, - repo_paths: &[(String, String)], -) -> Result { - let root = fs::read_to_string(project_root.join(".dockerignore"))?; - let mut out = String::default(); - let _write_result = writeln!( - out, - concat!( - "# AUTO-GENERATED by `et-cli` for the `{name}` scenario -- do not edit.\n", - "# Regenerate with `mise run regen-verification`.\n", - "#\n", - "# BuildKit reads this file instead of the repository root `.dockerignore` when building the\n", - "# Dockerfile beside it, so the root file keeps governing the CI images built from `./Dockerfile`\n", - "# and only this build sees the re-includes at the end.\n", - ), - name = cluster_name, - ); - out.push_str(&root); - out.push_str(concat!( - "\n", - "# Re-admit the built module artifacts this scenario serves.\n", - "# The rules above drop every `*.wasm` and `*.onnx`, which is what keeps the CI images small; these\n", - "# negations put back the `pkg/` directories this image copies, and nothing else.\n", - )); - // A BTreeSet both de-duplicates paths shared by two modules and keeps the emitted order stable. - let negations: BTreeSet = repo_paths +fn render_dockerfile(cluster_name: &str, published: bool, sources: &ImageSources) -> Result { + let scenario_dirs = sources + .scenario_dirs .iter() - .map(|(repo_path, _)| format!("!{repo_path}/pkg/**")) + .map(|dir| ScenarioCopy { + context: &dir.context, + destination: dockerfile_word(&dir.docker_path), + source: dockerfile_word(&dir.context_path), + }) .collect(); - for negation in negations { - out.push_str(&negation); - out.push('\n'); - } + render( + "scenario-image.j2", + &DockerfileContext { + deps_mise_env: DEPS_MISE_ENV, + hub_dockerfile: HUB_DOCKERFILE, + hub_image_build: super::HUB_IMAGE_BUILD, + installs_node: sources.mise_tools.iter().any(|staged| staged.tool.starts_with("npm:")), + mise_tools: &sources.mise_tools, + name: cluster_name, + node_image: NODE_IMAGE, + npmrc: super::mise::npmrc_contents()?, + published, + registry: IMAGE_REGISTRY, + released: &sources.released, + repo_paths: &sources.repo_paths, + repository_url: et_org::repository_url!(), + scenario_dirs, + token_env: REGISTRY_TOKEN_ENV, + token_secret: REGISTRY_TOKEN_SECRET, + }, + ) +} - Ok(out) +/// Render the ignore file the scenario Dockerfile is built with, from the repository's own when it builds from there. +fn render_dockerignore(cluster_name: &str, repository: bool, repo_paths: &[RepoModule]) -> Result { + let root_dockerignore = if repository { + fs::read_to_string(edge_toolkit::config::get_project_root().join(".dockerignore"))? + } else { + String::default() + }; + render( + "scenario-image.dockerignore.j2", + &DockerignoreContext { + built_artifacts: repo_paths + .iter() + .map(|module| format!("{}/pkg/**", module.repo_path)) + .collect(), + name: cluster_name, + repository, + root_dockerignore, + }, + ) } diff --git a/utilities/cli/src/deployment_types/yaml.rs b/utilities/cli/src/deployment_types/yaml.rs index 6579a47f..0dc765de 100644 --- a/utilities/cli/src/deployment_types/yaml.rs +++ b/utilities/cli/src/deployment_types/yaml.rs @@ -4,17 +4,20 @@ use serde_yaml::{Mapping, Value}; use crate::error::CliError; /// Serialise one object, in the YAML style the repo's formatters hold a committed file to. -#[expect( - clippy::unwrap_in_result, - clippy::unwrap_used, - reason = "pretty_yaml only fails on malformed YAML and serde output is always well-formed" -)] pub(crate) fn document(object: &T) -> Result where T: Serialize, { - let yaml = serde_yaml::to_string(object)?; - Ok(pretty_yaml::format_text(&yaml, &pretty_yaml::config::FormatOptions::default()).unwrap()) + Ok(formatted(&serde_yaml::to_string(object)?)) +} + +/// `yaml`, a serializer's output, restyled as the repo's formatters hold a committed file to. +#[expect( + clippy::unwrap_used, + reason = "pretty_yaml only fails on malformed YAML and serde output is always well-formed" +)] +pub(crate) fn formatted(yaml: &str) -> String { + pretty_yaml::format_text(yaml, &pretty_yaml::config::FormatOptions::default()).unwrap() } /// Set `key` in `mapping`, which never holds it already: each key is written once, in the order the file shows them. diff --git a/utilities/cli/src/error.rs b/utilities/cli/src/error.rs index c61c7bf7..c14a75a8 100644 --- a/utilities/cli/src/error.rs +++ b/utilities/cli/src/error.rs @@ -50,6 +50,13 @@ pub enum CliError { #[error("Failed to serialize mise TOML")] SerializeToml(#[from] toml::ser::Error), + #[error("Failed to serialize compose YAML")] + SerializeYaml(#[from] serde_saphyr::SerializeError), + + // The alternate form is minijinja's long one, which quotes the template lines around the one that failed. + #[error("Failed to render a deployment template: {0:#}")] + Template(#[from] minijinja::Error), + #[error("The hub refused or failed the request: {0}")] HubRequest(#[from] reqwest::Error), diff --git a/utilities/cli/src/expose.rs b/utilities/cli/src/expose.rs index 0282d916..61298715 100644 --- a/utilities/cli/src/expose.rs +++ b/utilities/cli/src/expose.rs @@ -6,9 +6,9 @@ //! not anything declared the port, which is why the hub's and the collector's ports are always reserved. use std::collections::BTreeMap; -use std::fmt::Write as _; use edge_toolkit::ports::Services as Ports; +use serde::Serialize; use crate::error::CliError; use crate::input::{Agent, ClusterInput, ExposedPort, Protocol}; @@ -230,29 +230,37 @@ fn validate_collisions(cluster: &ClusterInput) -> Result<(), CliError> { Ok(()) } -/// Render the list of every exposed port, as the address it is reached at from outside, or nothing when none is. +/// One exposed port, as the README lists it: whose it is, its name, and where it is reached from outside. +#[derive(Debug, Serialize)] +pub(crate) struct ExposedAddress { + owner: String, + name: String, + /// The address as Markdown: a link for a port named after a web scheme, and its address and transport otherwise. + address: String, +} + +/// Every exposed port, as the address it is reached at from outside; none when nothing is exposed. /// -/// A port named after a web scheme is a link with that scheme; anything else is its address and transport. The hub's -/// are left out unless `with_hubs`, for a README that already gives the hub's addresses itself. -pub(crate) fn address_list(cluster: &ClusterInput, with_hubs: bool) -> String { - let mut list = String::default(); +/// The hub's are left out unless `with_hubs`, for a README that already gives the hub's addresses itself. +pub(crate) fn addresses(cluster: &ClusterInput, with_hubs: bool) -> Vec { + let mut list = Vec::new(); let hub_ports = if with_hubs { hub_ports(cluster) } else { Vec::new() }; for hub in hubs(cluster) { for exposed in &hub_ports { - push_address(&mut list, cluster, &hub.name, &hub.site, exposed); + list.push(exposed_address(cluster, &hub.name, &hub.site, exposed)); } } for agent in &cluster.agents { let site = agent_site(cluster, &agent.name); for exposed in &agent.expose { - push_address(&mut list, cluster, &agent.name, &site, exposed); + list.push(exposed_address(cluster, &agent.name, &site, exposed)); } } list } -/// Append one exposed port of `owner` at `site` to the list [`address_list`] renders. -fn push_address(list: &mut String, cluster: &ClusterInput, owner: &str, site: &Site, exposed: &ExposedPort) { +/// One exposed port of `owner` at `site`, as [`addresses`] lists it. +fn exposed_address(cluster: &ClusterInput, owner: &str, site: &Site, exposed: &ExposedPort) -> ExposedAddress { let machine = site.machine_names().join(""); let host = cluster .machines @@ -270,5 +278,9 @@ fn push_address(list: &mut String, cluster: &ClusterInput, owner: &str, site: &S } else { format!("<{scheme}://{host}:{port}/>") }; - let _write_result = writeln!(list, "- `{owner}` `{name}`: {address}"); + ExposedAddress { + address, + name: name.clone(), + owner: owner.to_owned(), + } } diff --git a/utilities/cli/src/lib.rs b/utilities/cli/src/lib.rs index ddaa7ac3..bd03db26 100644 --- a/utilities/cli/src/lib.rs +++ b/utilities/cli/src/lib.rs @@ -4,7 +4,6 @@ )] use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::slice; @@ -13,7 +12,7 @@ use edge_toolkit::ports::Services as Ports; use et_path::{absolute_from, relative_path_from}; use et_sanitise::{is_plain_path, is_quotable_path}; use fs_err as fs; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; mod admin; pub mod cli; @@ -25,9 +24,11 @@ mod input; mod limits; mod module_package_json; mod placement; +mod readme; mod recovery; mod scenario_password; mod schedule; +mod templates; // `pub` here means "reachable from the binary or from `tests/`", and nothing else. This crate is a command line // tool that happens to be split into a lib target so integration tests can drive it; the only consumer outside this @@ -46,9 +47,10 @@ pub use self::input::{ ArtifactSource, ClusterInput, DEFAULT_CLUSTER_NAME, Device, OutputType, Service, Services, infer_artifact_source, manifest_declares_this_repository, }; -pub(crate) use self::input::{Board, BoardSpec, Delivery, running_in_this_repository}; +pub(crate) use self::input::{Board, Delivery, running_in_this_repository}; pub use self::limits::{Cpus, Limits, LimitsError, Ulimit}; pub use self::module_package_json::generate_module_package_json; +pub use self::readme::scenario_dockerfile_path; pub use self::recovery::{Recovery, RecoverySettings}; pub(crate) use self::scenario_password::{scenario_password, scenario_seed}; pub use self::schedule::Schedule; @@ -164,66 +166,6 @@ pub(crate) const RESERVED_RUNNER_NAMES: [&str; 9] = [ deployment_types::SHARED_STORAGE_TASK, ]; -/// What the README says about committing the lockfile, whichever way the deployment's artifacts come. -/// -/// A macro rather than a `const`, because `concat!` takes only literals and each section is one literal. -macro_rules! mise_lock_commit_note { - () => { - concat!( - "Commit `mise.lock` alongside the deployment, together with the `.mise/locks/` directory when\n", - "mise writes one -- it holds the dependency graph of each npm tool, which `mise.lock` refers to.\n", - "The two record the version and checksum every `latest` in `mise.toml` resolved to, for each of\n", - "mise's common platforms, so every later install -- on any machine -- fetches the same binaries\n", - "rather than whatever is newest that day. Move to newer releases deliberately, with\n", - "`mise lock --bump`, and commit the result.\n\n" - ) - }; -} - -/// The command that starts a process-compose deployment, naming its file outright. -/// -/// process-compose looks for `compose.yaml` among its default names and takes it first, so in a directory that also -/// holds the Docker Compose file it would load that one instead. Macros rather than `const`s here and below, because -/// `concat!` takes only literals and each README section is one literal. -macro_rules! process_compose_up { - () => { - "process-compose up --no-server -f process-compose.yaml" - }; -} - -/// What the process-compose README section says after its commands, whichever way the deployment's artifacts come. -macro_rules! process_compose_run_notes { - () => { - concat!( - "Each process runs one task of `mise.toml`, in order: `ws-server` waits for OpenObserve to report\n", - "healthy, and every runner waits for `ws-server`. A scheduled agent's runner is started on each\n", - "tick of its schedule and runs its module once; a tick that finds the previous run still going is\n", - "skipped. `--no-server` keeps process-compose's own API off port 8080, where `ws-server` listens.\n", - "Quitting the TUI stops every process. Open the OpenObserve UI, once it is up, by starting the\n", - "`open-o2` process from the TUI.\n" - ) - }; -} - -/// The files a deployment of `output_types` writes for `cluster`, in the order the README lists them. -fn generated_output_files(cluster: &ClusterInput, output_types: &[OutputType]) -> Vec<&'static str> { - let mut files = Vec::new(); - for output_type in output_types { - // `dekit.yaml` and `process-compose.yaml` run the tasks of `mise.toml`, which is written with either whether or - // not it was asked for -- and listed once, however many formats wrote it. - if matches!(output_type, OutputType::Dekit | OutputType::ProcessCompose) { - files.push(OutputType::Mise.output_file_name()); - } - files.push(output_type.output_file_name()); - if matches!(output_type, OutputType::K3s) && deployment_types::needs_containerd_template(cluster) { - files.push(deployment_types::CONTAINERD_TEMPLATE_FILE); - } - } - let mut listed = BTreeSet::new(); - files.retain(|file| listed.insert(*file)); - files -} - #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct DeploymentSummary { @@ -600,13 +542,13 @@ fn generate_deployment_outputs( let password = scenario_password(seed); fs::write( output_dir.join(SECRETS_ENV_FILE), - secrets_env(&password, placement::shares_hubs(cluster)), + secrets_env(&password, placement::shares_hubs(cluster))?, )?; write_deployment_gitignore(output_dir)?; for output_type in output_types { generate_format(cluster, output_dir, *output_type, with_process_compose)?; } - write_readme(cluster, output_dir, output_types)?; + readme::write_readme(cluster, output_dir, output_types)?; recovery::remove_unsupported_outputs(cluster, output_dir) } @@ -666,57 +608,6 @@ fn generate_format( } } -/// Write the README for a deployment of `output_types`: what the directory holds and how to run each format. -fn write_readme(cluster: &ClusterInput, output_dir: &Path, output_types: &[OutputType]) -> Result<(), CliError> { - let readme_path = output_dir.join("README.md"); - let module_names = cluster_module_names(cluster); - let needs = deployment_types::scenario_image_needs(cluster)?; - let image_build = ImageBuild { - needs, - dockerfile: if needs.repository { - scenario_dockerfile_path(output_dir) - } else { - "Dockerfile".to_string() - }, - context_root: if needs.repository { - edge_toolkit::config::get_project_root() - } else { - std::path::absolute(output_dir)? - }, - }; - fs::write( - &readme_path, - generated_readme(cluster, &module_names, output_types, &image_build), - )?; - Ok(()) -} - -/// The path the generated README tells a reader to build this scenario's image from. -/// -/// Only the parent is rendered, so the command keeps naming the last segment through the `scenario` shell variable it -/// already sets rather than repeating the name. Joined from the path's own components rather than displayed, because -/// the result is committed: a `Display` of the same path writes `\` on Windows and `/` everywhere else, which would -/// make the file drift by platform and fail the check that holds it stable. Regeneration passes a repository-relative -/// path, which is what the command needs, since it runs from the repository root. -/// -/// A single-component output directory has a parent, and it is the empty path rather than `None` -- so this cannot lean -/// on `unwrap_or` and has to check the rendered parent. Prefixing an empty one would produce `/$scenario/Dockerfile`, -/// an absolute path to a directory nobody has. -#[must_use] -pub fn scenario_dockerfile_path(output_dir: &Path) -> String { - let parent = output_dir - .parent() - .unwrap_or(output_dir) - .iter() - .map(|component| component.to_string_lossy()) - .collect::>() - .join("/"); - if parent.is_empty() { - return "$scenario/Dockerfile".to_string(); - } - format!("{parent}/$scenario/Dockerfile") -} - /// Keep a generated deployment's credential and runtime state out of whatever repository it was generated into. /// /// Written beside the files it covers rather than left to the operator, because the failure is silent and permanent: @@ -729,33 +620,22 @@ pub fn scenario_dockerfile_path(output_dir: &Path) -> String { /// it here would only hide it from the drift check that exists to read it. What the hub writes at runtime is ignored /// everywhere, since it is never evidence of anything the generator did. fn write_deployment_gitignore(output_dir: &Path) -> Result<(), CliError> { - let credential = if running_in_this_repository() { - String::default() - } else { - format!( - concat!( - "# The password is derived from the scenario input, so regenerating this deployment rewrites it;\n", - "# committing it would publish the credential of every deployment generated from that input.\n", - "{file}\n", - "\n", - ), - file = SECRETS_ENV_FILE, - ) - }; - let body = format!( - concat!( - "# Written by `et-cli`.\n", - "{credential}", - "# What the hub writes into the directory it runs from: its self-signed TLS pair (the key is a\n", - "# credential too), the agent registry it saves on shutdown, and agent storage.\n", - "cert.pem\n", - "key.pem\n", - "registry.yaml\n", - "{storage}/\n", - ), - credential = credential, - storage = crate::deployment_types::STORAGE_DIR, - ); + /// What the ignore file is rendered from. + #[derive(Serialize)] + struct Gitignore { + /// Whether the credential is ignored too, which it is everywhere but in this repository. + ignores_credential: bool, + secrets_file: &'static str, + storage_dir: &'static str, + } + let body = templates::render( + "gitignore.j2", + &Gitignore { + ignores_credential: !running_in_this_repository(), + secrets_file: SECRETS_ENV_FILE, + storage_dir: deployment_types::STORAGE_DIR, + }, + )?; fs::write(output_dir.join(".gitignore"), body)?; Ok(()) } @@ -769,37 +649,18 @@ fn write_deployment_gitignore(output_dir: &Path) -> Result<(), CliError> { /// not secrets and each format states it outright, so keeping it in this file would have meant an uncommitted file /// standing between a reader and a value there was never any reason to withhold -- and, in the Kubernetes case, a /// `Secret` holding something that is not one. -/// -/// Each value carries a `skipcq` pragma for `DeepSource SCT-A000`: the copies committed under `verification/` are -/// read as hardcoded credentials, and the path excludes that keep the rest of that tree out of analysis do not reach -/// a secrets scan. The pragma sits on the line above rather than at the end of its own, because an env file has no -/// inline comments: every consumer keeps what follows the `=` verbatim, so a trailing marker would become part of the -/// password. -fn secrets_env(password: &str, shares_hubs: bool) -> String { - let mut body = format!( - concat!( - "# Generated from the scenario input -- regenerating this deployment rewrites it.\n", - "# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret\n", - "ZO_ROOT_USER_PASSWORD={password}\n", - "# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret\n", - "OTLP_AUTH_PASSWORD={password}\n", - ), - password = password - ); +fn secrets_env(password: &str, shares_hubs: bool) -> Result { + /// What the env file is rendered from: each variable the password is set as. + #[derive(Serialize)] + struct SecretsEnv<'password> { + names: Vec<&'static str>, + password: &'password str, + } + let mut names = vec!["ZO_ROOT_USER_PASSWORD", "OTLP_AUTH_PASSWORD"]; if shares_hubs { - for name in deployment_types::STORAGE_SECRET_ENVS { - let _write_result = writeln!( - body, - concat!( - "# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret\n", - "{name}={password}", - ), - name = name, - password = password, - ); - } + names.extend(deployment_types::STORAGE_SECRET_ENVS); } - body + templates::render("secrets.env.j2", &SecretsEnv { names, password }) } fn discover_verification_scenarios(verification_root: &Path) -> Result, CliError> { @@ -854,1021 +715,6 @@ fn discover_verification_scenarios(verification_root: &Path) -> Result String { - let cluster_name = &cluster.cluster_name; - let output_files = generated_output_files(cluster, output_types); - if let [only_file] = output_files.as_slice() { - return format!("This directory contains the generated `{only_file}` for the `{cluster_name}` scenario."); - } - let listed = output_files - .iter() - .map(|output_file| format!("`{output_file}`")) - .collect::>() - .join(", "); - // Broken only between names, so the list fits the line length however many formats it holds. - let files = textwrap::Options::new(120) - .break_words(false) - .word_separator(textwrap::WordSeparator::AsciiSpace) - .word_splitter(textwrap::WordSplitter::NoHyphenation); - let files = textwrap::fill(&format!("Files: {listed}."), files); - format!("This directory contains generated deployment configs for the `{cluster_name}` scenario.\n{files}") -} - -/// How the README's `docker build` of the scenario image runs: what it needs, and the paths it names. -struct ImageBuild { - needs: deployment_types::ImageNeeds, - /// The scenario Dockerfile, relative to where the build runs. - dockerfile: String, - /// Where the build runs, which the scenario's own module directories are named relative to. - context_root: PathBuf, -} - -/// Render the README a deployment directory carries: what it holds, then how to run each format it was written in. -fn generated_readme( - cluster: &ClusterInput, - module_names: &[String], - output_types: &[OutputType], - image_build: &ImageBuild, -) -> String { - let module_summary = if module_names.is_empty() { - "No workflow modules were selected in the scenario input.".to_string() - } else { - format!( - "The scenario exposes these workflow modules: {}.", - module_names.join(", ") - ) - }; - - let output_summary = output_summary(cluster, output_types); - // Relative to where the README's `docker build` runs from. - let build_contexts = module_path_contexts(&cluster.module_paths, &image_build.context_root) - .iter() - .fold(String::default(), |mut flags, (name, path)| { - let _write_result = write!(flags, " --build-context \"{name}={path}\""); - flags - }); - let mut run_instructions = output_types - .iter() - .map(|output_type| { - let mut section = generated_run_instructions( - *output_type, - cluster, - image_build, - &runner_kinds(cluster), - cluster.artifact_source, - &build_contexts, - ); - section.push_str(&machine_instructions(*output_type, cluster)); - section.push_str(&limits::limits_note(*output_type, cluster)); - section.push_str(schedule::schedule_note(*output_type, cluster)); - section - }) - .collect::>() - .join("\n"); - run_instructions.push_str(&board_instructions(cluster)); - // Every way of running the tasks needs what they name installed first, so the step comes once, ahead of them all. - let install_note = if output_types.iter().any(|output_type| output_type.runs_mise_tasks()) { - mise_install_note(cluster.artifact_source) - } else { - "" - }; - - format!( - concat!( - "# {name}\n\n", - "{output_summary}\n\n", - "{module_summary}\n\n", - "{artifact_note}", - "{secrets_note}\n\n", - "{install_note}", - "{run_instructions}", - ), - name = cluster.cluster_name, - artifact_note = artifact_source_note(cluster.artifact_source), - install_note = install_note, - output_summary = output_summary, - module_summary = module_summary, - secrets_note = secrets_note(placement::shares_hubs(cluster)), - run_instructions = run_instructions, - ) -} - -/// State where this scenario's artifacts come from, for a scenario that does not build them. -/// -/// Above the run sections rather than inside one, because it is true of every way this scenario starts: the `mise` -/// deployment runs released binaries, and the compose and Kubernetes ones run published images. Said once per run mode -/// it would be three copies of one fact, and said inside `mise` alone it would read as a property of that mode. A local -/// scenario says nothing here -- building what it runs is the unremarkable case, and the run sections already show -/// the builds. -const fn artifact_source_note(artifacts: ArtifactSource) -> &'static str { - if matches!(artifacts, ArtifactSource::Published) { - return concat!( - "This scenario is rendered against released artifacts rather than builds of this repository:\n", - "the images it names are published, and the binaries it runs are the released ones. Only the\n", - "image carrying its own module set is still built locally, since no release can hold a module\n", - "set particular to one deployment.\n\n" - ); - } - "" -} - -/// Explain the credential file, since a deployment that reaches a new machine without it fails obscurely. -/// -/// Worth saying out loud because its absence is silent: `mise` skips an `_.file` it cannot find without a warning, -/// and Docker Compose treats a missing `env_file` the same way, so a copy without it starts a collector with no root -/// password rather than failing. -fn secrets_note(shares_hubs: bool) -> String { - let credentials = if shares_hubs { - "OpenObserve, OTLP and object store credentials" - } else { - "OpenObserve and OTLP credentials" - }; - format!( - concat!( - "`{file}` holds the scenario's derived {credentials}. It is derived from the\n", - "scenario input, so regenerating this deployment rewrites it; if it is missing, regenerate before\n", - "starting the stack. A deployment generated outside the repository is written with a `.gitignore`\n", - "covering it, so its credential is not committed by whatever repository it lands in.", - ), - credentials = credentials, - file = SECRETS_ENV_FILE - ) -} - -/// The distinct runner kinds a cluster names, in the order they first appear. -/// -/// Deduplicated because a scenario with two agents on the same runner needs its image built once, and ordered by first -/// appearance rather than sorted so the generated instructions read in the order the input declares. -fn runner_kinds(cluster: &ClusterInput) -> Vec { - runner_kinds_where(cluster, |_agent| true) -} - -/// The distinct runner kinds of the agents `include` accepts, in the order they first appear. -fn runner_kinds_where(cluster: &ClusterInput, include: F) -> Vec -where - F: Fn(&input::Agent) -> bool, -{ - let mut kinds: Vec = Vec::new(); - for agent in cluster.agents.iter().filter(|agent| include(agent)) { - let Some(kind) = agent.runner.as_deref().map(str::trim).filter(|kind| !kind.is_empty()) else { - continue; - }; - if !kinds.iter().any(|seen| seen == kind) { - kinds.push(kind.to_string()); - } - } - kinds -} - -/// Render the step that locks the tools `mise.toml` names, and for a published deployment installs them. -/// -/// Every deployment that runs the tasks declares tools there -- at least the UI opener, and a published one its -/// binaries as `cargo:` tools -- each requested as `latest`, so without a committed lockfile two machines can install -/// different releases from the same files. A local deployment compiles what it runs from the working tree, so locking -/// is all it needs here. A published one also installs, since `task.run_auto_install` is off and the first task would -/// otherwise die on a command it cannot find rather than fetching it. Why the binaries are released rather than built -/// is said once, above the run sections, because it is true of every mode. -/// -/// `NPM_CONFIG_USERCONFIG` is exported rather than left to the `[env]` beside it, which carries the same value. mise -/// computes that entry but does not apply it to its own tool resolution, so the npm client it embeds reads no user -/// config, falls back to registry.npmjs.org, and reports the scoped module packages as `package not found` -- they -/// exist only on GitHub Packages. Exported into the process, the same file resolves against the right registry, for the -/// lock and the install alike. The `[env]` entry stays because the tasks do get it. -const fn mise_install_note(artifacts: ArtifactSource) -> &'static str { - if matches!(artifacts, ArtifactSource::Published) { - return concat!( - "## Install\n\n", - "Lock and fetch the binaries and module packages the `mise.toml` tasks name before the first run.\n", - "`GITHUB_TOKEN` has to be set: GitHub Packages rejects an unauthenticated read even for a public\n", - "package. The registry configuration is exported rather than relied on from `mise.toml`, because\n", - "mise does not apply its own `[env]` to the resolution these commands perform:\n\n", - "```bash\n", - "export NPM_CONFIG_USERCONFIG=\"$PWD/npmrc\"\n", - "mise lock\n", - "mise install --locked\n", - "```\n\n", - "`--locked` installs exactly what the lockfile records, and fails rather than resolving anew\n", - "when it is missing an entry.\n\n", - mise_lock_commit_note!(), - ); - } - concat!( - "## Lock The Tools\n\n", - "Before the first run, lock the tools `mise.toml` names:\n\n", - "```bash\n", - "mise lock\n", - "```\n\n", - mise_lock_commit_note!(), - ) -} - -/// Render the README section that runs the scenario with process-compose. -/// -/// A local deployment installs process-compose here, since the README's own install step is only written for a -/// published one; a published deployment's install step already fetches it with everything else `mise.toml` names. -#[expect( - clippy::single_call_fn, - reason = "the process-compose arm of generated_run_instructions" -)] -const fn process_compose_run_instructions(artifacts: ArtifactSource) -> &'static str { - if matches!(artifacts, ArtifactSource::Published) { - return concat!( - "## Run With process-compose\n\n", - "From this directory, start the scenario and watch each process with:\n\n", - "```bash\n", - process_compose_up!(), - "\n```\n\n", - process_compose_run_notes!(), - ); - } - concat!( - "## Run With process-compose\n\n", - "From this directory, install the process-compose `mise.toml` declares, then start the scenario\n", - "and watch each process with:\n\n", - "```bash\n", - "mise install\n", - process_compose_up!(), - "\n```\n\n", - process_compose_run_notes!(), - ) -} - -/// Render the README section that runs one deployment format. -fn generated_run_instructions( - output_type: OutputType, - cluster: &ClusterInput, - image_build: &ImageBuild, - runners: &[String], - artifacts: ArtifactSource, - build_contexts: &str, -) -> String { - if let (Delivery::Firmware(board), Some(server)) = (placement::server_delivery(cluster), cluster.machines.first()) { - return board_hub_run_instructions(output_type, &server.name, board.spec()); - } - match output_type { - OutputType::Mise => concat!( - "## Run With Mise\n\n", - "From this directory, start the scenario with:\n\n", - "```bash\n", - "mise run generated-scenario\n", - "```\n\n", - "That task starts both OpenObserve and `ws-server` for this scenario.\n\n", - "### Open The OpenObserve UI\n\n", - "From this directory, open the OpenObserve UI with:\n\n", - "```bash\n", - "mise run open-o2\n", - "```\n" - ) - .to_string(), - OutputType::Dekit => concat!( - "## Run With dekit\n\n", - "From this directory, start the scenario and watch each process in its own pane with:\n\n", - "```bash\n", - "dekit up\n", - "dekit attach\n", - "```\n\n", - "Each task runs one task of `mise.toml`, so these are the processes `mise run generated-scenario`\n", - "starts, but in order: `ws-server` waits for OpenObserve to report healthy, and every runner\n", - "waits for `ws-server`. The tasks keep running after the terminal closes; stop them with:\n\n", - "```bash\n", - "dekit down\n", - "```\n\n", - "Open the OpenObserve UI, once it is up, with:\n\n", - "```bash\n", - "dekit start open-o2\n", - "```\n" - ) - .to_string(), - OutputType::ProcessCompose => process_compose_run_instructions(artifacts).to_string(), - OutputType::DockerCompose => docker_compose_run_instructions(cluster, artifacts), - OutputType::K3s => k3s_run_instructions(cluster, image_build, runners, artifacts, build_contexts), - OutputType::FirmwarePico2 => firmware_output_instructions(Board::Pico2.spec()), - OutputType::FirmwareEsp32c6 => firmware_output_instructions(Board::Esp32c6.spec()), - OutputType::FirmwarePicoPlus2 => firmware_output_instructions(Board::PicoPlus2.spec()), - } -} - -/// Render the README section of a format whose server is a board, which leaves the format only the runners elsewhere. -/// -/// Only a format that runs `mise.toml` tasks gets here, since the others are refused a board before anything is -/// written. -fn board_hub_run_instructions(output_type: OutputType, server: &str, board: BoardSpec) -> String { - let format_name = match output_type { - OutputType::Dekit => "dekit", - OutputType::ProcessCompose => "process-compose", - OutputType::Mise - | OutputType::DockerCompose - | OutputType::K3s - | OutputType::FirmwarePico2 - | OutputType::FirmwareEsp32c6 - | OutputType::FirmwarePicoPlus2 => "Mise", - }; - format!( - concat!( - "## Run With {format_name}\n\n", - "The hub runs on `{server}`, a {title},\n", - "as firmware the section loading it describes. The board runs no OpenObserve, so there is no UI to\n", - "open. Each runner on another machine reaches the hub across the network, and starts as that\n", - "machine's share below.\n" - ), - format_name = format_name, - server = server, - title = board.title, - ) -} - -/// Render the README sections that load each board among the machines. -/// -/// That is the hub onto the server, when it is a board, and the runners onto every other board; nothing when no -/// machine is a board. -fn board_instructions(cluster: &ClusterInput) -> String { - let Some(server) = cluster.machines.first() else { - return String::default(); - }; - let mut text = String::default(); - for machine in &cluster.machines { - let Delivery::Firmware(board) = machine.device.delivery() else { - continue; - }; - let flash = format!("mise run {}", deployment_types::firmware_flash_task(&machine.name)); - let section = if machine.name == server.name { - let heading = format!("## Load The Hub Onto `{}`", machine.name); - let host_test = format!( - "mise run {}-{}", - deployment_types::FIRMWARE_HOST_TEST_TASK, - machine.name - ); - firmware_run_instructions(board.spec(), &heading, &flash, &host_test) - } else { - runner_board_instructions(board.spec(), &machine.name, &hub_ws_url_at(&server.address), &flash) - }; - let _write_result = write!(text, "\n{section}"); - } - text -} - -/// Render the README section that loads the runners onto `machine`, a board reaching the hub at `hub_url`. -fn runner_board_instructions(board: BoardSpec, machine: &str, hub_url: &str, flash: &str) -> String { - format!( - concat!( - "## Load The Runners Onto `{machine}`\n\n", - "`{machine}` is a {title} running each {runners} runner the scenario places on it, compiled into the\n", - "firmware from its module's component, and no hub: the runners join the one at `{hub_url}`.\n", - "Build those modules first, since the firmware takes each one's built package.\n", - "{network}\n", - "Then {preparation}.\n", - "From this directory, build the firmware and load it with:\n\n", - "```bash\n", - "{flash}\n", - "```\n" - ), - flash = flash, - hub_url = hub_url, - machine = machine, - network = board.network_setup, - preparation = board.load_preparation, - runners = board.runners.join(", "), - title = board.title, - ) -} - -/// Render the README section of a `firmware-*` format, which puts the whole scenario on `board`. -fn firmware_output_instructions(board: BoardSpec) -> String { - let heading = format!("## Run As {} Firmware", board.title); - let env = board.output_type.name(); - let flash = format!("MISE_ENV={env} mise run {}", deployment_types::FIRMWARE_FLASH_TASK); - let host_test = format!("MISE_ENV={env} mise run {}", deployment_types::FIRMWARE_HOST_TEST_TASK); - firmware_run_instructions(board, &heading, &flash, &host_test) -} - -/// Render a README section, under `heading`, that builds the hub as firmware for `board` and loads it onto one. -/// -/// `flash` is the command that builds and loads it, and `host_test` the one that serves the same modules from here. -fn firmware_run_instructions(board: BoardSpec, heading: &str, flash: &str, host_test: &str) -> String { - let runs = if board.runners.is_empty() { - "The board runs the hub alone and".to_owned() - } else { - format!( - concat!( - "The board runs the hub, and beside it each {} runner the scenario places on it, compiled into the\n", - "firmware from its module's component. The build refuses a runner that imports what the board\n", - "does not host, or runners needing more memory than it holds.\nThe board" - ), - board.runners.join(", ") - ) - }; - format!( - concat!( - "{heading}\n\n", - "{runs} serves this scenario's modules from its {flash_size} of flash.\n", - "Build those modules first, since the firmware takes each one's built package.\n", - "{network}\n", - "Then {preparation}.\n", - "From this directory, build the firmware and load it with:\n\n", - "```bash\n", - "{flash}\n", - "```\n\n", - "To check what the board will serve without one, serve the same modules from this host and fetch\n", - "each, as a page would:\n\n", - "```bash\n", - "{host_test}\n", - "```\n\n", - "The board takes an address over DHCP and serves the page at port 8080 of it. npm packages the\n", - "modules use that are too big for its flash, such as onnxruntime-web, are not built in: the board\n", - "redirects a request for one to the same path under `{elsewhere}`, which the browser has to be\n", - "able to reach.\n" - ), - heading = heading, - runs = runs, - flash_size = board.flash, - network = board.network_setup, - preparation = board.load_preparation, - flash = flash, - host_test = host_test, - elsewhere = deployment_types::FIRMWARE_ELSEWHERE_URL, - ) -} - -/// Render the README section that runs the Docker Compose deployment on one host. -#[expect( - clippy::single_call_fn, - reason = "the Docker Compose step of generated_run_instructions" -)] -fn docker_compose_run_instructions(cluster: &ClusterInput, artifacts: ArtifactSource) -> String { - let other_hubs = if placement::shares_hubs(cluster) { - concat!( - "Every hub listens on the same ports, so on one host only the server's starts: this leaves out\n", - "each other machine running a hub, which runs its share on its own machine instead.\n\n" - ) - } else { - "" - }; - format!( - concat!( - "## Run With Docker Compose\n\n", - "From this directory, start the scenario with:\n\n", - "```bash\n", - "docker compose{profiles} up --build\n", - "```\n\n", - "{other_hubs}", - "{hub}", - "`ws-server` runs with host networking so it advertises the same LAN IP as the `mise` deployment.\n\n", - "### Open The UIs\n\n", - "OpenObserve is available at .\n", - "`ws-server` is available at and .\n\n", - "{runner_ports}", - "Stop the scenario with:\n\n", - "```bash\n", - "docker compose{profiles} down\n", - "```\n" - ), - hub = compose_hub_note(artifacts), - runner_ports = compose_runner_ports(cluster), - other_hubs = other_hubs, - profiles = compose_single_host_profiles(cluster), - ) -} - -/// Render where the runners' exposed ports are reached, or nothing when no runner exposes one. -/// -/// The runners share their host's network like the hub, so a port one listens on is open on that host already. -#[expect( - clippy::single_call_fn, - reason = "the runner-port step of docker_compose_run_instructions" -)] -fn compose_runner_ports(cluster: &ClusterInput) -> String { - let addresses = expose::address_list(cluster, false); - if addresses.is_empty() { - return String::default(); - } - format!("The runners listen on their host's network too, at:\n\n{addresses}\n") -} - -/// Render how one format runs across the machines a scenario names, or nothing for a single-host scenario. -/// -/// A subsection after the single-host instructions rather than a rewrite of them, because most of what those say -- -/// building the images, loading the credential, opening the UIs -- holds on the server machine unchanged. What differs -/// is which machine starts what, and for k3s how the machines become one cluster and which of them hold which image. -fn machine_instructions(output_type: OutputType, cluster: &ClusterInput) -> String { - let Some(server) = cluster.machines.first() else { - return String::default(); - }; - // Named per format, because every format's section lands in the one README and markdownlint rejects a repeated - // heading. - let (format_name, intro, command): (&str, &str, fn(&str) -> String) = match output_type { - OutputType::Mise => ( - "Mise", - "Copy this directory to each machine and start that machine's share of the scenario there.\n", - |machine| format!("mise run {}", deployment_types::machine_task(machine)), - ), - OutputType::Dekit => ( - "dekit", - concat!( - "Copy this directory to each machine and start that machine's share there. Every task is tagged\n", - "with its machine and none starts on `dekit up` alone, so `dekit up` on any machine starts nothing\n", - "until its tag is started.\n" - ), - |machine| format!("dekit up\ndekit start +{machine}"), - ), - OutputType::ProcessCompose => ( - "process-compose", - "Copy this directory to each machine and start only that machine's namespace there.\n", - |machine| format!(concat!(process_compose_up!(), " -n {}"), machine), - ), - OutputType::DockerCompose => ( - "Docker Compose", - "Copy this directory to each machine and start only that machine's profile there.\n", - |machine| format!("docker compose --profile {machine} up --build"), - ), - OutputType::K3s => return k3s_machine_instructions(cluster, server), - // Refused before anything is written: the board is one machine. - OutputType::FirmwarePico2 | OutputType::FirmwareEsp32c6 | OutputType::FirmwarePicoPlus2 => { - return String::default(); - } - }; - let roles = machine_roles(cluster, server); - let loopback = loopback_note(cluster); - let mut text = format!("\n### Run Across Machines With {format_name}\n\n{intro}{roles}{loopback}"); - for machine in &cluster.machines { - // A board's share is its firmware, which every format loads the same way. - let start = match machine.device.delivery() { - Delivery::Tasks => command(&machine.name), - Delivery::Firmware(_) => format!("mise run {}", deployment_types::firmware_flash_task(&machine.name)), - }; - let _write_result = writeln!(text, "On `{}`:\n\n```bash\n{start}\n```\n", machine.name); - } - text.truncate(text.len().saturating_sub(1)); - text -} - -/// Say that the scenario's machine addresses are loopback, when any is, or nothing. -/// -/// Loopback addresses run every machine's share on one host, which is what a verification scenario wants, and none of -/// them reaches another machine. The commands that follow use the addresses as written, so they have to change in the -/// input, and the deployment regenerated, before it runs across machines. -fn loopback_note(cluster: &ClusterInput) -> &'static str { - let loopback = cluster - .machines - .iter() - .any(|machine| machine.address == "localhost" || machine.address.starts_with("127.")); - if !loopback { - return ""; - } - concat!( - "The machines' addresses are loopback, which plays every machine on this one host. To run them on\n", - "separate machines, give each its reachable address in the scenario input and regenerate first.\n\n" - ) -} - -/// The `--profile` flags that start the scenario on one host, or nothing for a scenario naming no machines. -/// -/// Every machine's profile, but that of a machine running a hub besides the server: every hub listens on the same -/// ports, so on one host the server's is the only one that can start, and each runner reaches it at `localhost`. -fn compose_single_host_profiles(cluster: &ClusterInput) -> String { - let hubs = placement::hubs(cluster); - cluster - .machines - .iter() - .filter(|machine| { - !hubs - .iter() - .any(|hub| !hub.on_server() && hub.site.machine_names().contains(&machine.name)) - }) - .fold(String::default(), |mut flags, machine| { - let _write_result = write!(flags, " --profile {}", machine.name); - flags - }) -} - -/// Render what the server runs and where each runner finds its hub. -/// -/// The paragraph every format's machine section opens with. -#[expect(clippy::single_call_fn, reason = "the roles step of machine_instructions")] -fn machine_roles(cluster: &ClusterInput, server: &input::Machine) -> String { - if let Delivery::Firmware(board) = server.device.delivery() { - return format!( - concat!( - "`{server}` is a {title} running `ws-server` as firmware,\n", - "and the runners on every other machine reach the hub at `{address}`, which the network the board\n", - "joins has to resolve to it. Its share is loading the firmware, which it then runs from power-on.\n\n" - ), - address = server.address, - server = server.name, - title = board.spec().title, - ); - } - if placement::shares_hubs(cluster) { - return format!( - concat!( - "`{server}` runs OpenObserve, `ws-server`, and the NATS server and object store the hubs share,\n", - "which the other machines' hubs reach at `{address}`. Each runner joins the hub on its own\n", - "machine, or the one on `{server}` when its machine runs none.\n\n" - ), - address = server.address, - server = server.name, - ); - } - format!( - concat!( - "`{server}` runs OpenObserve and `ws-server`, and the runners on every other machine reach\n", - "the hub at `{address}`.\n\n" - ), - address = server.address, - server = server.name, - ) -} - -/// Render how the machines of a k3s scenario become one cluster and receive the images their pods run. -/// -/// Each machine is labelled as it joins rather than afterwards, so the manifests' node selectors have something to -/// match the moment the node exists. -fn k3s_machine_instructions(cluster: &ClusterInput, server: &input::Machine) -> String { - let mut joins = String::default(); - for machine in &cluster.machines { - if machine.name != server.name { - let _write_result = writeln!( - joins, - "sudo k3s agent --server \"https://${{server}}:6443\" --token \"$token\"{labels}", - labels = k3s_node_labels(cluster, &machine.name), - ); - } - } - let imports = k3s_machine_imports(cluster, server); - format!( - concat!( - "\n### Run Across Machines With k3s\n\n", - "{loopback}", - "The machines have to form one k3s cluster first, each labelled with the machine name the manifests\n", - "pin pods to. With `k3s` installed on every machine, start the server on `{server}`. It stays in\n", - "the foreground, so read the token the others join with from a second shell once it is up:\n\n", - "```bash\n", - "sudo k3s server{server_labels}\n", - "```\n\n", - "```bash\n", - "sudo cat /var/lib/rancher/k3s/server/node-token\n", - "```\n\n", - "Then join each other machine, with that token in `token`:\n\n", - "```bash\n", - "server={address}\n", - "{joins}", - "```\n\n", - "Each image goes to the machines that run it rather than to one node, so the import steps above\n", - "become these, with each machine reachable over `ssh` by its address:\n\n", - "```bash\n", - "{imports}", - "```\n" - ), - address = server.address, - imports = imports, - joins = joins, - loopback = loopback_note(cluster), - server = server.name, - server_labels = k3s_node_labels(cluster, &server.name), - ) -} - -/// The `--node-label` flags `machine` joins the k3s cluster with. -/// -/// The machine label the manifests pin pods with, and for a scenario that exposes a port, the load-balancer pool that -/// keeps each exposed `Service` to the machine it names. -fn k3s_node_labels(cluster: &ClusterInput, machine: &str) -> String { - let mut flags = format!(" --node-label {}={machine}", placement::MACHINE_LABEL); - if expose::any_exposed(cluster) { - let _write_result = write!(flags, " --node-label {}={machine}", deployment_types::K3S_LB_POOL_LABEL); - } - flags -} - -/// The image imports of a k3s scenario spread across machines. -/// -/// The scenario image goes to each machine running a hub, and each locally built runner image to each machine with a -/// runner of that kind. A published scenario's runner images are pulled by the cluster, so only the scenario image is -/// imported for it. -fn k3s_machine_imports(cluster: &ClusterInput, server: &input::Machine) -> String { - let mut imports = format!("image=\"et-ws-server-{}:latest\"\n", cluster.cluster_name); - for hub in placement::hubs(cluster) { - for machine in cluster - .machines - .iter() - .filter(|machine| hub.site.machine_names().contains(&machine.name)) - { - let _write_result = writeln!( - imports, - "docker save \"$image\" | ssh {} sudo k3s ctr images import -", - machine.address - ); - } - } - if matches!(cluster.artifact_source, ArtifactSource::Local) { - if !cluster.recovery.is_unset() { - let _write_result = writeln!( - imports, - "docker save et-recovery-supervisor:latest | ssh {} sudo k3s ctr images import -", - server.address - ); - } - for machine in &cluster.machines { - for kind in machine_runner_kinds(cluster, &machine.name) { - let _write_result = writeln!( - imports, - "docker save et-ws-{kind}-runner:latest | ssh {} sudo k3s ctr images import -", - machine.address - ); - } - } - } - imports -} - -/// The distinct runner kinds of the agents placed on `machine`, in the order they first appear. -fn machine_runner_kinds(cluster: &ClusterInput, machine: &str) -> Vec { - runner_kinds_where(cluster, |agent| { - placement::agent_site(cluster, &agent.name) - .machine_names() - .iter() - .any(|name| name == machine) - }) -} - -/// Explain where the module-less hub image the scenario layers onto comes from. -/// -/// The two answers are structurally different rather than differently worded: a local scenario builds the hub too, so -/// `docker compose up` builds two images; a published one's Dockerfile names the released image and builds one. -const fn compose_hub_note(artifacts: ArtifactSource) -> &'static str { - if matches!(artifacts, ArtifactSource::Published) { - return concat!( - "The compose stack starts OpenObserve and builds one image: the `Dockerfile` in this directory,\n", - "which stages this scenario's modules onto the released hub image it names.\n" - ); - } - concat!( - "The compose stack starts OpenObserve and builds `ws-server` in two layers: the module-less hub\n", - "image from the repository's `services/ws-server/Dockerfile`, then the `Dockerfile` in this\n", - "directory, which stages this scenario's modules onto it.\n" - ) -} - -/// Render the section covering each runner image the scenario's manifests name. -/// -/// Commands for a locally sourced scenario and a plain list for a published one, because that is the difference the -/// reader has to act on: in the first case a runner image the node does not have leaves its pod in `ErrImagePull` and -/// nothing in the deployment builds it, and in the second the cluster fetches it and there is nothing to run at all. -/// The published case still names the refs, since a reader who just built the scenario image by hand will otherwise go -/// looking for the step that produces these. A scenario whose agents are all browser-side names no runner image, so the -/// whole section including its heading sentence is omitted rather than left as an empty code fence. -fn runner_image_note(runners: &[String], images: ArtifactSource) -> String { - if runners.is_empty() { - return String::default(); - } - if matches!(images, ArtifactSource::Published) { - let mut refs = String::default(); - for kind in runners { - let _write_result = writeln!(refs, "- `{IMAGE_REGISTRY}/et-ws-{kind}-runner:latest`"); - } - return format!( - concat!( - "The cluster pulls this scenario's runner images, so nothing has to be built or imported for\n", - "them:\n\n", - "{refs}\n" - ), - refs = refs - ); - } - let mut commands = String::default(); - for kind in runners { - let image = format!("et-ws-{kind}-runner:latest"); - let _write_result = writeln!( - commands, - "docker build -t {image} -f services/ws-{kind}-runner/Dockerfile ." - ); - let _write_result = writeln!(commands, "docker save {image} | sudo k3s ctr images import -"); - } - format!( - concat!( - "Then this scenario's runner images, which need no build context of their own:\n\n", - "```bash\n", - "{commands}", - "```\n\n" - ), - commands = commands - ) -} - -/// The paragraph, and the hub lines of the build commands, that differ between the two image sources. -/// -/// Kept apart from the shell below rather than templating two whole sections, because everything else about producing -/// the scenario image is the same either way: only where the hub comes from changes. A published scenario's Dockerfile -/// names the released hub image itself, so its build needs no hub of its own and no context to hand one over in. -struct K3sHub { - /// The paragraph that opens the section. - preamble: &'static str, - /// The `hub=` assignment, or nothing when the Dockerfile names its hub itself. - variable: &'static str, - /// The command that builds the hub, or nothing when it is a released image. - build: &'static str, - /// The flag that hands the hub to the scenario build as a named context, or nothing when none is needed. - context: &'static str, -} - -/// The hub half of the k3s image steps for `images`. -const fn k3s_hub(images: ArtifactSource) -> K3sHub { - if matches!(images, ArtifactSource::Published) { - return K3sHub { - preamble: concat!( - "The manifests reference images by name and never build them, so this scenario's own image has\n", - "to reach the node first. It layers its modules onto the released hub image, which its Dockerfile\n", - "names and the build pulls. " - ), - variable: "", - build: "", - context: "", - }; - } - K3sHub { - preamble: concat!( - "The manifests reference images by name and never build them, so build and import each one first.\n", - "This scenario's image layers its modules onto the module-less hub image and takes that hub as a\n", - "_named build context_ rather than building it, so the hub has to exist first -- a plain\n", - "`docker build` of the scenario Dockerfile fails on `FROM hub`. " - ), - variable: "hub=et-ws-server-hub:latest\n", - build: "docker build -t \"$hub\" -f services/ws-server/Dockerfile .\n", - context: " --build-context \"hub=docker-image://$hub\"", - } -} - -/// The flag that mounts the registry token into the scenario build, or nothing when the build fetches no release. -/// -/// The secret's id and the variable it is read from, passed straight to the flag. Held in a shell variable of its own, -/// the pair reads to a secret scanner as a credential assigned in the clear. -#[expect( - clippy::single_call_fn, - reason = "the token half of k3s_run_instructions, split out to keep it within its length budget" -)] -fn registry_secret_flag(needs_token: bool) -> String { - if needs_token { - format!( - " --secret id={},env={}", - deployment_types::REGISTRY_TOKEN_SECRET, - deployment_types::REGISTRY_TOKEN_ENV - ) - } else { - String::default() - } -} - -/// Render the k3s half of the generated README. -/// -/// Longer than the other two because a Kubernetes deployment needs two things done before `kubectl apply` that neither -/// `mise` nor compose does: the images have to exist on the node, since manifests reference images rather than building -/// them, and the credential has to be loaded as a `Secret`, since it is the one generated file the repository does -/// not carry. -fn k3s_run_instructions( - cluster: &ClusterInput, - image_build: &ImageBuild, - runners: &[String], - images: ArtifactSource, - build_contexts: &str, -) -> String { - let hub = k3s_hub(images); - // Where the build runs is the one thing that follows from what the image reads: a checkout when it copies anything - // out of one, and otherwise this directory, plus the token when it fetches released modules. A build from this - // directory names no Dockerfile, since `Dockerfile` is docker's own default; one from the repository root has to - // name the scenario's. - let (place, dockerfile_var, dockerfile_flag) = if image_build.needs.repository { - ( - "From the repository root", - format!("dockerfile=\"{}\"\n", image_build.dockerfile), - " -f \"$dockerfile\"", - ) - } else { - ("From this directory", String::default(), "") - }; - // Said wherever the build runs: a scenario staging a mise-provisioned package builds from the repository root and - // can still fetch released modules, and then needs the token just the same. - let token_clause = if image_build.needs.registry_token { - concat!( - ",\n", - "with `GITHUB_TOKEN` set to a token that can read the GitHub Packages the build fetches this\n", - "scenario's released modules from" - ) - } else { - "" - }; - let location = format!("{place}{token_clause}:\n\n"); - let secret = registry_secret_flag(image_build.needs.registry_token); - format!( - concat!( - "## Run With k3s\n\n", - "{preamble}{location}", - "```bash\n", - "scenario={name}\n", - "{hub_variable}", - "image=\"et-ws-server-$scenario:latest\"\n", - "{dockerfile_var}", - "{hub_build}", - "docker build{hub_context}{build_contexts}{secret}", - " -t \"$image\"{dockerfile_flag} .\n", - "docker save \"$image\" | sudo k3s ctr images import -\n", - "```\n\n", - "{runner_note}", - "{recovery_note}", - "{prepare}", - "{apply}", - "{expose}", - ), - apply = k3s_apply_instructions(&cluster.cluster_name), - expose = k3s_expose_instructions(cluster), - prepare = deployment_types::k3s_node_preparation_note(cluster), - build_contexts = build_contexts, - dockerfile_flag = dockerfile_flag, - dockerfile_var = dockerfile_var, - hub_build = hub.build, - hub_context = hub.context, - hub_variable = hub.variable, - location = location, - name = cluster.cluster_name, - preamble = hub.preamble, - secret = secret, - runner_note = runner_image_note(runners, images), - recovery_note = recovery::image_note(cluster) - ) -} - -/// The k3s steps after the images are on the node: load the credential, apply the manifests, and watch them settle. -/// -/// Apart from the image steps before them because nothing here depends on how the images were produced. -#[expect( - clippy::single_call_fn, - reason = "distinct half of k3s_run_instructions, split out to keep each readable" -)] -fn k3s_apply_instructions(cluster_name: &str) -> String { - format!( - concat!( - "### Load The Credential\n\n", - "The credential reaches the pods as a `Secret` created from `{file}`, rather than written into\n", - "`k3s.yaml` where it would be committed alongside the manifests. From this directory:\n\n", - "```bash\n", - "ns=et-{name}\n", - "kubectl create namespace \"$ns\" --save-config\n", - "kubectl create secret generic \"$ns-secrets\" --from-env-file={file} -n \"$ns\"\n", - "```\n\n", - "### Apply\n\n", - "```bash\n", - "kubectl apply -f k3s.yaml\n", - "```\n\n", - "The runners exit and restart until the hub reports ready, so `CrashLoopBackOff` while the hub\n", - "starts is expected here rather than a fault. Watch it settle with:\n\n", - "```bash\n", - "kubectl get pods -n \"$ns\" --watch\n", - "```\n" - ), - file = SECRETS_ENV_FILE, - name = cluster_name, - ) -} - -/// Render where a k3s scenario is reached from outside the cluster, or nothing for one nobody outside needs. -/// -/// A scenario whose hub serves a page but is not exposed is still told so, since without it no browser can load the -/// page the deployment exists to serve. -#[expect(clippy::single_call_fn, reason = "the exposure step of k3s_run_instructions")] -fn k3s_expose_instructions(cluster: &ClusterInput) -> String { - let addresses = expose::address_list(cluster, true); - let hidden_page = if deployment_types::serves_a_page(cluster) && cluster.services.hub.expose.is_empty() { - concat!( - "Nothing outside the cluster can reach `ws-server`, so no browser can open its page. List `ws`\n", - "and `wss` under `services.hub.expose` in the scenario input and regenerate to publish them.\n" - ) - } else { - "" - }; - if addresses.is_empty() && hidden_page.is_empty() { - return String::default(); - } - let published = if addresses.is_empty() { - String::default() - } else { - format!( - concat!( - "Each exposed port is published on the machine its process runs on:\n\n", - "{addresses}\n", - "Each binds its port on that node, so another scenario on this cluster exposing the same port\n", - "cannot publish it until this one is deleted.\n" - ), - addresses = addresses, - ) - }; - let gap = if published.is_empty() || hidden_page.is_empty() { - "" - } else { - "\n" - }; - format!("\n### Open From Outside The Cluster\n\n{hidden_page}{gap}{published}") -} - #[must_use] pub(crate) fn module_registry( project_root: &Path, diff --git a/utilities/cli/src/limits.rs b/utilities/cli/src/limits.rs index df237ec4..2a923090 100644 --- a/utilities/cli/src/limits.rs +++ b/utilities/cli/src/limits.rs @@ -13,7 +13,7 @@ //! each process directly on the host, outside any container, and apply none of them. use std::collections::BTreeMap; -use std::fmt::{self, Write as _}; +use std::fmt; use std::num::NonZeroU64; use bytesize::ByteSize; @@ -22,7 +22,7 @@ use thiserror::Error; use crate::deployment_types::{BACKPLANE_TASK, COLLECTOR_TASK, HUB_TASK, SHARED_STORAGE_TASK}; use crate::error::CliError; -use crate::input::{ClusterInput, OutputType}; +use crate::input::ClusterInput; /// The CPU limit a process runs under when its scenario sets none. /// @@ -516,7 +516,7 @@ pub(crate) fn k8s_cpus(cpus: Cpus) -> String { } /// Every process the scenario sets limits for, by the name each deployment format gives it. -fn limited_processes(cluster: &ClusterInput) -> Vec<&str> { +pub(crate) fn limited_processes(cluster: &ClusterInput) -> Vec<&str> { let fixed = [ (BACKPLANE_TASK, &cluster.services.backplane.limits), (COLLECTOR_TASK, &cluster.services.collector.limits), @@ -531,43 +531,3 @@ fn limited_processes(cluster: &ClusterInput) -> Vec<&str> { .map(|(process, _)| process) .collect() } - -/// Render that a format which runs processes on the host applies none of the scenario's limits, or nothing otherwise. -/// -/// Said in the README rather than left out of the output silently, so an operator relying on a limit learns which -/// deployments do not hold it. -pub(crate) fn limits_note(output_type: OutputType, cluster: &ClusterInput) -> String { - // Named per format, because every format's section lands in the one README and markdownlint rejects a repeated - // heading. - let heading = match output_type { - // A board runs no agent, or refuses a runner that sets limits, since it has no way to hold one to them. - OutputType::DockerCompose - | OutputType::K3s - | OutputType::FirmwarePico2 - | OutputType::FirmwareEsp32c6 - | OutputType::FirmwarePicoPlus2 => { - return String::default(); - } - OutputType::Mise => "Mise", - OutputType::Dekit => "dekit", - OutputType::ProcessCompose => "process-compose", - }; - let processes = limited_processes(cluster); - if processes.is_empty() { - return String::default(); - } - let lines = processes.iter().fold(String::default(), |mut lines, process| { - let _write_result = writeln!(lines, "- `{process}`"); - lines - }); - format!( - concat!( - "\n### Limits Not Applied With {heading}\n\n", - "Each process runs directly on the host rather than in a container, so the limits the scenario\n", - "sets for these are not applied; the compose and k3s deployments enforce them:\n\n", - "{lines}" - ), - heading = heading, - lines = lines, - ) -} diff --git a/utilities/cli/src/readme.rs b/utilities/cli/src/readme.rs new file mode 100644 index 00000000..4e636252 --- /dev/null +++ b/utilities/cli/src/readme.rs @@ -0,0 +1,531 @@ +//! The README a generated deployment carries: what the directory holds, then how to run each format it was written in. +//! +//! What it says is the templates' under `templates/readme`; this module works out the facts each paragraph turns on. + +use std::collections::BTreeSet; +use std::path::Path; + +use fs_err as fs; +use serde::Serialize; + +use crate::deployment_types::{self, ImageNeeds}; +use crate::error::CliError; +use crate::expose::{self, ExposedAddress}; +use crate::input::{self, ArtifactSource, Board, BoardSpec, ClusterInput, Delivery, OutputType}; +use crate::templates::render; +use crate::{ + IMAGE_REGISTRY, SECRETS_ENV_FILE, cluster_module_names, hub_ws_url_at, limits, module_path_contexts, placement, +}; + +/// What the README is rendered from. +#[derive(Serialize)] +struct Readme<'cluster> { + name: &'cluster str, + /// The files the deployment wrote, in the order the README lists them. + output_files: Vec<&'static str>, + module_names: Vec, + published: bool, + shares_hubs: bool, + /// Whether a format runs the `mise.toml` tasks, which need what they name installed before the first run. + installs: bool, + sections: Vec>, + /// The sections loading each board among the machines. + boards: Vec>, + registry: &'static str, + secrets_file: &'static str, + token_env: &'static str, + token_secret: &'static str, +} + +/// The README section running one format, with the subsections that format adds. +#[derive(Serialize)] +struct Section<'cluster> { + /// The format's own name, as `--output-type` takes it. + format: &'static str, + /// What the format's headings call it. + /// + /// Every format's section lands in the one README, and markdownlint rejects a repeated heading. + heading: &'static str, + run: Launch<'cluster>, + /// Every machine the scenario names, the server first; none for a scenario on a single host. + machines: Vec>, + /// Whether any machine's address is loopback, which plays every machine on one host. + loopback: bool, + /// The processes whose limits the format does not apply. + unapplied_limits: Vec<&'cluster str>, + /// Whether the format runs scheduled agents in a way the operator starting it has to be told about. + scheduled: bool, +} + +/// How a section runs its format, which picks the template under `templates/readme` that says so. +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +enum Launch<'cluster> { + /// The server is a board running the hub, which leaves the format only the runners on the other machines. + BoardHub { + server: &'cluster str, + title: &'static str, + }, + Mise, + Dekit, + ProcessCompose, + DockerCompose { + /// The machines whose profile starts on one host. + profiles: Vec<&'cluster str>, + runner_addresses: Vec, + }, + K3s(K3s<'cluster>), + /// A board running the hub: the whole scenario as a `firmware-*` format, or the server of one spanning machines. + Firmware(Firmware), + /// A board running only the runners placed on it, which reach the server's hub across the network. + RunnerBoard { + machine: &'cluster str, + title: &'static str, + runners: &'static [&'static str], + hub_url: String, + network_setup: &'static str, + load_preparation: &'static str, + flash: String, + }, +} + +/// What the k3s section says about producing the images, loading the credential and reaching the cluster. +#[derive(Serialize)] +struct K3s<'cluster> { + /// Whether the scenario image builds from the repository root rather than from this directory. + repository: bool, + /// Whether the build fetches released modules, and so needs the registry token. + registry_token: bool, + /// The scenario Dockerfile, relative to where the build runs. + dockerfile: String, + /// The named build context each `module_paths:` entry is handed to the build as, each `name=path`. + build_contexts: Vec, + /// The runner kinds whose images the manifests name, in the order the input first names them. + runners: Vec, + /// Whether the manifests name the recovery supervisor's image too. + recovery: bool, + /// The container runtime plugins every node has to be readied for. + plugins: Vec<&'static str>, + containerd_template_file: &'static str, + containerd_template_path: &'static str, + addresses: Vec, + /// Whether the hub serves a page nothing outside the cluster can reach. + hidden_page: bool, + /// The address of each machine the scenario image is imported onto. + hub_imports: Vec<&'cluster str>, + /// Every other image a machine runs, imported onto it. + image_imports: Vec>, +} + +/// A board's firmware running the hub, and how it is built and loaded. +#[derive(Serialize)] +struct Firmware { + heading: String, + runners: &'static [&'static str], + flash_size: &'static str, + network_setup: &'static str, + load_preparation: &'static str, + /// The command that builds the firmware and loads it. + flash: String, + /// The command that serves the same modules from this host instead. + host_test: String, + /// Where the board redirects a package too big for its flash. + elsewhere: &'static str, +} + +/// One machine's share of the scenario, and the names each format starts it by. +#[derive(Serialize)] +struct MachineShare<'cluster> { + name: &'cluster str, + address: &'cluster str, + /// The board the machine is, by what a person calls it; a machine running its share as the format's tasks is none. + board: Option<&'static str>, + /// The mise task starting the machine's share. + task: String, + /// The mise task loading the machine's board, which is its whole share when it is one. + flash: String, + /// The `--node-label` values the machine joins a k3s cluster with. + labels: Vec, +} + +/// An image a machine runs, other than the scenario image. +#[derive(Serialize)] +struct ImageImport<'cluster> { + image: String, + address: &'cluster str, +} + +/// How the README's `docker build` of the scenario image runs: what it needs, and the paths it names. +struct ImageBuild { + needs: ImageNeeds, + /// The scenario Dockerfile, relative to where the build runs. + dockerfile: String, + build_contexts: Vec, +} + +/// Write the README for a deployment of `output_types`: what the directory holds and how to run each format. +pub(crate) fn write_readme( + cluster: &ClusterInput, + output_dir: &Path, + output_types: &[OutputType], +) -> Result<(), CliError> { + let needs = deployment_types::scenario_image_needs(cluster)?; + let (dockerfile, context_root) = if needs.repository { + ( + scenario_dockerfile_path(output_dir), + edge_toolkit::config::get_project_root(), + ) + } else { + ("Dockerfile".to_string(), std::path::absolute(output_dir)?) + }; + // Relative to where the README's `docker build` runs from. + let build_contexts = module_path_contexts(&cluster.module_paths, &context_root) + .into_iter() + .map(|(name, path)| format!("{name}={path}")) + .collect(); + let image = ImageBuild { + needs, + dockerfile, + build_contexts, + }; + let readme = Readme { + boards: boards(cluster), + installs: output_types.iter().any(|output_type| output_type.runs_mise_tasks()), + module_names: cluster_module_names(cluster), + name: &cluster.cluster_name, + output_files: generated_output_files(cluster, output_types), + published: matches!(cluster.artifact_source, ArtifactSource::Published), + registry: IMAGE_REGISTRY, + secrets_file: SECRETS_ENV_FILE, + sections: output_types + .iter() + .map(|output_type| section(cluster, *output_type, &image)) + .collect(), + shares_hubs: placement::shares_hubs(cluster), + token_env: deployment_types::REGISTRY_TOKEN_ENV, + token_secret: deployment_types::REGISTRY_TOKEN_SECRET, + }; + fs::write(output_dir.join("README.md"), render("readme.md.j2", &readme)?)?; + Ok(()) +} + +/// The path the generated README tells a reader to build this scenario's image from. +/// +/// Only the parent is rendered, so the command keeps naming the last segment through the `scenario` shell variable it +/// already sets rather than repeating the name. Joined from the path's own components rather than displayed, because +/// the result is committed: a `Display` of the same path writes `\` on Windows and `/` everywhere else, which would +/// make the file drift by platform and fail the check that holds it stable. Regeneration passes a repository-relative +/// path, which is what the command needs, since it runs from the repository root. +/// +/// A single-component output directory has a parent, and it is the empty path rather than `None` -- so this cannot lean +/// on `unwrap_or` and has to check the rendered parent. Prefixing an empty one would produce `/$scenario/Dockerfile`, +/// an absolute path to a directory nobody has. +#[must_use] +pub fn scenario_dockerfile_path(output_dir: &Path) -> String { + let parent = output_dir + .parent() + .unwrap_or(output_dir) + .iter() + .map(|component| component.to_string_lossy()) + .collect::>() + .join("/"); + if parent.is_empty() { + return "$scenario/Dockerfile".to_string(); + } + format!("{parent}/$scenario/Dockerfile") +} + +/// The files a deployment of `output_types` writes for `cluster`, in the order the README lists them. +fn generated_output_files(cluster: &ClusterInput, output_types: &[OutputType]) -> Vec<&'static str> { + let mut files = Vec::new(); + for output_type in output_types { + // `dekit.yaml` and `process-compose.yaml` run the tasks of `mise.toml`, which is written with either whether or + // not it was asked for -- and listed once, however many formats wrote it. + if matches!(output_type, OutputType::Dekit | OutputType::ProcessCompose) { + files.push(OutputType::Mise.output_file_name()); + } + files.push(output_type.output_file_name()); + if matches!(output_type, OutputType::K3s) && deployment_types::needs_containerd_template(cluster) { + files.push(deployment_types::CONTAINERD_TEMPLATE_FILE); + } + } + let mut listed = BTreeSet::new(); + files.retain(|file| listed.insert(*file)); + files +} + +/// The section running `output_type`. +fn section<'cluster>( + cluster: &'cluster ClusterInput, + output_type: OutputType, + image: &ImageBuild, +) -> Section<'cluster> { + let scheduled = cluster.agents.iter().any(|agent| !agent.schedule.is_unset()); + Section { + format: output_type.name(), + heading: heading(output_type), + loopback: cluster + .machines + .iter() + .any(|machine| machine.address == "localhost" || machine.address.starts_with("127.")), + machines: machine_shares(cluster), + run: run(cluster, output_type, image), + // Compose is the one format whose schedule differs visibly from a `CronJob`, and the one that asks for a + // privilege. + scheduled: scheduled && matches!(output_type, OutputType::DockerCompose), + unapplied_limits: unapplied_limits(cluster, output_type), + } +} + +/// What `output_type`'s headings call it. +const fn heading(output_type: OutputType) -> &'static str { + match output_type { + OutputType::Mise => "Mise", + OutputType::Dekit => "dekit", + OutputType::ProcessCompose => "process-compose", + OutputType::DockerCompose => "Docker Compose", + OutputType::K3s => "k3s", + OutputType::FirmwarePico2 => Board::Pico2.spec().title, + OutputType::FirmwareEsp32c6 => Board::Esp32c6.spec().title, + OutputType::FirmwarePicoPlus2 => Board::PicoPlus2.spec().title, + } +} + +/// The processes whose limits `output_type` does not apply; none for a format that applies them. +/// +/// Named in the README rather than left out of the output silently, so an operator relying on a limit learns which +/// deployments do not hold it. Only the formats running each process on the host leave limits unapplied: compose and +/// k3s enforce them, and a board refuses a runner that sets any, since it has no way to hold one to them. +fn unapplied_limits(cluster: &ClusterInput, output_type: OutputType) -> Vec<&str> { + if output_type.runs_mise_tasks() { + return limits::limited_processes(cluster); + } + Vec::new() +} + +/// How the section of `output_type` runs it. +fn run<'cluster>(cluster: &'cluster ClusterInput, output_type: OutputType, image: &ImageBuild) -> Launch<'cluster> { + if let (Delivery::Firmware(board), Some(server)) = (placement::server_delivery(cluster), cluster.machines.first()) { + return Launch::BoardHub { + server: &server.name, + title: board.spec().title, + }; + } + match output_type { + OutputType::Mise => Launch::Mise, + OutputType::Dekit => Launch::Dekit, + OutputType::ProcessCompose => Launch::ProcessCompose, + OutputType::DockerCompose => Launch::DockerCompose { + profiles: compose_single_host_profiles(cluster), + runner_addresses: expose::addresses(cluster, false), + }, + OutputType::K3s => Launch::K3s(k3s(cluster, image)), + OutputType::FirmwarePico2 => firmware_output(Board::Pico2.spec()), + OutputType::FirmwareEsp32c6 => firmware_output(Board::Esp32c6.spec()), + OutputType::FirmwarePicoPlus2 => firmware_output(Board::PicoPlus2.spec()), + } +} + +/// The machines whose `--profile` starts the scenario on one host, or none for a scenario naming no machines. +/// +/// Every machine's profile, but that of a machine running a hub besides the server: every hub listens on the same +/// ports, so on one host the server's is the only one that can start, and each runner reaches it at `localhost`. +fn compose_single_host_profiles(cluster: &ClusterInput) -> Vec<&str> { + let hubs = placement::hubs(cluster); + cluster + .machines + .iter() + .filter(|machine| { + !hubs + .iter() + .any(|hub| !hub.on_server() && hub.site.machine_names().contains(&machine.name)) + }) + .map(|machine| machine.name.as_str()) + .collect() +} + +/// Every machine's share of the scenario, the server first. +fn machine_shares(cluster: &ClusterInput) -> Vec> { + cluster + .machines + .iter() + .map(|machine| MachineShare { + address: &machine.address, + board: match machine.device.delivery() { + Delivery::Firmware(board) => Some(board.spec().title), + Delivery::Tasks => None, + }, + flash: deployment_types::firmware_flash_task(&machine.name), + labels: k3s_node_labels(cluster, &machine.name), + name: &machine.name, + task: deployment_types::machine_task(&machine.name), + }) + .collect() +} + +/// The `--node-label` values `machine` joins the k3s cluster with. +/// +/// The machine label the manifests pin pods with, and for a scenario that exposes a port, the load-balancer pool that +/// keeps each exposed `Service` to the machine it names. +fn k3s_node_labels(cluster: &ClusterInput, machine: &str) -> Vec { + let mut labels = vec![format!("{}={machine}", placement::MACHINE_LABEL)]; + if expose::any_exposed(cluster) { + labels.push(format!("{}={machine}", deployment_types::K3S_LB_POOL_LABEL)); + } + labels +} + +/// The k3s section's facts, `image` being how the scenario image builds. +fn k3s<'cluster>(cluster: &'cluster ClusterInput, image: &ImageBuild) -> K3s<'cluster> { + let (hub_imports, image_imports) = k3s_machine_imports(cluster); + K3s { + addresses: expose::addresses(cluster, true), + build_contexts: image.build_contexts.clone(), + containerd_template_file: deployment_types::CONTAINERD_TEMPLATE_FILE, + containerd_template_path: deployment_types::CONTAINERD_TEMPLATE_PATH, + dockerfile: image.dockerfile.clone(), + hidden_page: deployment_types::serves_a_page(cluster) && cluster.services.hub.expose.is_empty(), + hub_imports, + image_imports, + plugins: deployment_types::k3s_plugin_names(cluster), + recovery: !cluster.recovery.is_unset(), + registry_token: image.needs.registry_token, + repository: image.needs.repository, + runners: runner_kinds_where(cluster, |_agent| true), + } +} + +/// The image imports of a k3s scenario spread across machines, none for one on a single host. +/// +/// The scenario image goes to each machine running a hub, and each locally built image to each machine running it: the +/// recovery supervisor to the server, and a runner image to each machine with a runner of that kind. A published +/// scenario's are pulled by the cluster, so only the scenario image is imported for it. +fn k3s_machine_imports(cluster: &ClusterInput) -> (Vec<&str>, Vec>) { + let Some(server) = cluster.machines.first() else { + return (Vec::new(), Vec::new()); + }; + let mut hub_imports = Vec::new(); + for hub in placement::hubs(cluster) { + hub_imports.extend( + cluster + .machines + .iter() + .filter(|machine| hub.site.machine_names().contains(&machine.name)) + .map(|machine| machine.address.as_str()), + ); + } + let mut image_imports = Vec::new(); + if matches!(cluster.artifact_source, ArtifactSource::Local) { + if !cluster.recovery.is_unset() { + image_imports.push(ImageImport { + address: &server.address, + image: "et-recovery-supervisor:latest".to_owned(), + }); + } + for machine in &cluster.machines { + for kind in machine_runner_kinds(cluster, &machine.name) { + image_imports.push(ImageImport { + address: &machine.address, + image: format!("et-ws-{kind}-runner:latest"), + }); + } + } + } + (hub_imports, image_imports) +} + +/// The distinct runner kinds of the agents placed on `machine`, in the order they first appear. +fn machine_runner_kinds(cluster: &ClusterInput, machine: &str) -> Vec { + runner_kinds_where(cluster, |agent| { + placement::agent_site(cluster, &agent.name) + .machine_names() + .iter() + .any(|name| name == machine) + }) +} + +/// The distinct runner kinds of the agents `include` accepts, in the order they first appear. +/// +/// Deduplicated because two agents on the same runner need its image built once, and ordered by first appearance +/// rather than sorted so the generated instructions read in the order the input declares. +fn runner_kinds_where(cluster: &ClusterInput, include: F) -> Vec +where + F: Fn(&input::Agent) -> bool, +{ + let mut kinds: Vec = Vec::new(); + for agent in cluster.agents.iter().filter(|agent| include(agent)) { + let Some(kind) = agent.runner.as_deref().map(str::trim).filter(|kind| !kind.is_empty()) else { + continue; + }; + if !kinds.iter().any(|seen| seen == kind) { + kinds.push(kind.to_string()); + } + } + kinds +} + +/// The section of a `firmware-*` format, which puts the whole scenario on `board`. +fn firmware_output(board: BoardSpec) -> Launch<'static> { + let env = board.output_type.name(); + Launch::Firmware(firmware( + board, + format!("Run As {} Firmware", board.title), + format!("MISE_ENV={env} mise run {}", deployment_types::FIRMWARE_FLASH_TASK), + format!("MISE_ENV={env} mise run {}", deployment_types::FIRMWARE_HOST_TEST_TASK), + )) +} + +/// `board`'s firmware running the hub, under `heading`, loaded by `flash` and served from here by `host_test`. +const fn firmware(board: BoardSpec, heading: String, flash: String, host_test: String) -> Firmware { + Firmware { + elsewhere: deployment_types::FIRMWARE_ELSEWHERE_URL, + flash, + flash_size: board.flash, + heading, + host_test, + load_preparation: board.load_preparation, + network_setup: board.network_setup, + runners: board.runners, + } +} + +/// The sections loading each board among the machines; none when no machine is a board. +/// +/// That is the hub onto the server, when it is a board, and the runners onto every other board. +fn boards(cluster: &ClusterInput) -> Vec> { + let Some(server) = cluster.machines.first() else { + return Vec::new(); + }; + let mut boards = Vec::new(); + for machine in &cluster.machines { + let Delivery::Firmware(board) = machine.device.delivery() else { + continue; + }; + let board = board.spec(); + let flash = format!("mise run {}", deployment_types::firmware_flash_task(&machine.name)); + boards.push(if machine.name == server.name { + let host_test = format!( + "mise run {}-{}", + deployment_types::FIRMWARE_HOST_TEST_TASK, + machine.name + ); + Launch::Firmware(firmware( + board, + format!("Load The Hub Onto `{}`", machine.name), + flash, + host_test, + )) + } else { + Launch::RunnerBoard { + flash, + hub_url: hub_ws_url_at(&server.address), + load_preparation: board.load_preparation, + machine: &machine.name, + network_setup: board.network_setup, + runners: board.runners, + title: board.title, + } + }); + } + boards +} diff --git a/utilities/cli/src/recovery.rs b/utilities/cli/src/recovery.rs index ed9fcf62..e805d758 100644 --- a/utilities/cli/src/recovery.rs +++ b/utilities/cli/src/recovery.rs @@ -212,27 +212,3 @@ pub(crate) fn bindings(settings: &RecoverySettings, workload: &Workload) -> BTre ("RECOVERY_SUPERVISOR_AGENT_ID".into(), "recovery-supervisor".into()), ]) } - -/// Include the service image in the ordinary image preparation steps before Kubernetes applies the scenario. -pub(crate) fn image_note(cluster: &ClusterInput) -> String { - if cluster.recovery.is_unset() { - return String::default(); - } - if matches!(cluster.artifact_source, crate::ArtifactSource::Published) { - return format!( - "The cluster also pulls `{}/et-recovery-supervisor:latest`.\n\n", - crate::IMAGE_REGISTRY - ); - } - concat!( - "Build and import the recovery service image from the repository root:\n\n", - "```bash\n", - "image=et-recovery-supervisor:latest\n", - "docker build -t \"$image\" -f services/recovery-supervisor/Dockerfile .\n", - "docker save \"$image\" | sudo k3s ctr images import -\n", - "```\n\n", - "The supervisor starts automatically with scoped ServiceAccount credentials and persistent state.\n", - "Application modules must implement lifecycle reporting and any required checkpoint restoration.\n\n" - ) - .into() -} diff --git a/utilities/cli/src/schedule.rs b/utilities/cli/src/schedule.rs index a3b5a2bc..151866c4 100644 --- a/utilities/cli/src/schedule.rs +++ b/utilities/cli/src/schedule.rs @@ -10,7 +10,6 @@ use croner::parser::{CronParser, Seconds, Year}; use serde::{Deserialize, Deserializer, Serialize, de}; use crate::error::CliError; -use crate::input::{ClusterInput, OutputType}; /// The `@` shorthands robfig's standard parser expands, and so the only ones every scheduler here understands. /// @@ -135,24 +134,6 @@ pub(crate) fn validate_schedule(agent: &str, schedule: &Schedule) -> Result<(), } } -/// Render how the compose deployment runs its scheduled agents, or nothing for any other format or an unscheduled one. -/// -/// Compose is the one format whose schedule differs visibly from a `CronJob`, and the one that asks for a privilege, so -/// both are said where the operator starting it will read them. -pub(crate) fn schedule_note(output_type: OutputType, cluster: &ClusterInput) -> &'static str { - let scheduled = cluster.agents.iter().any(|agent| !agent.schedule.is_unset()); - if !scheduled || !matches!(output_type, OutputType::DockerCompose) { - return ""; - } - concat!( - "\n### Scheduled Agents With Docker Compose\n\n", - "`docker compose up` runs each scheduled agent's runner once, as it does every service. The\n", - "`scheduler` service, Ofelia, then starts that same container again on each tick of its schedule,\n", - "skipping a tick while the previous run is still going. It mounts the Docker socket to do so, which\n", - "gives it the Docker daemon's privileges.\n" - ) -} - /// Check one field uses only the standard grammar: numbers, `*`, ranges, steps, lists, and names where they belong. /// /// The cron parser also takes Quartz's `L`, `W`, `#` and `?`, which robfig's standard parser does not, so a field using diff --git a/utilities/cli/src/templates.rs b/utilities/cli/src/templates.rs new file mode 100644 index 00000000..66974fce --- /dev/null +++ b/utilities/cli/src/templates.rs @@ -0,0 +1,117 @@ +//! The Jinja templates every hand-written text file of a generated deployment is rendered from. +//! +//! What a file says lives in its template under `templates/`, and what varies by scenario is worked out in Rust and +//! handed over as a serialisable context. Files with a schema of their own -- `mise.toml`, `compose.yaml`, the +//! Kubernetes manifests -- are still built as values, since a template would have to reimplement their quoting. + +use std::fmt::Write as _; + +use minijinja::syntax::SyntaxConfig; +use minijinja::value::{Serde, Value}; +use minijinja::{AutoEscape, Environment, UndefinedBehavior}; +use serde::Serialize; + +use crate::error::CliError; + +/// Each template under its name, which is its path below `templates/`, with its source compiled into the binary. +/// +/// Compiled in rather than read at run time, so an installed `et-cli` carries everything it renders from. +macro_rules! templates { + ($($name:literal),+ $(,)?) => { + [$(($name, include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/", $name)))),+] + }; +} + +/// Every template, including the ones only ever included by another. +const TEMPLATES: [(&str, &str); 22] = templates![ + "gitignore.j2", + "npmrc.j2", + "readme.md.j2", + "readme/addresses.md.j2", + "readme/board-hub.md.j2", + "readme/commands.j2", + "readme/dekit.md.j2", + "readme/docker-compose.md.j2", + "readme/firmware.md.j2", + "readme/install.md.j2", + "readme/k3s-machines.md.j2", + "readme/k3s.md.j2", + "readme/load-board.md.j2", + "readme/loopback.md.j2", + "readme/machines.md.j2", + "readme/mise.md.j2", + "readme/process-compose.md.j2", + "readme/run.md.j2", + "readme/runner-board.md.j2", + "scenario-image.dockerignore.j2", + "scenario-image.j2", + "secrets.env.j2", +]; + +/// Render the template `name` with `context`. +pub(crate) fn render(name: &str, context: &C) -> Result +where + C: Serialize, +{ + let environment = environment()?; + Ok(environment.get_template(name)?.render(Serde(context))?) +} + +/// The environment every template renders in. +/// +/// A block tag takes its own line with it, so a template reads as the file it renders rather than as a run of +/// `{%-` markers, and a file keeps the newline it ends with. A name the context does not carry fails the render instead +/// of printing nothing, which is how a template that drifted from its context is caught. +/// +/// Nothing is escaped, whatever a template's name ends in. Every file here is read as itself -- Markdown, shell, a +/// Dockerfile -- and minijinja's own default picks escaping by extension and changes with the features the workspace +/// happens to unify on. +fn environment() -> Result, minijinja::Error> { + let mut environment = Environment::new(); + environment.set_syntax( + SyntaxConfig::builder() + .trim_blocks(true) + .lstrip_blocks(true) + .keep_trailing_newline(true) + .build()?, + ); + environment.set_undefined_behavior(UndefinedBehavior::Strict); + environment.set_auto_escape_callback(|_name| AutoEscape::None); + environment.add_filter("code", code); + environment.add_filter("fill", fill); + environment.add_filter("flags", flags); + environment.add_filter("quote", quote); + for (name, source) in TEMPLATES { + environment.add_template(name, source)?; + } + Ok(environment) +} + +/// `text` as Markdown inline code. +fn code(text: &str) -> String { + format!("`{text}`") +} + +/// `text` in shell double quotes. +fn quote(text: &str) -> String { + format!("\"{text}\"") +} + +/// Each of `values` as the argument of a `flag` of its own, each led by the space that separates it from the command. +/// +/// An empty list adds nothing, so a command naming the flag for each of a list writes it once whatever the list holds. +fn flags(values: &Value, flag: &str) -> Result { + Ok(values.try_iter()?.fold(String::default(), |mut flags, value| { + let _write_result = write!(flags, " {flag} {value}"); + flags + })) +} + +/// `text` wrapped to the editorconfig line length every generated file is held to, broken only at spaces. +fn fill(text: &str) -> String { + let options = textwrap::Options::new(120) + .break_words(false) + .word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::NoHyphenation); + textwrap::fill(text, options) +} diff --git a/utilities/cli/templates/gitignore.j2 b/utilities/cli/templates/gitignore.j2 new file mode 100644 index 00000000..307f4dc0 --- /dev/null +++ b/utilities/cli/templates/gitignore.j2 @@ -0,0 +1,13 @@ +# Written by `et-cli`. +{% if ignores_credential %} +# The password is derived from the scenario input, so regenerating this deployment rewrites it; +# committing it would publish the credential of every deployment generated from that input. +{{ secrets_file }} + +{% endif %} +# What the hub writes into the directory it runs from: its self-signed TLS pair (the key is a +# credential too), the agent registry it saves on shutdown, and agent storage. +cert.pem +key.pem +registry.yaml +{{ storage_dir }}/ diff --git a/utilities/cli/templates/npmrc.j2 b/utilities/cli/templates/npmrc.j2 new file mode 100644 index 00000000..e845c6a0 --- /dev/null +++ b/utilities/cli/templates/npmrc.j2 @@ -0,0 +1,6 @@ +{# + The credential is a `${GITHUB_TOKEN}` reference that npm expands when it reads the file, so the deployment carries no + secret. +#} +{{ scope }}:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/utilities/cli/templates/readme.md.j2 b/utilities/cli/templates/readme.md.j2 new file mode 100644 index 00000000..2241f193 --- /dev/null +++ b/utilities/cli/templates/readme.md.j2 @@ -0,0 +1,63 @@ +{# + The README a deployment directory carries: what it holds, then how to run each format it was written in. + + Every section ends with a single newline, and whatever follows one opens with the blank line that separates them. +#} +# {{ name }} + +{% if output_files | length == 1 %} +This directory contains the generated `{{ output_files | first }}` for the `{{ name }}` scenario. +{% else %} +This directory contains generated deployment configs for the `{{ name }}` scenario. +{# Broken only between names, so the list fits the line length however many formats it holds. #} +{{ ("Files: " ~ output_files | map("code") | join(", ") ~ ".") | fill }} +{% endif %} + +{% if module_names %} +The scenario exposes these workflow modules: {{ module_names | join(", ") }}. +{% else %} +No workflow modules were selected in the scenario input. +{% endif %} + +{# + Where a published scenario's artifacts come from, above the run sections rather than inside one, because it is true + of every way the scenario starts: the `mise` deployment runs released binaries, and the compose and Kubernetes ones + run published images. Said once per run mode it would be three copies of one fact, and said inside `mise` alone it + would read as a property of that mode. A local scenario says nothing here -- building what it runs is the + unremarkable case, and the run sections already show the builds. +#} +{% if published %} +This scenario is rendered against released artifacts rather than builds of this repository: +the images it names are published, and the binaries it runs are the released ones. Only the +image carrying its own module set is still built locally, since no release can hold a module +set particular to one deployment. + +{% endif %} +{# + The credential file is worth explaining because its absence is silent: `mise` skips an `_.file` it cannot find + without a warning, and Docker Compose treats a missing `env_file` the same way, so a copy without it starts a + collector with no root password rather than failing. +#} +{% set credentials = "OpenObserve, OTLP and object store" if shares_hubs else "OpenObserve and OTLP" %} +`{{ secrets_file }}` holds the scenario's derived {{ credentials }} credentials. It is derived from the +scenario input, so regenerating this deployment rewrites it; if it is missing, regenerate before +starting the stack. A deployment generated outside the repository is written with a `.gitignore` +covering it, so its credential is not committed by whatever repository it lands in. + +{# Every way of running the tasks needs what they name installed first, so the step comes once, ahead of them all. #} +{% if installs %} +{% include "readme/install.md.j2" %} + +{% endif %} +{% for section in sections %} +{% if not loop.first %} + +{% endif %} +{% include "readme/run.md.j2" %} +{% endfor %} +{% for board in boards %} + +{% with run = board %} +{% include "readme/" ~ run.kind ~ ".md.j2" %} +{% endwith %} +{% endfor %} diff --git a/utilities/cli/templates/readme/addresses.md.j2 b/utilities/cli/templates/readme/addresses.md.j2 new file mode 100644 index 00000000..044229a7 --- /dev/null +++ b/utilities/cli/templates/readme/addresses.md.j2 @@ -0,0 +1,3 @@ +{% for address in addresses %} +- `{{ address.owner }}` `{{ address.name }}`: {{ address.address }} +{% endfor %} diff --git a/utilities/cli/templates/readme/board-hub.md.j2 b/utilities/cli/templates/readme/board-hub.md.j2 new file mode 100644 index 00000000..ae54003f --- /dev/null +++ b/utilities/cli/templates/readme/board-hub.md.j2 @@ -0,0 +1,10 @@ +{# + A format whose server is a board, which leaves the format only the runners elsewhere. Only a format that runs + `mise.toml` tasks gets here, since the others are refused a board before anything is written. +#} +## Run With {{ section.heading }} + +The hub runs on `{{ run.server }}`, a {{ run.title }}, +as firmware the section loading it describes. The board runs no OpenObserve, so there is no UI to +open. Each runner on another machine reaches the hub across the network, and starts as that +machine's share below. diff --git a/utilities/cli/templates/readme/commands.j2 b/utilities/cli/templates/readme/commands.j2 new file mode 100644 index 00000000..081d7044 --- /dev/null +++ b/utilities/cli/templates/readme/commands.j2 @@ -0,0 +1,7 @@ +{# + The command that starts a process-compose deployment, naming its file outright. + + process-compose looks for `compose.yaml` among its default names and takes it first, so in a directory that also + holds the Docker Compose file it would load that one instead. +#} +{% set process_compose_up = "process-compose up --no-server -f process-compose.yaml" %} diff --git a/utilities/cli/templates/readme/dekit.md.j2 b/utilities/cli/templates/readme/dekit.md.j2 new file mode 100644 index 00000000..d3b23345 --- /dev/null +++ b/utilities/cli/templates/readme/dekit.md.j2 @@ -0,0 +1,22 @@ +## Run With dekit + +From this directory, start the scenario and watch each process in its own pane with: + +```bash +dekit up +dekit attach +``` + +Each task runs one task of `mise.toml`, so these are the processes `mise run generated-scenario` +starts, but in order: `ws-server` waits for OpenObserve to report healthy, and every runner +waits for `ws-server`. The tasks keep running after the terminal closes; stop them with: + +```bash +dekit down +``` + +Open the OpenObserve UI, once it is up, with: + +```bash +dekit start open-o2 +``` diff --git a/utilities/cli/templates/readme/docker-compose.md.j2 b/utilities/cli/templates/readme/docker-compose.md.j2 new file mode 100644 index 00000000..840d11d7 --- /dev/null +++ b/utilities/cli/templates/readme/docker-compose.md.j2 @@ -0,0 +1,48 @@ +{% set profiles = run.profiles | flags("--profile") %} +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose{{ profiles }} up --build +``` + +{% if shares_hubs %} +Every hub listens on the same ports, so on one host only the server's starts: this leaves out +each other machine running a hub, which runs its share on its own machine instead. + +{% endif %} +{# + Where the module-less hub image the scenario layers onto comes from. The two answers are structurally different + rather than differently worded: a local scenario builds the hub too, so `docker compose up` builds two images; a + published one's Dockerfile names the released image and builds one. +#} +{% if published %} +The compose stack starts OpenObserve and builds one image: the `Dockerfile` in this directory, +which stages this scenario's modules onto the released hub image it names. +{% else %} +The compose stack starts OpenObserve and builds `ws-server` in two layers: the module-less hub +image from the repository's `services/ws-server/Dockerfile`, then the `Dockerfile` in this +directory, which stages this scenario's modules onto it. +{% endif %} +`ws-server` runs with host networking so it advertises the same LAN IP as the `mise` deployment. + +### Open The UIs + +OpenObserve is available at . +`ws-server` is available at and . + +{# The runners share their host's network like the hub, so a port one listens on is open on that host already. #} +{% if run.runner_addresses %} +The runners listen on their host's network too, at: + +{% with addresses = run.runner_addresses %} +{% include "readme/addresses.md.j2" %} +{% endwith %} + +{% endif %} +Stop the scenario with: + +```bash +docker compose{{ profiles }} down +``` diff --git a/utilities/cli/templates/readme/firmware.md.j2 b/utilities/cli/templates/readme/firmware.md.j2 new file mode 100644 index 00000000..2e02e2bb --- /dev/null +++ b/utilities/cli/templates/readme/firmware.md.j2 @@ -0,0 +1,28 @@ +{# + A board's firmware running the hub, built for the board and loaded onto one: the whole scenario as a `firmware-*` + format, or the server of a scenario spanning machines. +#} +{% set runners = run.runners | join(", ") %} +## {{ run.heading }} + +{% if run.runners %} +The board runs the hub, and beside it each {{ runners }} runner the scenario places on it, compiled into the +firmware from its module's component. The build refuses a runner that imports what the board +does not host, or runners needing more memory than it holds. +The board serves this scenario's modules from its {{ run.flash_size }} of flash. +{% else %} +The board runs the hub alone and serves this scenario's modules from its {{ run.flash_size }} of flash. +{% endif %} +{% include "readme/load-board.md.j2" %} + +To check what the board will serve without one, serve the same modules from this host and fetch +each, as a page would: + +```bash +{{ run.host_test }} +``` + +The board takes an address over DHCP and serves the page at port 8080 of it. npm packages the +modules use that are too big for its flash, such as onnxruntime-web, are not built in: the board +redirects a request for one to the same path under `{{ run.elsewhere }}`, which the browser has to be +able to reach. diff --git a/utilities/cli/templates/readme/install.md.j2 b/utilities/cli/templates/readme/install.md.j2 new file mode 100644 index 00000000..18bb379d --- /dev/null +++ b/utilities/cli/templates/readme/install.md.j2 @@ -0,0 +1,47 @@ +{# + The step that locks the tools `mise.toml` names, and for a published deployment installs them. + + Every deployment that runs the tasks declares tools there -- at least the UI opener, and a published one its binaries + as `cargo:` tools -- each requested as `latest`, so without a committed lockfile two machines can install different + releases from the same files. A local deployment compiles what it runs from the working tree, so locking is all it + needs here. A published one also installs, since `task.run_auto_install` is off and the first task would otherwise + die on a command it cannot find rather than fetching it. + + `NPM_CONFIG_USERCONFIG` is exported rather than left to the `[env]` beside it, which carries the same value. mise + computes that entry but does not apply it to its own tool resolution, so the npm client it embeds reads no user + config, falls back to registry.npmjs.org, and reports the scoped module packages as `package not found` -- they exist + only on GitHub Packages. Exported into the process, the same file resolves against the right registry, for the lock + and the install alike. The `[env]` entry stays because the tasks do get it. +#} +{% if published %} +## Install + +Lock and fetch the binaries and module packages the `mise.toml` tasks name before the first run. +`GITHUB_TOKEN` has to be set: GitHub Packages rejects an unauthenticated read even for a public +package. The registry configuration is exported rather than relied on from `mise.toml`, because +mise does not apply its own `[env]` to the resolution these commands perform: + +```bash +export NPM_CONFIG_USERCONFIG="$PWD/npmrc" +mise lock +mise install --locked +``` + +`--locked` installs exactly what the lockfile records, and fails rather than resolving anew +when it is missing an entry. +{% else %} +## Lock The Tools + +Before the first run, lock the tools `mise.toml` names: + +```bash +mise lock +``` +{% endif %} + +Commit `mise.lock` alongside the deployment, together with the `.mise/locks/` directory when +mise writes one -- it holds the dependency graph of each npm tool, which `mise.lock` refers to. +The two record the version and checksum every `latest` in `mise.toml` resolved to, for each of +mise's common platforms, so every later install -- on any machine -- fetches the same binaries +rather than whatever is newest that day. Move to newer releases deliberately, with +`mise lock --bump`, and commit the result. diff --git a/utilities/cli/templates/readme/k3s-machines.md.j2 b/utilities/cli/templates/readme/k3s-machines.md.j2 new file mode 100644 index 00000000..c359ac40 --- /dev/null +++ b/utilities/cli/templates/readme/k3s-machines.md.j2 @@ -0,0 +1,42 @@ +{# + How the machines of a k3s scenario become one cluster and receive the images their pods run. Each machine is labelled + as it joins rather than afterwards, so the manifests' node selectors have something to match the moment the node + exists. +#} +{% set server = section.machines | first %} +### Run Across Machines With k3s + +{% include "readme/loopback.md.j2" %} +The machines have to form one k3s cluster first, each labelled with the machine name the manifests +pin pods to. With `k3s` installed on every machine, start the server on `{{ server.name }}`. It stays in +the foreground, so read the token the others join with from a second shell once it is up: + +```bash +sudo k3s server{{ server.labels | flags("--node-label") }} +``` + +```bash +sudo cat /var/lib/rancher/k3s/server/node-token +``` + +Then join each other machine, with that token in `token`: + +```bash +server={{ server.address }} +{% for machine in section.machines[1:] %} +sudo k3s agent --server "https://${server}:6443" --token "$token"{{ machine.labels | flags("--node-label") }} +{% endfor %} +``` + +Each image goes to the machines that run it rather than to one node, so the import steps above +become these, with each machine reachable over `ssh` by its address: + +```bash +image="et-ws-server-{{ name }}:latest" +{% for address in section.run.hub_imports %} +docker save "$image" | ssh {{ address }} sudo k3s ctr images import - +{% endfor %} +{% for shipped in section.run.image_imports %} +docker save {{ shipped.image }} | ssh {{ shipped.address }} sudo k3s ctr images import - +{% endfor %} +``` diff --git a/utilities/cli/templates/readme/k3s.md.j2 b/utilities/cli/templates/readme/k3s.md.j2 new file mode 100644 index 00000000..5762a48f --- /dev/null +++ b/utilities/cli/templates/readme/k3s.md.j2 @@ -0,0 +1,171 @@ +{# + The k3s section, longer than the others because a Kubernetes deployment needs two things done before `kubectl apply` + that neither `mise` nor compose does: the images have to exist on the node, since manifests reference images rather + than building them, and the credential has to be loaded as a `Secret`, since it is the one generated file the + repository does not carry. + + Only where the hub comes from differs between the two image sources; everything else about producing the scenario + image is the same either way. A published scenario's Dockerfile names the released hub image itself, so its build + needs no hub of its own and no context to hand one over in. + + Where the build runs follows from what the image reads: a checkout when it copies anything out of one, and otherwise + this directory. A build from this directory names no Dockerfile, since `Dockerfile` is docker's own default; one from + the repository root has to name the scenario's. The token is said wherever the build runs: a scenario staging a + mise-provisioned package builds from the repository root and can still fetch released modules, and then needs the + token just the same. The secret's id and the variable it is read from go straight to the flag, since held in a shell + variable of its own the pair reads to a secret scanner as a credential assigned in the clear. The template variable + holding that flag is named for the registry rather than the secret for the same reason. +#} +{% set place = "From the repository root" if run.repository else "From this directory" %} +{% set location = place ~ ("," if run.registry_token else ":") %} +{% set build_contexts = run.build_contexts | map("quote") | flags("--build-context") %} +{% set hub_context = "" if published else ' --build-context "hub=docker-image://$hub"' %} +{% set registry_flag = " --secret id=" ~ token_secret ~ ",env=" ~ token_env if run.registry_token else "" %} +{% set dockerfile_flag = ' -f "$dockerfile"' if run.repository else "" %} +## Run With k3s + +{% if published %} +The manifests reference images by name and never build them, so this scenario's own image has +to reach the node first. It layers its modules onto the released hub image, which its Dockerfile +names and the build pulls. {{ location }} +{% else %} +The manifests reference images by name and never build them, so build and import each one first. +This scenario's image layers its modules onto the module-less hub image and takes that hub as a +_named build context_ rather than building it, so the hub has to exist first -- a plain +`docker build` of the scenario Dockerfile fails on `FROM hub`. {{ location }} +{% endif %} +{% if run.registry_token %} +with `GITHUB_TOKEN` set to a token that can read the GitHub Packages the build fetches this +scenario's released modules from: +{% endif %} + +```bash +scenario={{ name }} +{% if not published %} +hub=et-ws-server-hub:latest +{% endif %} +image="et-ws-server-$scenario:latest" +{% if run.repository %} +dockerfile="{{ run.dockerfile }}" +{% endif %} +{% if not published %} +docker build -t "$hub" -f services/ws-server/Dockerfile . +{% endif %} +docker build{{ hub_context }}{{ build_contexts }}{{ registry_flag }} -t "$image"{{ dockerfile_flag }} . +docker save "$image" | sudo k3s ctr images import - +``` + +{# + Commands for a locally sourced scenario and a plain list for a published one, because that is the difference the + reader has to act on: in the first case a runner image the node does not have leaves its pod in `ErrImagePull` and + nothing in the deployment builds it, and in the second the cluster fetches it and there is nothing to run at all. The + published case still names the refs, since a reader who just built the scenario image by hand will otherwise go + looking for the step that produces these. A scenario whose agents are all browser-side names no runner image, so the + whole paragraph is left out rather than left as an empty code fence. +#} +{% if run.runners %} +{% if published %} +The cluster pulls this scenario's runner images, so nothing has to be built or imported for +them: + +{% for kind in run.runners %} +- `{{ registry }}/et-ws-{{ kind }}-runner:latest` +{% endfor %} +{% else %} +Then this scenario's runner images, which need no build context of their own: + +```bash +{% for kind in run.runners %} +docker build -t et-ws-{{ kind }}-runner:latest -f services/ws-{{ kind }}-runner/Dockerfile . +docker save et-ws-{{ kind }}-runner:latest | sudo k3s ctr images import - +{% endfor %} +``` +{% endif %} + +{% endif %} +{% if run.recovery %} +{% if published %} +The cluster also pulls `{{ registry }}/et-recovery-supervisor:latest`. +{% else %} +Build and import the recovery service image from the repository root: + +```bash +image=et-recovery-supervisor:latest +docker build -t "$image" -f services/recovery-supervisor/Dockerfile . +docker save "$image" | sudo k3s ctr images import - +``` + +The supervisor starts automatically with scoped ServiceAccount credentials and persistent state. +Application modules must implement lifecycle reporting and any required checkpoint restoration. +{% endif %} + +{% endif %} +{% if run.plugins %} +### Prepare The Nodes + +Some of this scenario's limits have no field in the Pod API, so `k3s.yaml` runs these container +runtime plugins on every node to write them into each container's cgroup: +{{ run.plugins | map("code") | join(" and ") }}. + +Each limited pod names the plugins it needs, and containerd refuses it on a node where one of them +did not adjust it -- once NRI's validator is on, which k3s leaves off. Before applying `k3s.yaml`, +copy the template below from this directory to every machine of the cluster and restart k3s there +(`k3s-agent` on a machine that joined as an agent): + +```bash +sudo install -D -m 0644 {{ run.containerd_template_file }} {{ run.containerd_template_path }} +sudo systemctl restart k3s +``` + +{% endif %} +{# Nothing from here on depends on how the images were produced. #} +### Load The Credential + +The credential reaches the pods as a `Secret` created from `{{ secrets_file }}`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: + +```bash +ns=et-{{ name }} +kubectl create namespace "$ns" --save-config +kubectl create secret generic "$ns-secrets" --from-env-file={{ secrets_file }} -n "$ns" +``` + +### Apply + +```bash +kubectl apply -f k3s.yaml +``` + +The runners exit and restart until the hub reports ready, so `CrashLoopBackOff` while the hub +starts is expected here rather than a fault. Watch it settle with: + +```bash +kubectl get pods -n "$ns" --watch +``` +{# + Where the scenario is reached from outside the cluster, or nothing for one nobody outside needs. A scenario whose hub + serves a page but is not exposed is still told so, since without it no browser can load the page the deployment + exists to serve. +#} +{% if run.addresses or run.hidden_page %} + +### Open From Outside The Cluster + +{% if run.hidden_page %} +Nothing outside the cluster can reach `ws-server`, so no browser can open its page. List `ws` +and `wss` under `services.hub.expose` in the scenario input and regenerate to publish them. +{% endif %} +{% if run.addresses %} +{% if run.hidden_page %} + +{% endif %} +Each exposed port is published on the machine its process runs on: + +{% with addresses = run.addresses %} +{% include "readme/addresses.md.j2" %} +{% endwith %} + +Each binds its port on that node, so another scenario on this cluster exposing the same port +cannot publish it until this one is deleted. +{% endif %} +{% endif %} diff --git a/utilities/cli/templates/readme/load-board.md.j2 b/utilities/cli/templates/readme/load-board.md.j2 new file mode 100644 index 00000000..65fe7e62 --- /dev/null +++ b/utilities/cli/templates/readme/load-board.md.j2 @@ -0,0 +1,9 @@ +{# How a board's firmware is built and loaded, which is the same steps whatever the board runs. #} +Build those modules first, since the firmware takes each one's built package. +{{ run.network_setup }} +Then {{ run.load_preparation }}. +From this directory, build the firmware and load it with: + +```bash +{{ run.flash }} +``` diff --git a/utilities/cli/templates/readme/loopback.md.j2 b/utilities/cli/templates/readme/loopback.md.j2 new file mode 100644 index 00000000..6a8b4139 --- /dev/null +++ b/utilities/cli/templates/readme/loopback.md.j2 @@ -0,0 +1,10 @@ +{# + Loopback addresses run every machine's share on one host, which is what a verification scenario wants, and none of + them reaches another machine. The commands that follow use the addresses as written, so they have to change in the + input, and the deployment regenerated, before it runs across machines. +#} +{% if section.loopback %} +The machines' addresses are loopback, which plays every machine on this one host. To run them on +separate machines, give each its reachable address in the scenario input and regenerate first. + +{% endif %} diff --git a/utilities/cli/templates/readme/machines.md.j2 b/utilities/cli/templates/readme/machines.md.j2 new file mode 100644 index 00000000..e6475632 --- /dev/null +++ b/utilities/cli/templates/readme/machines.md.j2 @@ -0,0 +1,56 @@ +{# + How one format runs across the machines a scenario names: a subsection after the single-host instructions rather than + a rewrite of them, because most of what those say -- building the images, loading the credential, opening the UIs -- + holds on the server machine unchanged. What differs is which machine starts what. +#} +{% from "readme/commands.j2" import process_compose_up %} +{% set server = section.machines | first %} +### Run Across Machines With {{ section.heading }} + +{% if section.format == "dekit" %} +Copy this directory to each machine and start that machine's share there. Every task is tagged +with its machine and none starts on `dekit up` alone, so `dekit up` on any machine starts nothing +until its tag is started. +{% elif section.format == "process-compose" %} +Copy this directory to each machine and start only that machine's namespace there. +{% elif section.format == "docker-compose" %} +Copy this directory to each machine and start only that machine's profile there. +{% else %} +Copy this directory to each machine and start that machine's share of the scenario there. +{% endif %} +{% if server.board %} +`{{ server.name }}` is a {{ server.board }} running `ws-server` as firmware, +and the runners on every other machine reach the hub at `{{ server.address }}`, which the network the board +joins has to resolve to it. Its share is loading the firmware, which it then runs from power-on. +{% elif shares_hubs %} +`{{ server.name }}` runs OpenObserve, `ws-server`, and the NATS server and object store the hubs share, +which the other machines' hubs reach at `{{ server.address }}`. Each runner joins the hub on its own +machine, or the one on `{{ server.name }}` when its machine runs none. +{% else %} +`{{ server.name }}` runs OpenObserve and `ws-server`, and the runners on every other machine reach +the hub at `{{ server.address }}`. +{% endif %} + +{% include "readme/loopback.md.j2" %} +{% for machine in section.machines %} +On `{{ machine.name }}`: + +```bash +{# A board's share is its firmware, which every format loads the same way. #} +{% if machine.board %} +mise run {{ machine.flash }} +{% elif section.format == "dekit" %} +dekit up +dekit start +{{ machine.name }} +{% elif section.format == "process-compose" %} +{{ process_compose_up }} -n {{ machine.name }} +{% elif section.format == "docker-compose" %} +docker compose --profile {{ machine.name }} up --build +{% else %} +mise run {{ machine.task }} +{% endif %} +``` +{% if not loop.last %} + +{% endif %} +{% endfor %} diff --git a/utilities/cli/templates/readme/mise.md.j2 b/utilities/cli/templates/readme/mise.md.j2 new file mode 100644 index 00000000..2bdb5715 --- /dev/null +++ b/utilities/cli/templates/readme/mise.md.j2 @@ -0,0 +1,17 @@ +## Run With Mise + +From this directory, start the scenario with: + +```bash +mise run generated-scenario +``` + +That task starts both OpenObserve and `ws-server` for this scenario. + +### Open The OpenObserve UI + +From this directory, open the OpenObserve UI with: + +```bash +mise run open-o2 +``` diff --git a/utilities/cli/templates/readme/process-compose.md.j2 b/utilities/cli/templates/readme/process-compose.md.j2 new file mode 100644 index 00000000..943283f5 --- /dev/null +++ b/utilities/cli/templates/readme/process-compose.md.j2 @@ -0,0 +1,27 @@ +{# + A local deployment installs process-compose here, since the README's own install step is only written for a published + one; a published deployment's install step already fetches it with everything else `mise.toml` names. +#} +{% from "readme/commands.j2" import process_compose_up %} +## Run With process-compose + +{% if published %} +From this directory, start the scenario and watch each process with: + +```bash +{% else %} +From this directory, install the process-compose `mise.toml` declares, then start the scenario +and watch each process with: + +```bash +mise install +{% endif %} +{{ process_compose_up }} +``` + +Each process runs one task of `mise.toml`, in order: `ws-server` waits for OpenObserve to report +healthy, and every runner waits for `ws-server`. A scheduled agent's runner is started on each +tick of its schedule and runs its module once; a tick that finds the previous run still going is +skipped. `--no-server` keeps process-compose's own API off port 8080, where `ws-server` listens. +Quitting the TUI stops every process. Open the OpenObserve UI, once it is up, by starting the +`open-o2` process from the TUI. diff --git a/utilities/cli/templates/readme/run.md.j2 b/utilities/cli/templates/readme/run.md.j2 new file mode 100644 index 00000000..09b789a1 --- /dev/null +++ b/utilities/cli/templates/readme/run.md.j2 @@ -0,0 +1,32 @@ +{# One format's section, then what it adds when the scenario spans machines, sets limits, or schedules. #} +{% with run = section.run %} +{% include "readme/" ~ run.kind ~ ".md.j2" %} +{% endwith %} +{% if section.machines %} + +{% if section.format == "k3s" %} +{% include "readme/k3s-machines.md.j2" %} +{% else %} +{% include "readme/machines.md.j2" %} +{% endif %} +{% endif %} +{% if section.unapplied_limits %} + +### Limits Not Applied With {{ section.heading }} + +Each process runs directly on the host rather than in a container, so the limits the scenario +sets for these are not applied; the compose and k3s deployments enforce them: + +{% for process in section.unapplied_limits %} +- `{{ process }}` +{% endfor %} +{% endif %} +{% if section.scheduled %} + +### Scheduled Agents With Docker Compose + +`docker compose up` runs each scheduled agent's runner once, as it does every service. The +`scheduler` service, Ofelia, then starts that same container again on each tick of its schedule, +skipping a tick while the previous run is still going. It mounts the Docker socket to do so, which +gives it the Docker daemon's privileges. +{% endif %} diff --git a/utilities/cli/templates/readme/runner-board.md.j2 b/utilities/cli/templates/readme/runner-board.md.j2 new file mode 100644 index 00000000..fdd1cdda --- /dev/null +++ b/utilities/cli/templates/readme/runner-board.md.j2 @@ -0,0 +1,8 @@ +{# A board running only the runners placed on it, which reach the server's hub across the network. #} +{% set machine = run.machine %} +{% set runners = run.runners | join(", ") %} +## Load The Runners Onto `{{ machine }}` + +`{{ machine }}` is a {{ run.title }} running each {{ runners }} runner the scenario places on it, compiled into the +firmware from its module's component, and no hub: the runners join the one at `{{ run.hub_url }}`. +{% include "readme/load-board.md.j2" %} diff --git a/utilities/cli/templates/scenario-image.dockerignore.j2 b/utilities/cli/templates/scenario-image.dockerignore.j2 new file mode 100644 index 00000000..145ab0bd --- /dev/null +++ b/utilities/cli/templates/scenario-image.dockerignore.j2 @@ -0,0 +1,30 @@ +{# + The ignore file BuildKit applies to the scenario Dockerfile in place of the repository root one. + + An image built from the repository gets the root file verbatim plus a re-include block, rather than a hand-picked + subset, so a rule added to `.gitignore` reaches this build too. Docker's matcher is last-match-wins and, unlike git's, + still descends into an excluded directory to honour a later negation, so the trailing block is enough to bring back + the built artifacts while every other exclusion keeps applying. + + An image built from its own deployment directory copies nothing out of its context, so everything is excluded and + the build is sent nothing at all -- the directory's `secrets.env` included. +#} +# AUTO-GENERATED by `et-cli` for the `{{ name }}` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +{% if repository %} +# BuildKit reads this file instead of the repository root `.dockerignore` when building the +# Dockerfile beside it, so the root file keeps governing the CI images built from `./Dockerfile` +# and only this build sees the re-includes at the end. + +{{ root_dockerignore }} +# Re-admit the built module artifacts this scenario serves. +# The rules above drop every `*.wasm` and `*.onnx`, which is what keeps the CI images small; these +# negations put back the `pkg/` directories this image copies, and nothing else. +{% for built in built_artifacts %} +!{{ built }} +{% endfor %} +{% else %} +# The Dockerfile beside this file copies nothing from its build context, so the context is empty. +* +{% endif %} diff --git a/utilities/cli/templates/scenario-image.j2 b/utilities/cli/templates/scenario-image.j2 new file mode 100644 index 00000000..a58262f4 --- /dev/null +++ b/utilities/cli/templates/scenario-image.j2 @@ -0,0 +1,241 @@ +{# + The scenario image, which layers the cluster's modules onto the hub image. + + Every list it is rendered from is sorted, since resolution order moves a module whenever an unrelated one gains a + dependency, and this file is committed. Each stage ends with its own blank line, so none starts with one; doing it + the other way round doubles the separator wherever two stages meet. +#} +# syntax=docker/dockerfile:1 + +# AUTO-GENERATED by `et-cli` for the `{{ name }}` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# The hub image ({{ hub_dockerfile }}) carries no workflow modules at all, because which +# modules a deployment serves is a per-cluster decision. This image is that decision made concrete: it +# stages exactly the modules this cluster's agents declare, plus the packages those modules depend on, +# into the `/app` paths the hub already scans. +# +{# + A published image copies nothing out of a checkout, so the ignore file that re-admits built artifacts has no part in + it; what it says instead is where its modules come from. +#} +{% if published %} +# This scenario consumes the project's releases, so its modules are fetched from the registry they are +# published to rather than copied from a checkout, and the image builds from the deployment directory +# alone. +{% else %} +# The sibling `Dockerfile.dockerignore` is what makes the COPYs below possible. The repository's root +# `.dockerignore` drops every `*.wasm` and `*.onnx` so the CI images built from `./Dockerfile` stay +# small; BuildKit uses this Dockerfile's own ignore file instead, which re-admits the built `pkg/` +# directories and nothing else. +{% endif %} + +{% if mise_tools %} +{# + The stage that installs the mise-staged packages the cluster's modules depend on. + + The repo's own `.mise/` configs and lockfiles are the version source, so nothing here pins a version that could drift + from what a workstation resolves; only the tool ids come from the module registry. +#} +# Stage the packages that live outside the repository. +# These are published packages the modules load at runtime, provisioned by mise rather than built +# here, so this stage installs them from the repo's own pins and copies each one to the `/app` path +# the hub serves it from. +FROM docker.io/library/debian:bookworm-slim AS deps + +# The package list is named rather than inlined so a policy can hold it against the repo-root Dockerfile. +# Every name here is drawn from that file's own COMMON_PACKAGES: the shell, the archive tools mise unpacks +# downloads with, plus git and curl. Its compiler packages are deliberately absent -- nothing is compiled +# in this stage -- so the two lists are checked as subset rather than equality. +# Promoted to ENV because the quoted heredoc below is expanded by bash at run time, which an ARG alone +# does not reach. +ARG COMMON_PACKAGES="bash bzip2 ca-certificates curl git gzip tar unzip" +ENV COMMON_PACKAGES=${COMMON_PACKAGES} +RUN bash <<'EOF' +set -euo pipefail +apt-get update +# Word-splitting $COMMON_PACKAGES into separate arguments is the intent, so it is deliberately unquoted. +apt-get install -y --no-install-recommends $COMMON_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb +EOF + +# mise is fetched to a file and checksum-verified, never piped from mise.run into a shell. +# Piping a remote script into an interpreter runs unreviewed code straight off the network, which the +# repo's no-curl-pipe rule bans. The release's SHASUMS256.txt covers every asset, so `--ignore-missing` +# checks exactly the archive downloaded here. Pinned rather than installed from mise's apt repository, +# which carries only the newest version: a floating mise writes lockfile entries an older CI mise +# then refuses to install. +ARG MISE_VERSION=v2026.10.15 +ARG TARGETARCH +ENV MISE_VERSION=${MISE_VERSION} +ENV TARGETARCH=${TARGETARCH} +RUN bash <<'EOF' +set -euo pipefail +case "${TARGETARCH}" in + amd64) arch=x64 ;; + arm64) arch=arm64 ;; + *) echo "unsupported TARGETARCH ${TARGETARCH}" >&2; exit 1 ;; +esac +asset="mise-${MISE_VERSION}-linux-${arch}.tar.gz" +base="https://github.com/jdx/mise/releases/download/${MISE_VERSION}" +cd /tmp +curl -fsSL -o "${asset}" "${base}/${asset}" +curl -fsSL -o SHASUMS256.txt "${base}/SHASUMS256.txt" +sha256sum -c --ignore-missing SHASUMS256.txt +tar -xzf "${asset}" +install -m 0755 /tmp/mise/bin/mise /usr/local/bin/mise +rm -rf /tmp/mise "/tmp/${asset}" /tmp/SHASUMS256.txt +EOF + +ENV HOME=/root +ENV PATH="/root/.local/share/mise/shims:${PATH}" +WORKDIR /workspace +COPY .miserc.toml .miserc.toml +COPY .mise/ .mise/ +RUN mise trust +ENV MISE_ENV={{ deps_mise_env }} +{% if installs_node %} +{# + mise's npm backend shells out to npm, and refuses an `npm:` tool whose configured `node` dependency is not installed + yet ("requires configured install dependency 'node@22', but its selected version is not installed"). Its version + comes from the repo's own [tools] pin, like every other tool staged here. The blank line after `EOF` is load-bearing, + not formatting. hadolint's parser reads the next instruction as a continuation of the heredoc without it and fails + with `unexpected 'E' expecting a new line followed by the next instruction`. +#} +RUN bash <<'EOF' +set -euo pipefail +mise install node +EOF + +{% endif %} +ENV SCENARIO_TOOLS="{{ mise_tools | map(attribute="tool") | join(" ") }}" +RUN bash <<'EOF' +set -euo pipefail +# Word-splitting SCENARIO_TOOLS is intentional; it is a space-separated tool list. +# shellcheck disable=SC2086 +mise install ${SCENARIO_TOOLS} +EOF + +{# + Each package is located rather than assuming where the backend put it. The npm backend nests it under one of several + layouts (`lib/node_modules/`, `node_modules/`, or an aube virtual store below a content-hashed directory) that vary by + platform, so the copy searches for the named directory instead. The named directory is tried FIRST and the install + root only as a fallback: the npm backend drops its own wrapper manifest (`"name": "mise-npm-install"`) at that root, + so a root-first probe stages the wrapper and the module is then served under the wrapper's name. The fallback is what + covers an archive-backed `http:` tool, which extracts flat so the root really is the package. `-L` and `cp -L` + resolve the symlinks the aube store is built from, so real files land in the image. +#} +{% for tool in mise_tools %} +ENV STAGE_TOOL="{{ tool.tool }}" +ENV STAGE_PACKAGE="{{ tool.package }}" +ENV STAGE_DEST="/staged{{ tool.docker_path }}" +RUN bash <<'EOF' +set -euo pipefail +root="$(mise where "${STAGE_TOOL}")" +src="$(find -L "${root}" -type d -name "${STAGE_PACKAGE}" -exec test -f '{}/package.json' ';' -print -quit)" +if [ -z "${src}" ] && [ -f "${root}/package.json" ]; then + src="${root}" +fi +if [ -z "${src}" ]; then + echo "no ${STAGE_PACKAGE} package dir under the ${STAGE_TOOL} install at ${root}" >&2 + exit 1 +fi +mkdir -p "$(dirname "${STAGE_DEST}")" +cp -rL "${src}" "${STAGE_DEST}" +EOF + +{% endfor %} +{% endif %} +{% if released %} +{# + The stage that fetches this repository's modules from the registry a published scenario consumes them from. + + `npm pack` rather than a download by URL, because it checks each tarball against the integrity the registry records + for it before writing it. The tarball holds the module's `pkg/` exactly as it was published, under npm's `package/` + prefix, which the extraction strips. The token reaches the step as a secret mount, never as a build argument or a + file in a layer, and the npm config only names it. It is written one `printf` per line, appending, so no line of the + step outgrows the file's width. +#} +# Fetch the released modules this scenario serves. +# GitHub Packages refuses an anonymous read even of a public package, so the build takes a `gh_token` +# secret, which is only ever mounted into the step that needs it. +FROM {{ node_image }} AS released + +ENV NPM_CONFIG_USERCONFIG=/released.npmrc +RUN bash <<'EOF' +set -euo pipefail +{% for line in npmrc | lines %} +printf '%s\n' '{{ line }}' >> /released.npmrc +{% endfor %} +EOF + +{% for module in released %} +ENV STAGE_PACKAGE="{{ module.package }}" +ENV STAGE_DEST="/staged{{ module.docker_path }}/pkg" +RUN --mount=type=secret,id={{ token_secret }},env={{ token_env }} bash <<'EOF' +set -euo pipefail +cd /tmp +tarball="$(npm pack --silent "${STAGE_PACKAGE}@latest")" +mkdir -p "${STAGE_DEST}" +tar -xzf "${tarball}" -C "${STAGE_DEST}" --strip-components=1 +rm "${tarball}" +EOF + +{% endfor %} +{% endif %} +{% if published %} +# The released hub image, which this scenario layers its modules onto. +# `latest` because that is the one tag the hub is published under: it is assembled from each +# architecture's build, so a node of either architecture resolves it. That is why the rule against +# a `latest` base is suppressed on the next line. +# hadolint ignore=DL3007 +FROM {{ registry }}/et-ws-server:latest +{% else %} +# `hub` is a named build context, not a stage defined here. +# The generated compose file wires it to its `{{ hub_image_build }}` service, so compose builds the hub image +# first and hands the result over under this name. That is also why the version-tag rule is +# suppressed on the next line: hadolint reads `hub` as an untagged image, but there is no tag to give a +# build context, and the image it resolves to is pinned by the hub Dockerfile rather than here. +# hadolint ignore=DL3006 +FROM hub +{% endif %} + +LABEL org.opencontainers.image.source="{{ repository_url }}" + +{# + `pkg/` unconditionally, never probed for on disk. Every module serves out of `pkg/`, whether that directory is + committed (the JS shims) or produced by a build (wasm-pack output, wheels). Probing for it makes this file depend on + what happens to be built in the working tree: CI checks out unbuilt Rust modules, found no + `services/ws-modules/har1/pkg`, and emitted the module root instead -- so the committed output and the CI regeneration + disagreed, and the drift check failed. A module that genuinely has no `pkg/` now fails loudly at image build rather + than silently generating a different Dockerfile per machine. +#} +{% for module in repo_paths %} +COPY --chown=10001:10001 {{ module.repo_path }}/pkg {{ module.docker_path }}/pkg +{% endfor %} +{# One copy of the whole staged tree, which mirrors `/app` path for path. #} +{% if released %} +COPY --from=released --chown=10001:10001 /staged/app /app +{% endif %} +{% for tool in mise_tools %} +COPY --from=deps --chown=10001:10001 /staged{{ tool.docker_path }} {{ tool.docker_path }} +{% endfor %} +{# + Each from the named build context its `module_paths:` entry is handed to the build as, and whole rather than its + `pkg/`: whether the module serves from `pkg/` or its root is the scenario author's layout, and the hub finds either + once the directory is there. hadolint reads a context name as an undefined stage, as with `hub` above. +#} +{% for module in scenario_dirs %} +# hadolint ignore=DL3022 +COPY --from={{ module.context }} --chown=10001:10001 [{{ module.source }}, {{ module.destination }}] +{% endfor %} +{# + The user the hub image already runs as, restated so the image says so itself rather than only through a base a + scanner cannot see into; every COPY above hands its files to the same id. An image with no COPY already ends in the + blank line that closes its LABEL. +#} +{% if repo_paths or released or mise_tools or scenario_dirs %} + +{% endif %} +USER 10001 diff --git a/utilities/cli/templates/secrets.env.j2 b/utilities/cli/templates/secrets.env.j2 new file mode 100644 index 00000000..a5d8132c --- /dev/null +++ b/utilities/cli/templates/secrets.env.j2 @@ -0,0 +1,11 @@ +{# + Each value carries a `skipcq` pragma for `DeepSource SCT-A000`: the copies committed under `verification/` are read + as hardcoded credentials, and the path excludes that keep the rest of that tree out of analysis do not reach a + secrets scan. The pragma sits on the line above rather than at the end of its own, because an env file has no inline + comments: every consumer keeps what follows the `=` verbatim, so a trailing marker would become part of the password. +#} +# Generated from the scenario input -- regenerating this deployment rewrites it. +{% for name in names %} +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +{{ name }}={{ password }} +{% endfor %} diff --git a/utilities/cli/tests/scenario_generation.rs b/utilities/cli/tests/scenario_generation.rs index 92568a99..500985ba 100644 --- a/utilities/cli/tests/scenario_generation.rs +++ b/utilities/cli/tests/scenario_generation.rs @@ -746,7 +746,8 @@ fn a_published_scenario_with_no_modules_of_its_own_needs_no_token() { assert!(!files["Dockerfile"].contains("AS released")); assert!(!files["compose.yaml"].contains("context:")); assert!(!files["compose.yaml"].contains("secrets:")); - assert!(files["compose.yaml"].contains(" build: .\n")); + let compose: serde_yaml::Value = serde_yaml::from_str(&files["compose.yaml"]).unwrap(); + assert_eq!(compose["services"]["ws-server"]["build"].as_str(), Some(".")); assert!(files["README.md"].contains("From this directory:\n\n```bash\nscenario=bare\n")); } diff --git a/verification/local/output/core-math1/compose.yaml b/verification/local/output/core-math1/compose.yaml index 8265751a..23451b5d 100644 --- a/verification/local/output/core-math1/compose.yaml +++ b/verification/local/output/core-math1/compose.yaml @@ -58,8 +58,7 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/modules/core/core-math1, - /app/modules/wasi/wasi-math1-sender" + MODULES_PATHS: /app/modules/core/core-math1, /app/modules/wasi/wasi-math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/default/compose.yaml b/verification/local/output/default/compose.yaml index 4f87e511..06a15ce5 100644 --- a/verification/local/output/default/compose.yaml +++ b/verification/local/output/default/compose.yaml @@ -58,10 +58,9 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/node_modules/onnxruntime-web, - /app/node_modules/stats-gl, - /app/services/ws-server/static, - /app/services/ws-wasm-agent" + MODULES_PATHS: >- + /app/node_modules/onnxruntime-web, /app/node_modules/stats-gl, + /app/services/ws-server/static, /app/services/ws-wasm-agent MODULES_ROOT: "@edge-toolkit/et-ws-server-static" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/expose/compose.yaml b/verification/local/output/expose/compose.yaml index b81cb678..ddc25776 100644 --- a/verification/local/output/expose/compose.yaml +++ b/verification/local/output/expose/compose.yaml @@ -58,13 +58,10 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/data/model-modules/model-har-motion1, - /app/modules/pyo3/pyo3-math1, - /app/modules/web/har1, - /app/node_modules/onnxruntime-web, - /app/node_modules/stats-gl, - /app/services/ws-server/static, - /app/services/ws-wasm-agent" + MODULES_PATHS: >- + /app/data/model-modules/model-har-motion1, /app/modules/pyo3/pyo3-math1, + /app/modules/web/har1, /app/node_modules/onnxruntime-web, /app/node_modules/stats-gl, + /app/services/ws-server/static, /app/services/ws-wasm-agent MODULES_ROOT: "@edge-toolkit/et-ws-server-static" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/facility-security-scenario/compose.yaml b/verification/local/output/facility-security-scenario/compose.yaml index adacce25..459b53df 100644 --- a/verification/local/output/facility-security-scenario/compose.yaml +++ b/verification/local/output/facility-security-scenario/compose.yaml @@ -58,19 +58,12 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/data/model-modules/model-face1, - /app/data/model-modules/model-har-motion1, - /app/generated/python-ws, - /app/libs/python-edge-toolkit, - /app/libs/python-org, - /app/modules/web/face-detection, - /app/modules/web/har1, - /app/modules/web/pyface1, - /app/node_modules/onnxruntime-web, - /app/node_modules/pyodide, - /app/node_modules/stats-gl, - /app/services/ws-server/static, - /app/services/ws-wasm-agent" + MODULES_PATHS: >- + /app/data/model-modules/model-face1, /app/data/model-modules/model-har-motion1, + /app/generated/python-ws, /app/libs/python-edge-toolkit, /app/libs/python-org, + /app/modules/web/face-detection, /app/modules/web/har1, /app/modules/web/pyface1, + /app/node_modules/onnxruntime-web, /app/node_modules/pyodide, + /app/node_modules/stats-gl, /app/services/ws-server/static, /app/services/ws-wasm-agent MODULES_ROOT: "@edge-toolkit/et-ws-server-static" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/math1-cron/compose.yaml b/verification/local/output/math1-cron/compose.yaml index 3c8413b9..bd5fa3ad 100644 --- a/verification/local/output/math1-cron/compose.yaml +++ b/verification/local/output/math1-cron/compose.yaml @@ -58,8 +58,7 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 @@ -95,7 +94,7 @@ services: math1-trigger: container_name: et-math1-cron-math1-trigger labels: - edge-toolkit/scenario: "math1-cron" + edge-toolkit/scenario: math1-cron build: context: ../../../.. dockerfile: services/ws-web-runner/Dockerfile @@ -120,10 +119,10 @@ services: - --docker-filter - label=edge-toolkit/scenario=math1-cron labels: - edge-toolkit/scenario: "math1-cron" + edge-toolkit/scenario: math1-cron ofelia.enabled: "true" - ofelia.job-run.math1-trigger.schedule: "CRON_TZ=UTC * * * * *" - ofelia.job-run.math1-trigger.container: "et-math1-cron-math1-trigger" + ofelia.job-run.math1-trigger.schedule: CRON_TZ=UTC * * * * * + ofelia.job-run.math1-trigger.container: et-math1-cron-math1-trigger ofelia.job-run.math1-trigger.no-overlap: "true" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/verification/local/output/math1-limits/compose.yaml b/verification/local/output/math1-limits/compose.yaml index 2177a757..ab505e91 100644 --- a/verification/local/output/math1-limits/compose.yaml +++ b/verification/local/output/math1-limits/compose.yaml @@ -60,8 +60,7 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/math1-multi-hub/compose.yaml b/verification/local/output/math1-multi-hub/compose.yaml index 66f9aa74..a0b0aff9 100644 --- a/verification/local/output/math1-multi-hub/compose.yaml +++ b/verification/local/output/math1-multi-hub/compose.yaml @@ -42,7 +42,9 @@ services: healthcheck: test: - CMD-SHELL - - printf 'GET /healthz?js-enabled-only=true HTTP/1.0\r\n\r\n' | nc localhost 8222 | head -1 | grep -q ' 200 ' + - >- + printf 'GET /healthz?js-enabled-only=true HTTP/1.0\r\n\r\n' | nc localhost 8222 + | head -1 | grep -q ' 200 ' interval: 5s timeout: 3s retries: 20 @@ -131,8 +133,7 @@ services: AWS_ALLOW_HTTP: "true" AWS_DEFAULT_REGION: us-east-1 AWS_ENDPOINT: http://localhost:9000 - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 @@ -178,8 +179,7 @@ services: AWS_ALLOW_HTTP: "true" AWS_DEFAULT_REGION: us-east-1 AWS_ENDPOINT: http://127.0.0.1:9000 - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/math1-multi/compose.yaml b/verification/local/output/math1-multi/compose.yaml index 707d05b9..e1b2880e 100644 --- a/verification/local/output/math1-multi/compose.yaml +++ b/verification/local/output/math1-multi/compose.yaml @@ -64,8 +64,7 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/math1/compose.yaml b/verification/local/output/math1/compose.yaml index 36153d35..393c992c 100644 --- a/verification/local/output/math1/compose.yaml +++ b/verification/local/output/math1/compose.yaml @@ -58,8 +58,7 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/pyo3-math1/compose.yaml b/verification/local/output/pyo3-math1/compose.yaml index 08dcbf99..fd617eca 100644 --- a/verification/local/output/pyo3-math1/compose.yaml +++ b/verification/local/output/pyo3-math1/compose.yaml @@ -58,8 +58,7 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/modules/pyo3/pyo3-math1, - /app/modules/wasi/wasi-math1-sender" + MODULES_PATHS: /app/modules/pyo3/pyo3-math1, /app/modules/wasi/wasi-math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/local/output/wasi-math1/compose.yaml b/verification/local/output/wasi-math1/compose.yaml index 02968aab..314355df 100644 --- a/verification/local/output/wasi-math1/compose.yaml +++ b/verification/local/output/wasi-math1/compose.yaml @@ -58,8 +58,7 @@ services: hub: service:hub-image-build network_mode: host environment: - MODULES_PATHS: "/app/modules/wasi/wasi-math1, - /app/modules/wasi/wasi-math1-sender" + MODULES_PATHS: /app/modules/wasi/wasi-math1, /app/modules/wasi/wasi-math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/published/output/default/compose.yaml b/verification/published/output/default/compose.yaml index 0da494b0..e02644a5 100644 --- a/verification/published/output/default/compose.yaml +++ b/verification/published/output/default/compose.yaml @@ -40,13 +40,12 @@ services: start_period: 10s env_file: - secrets.env - build: . + build: "." network_mode: host environment: - MODULES_PATHS: "/app/node_modules/onnxruntime-web, - /app/node_modules/stats-gl, - /app/services/ws-server/static, - /app/services/ws-wasm-agent" + MODULES_PATHS: >- + /app/node_modules/onnxruntime-web, /app/node_modules/stats-gl, + /app/services/ws-server/static, /app/services/ws-wasm-agent MODULES_ROOT: "@edge-toolkit/et-ws-server-static" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/published/output/math1-cron/compose.yaml b/verification/published/output/math1-cron/compose.yaml index 40e6b1a2..29c3f37a 100644 --- a/verification/published/output/math1-cron/compose.yaml +++ b/verification/published/output/math1-cron/compose.yaml @@ -45,8 +45,7 @@ services: - gh_token network_mode: host environment: - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 @@ -81,7 +80,7 @@ services: image: ghcr.io/edge-toolkit/core/et-ws-web-runner:latest container_name: et-math1-cron-math1-trigger labels: - edge-toolkit/scenario: "math1-cron" + edge-toolkit/scenario: math1-cron network_mode: host environment: RUNNER_MODULE: "@edge-toolkit/et-ws-math1-sender" @@ -103,10 +102,10 @@ services: - --docker-filter - label=edge-toolkit/scenario=math1-cron labels: - edge-toolkit/scenario: "math1-cron" + edge-toolkit/scenario: math1-cron ofelia.enabled: "true" - ofelia.job-run.math1-trigger.schedule: "CRON_TZ=UTC * * * * *" - ofelia.job-run.math1-trigger.container: "et-math1-cron-math1-trigger" + ofelia.job-run.math1-trigger.schedule: CRON_TZ=UTC * * * * * + ofelia.job-run.math1-trigger.container: et-math1-cron-math1-trigger ofelia.job-run.math1-trigger.no-overlap: "true" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/verification/published/output/math1-multi/compose.yaml b/verification/published/output/math1-multi/compose.yaml index b42245b3..b2bd0d8e 100644 --- a/verification/published/output/math1-multi/compose.yaml +++ b/verification/published/output/math1-multi/compose.yaml @@ -49,8 +49,7 @@ services: - gh_token network_mode: host environment: - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/published/output/math1/compose.yaml b/verification/published/output/math1/compose.yaml index 4b0d6936..236dcfbd 100644 --- a/verification/published/output/math1/compose.yaml +++ b/verification/published/output/math1/compose.yaml @@ -45,8 +45,7 @@ services: - gh_token network_mode: host environment: - MODULES_PATHS: "/app/modules/web/math1, - /app/modules/web/math1-sender" + MODULES_PATHS: /app/modules/web/math1, /app/modules/web/math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/published/output/pyo3-math1/compose.yaml b/verification/published/output/pyo3-math1/compose.yaml index 5d41117c..ad97358c 100644 --- a/verification/published/output/pyo3-math1/compose.yaml +++ b/verification/published/output/pyo3-math1/compose.yaml @@ -45,8 +45,7 @@ services: - gh_token network_mode: host environment: - MODULES_PATHS: "/app/modules/pyo3/pyo3-math1, - /app/modules/wasi/wasi-math1-sender" + MODULES_PATHS: /app/modules/pyo3/pyo3-math1, /app/modules/wasi/wasi-math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 diff --git a/verification/published/output/wasi-math1/compose.yaml b/verification/published/output/wasi-math1/compose.yaml index 51d22b85..17c9f35b 100644 --- a/verification/published/output/wasi-math1/compose.yaml +++ b/verification/published/output/wasi-math1/compose.yaml @@ -45,8 +45,7 @@ services: - gh_token network_mode: host environment: - MODULES_PATHS: "/app/modules/wasi/wasi-math1, - /app/modules/wasi/wasi-math1-sender" + MODULES_PATHS: /app/modules/wasi/wasi-math1, /app/modules/wasi/wasi-math1-sender MODULES_ROOT: "" OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1