diff --git a/.dockerignore b/.dockerignore index 57f5cad8..3671d9dc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -52,10 +52,11 @@ lcov.info # Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). **/.dart_tool/ services/ws-server/storage/ -# Per-scenario credential file written by `et-cli generate-deployment` beside its mise.toml / compose.yaml. -# Derived from the scenario input, so regenerating a deployment rewrites it; it is the one place the password -# lives, which is what keeps it out of the generated files that ARE committed under verification/. -**/secrets.env +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. **/.git/ **/Dockerfile* README.md diff --git a/.gitignore b/.gitignore index 9483e4e9..38ec2da6 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,8 @@ coverage-python.xml # Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). **/.dart_tool/ services/ws-server/storage/ -# Per-scenario credential file written by `et-cli generate-deployment` beside its mise.toml / compose.yaml. -# Derived from the scenario input, so regenerating a deployment rewrites it; it is the one place the password -# lives, which is what keeps it out of the generated files that ARE committed under verification/. -**/secrets.env +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. diff --git a/.mise/config.maint.toml b/.mise/config.maint.toml index de498677..9e85b302 100644 --- a/.mise/config.maint.toml +++ b/.mise/config.maint.toml @@ -611,32 +611,127 @@ docker run --rm --platform linux/amd64 -v "$mnt" et-mise-linux mise run oci-buil """ shell = "{{ vars.task_shell }}" -# Verify every mise-tools GHCR store is public, so unauthenticated users can pull-mise-tools. +# Publish the hub and runner images to GHCR as multi-arch manifests (maintainer-only). +# A generated k3s deployment names these images rather than building them, so the cluster pulls +# ghcr.io/edge-toolkit/core/et-:latest and only the scenario image -- the one carrying that +# deployment's module set -- is still built by whoever deploys it. Written ONLY from a maintainer's machine, +# for the same reason the tool stores are: no workflow holds packages:write, so a compromised workflow cannot +# replace what a cluster then runs. +# +# Auth is the one-time `docker login ghcr.io` the tool-store tasks above set up; docker pushes here rather +# than crane because buildx is what assembles the manifest list, and it uploads as part of the build. +# +# Each image's runtime stage carries `org.opencontainers.image.source`. GHCR reads that label to attach the +# package to the repository, which is what gives the package page its README and source link and lets it +# inherit the repository's access rather than standing on its own; a package pushed without it arrives +# unlinked and stays that way until a later push supplies one. +# +# Both platforms come from one build by default, so an arm64 node and an amd64 node resolve the same tag. That +# needs a `docker-container` builder -- the default `docker` driver builds a single platform and fails the +# moment two are asked for -- and binfmt registered for the foreign architecture. The foreign leg is emulated, +# and ws-web-runner links V8, so expect that one to take hours rather than minutes; pass image names to publish +# a subset rather than rebuilding all four. +# +# `IMAGE_PLATFORMS` narrows that list for a host that cannot emulate the other architecture. On Apple silicon +# the emulator decides whether it can at all: under Rosetta the x86 build works, while plain QEMU cannot run +# rustc and dies before compiling anything, with +# +# qemu: uncaught target signal 11 (Segmentation fault) - core dumped +# error: process didn't exit successfully: `.../rustc -vV` (signal: 11, SIGSEGV: invalid memory reference) +# +# (observed on a macOS arm64 host running Rancher Desktop with `useRosetta: false`, building ws-server for +# linux/amd64). +# +# Which is why each architecture is pushed under its own `:latest-` tag and `:latest` is then assembled +# from those with `docker buildx imagetools create`. A push of a tag replaces it rather than adding to it, so +# building straight to `:latest` would mean the two halves could only ever come from one host in one run -- +# the amd64 push would drop the arm64 image. Assembling instead lets a host publish the architecture it can +# build and leaves the other half of `:latest` intact, and the assembly reads the registry rather than this +# run, so it picks up an architecture a different machine published months earlier. The cost is one extra tag +# per architecture, which shares every blob with the image it points at. +[tasks.push-service-images] +description = "Build and push the hub and runner images to GHCR for linux/amd64 + linux/arm64 (maintainer-only)" +# docker is a host daemon rather than a mise tool, and these images install nothing through mise. +run = """ +images="${usage_image:-ws-server ws-web-runner ws-wasi-runner ws-pyo3-runner}" +every_platform="linux/amd64,linux/arm64" +platforms="${IMAGE_PLATFORMS:-$every_platform}" +tag=latest +builder=et-service-images +if ! docker buildx inspect "$builder" >/dev/null 2>&1; then + docker buildx create --name "$builder" --driver docker-container --bootstrap >/dev/null +fi +for name in $images; do + dockerfile="services/$name/Dockerfile" + if [ ! -f "$dockerfile" ]; then + echo "push-service-images: no Dockerfile at $dockerfile" >&2 + exit 1 + fi + repo="ghcr.io/edge-toolkit/core/et-$name" + for platform in ${platforms//,/ }; do + arch="${platform##*/}" + echo "push-service-images: $dockerfile -> $repo:$tag-$arch" + args="--builder $builder --platform $platform --file $dockerfile --tag $repo:$tag-$arch --push" + # $args is a word-split flag list by design; do not quote it. + # shellcheck disable=SC2086 + docker buildx build $args . + done + # Rebuild the multi-arch tag from whichever per-architecture tags the registry now holds. + # Every architecture is probed, not just the ones this run built, so a host that can only produce one of + # them still leaves the tag naming both once the other host has published its half. + # + # Mixing revisions across architectures is the accepted cost of that, not an oversight. This is the whole + # reason the per-architecture tags exist: no machine here can build both halves. Apple silicon cannot + # produce the amd64 images at all -- QEMU's x86 emulation cannot run rustc, which dies on `rustc -vV` with + # the SIGSEGV quoted above -- so the two halves are published from different hosts, days apart, and each + # run can only see what the registry already holds. Revision-qualifying the source tags, or withholding + # `latest` until one run has built both, would mean `latest` could never be assembled at all until a single + # host can build both architectures. Until that is true, a `latest` whose halves may differ in revision is + # strictly better than no `latest`, and the per-architecture tags stay available for pinning an exact pair. + # Do not "fix" this by gating on a single revision; that trade has been made deliberately. + sources="" + for platform in ${every_platform//,/ }; do + candidate="$repo:$tag-${platform##*/}" + if docker buildx imagetools inspect "$candidate" >/dev/null 2>&1; then + sources="$sources $candidate" + fi + done + echo "push-service-images: $repo:$tag <-$sources" + # $sources is a word-split list of image references by design; do not quote it. + # shellcheck disable=SC2086 + docker buildx imagetools create --tag "$repo:$tag" $sources +done +""" +shell = "{{ vars.task_shell }}" +usage = 'arg "[image]..." var=#true help="Service directory names to publish (default: the hub and every runner)"' + +# Verify every GHCR package is public, so unauthenticated users can pull a tool store or a service image. # GHCR has NO API to change package visibility -- the packages REST surface is read/delete/restore only, -# and the web UI's toggle posts a session-cookie form -- so flipping a store public is a one-time manual +# and the web UI's toggle posts a session-cookie form -- so flipping a package public is a one-time manual # click. This task automates everything around that click: it lists the org's container packages, prints -# each store's visibility, and fails with the exact settings page URL for any still-private store (flip it -# under Danger Zone -> Change visibility -> Public). Visibility survives later pushes, so each store needs -# this exactly once, right after its first `push-mise-tools*` publish. The listing call needs gh auth with -# the read:packages scope -- grant it once with `gh auth refresh -s read:packages`. -[tasks.check-mise-tools-visibility] +# each one's visibility, and fails with the exact settings page URL for any still private (flip it under +# Danger Zone -> Change visibility -> Public). Visibility survives later pushes, so each package needs this +# exactly once, right after its first publish -- a private service image is what leaves a pod in +# ImagePullBackOff on a cluster holding no credential. The listing call needs gh auth with the read:packages +# scope -- grant it once with `gh auth refresh -s read:packages`. +[tasks.check-ghcr-visibility] depends = ["_setup-store-tools"] -description = "Verify every mise-tools GHCR store is public; print settings URLs for any still private" +description = "Verify every GHCR package is public; print settings URLs for any still private" run = """ gh="$(mise which gh)" jaq="$(mise which jaq)" json=$("$gh" api "/orgs/edge-toolkit/packages?package_type=container" --paginate) -filter='.[] | select(.name | startswith("core/mise-tools/"))' +filter='.[] | select(.name | startswith("core/"))' report='.name + ": " + .visibility' still_private='select(.visibility != "public") | .name | @uri' echo "$json" | "$jaq" -r "$filter | $report" if [ -z "$(echo "$json" | "$jaq" -r "$filter | .name")" ]; then - echo "no mise-tools packages exist yet; publish one with push-mise-tools first" >&2 + echo "no packages exist yet; publish one first" >&2 exit 1 fi private=$(echo "$json" | "$jaq" -r "$filter | $still_private") if [ -z "$private" ]; then - echo "all mise-tools stores are public" + echo "all packages are public" exit 0 fi echo "still private -- make each public at its settings page (Danger Zone -> Change visibility):" >&2 diff --git a/.mise/config.toml b/.mise/config.toml index d63b82e8..d0c00f44 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -439,6 +439,20 @@ url = "https://github.com/edge-toolkit/core/releases/download/augeas-v1/1.14.1-x # LLVM 22.1.8, which is the profraw-10 format minicov 0.3.8 emits. Bump this and minicov together, never one # alone, and check `rustc + --version --verbose` reports an LLVM the minicov release names. rust_nightly = "nightly-2026-08-05" +# The rustup host triple for this machine's arch, used as both the toolchain spec and CARGO_BUILD_TARGET. +# x64 keeps gnullvm: llvm-mingw supplies a full compiler + CRT from mise, so nothing depends on a system MSVC +# install, which is what lets the Nano Server image build at all. arm64 has no such option -- llvm-mingw +# publishes ucrt-x86_64 only, and every other native prerequisite the gnullvm lane leans on (conda:m2-make, +# conda:m2-gnupg, conda:libclang) is x86_64-only in its channel too. The windows-11-arm runner does ship +# Visual Studio with the arm64 MSVC toolset, so msvc is the one triple that can build there today; it is also +# rustup's own host default on that arch, so this asks for exactly the toolchain rustup would pick unaided. +# +# Defined here rather than beside the Windows tools that read it, and that placement is load-bearing. +# `lockfile_platforms` makes any lockfile-writing command resolve the `windows-x64` tools from every host, so +# `config.windows.toml`'s `[tools]` get rendered on macOS and Linux -- where that file is not an active config +# and its own `[vars]` are therefore absent from the context. Declaring it there failed every `mise upgrade` +# that had work to do, with `Field win_rust_host is not defined`; this file is active everywhere. +win_rust_host = "{% if arch() == 'arm64' %}aarch64-pc-windows-msvc{% else %}x86_64-pc-windows-gnullvm{% endif %}" # Tier-`a_` prefix marks leaf values referenced by later vars. # a_* vars are referenced by later vars (face1_src), and mise renders [vars] in alphabetical order; `a_` sorts # before every non-prefixed var name so the reference resolves. Shared GitHub release tag for the HF-asset diff --git a/.mise/config.windows.toml b/.mise/config.windows.toml index 24c76cc6..16eb1bb1 100644 --- a/.mise/config.windows.toml +++ b/.mise/config.windows.toml @@ -101,8 +101,8 @@ url = "https://github.com/edge-toolkit/core/releases/download/gnupg-w32-v1/2.5.2 # error: toolchain 'stable-x86_64-pc-windows-gnullvm' may not be able to run on this system # note: add the `--force-non-host` flag to install the toolchain anyway # -# so the x64 spelling cannot simply be reused on arm64 -- see win_rust_host in [vars] for which triple each -# arch picks and why. CARGO_BUILD_TARGET below tracks the same var, so host and target stay in step. +# so the x64 spelling cannot simply be reused on arm64, which is why the triple comes from `win_rust_host` +# rather than a literal. CARGO_BUILD_TARGET below tracks the same var, so host and target stay in step. [[tools.rust]] components = "clippy,rust-analyzer" profile = "minimal" @@ -206,14 +206,6 @@ gradle_bin = '{{ vars.mise_installs }}\gradle\9.7.0\gradle-9.7.0\bin' # conda:libclang's binary dir, for the LIBCLANG_PATH below. # Pinned to the [tools] version above -- bump both together (conftest's version_drift enforces). win_libclang = '{{ vars.mise_installs }}\conda-libclang\22.1.8\Library\bin' -# The rustup host triple for this machine's arch, used as both the toolchain spec and CARGO_BUILD_TARGET. -# x64 keeps gnullvm: llvm-mingw supplies a full compiler + CRT from mise, so nothing depends on a system MSVC -# install, which is what lets the Nano Server image build at all. arm64 has no such option -- llvm-mingw -# publishes ucrt-x86_64 only, and every other native prerequisite the gnullvm lane leans on (conda:m2-make, -# conda:m2-gnupg, conda:libclang) is x86_64-only in its channel too. The windows-11-arm runner does ship -# Visual Studio with the arm64 MSVC toolset, so msvc is the one triple that can build there today; it is also -# rustup's own host default on that arch, so this asks for exactly the toolchain rustup would pick unaided. -win_rust_host = "{% if arch() == 'arm64' %}aarch64-pc-windows-msvc{% else %}x86_64-pc-windows-gnullvm{% endif %}" # The flat dependency farm inside npm:oxlint-tsgolint's mise install, for the OXLINT_TSGOLINT_PATH entry below. # mise's embedded aube hoists a tool's dependencies rather than nesting them: `\node_modules\` # is a symlink to `.mise\@\node_modules\`, and that package dir holds only its own files @@ -268,7 +260,7 @@ MISE_BASH_PATH = "{{ vars.winsh }}" # both because only cmd.exe has the limit and because oxlint hard-fails on a set-but-invalid value, which an # off-Windows `{% if %}` empty-string render would be. Inert when js isn't in MISE_ENV; just an unused path. OXLINT_TSGOLINT_PATH = '{{ vars.tsgolint_pkg }}\@oxlint-tsgolint\win32-x64\tsgolint.exe' -# Build for the host's own target -- the LLVM mingw one on x64 (see win_rust_host above for the arm64 case). +# Build for the host's own target -- the LLVM mingw one on x64, and msvc on arm64, per `win_rust_host`. # gnullvm, not gnu: llvm-mingw ships compiler-rt + libunwind, not the GCC runtime (libgcc/libgcc_eh) the gnu # target's link line demands. The linker is llvm-mingw's clang and the raw-dylib import tool is its # llvm-dlltool, both given as absolute paths (see win_llvm_bin above) so the cargo: source-build subprocess diff --git a/.mise/mise.js.lock b/.mise/mise.js.lock index 58157758..82ec33e0 100644 --- a/.mise/mise.js.lock +++ b/.mise/mise.js.lock @@ -49,7 +49,7 @@ url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.73.0/oxlint- url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.73.0/oxlint-x86_64-pc-windows-msvc.zip" [[tools."npm:@huggingface/transformers"]] -version = "4.2.0" +version = "4.3.0" backend = "npm:@huggingface/transformers" [[tools."npm:@mediapipe/tasks-vision"]] diff --git a/Cargo.lock b/Cargo.lock index 38ac657e..d500d5e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4402,6 +4402,9 @@ dependencies = [ "opentelemetry-appender-tracing", "opentelemetry-otlp", "opentelemetry_sdk", + "serde", + "serde-env", + "serde_json", "tracing", "tracing-log", "tracing-opentelemetry", @@ -4796,6 +4799,7 @@ dependencies = [ "base64 0.23.1", "command-error", "edge-toolkit", + "et-otlp", "et-rest-client", "et-ws-runner-common", "et-ws-test-server", @@ -4810,7 +4814,6 @@ dependencies = [ "tokio", "tokio-tungstenite", "tracing", - "tracing-subscriber", ] [[package]] @@ -4818,6 +4821,7 @@ name = "et-ws-runner-common" version = "0.1.0" dependencies = [ "edge-toolkit", + "et-otlp", "et-rest-client", "et-test-helpers", "futures-util", @@ -5047,7 +5051,6 @@ dependencies = [ "tokio-tungstenite", "tracing", "tracing-opentelemetry", - "tracing-subscriber", "wasi-webgpu-wasmtime", "wasmtime", "wasmtime-wasi", @@ -5085,6 +5088,7 @@ dependencies = [ "deno_resolver", "deno_runtime", "edge-toolkit", + "et-otlp", "et-rest-client", "et-ws-runner-common", "et-ws-test-server", @@ -5099,7 +5103,6 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tracing", - "tracing-subscriber", "winapi", ] diff --git a/Cargo.toml b/Cargo.toml index 754f2078..45bd9533 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -413,9 +413,17 @@ ignore = [ # Unmaintained upstream at jethrogb/rust-cexpr. # Pulled by bindgen (build-time only via aws-lc-sys / ort-sys). "cexpr", + # Archived upstream at rust-lang/cfg-if, and the newer release the scanner points at is archived too. + # 1.0.4 is current, and the crate is a leaf of most of the graph; there is nothing to move to. + "cfg-if", # Unmaintained upstream at dajoha/color-print. # Pulled by deno_node via color-print. "color-print-proc-macro", + # Upstream 9999years/command-error has been quiet for a year, which is what trips the age heuristic. + # The scanner also notes its `process-wrap ^8` against a current 10. 0.8.0 is the latest release, so there + # is no upgrade to take, and this is a direct dependency the repo adopts deliberately -- a lint rule of its + # own holds every `Command` to it. + "command-error", # Archived upstream at matklad/countme. "countme", # Unmaintained upstream at denoland/deno_native_certs. diff --git a/Dockerfile b/Dockerfile index 37be5559..891c0693 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,8 @@ ARG BASE_IMAGE=ubuntu:24.04 FROM ${BASE_IMAGE} AS build-minimal +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + # Universal prereqs a typical dev box already has; everything else is mise's job. # gcc, g++, libc6-dev and make are the C/C++ toolchain rustc links through (`cc`) # and that C/C++ `-sys` crates build with (make for build scripts that shell out diff --git a/Dockerfile.nanoserver b/Dockerfile.nanoserver index 3a26c38c..8e81bfe9 100644 --- a/Dockerfile.nanoserver +++ b/Dockerfile.nanoserver @@ -135,6 +135,7 @@ RUN curl -fsSL -o git.zip %GIT_URL% && ` # (VC++ DLLs, mise.exe, TEMP) and the gnu-host rust flip live here too, # since every later stage builds on it. FROM mcr.microsoft.com/windows/nanoserver:${WINDOWS_VERSION} AS build-minimal +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" COPY --from=vcruntime C:\Windows\System32\vcruntime140.dll C:\Windows\System32\vcruntime140.dll COPY --from=vcruntime C:\Windows\System32\vcruntime140_1.dll C:\Windows\System32\vcruntime140_1.dll COPY --from=vcruntime C:\Windows\System32\msvcp140.dll C:\Windows\System32\msvcp140.dll diff --git a/Dockerfile.windows b/Dockerfile.windows index bf920664..3335e4c7 100644 --- a/Dockerfile.windows +++ b/Dockerfile.windows @@ -32,6 +32,7 @@ ARG WINDOWS_VERSION=ltsc2022 # stages mise.exe + the gh_token from the build context, trusts the project # config, and runs `mise install` against the always-loaded `[tools]` set. FROM mcr.microsoft.com/windows/servercore:${WINDOWS_VERSION} AS build-minimal +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" SHELL ["cmd", "/S", "/C"] # DL3066 wants a numeric user-id, which Windows containers have no equivalent of. # Identities are SIDs behind well-known account names; ContainerAdministrator is the canonical one. diff --git a/config/generated-trees.toml b/config/generated-trees.toml index 48f681fe..ca0b1b65 100644 --- a/config/generated-trees.toml +++ b/config/generated-trees.toml @@ -81,12 +81,4 @@ required_in = [ "config/semgrep/no-non-ascii.yaml", "config/semgrep/no-task-name-mentions.yaml", "config/semgrep/no-todo.yaml", - "config/semgrep/no-trailing-backslash.yaml", ] - -# Configs that deliberately skip only part of a tree, with the exact globs they carry instead. -# Recorded rather than waved through, so narrowing stays a visible decision: the trailing-backslash ban only has -# generated output to forgive in the two deployment artifacts regen-verification writes, and keeping the rest of -# the tree in scope is the point. -[tree.verification.narrower] -"config/semgrep/no-trailing-backslash.yaml" = ["/verification/**/compose.yaml", "/verification/**/mise.toml"] diff --git a/config/hadolint.yaml b/config/hadolint.yaml index 31f731cf..f01345f8 100644 --- a/config/hadolint.yaml +++ b/config/hadolint.yaml @@ -1,6 +1,23 @@ # hadolint config. # Each entry below is a best-practice rule we consciously skip, with the reason; # drop one once it no longer applies. + +# Every Dockerfile has to declare where its image came from. +# GHCR reads `org.opencontainers.image.source` to attach a pushed package to the repository, and a package that +# arrives without it stays unlinked until some later push supplies one -- which is a thing nobody notices until +# they are looking at a package page with no source. Requiring it of every Dockerfile rather than only the ones +# published today means an image that starts being published later cannot arrive unlabelled. A `LABEL` in the +# first stage is enough, since a derived image inherits its parent's labels. +# +# DL3049 is `info` by severity and hadolint fails on `info` already, so the override buys no strictness today. +# It is here to say the level is deliberate: a future `failure-threshold` that forgives `info` would otherwise +# turn this requirement off silently. +label-schema: + org.opencontainers.image.source: url +override: + error: + - DL3049 + ignored: # Pinning apt package versions is brittle. # They float with the Ubuntu base image + security updates, so an exact pin breaks on every base bump. We install diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json index 28c055be..cc450ede 100644 --- a/config/jscpd-baseline.json +++ b/config/jscpd-baseline.json @@ -69,7 +69,6 @@ "3369118fac56660d": 1, "34b2a7c1a1a932c1": 1, "34cd0de3c97e4264": 1, - "35ac92bba9a699ae": 1, "35d7f304546de8a8": 1, "389aea3e8662fb9b": 1, "3a075ed2f78fa99e": 1, @@ -90,6 +89,7 @@ "461eb8f3a4cf80d3": 1, "468a45c2019ca4cd": 1, "4a5b2cd87f30b9d3": 1, + "4ab1b3a095eeb338": 1, "4eff6767e2aba30c": 1, "506af004dd69d586": 1, "50b373907a5a8f78": 1, @@ -270,7 +270,6 @@ "d7ad53a1ef1dc59c": 1, "d8a0ad2f6e337e91": 1, "da8f06b38d34738e": 1, - "db6cec2e60d3687f": 1, "dd56a7d95c286b3e": 1, "e25456ad9cc7f9f9": 1, "e4b9e7313fc22777": 1, diff --git a/config/semgrep/no-task-name-mentions.yaml b/config/semgrep/no-task-name-mentions.yaml index ade60c21..5352a83d 100644 --- a/config/semgrep/no-task-name-mentions.yaml +++ b/config/semgrep/no-task-name-mentions.yaml @@ -153,8 +153,8 @@ rules: (?ix)(?- Do not name an install, prefetch or asset-publishing task outside the jobs, images and asset tables that invoke it. These run on a maintainer's machine or a runner, never from the code, so a source file naming @@ -218,7 +218,7 @@ rules: pattern-regex: >- (?ix)(?, - pub agents: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct Agent { - pub name: String, - /// Runner that executes this agent's modules, from `runner:`. - /// - /// Unset means the modules are only served, for a browser to load and run itself -- which is what every - /// deployment did before this field existed. Set, the generated deployment also starts a runner process per - /// resource, so the cluster runs headless. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub runner: Option, - pub resources: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct Resource { - #[serde(rename = "type")] - pub resource_type: String, -} diff --git a/libs/edge-toolkit/src/lib.rs b/libs/edge-toolkit/src/lib.rs index 7f91195f..b8c16ce9 100644 --- a/libs/edge-toolkit/src/lib.rs +++ b/libs/edge-toolkit/src/lib.rs @@ -1,7 +1,6 @@ pub mod args; pub mod auth; pub mod config; -pub mod input; pub mod ports; pub mod ws; pub mod ws_server; diff --git a/libs/et-otlp/Cargo.toml b/libs/et-otlp/Cargo.toml index 2386cb45..69244cc2 100644 --- a/libs/et-otlp/Cargo.toml +++ b/libs/et-otlp/Cargo.toml @@ -18,6 +18,8 @@ opentelemetry.workspace = true opentelemetry-appender-tracing.workspace = true opentelemetry-otlp.workspace = true opentelemetry_sdk.workspace = true +serde.workspace = true +serde-env.workspace = true tracing.workspace = true tracing-log.workspace = true tracing-opentelemetry.workspace = true @@ -25,6 +27,7 @@ tracing-subscriber.workspace = true [dev-dependencies] fs-err.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/libs/et-otlp/src/lib.rs b/libs/et-otlp/src/lib.rs index ee8ab791..b4ad0c44 100644 --- a/libs/et-otlp/src/lib.rs +++ b/libs/et-otlp/src/lib.rs @@ -79,6 +79,84 @@ pub fn service_descriptors(hostname: Option) -> Vec { descriptors } +/// Telemetry for the life of a scope, flushed when it ends however it ends. +/// +/// [`OtelHandles`] has to be shut down explicitly, which every binary holding one has to remember to do on +/// every exit path -- and the paths that matter most are the failing ones, whose spans are exactly what the +/// batch exporter is still holding. Tying the flush to a drop makes forgetting impossible and leaves `main` +/// free to use `?` again. +pub struct TelemetryGuard(Option); + +impl Drop for TelemetryGuard { + fn drop(&mut self) { + if let Some(handles) = self.0.take() { + handles.shutdown(); + } + } +} + +/// Initialise telemetry as [`init_or_stderr`] does, flushing it when the returned guard drops. +/// +/// # Errors +/// +/// Returns whatever [`init`] does; the fallback path cannot fail. +pub fn init_guarded(config: Option<&OtlpConfig>) -> Result> { + init_or_stderr(config).map(TelemetryGuard) +} + +/// A config that says where its process exports telemetry. +/// +/// Exists so [`load_telemetered`] can read a config and start its pipeline in one step, whatever the rest of +/// that config holds. +pub trait Telemetered { + /// Where this process exports telemetry, or `None` to log to stderr. + fn otlp(&self) -> Option<&OtlpConfig>; +} + +/// Read a config from the environment and start the telemetry it describes. +/// +/// The two belong together: the pipeline is configured by what was just read, and its guard has to outlive +/// the run so the exporter's last batch is flushed. One call is what stops a binary loading a config and +/// then forgetting the half that makes its spans reach anything -- which is how two of this project's three +/// runners came to have no telemetry at all. +/// +/// # Errors +/// +/// Returns the deserialisation error if the environment does not describe a valid config, or whatever +/// starting the pipeline reports. +pub fn load_telemetered() -> Result<(C, TelemetryGuard), Box> +where + C: serde::de::DeserializeOwned + Telemetered, +{ + let config = serde_env::from_env::()?; + let telemetry = init_guarded(config.otlp())?; + Ok((config, telemetry)) +} + +/// Initialise telemetry from `config`, falling back to plain stderr logging when there is none. +/// +/// Every runner faces the same choice -- a deployment that configured `OTLP_*` wants the pipeline, one that +/// did not still wants its logs somewhere -- and having each binary spell it out is how they drift: two of +/// the three runners silently had no telemetry at all until this existed, and nothing said so. +/// +/// Returning the handles rather than installing an exit hook keeps the flush the caller's to place. It has to +/// happen before the process ends and after the work does, and only the caller knows where that is. +/// +/// # Errors +/// +/// Returns whatever [`init`] does; the fallback path cannot fail. +pub fn init_or_stderr( + config: Option<&OtlpConfig>, +) -> Result, Box> { + let Some(config) = config else { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + return Ok(None); + }; + init(config).map(Some) +} + /// Initialise the global tracing subscriber + `OTel` pipeline against `config`. /// /// Call exactly once per process; a second call returns an error from diff --git a/libs/et-otlp/tests/init_or_stderr.rs b/libs/et-otlp/tests/init_or_stderr.rs new file mode 100644 index 00000000..de0bda97 --- /dev/null +++ b/libs/et-otlp/tests/init_or_stderr.rs @@ -0,0 +1,43 @@ +//! Covers both answers `init_or_stderr` can give, which no single process could otherwise observe. +//! +//! Each arm installs process-global state -- a plain subscriber on one side, the whole `OTel` pipeline on the +//! other -- so a second call in the same process fails whichever arm it takes. The two live in separate tests +//! for that reason and no other: the runner harness gives each test its own process, which is what makes the +//! pair observable at all. +//! +//! What they protect is the decision, not the pipeline. A runner whose deployment configured `OTLP_*` and +//! silently got stderr logging looks healthy and reports nothing, which is how two of the three runners went +//! without telemetry unnoticed. +#![cfg(test)] + +use edge_toolkit::config::OtlpConfig; +use et_otlp::init_or_stderr; + +/// A collector that is not listening, which the exporters accept because they connect lazily. +/// +/// Deserialised rather than built as a literal, both because the type is `#[non_exhaustive]` and because +/// that is how a runner obtains one: every field left out is a default a deployment would also inherit. +const UNREACHABLE_COLLECTOR: &str = r#"{"collector_url": "http://127.0.0.1:1/api/default/v1"}"#; + +#[test] +fn no_config_falls_back_to_stderr_and_hands_back_no_handles() { + // The absence of handles is the observable: it is what tells a caller there is nothing to flush. + let handles = init_or_stderr(None).unwrap(); + + assert!(handles.is_none()); +} + +#[test] +fn a_config_builds_the_pipeline_and_hands_back_handles_to_flush() { + // Nothing here exports: the test asserts a pipeline was built and torn down, and reaching a collector is + // the business of the end-to-end tests that run a real one. + let config: OtlpConfig = serde_json::from_str(UNREACHABLE_COLLECTOR).unwrap(); + + let handles = init_or_stderr(Some(&config)).unwrap(); + + let Some(handles) = handles else { + panic!("a configured runner must get handles back, or its spans are never flushed"); + }; + // Shutting down is the other half of the contract, and it is what every runner's exit path calls. + handles.shutdown(); +} diff --git a/libs/ws-runner-common/Cargo.toml b/libs/ws-runner-common/Cargo.toml index b39529fe..e56ed487 100644 --- a/libs/ws-runner-common/Cargo.toml +++ b/libs/ws-runner-common/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" [dependencies] edge-toolkit.workspace = true +et-otlp.workspace = true et-rest-client.workspace = true futures-util.workspace = true humantime-serde.workspace = true diff --git a/libs/ws-runner-common/src/config.rs b/libs/ws-runner-common/src/config.rs index b34bde7b..4d08c755 100644 --- a/libs/ws-runner-common/src/config.rs +++ b/libs/ws-runner-common/src/config.rs @@ -11,6 +11,48 @@ use serde::Deserialize; use serde_default::DefaultFromSerde; use serde_inline_default::serde_inline_default; +/// Declare a runner's `Config`, with the fields every runner reads supplied and its own added after. +/// +/// A macro rather than a shared struct the runners nest or flatten. Nesting changes the variable names +/// serde-env derives, and `#[serde(flatten)]` cannot rebuild these: serde-env buffers a flattened value as a +/// string, so the inner struct fails with `invalid type: string "", expected struct RunnerConfig`. Expanding +/// the fields leaves each runner deserialising exactly as it did while declaring them once. +/// +/// What that buys is the drift: all three runners read the same three groups -- which module, which hub, +/// where telemetry goes -- and while each spelled them out, two of them silently had no `otlp` at all. +/// +/// The expansion names `::et_otlp` and `::edge_toolkit`, so a crate invoking this needs both as direct +/// dependencies even where its own code mentions neither. Dropping one as unused fails at the call site +/// with `cannot find et_otlp in the crate root`, pointing at the macro rather than at the manifest. +#[macro_export] +macro_rules! runner_config { + ( + $(#[$struct_meta:meta])* + $vis:vis struct $name:ident { $($(#[$field_meta:meta])* $field_vis:vis $field:ident : $ty:ty),* $(,)? } + ) => { + $(#[$struct_meta])* + #[derive(Clone, Debug, ::serde::Deserialize)] + #[non_exhaustive] + $vis struct $name { + /// `RUNNER_*` settings (`RUNNER_MODULE`, `RUNNER_TIMEOUT`). + pub runner: $crate::config::RunnerConfig, + /// `WS_*` settings (`WS_SERVER_URL`). + #[serde(default)] + pub ws: $crate::config::WsConfig, + /// `OTLP_*` settings; `None` logs to stderr instead of exporting. + #[serde(default)] + pub otlp: ::core::option::Option<::edge_toolkit::config::OtlpConfig>, + $($(#[$field_meta])* $field_vis $field : $ty,)* + } + + impl ::et_otlp::Telemetered for $name { + fn otlp(&self) -> ::core::option::Option<&::edge_toolkit::config::OtlpConfig> { + self.otlp.as_ref() + } + } + }; +} + /// Shared `RUNNER_*` settings for both native runners. #[derive(Clone, Debug, Deserialize)] #[non_exhaustive] diff --git a/services/ws-pyo3-runner/Cargo.toml b/services/ws-pyo3-runner/Cargo.toml index efe2ed39..6d6968fd 100644 --- a/services/ws-pyo3-runner/Cargo.toml +++ b/services/ws-pyo3-runner/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" [dependencies] edge-toolkit.workspace = true +et-otlp.workspace = true # Typed ws-server REST client (storage GET/PUT). # `tracing` injects the W3C traceparent on each request, matching the other native runners. et-rest-client = { workspace = true, features = ["tracing"] } @@ -32,7 +33,6 @@ thiserror.workspace = true tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio-tungstenite = { workspace = true, features = ["connect"] } tracing.workspace = true -tracing-subscriber.workspace = true [build-dependencies] pyo3-build-config.workspace = true diff --git a/services/ws-pyo3-runner/Dockerfile b/services/ws-pyo3-runner/Dockerfile index e27f6295..b3a679d4 100644 --- a/services/ws-pyo3-runner/Dockerfile +++ b/services/ws-pyo3-runner/Dockerfile @@ -104,6 +104,8 @@ EOF FROM debian:bookworm-slim AS runtime +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + # ca-certificates supplies the trust store for a wss:// hub and for an https OTLP collector. # The unprivileged `app` account owns everything the runner writes. RUN bash <<'EOF' diff --git a/services/ws-pyo3-runner/src/config.rs b/services/ws-pyo3-runner/src/config.rs index f0c0486e..cbfff819 100644 --- a/services/ws-pyo3-runner/src/config.rs +++ b/services/ws-pyo3-runner/src/config.rs @@ -1,33 +1,29 @@ //! Environment-driven configuration for the pyo3 runner. //! -//! Deserialised from the process environment via `serde-env`. The `RUNNER_*` -//! and `WS_*` vars are parsed by the shared [`RunnerConfig`] / [`WsConfig`] -//! structs from `et-ws-runner-common` (so this runner reads the same vars as -//! the WASI / web runners); `PYO3_*` populates the runner-specific bits. +//! Deserialised from the process environment via `serde-env`. The fields every +//! runner reads come from [`et_ws_runner_common::runner_config`], so this runner +//! reads the same vars as the WASI and web runners; `PYO3_*` populates the +//! runner-specific bits written out below. //! //! `RUNNER_MODULE` (required) -- Python module name to import. //! `RUNNER_TIMEOUT` (optional) -- wall-clock run limit, e.g. `120s`, `3m`. //! `WS_SERVER_URL` (optional) -- defaults to the local insecure ws port. +//! `OTLP_*` (optional) -- populates `OtlpConfig`; absent logs to stderr. //! `PYO3_PYTHONPATH` (optional) -- colon-separated paths prepended to `sys.path`. //! `PYO3_AGENT_ID` (optional) -- request this `agent_id` on connect. use std::path::PathBuf; -use et_ws_runner_common::config::{RunnerConfig, WsConfig}; +use et_ws_runner_common::runner_config; use serde::Deserialize; -/// Configuration for the pyo3 runner, sourced from the environment. -#[derive(Clone, Debug, Deserialize)] -#[non_exhaustive] -pub struct Config { - /// `RUNNER_*` settings (`RUNNER_MODULE`, `RUNNER_TIMEOUT`). - pub runner: RunnerConfig, - /// `WS_*` settings (`WS_SERVER_URL`). - #[serde(default)] - pub ws: WsConfig, - /// `PYO3_*` settings unique to this runner. - #[serde(default)] - pub pyo3: Pyo3Config, +runner_config! { + /// Configuration for the pyo3 runner, sourced from the environment. + pub struct Config { + /// `PYO3_*` settings unique to this runner. + #[serde(default)] + pub pyo3: Pyo3Config, + } } /// Runner-specific `PYO3_*` settings with no shared equivalent. diff --git a/services/ws-pyo3-runner/src/main.rs b/services/ws-pyo3-runner/src/main.rs index 99421165..ff7ef3f5 100644 --- a/services/ws-pyo3-runner/src/main.rs +++ b/services/ws-pyo3-runner/src/main.rs @@ -20,12 +20,9 @@ use tracing::info; // thread on a oneshot reply; the runtime's worker threads keep driving the // storage task and the WS loop so those replies resolve while Python waits. #[tokio::main] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) - .init(); +async fn main() -> Result<(), Box> { + let (config, _telemetry) = et_otlp::load_telemetered::()?; - let config = serde_env::from_env::()?; let module = config.runner.module.clone(); let python_path = config.pyo3.python_path(); let ws_url = config.ws.server_url.clone(); diff --git a/services/ws-server/Dockerfile b/services/ws-server/Dockerfile index 0a98ecbc..3af92d6a 100644 --- a/services/ws-server/Dockerfile +++ b/services/ws-server/Dockerfile @@ -100,6 +100,8 @@ EOF FROM debian:bookworm-slim AS runtime +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + # ca-certificates supplies the trust store the OTLP exporter needs for an https collector. # curl is here so a deployment can health-check the hub, and for nothing else. The base image ships no HTTP client # of any kind, so without it a compose stack has no way to ask whether the hub is serving yet -- and anything that diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index 2d4fa182..3ea473ec 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -60,7 +60,6 @@ tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "syn tokio-tungstenite = { workspace = true, features = ["connect"] } tracing.workspace = true tracing-opentelemetry.workspace = true -tracing-subscriber.workspace = true wasmtime.workspace = true wasmtime-wasi.workspace = true # wasi-nn standardised ML inference. diff --git a/services/ws-wasi-runner/Dockerfile b/services/ws-wasi-runner/Dockerfile index 7e47ad43..b72e8491 100644 --- a/services/ws-wasi-runner/Dockerfile +++ b/services/ws-wasi-runner/Dockerfile @@ -86,6 +86,8 @@ EOF FROM debian:bookworm-slim AS runtime +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + # ca-certificates supplies the trust store for a wss:// hub and for an https OTLP collector. # libvulkan1 is the loader and mesa-vulkan-drivers carries lavapipe, the software rasteriser wgpu falls back to # when the container has no GPU. The unprivileged `app` account owns everything the runner writes. diff --git a/services/ws-wasi-runner/src/config.rs b/services/ws-wasi-runner/src/config.rs index 12af2e3b..5612d52e 100644 --- a/services/ws-wasi-runner/src/config.rs +++ b/services/ws-wasi-runner/src/config.rs @@ -1,25 +1,15 @@ //! Environment-driven configuration for the WASI runner. //! -//! Deserialised from the process environment via `serde-env`. The `RUNNER_*` -//! and `WS_*` vars are parsed by the shared [`RunnerConfig`] / [`WsConfig`] -//! structs from `et-ws-runner-common`; `OTLP_*` populates [`OtlpConfig`]. +//! Deserialised from the process environment via `serde-env`. The fields every +//! runner reads come from [`et_ws_runner_common::runner_config`], which supplies +//! the `RUNNER_*`, `WS_*` and `OTLP_*` groups; what is written out below is only +//! what this runner adds to them. -use edge_toolkit::config::OtlpConfig; -use et_ws_runner_common::config::{RunnerConfig, WsConfig}; -use serde::Deserialize; +use et_ws_runner_common::runner_config; +runner_config! { /// WASI-runner configuration sourced from the environment. -#[derive(Clone, Debug, Deserialize)] -#[non_exhaustive] pub struct Config { - /// `RUNNER_*` settings (`RUNNER_MODULE`, `RUNNER_TIMEOUT`). - pub runner: RunnerConfig, - /// `WS_*` settings (`WS_SERVER_URL`). - #[serde(default)] - pub ws: WsConfig, - /// OpenTelemetry config, from the `OTLP_*` env vars; `None` logs to stderr. - #[serde(default)] - pub otlp: Option, /// Runtime activation of the guest-coverage preopen, read from `ET_TEST_COVERAGE`. /// /// Only present under the `coverage` cargo feature -- the preopen code is compiled in only there. When the @@ -30,3 +20,4 @@ pub struct Config { #[serde(default)] pub et_test_coverage: bool, } +} diff --git a/services/ws-wasi-runner/src/main.rs b/services/ws-wasi-runner/src/main.rs index d21d2a41..531b753b 100644 --- a/services/ws-wasi-runner/src/main.rs +++ b/services/ws-wasi-runner/src/main.rs @@ -4,16 +4,8 @@ use tracing::info; #[tokio::main] async fn main() -> Result<(), Box> { - let config = serde_env::from_env::()?; - - let otel_handles = if let Some(otlp_config) = &config.otlp { - Some(et_otlp::init(otlp_config)?) - } else { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) - .init(); - None - }; + // The guard is named rather than discarded because this runner has an exit that runs no destructors. + let (config, telemetry) = et_otlp::load_telemetered::()?; let module = &config.runner.module; let ws_url = &config.ws.server_url; @@ -33,17 +25,13 @@ async fn main() -> Result<(), Box> { Some(run.await) }; - // Flush before exit so the mock OTLP collector sees the spans we emitted - // -- `BatchExporter` would otherwise drop the tail when the process exits. - if let Some(handles) = otel_handles { - handles.shutdown(); - } - let Some(result) = outcome else { return Err(format!("module {module} timed out after {:?}", timeout.unwrap_or_default()).into()); }; result?; info!("module {module} completed successfully"); + // Flushed here rather than left to the drop, because `_exit` below runs no destructors at all. + drop(telemetry); // macOS test-only fast exit. ORT 1.22's threadpool unwind races libc++ at // process exit on macOS, with this exact stderr at the crash site: // diff --git a/services/ws-web-runner/Cargo.toml b/services/ws-web-runner/Cargo.toml index fb0ebba7..3851ec30 100644 --- a/services/ws-web-runner/Cargo.toml +++ b/services/ws-web-runner/Cargo.toml @@ -23,6 +23,7 @@ deno_error.workspace = true deno_resolver.workspace = true deno_runtime.workspace = true edge-toolkit.workspace = true +et-otlp.workspace = true et-rest-client = { workspace = true, features = ["tracing"] } et-ws-runner-common.workspace = true futures-util.workspace = true @@ -36,7 +37,6 @@ sys_traits.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tracing.workspace = true -tracing-subscriber.workspace = true # Depend on winapi with `std` here so cargo feature unification turns it on for the shared winapi. # deno_io (pulled via deno_runtime) declares `winapi` without its `std` feature, diff --git a/services/ws-web-runner/Dockerfile b/services/ws-web-runner/Dockerfile index 08bfc62f..25ea6c76 100644 --- a/services/ws-web-runner/Dockerfile +++ b/services/ws-web-runner/Dockerfile @@ -54,6 +54,8 @@ EOF FROM debian:bookworm-slim AS runtime +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + # ca-certificates supplies the trust store for a wss:// hub and for an https OTLP collector. # The unprivileged `app` account owns everything the runner writes. RUN bash <<'EOF' diff --git a/services/ws-web-runner/src/config.rs b/services/ws-web-runner/src/config.rs index 5c482dd7..3c1595ba 100644 --- a/services/ws-web-runner/src/config.rs +++ b/services/ws-web-runner/src/config.rs @@ -1,20 +1,14 @@ //! Environment-driven configuration for the web runner. //! -//! Deserialised from the process environment via `serde-env`. The `RUNNER_*` and `WS_*` vars are parsed by the shared -//! [`RunnerConfig`] / [`WsConfig`] structs from `et-ws-runner-common`. +//! Deserialised from the process environment via `serde-env`. The fields every runner reads come from +//! [`et_ws_runner_common::runner_config`], which supplies the `RUNNER_*`, `WS_*` and `OTLP_*` groups; what is +//! written out below is only what this runner adds to them. -use et_ws_runner_common::config::{RunnerConfig, WsConfig}; -use serde::Deserialize; +use et_ws_runner_common::runner_config; +runner_config! { /// Web-runner configuration sourced from the environment. -#[derive(Clone, Debug, Deserialize)] -#[non_exhaustive] pub struct Config { - /// `RUNNER_*` settings (`RUNNER_MODULE`, `RUNNER_TIMEOUT`). - pub runner: RunnerConfig, - /// `WS_*` settings (`WS_SERVER_URL`). - #[serde(default)] - pub ws: WsConfig, /// Optional V8 flags from `V8_FLAGS`, applied via `v8::V8::set_flags_from_string` before the runtime initialises. /// /// Used to select the WASM compile tier when debugging the gnullvm dotnet-data1 crash (e.g. `--no-liftoff`, @@ -31,3 +25,4 @@ pub struct Config { #[serde(default)] pub et_test_coverage: bool, } +} diff --git a/services/ws-web-runner/src/main.rs b/services/ws-web-runner/src/main.rs index 37ab57d3..cf682708 100644 --- a/services/ws-web-runner/src/main.rs +++ b/services/ws-web-runner/src/main.rs @@ -3,23 +3,13 @@ use et_ws_web_runner::run_module; use tracing::info; #[tokio::main(flavor = "current_thread")] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) - .init(); +async fn main() -> Result<(), Box> { + let (config, _telemetry) = et_otlp::load_telemetered::()?; - let config = serde_env::from_env::()?; let module = &config.runner.module; let ws_url = &config.ws.server_url; - // Inject V8 flags from V8_FLAGS (config.v8_flags) before any JsRuntime touches V8 -- - // set_flags_from_string is a no-op once V8 has initialised. Used to bisect the gnullvm WASM crash in - // dotnet-data1 by selecting the WASM compile tier (e.g. --no-liftoff / --liftoff-only / --jitless). - let v8_flags = config.v8_flags.as_deref().unwrap_or_default(); - if !v8_flags.is_empty() { - deno_core::v8::V8::set_flags_from_string(v8_flags); - info!("applied V8_FLAGS: {v8_flags}"); - } + apply_v8_flags(&config); // Coverage capture exists only under the `coverage` feature; there `ET_TEST_COVERAGE` activates it. #[cfg(feature = "coverage")] @@ -42,3 +32,20 @@ async fn main() -> Result<(), Box> { info!("module {module} completed successfully"); Ok(()) } + +/// Inject `V8_FLAGS` before any `JsRuntime` touches V8. +/// +/// The ordering is load-bearing: `set_flags_from_string` is a no-op once V8 has initialised. Used to bisect +/// the gnullvm WASM crash in dotnet-data1 by selecting the WASM compile tier (e.g. `--no-liftoff`, +/// `--liftoff-only`, `--jitless`). +#[expect( + clippy::single_call_fn, + reason = "a distinct step that must run before the runtime starts; named so its ordering is legible" +)] +fn apply_v8_flags(config: &Config) { + let v8_flags = config.v8_flags.as_deref().unwrap_or_default(); + if !v8_flags.is_empty() { + deno_core::v8::V8::set_flags_from_string(v8_flags); + info!("applied V8_FLAGS: {v8_flags}"); + } +} diff --git a/utilities/cli/src/deployment_types/docker_compose.rs b/utilities/cli/src/deployment_types/docker_compose.rs index 46423a50..0bd05e2e 100644 --- a/utilities/cli/src/deployment_types/docker_compose.rs +++ b/utilities/cli/src/deployment_types/docker_compose.rs @@ -1,13 +1,13 @@ use std::path::Path; -use edge_toolkit::input::ClusterInput; use et_path::{absolute_from, relative_path_from}; use fs_err as fs; use crate::error::CliError; +use crate::input::{ArtifactSource, ClusterInput}; use crate::{ - OutputType, RunnerInstance, SECRETS_ENV_FILE, cluster_module_names, hub_http_base, hub_ws_url, module_registry, - resolve_cluster_runners, resolve_module_paths, + COLLECTOR_SETTINGS, COLLECTOR_USERNAME, IMAGE_REGISTRY, OutputType, RunnerInstance, SECRETS_ENV_FILE, + cluster_module_names, hub_http_base, hub_ws_url, module_registry, resolve_cluster_runners, resolve_module_paths, }; pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<(), CliError> { @@ -18,12 +18,20 @@ pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &P // `dockerfile` is resolved against the build context, not against this compose file, so the scenario image // is named by its path down from the repository root rather than as a sibling of the compose file. let scenario_dockerfile_rel = format!("{}/Dockerfile", relative_path_from(&workspace_root, &output_abs)); - let openobserve_env_file_rel = relative_path_from(&output_abs, &workspace_root.join("config/o2.env")); let module_names = cluster_module_names(cluster); let module_paths = docker_image_module_paths(&module_names)?; - let mut services = vec![ - ("openobserve".to_string(), openobserve_service(openobserve_env_file_rel)), - ( + let artifacts = cluster.artifact_source; + let published = matches!(artifacts, ArtifactSource::Published); + let mut services = vec![("openobserve".to_string(), openobserve_service())]; + // A local scenario builds the hub it layers onto; a published one takes the released image instead. + // The build-only service exists solely to be that named context, so it is absent when nothing is built. + // `scale: 0` is what keeps `docker compose up` from creating a second, module-less container; a + // `profiles:` entry would instead hide the service from the build resolver, which fails the `service:` + // reference below with "declares unknown service". + let hub_context = if published { + format!("docker-image://{IMAGE_REGISTRY}/et-ws-server:latest") + } else { + services.push(( "ws-server-hub".to_string(), ComposeService { build: Some(ComposeBuild { @@ -31,73 +39,75 @@ pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &P dockerfile: "services/ws-server/Dockerfile".to_string(), additional_contexts: Vec::new(), }), - // Build-only: `ws-server` layers this cluster's modules onto it, and nothing runs it directly. - // `scale: 0` is what keeps `docker compose up` from creating a second, module-less container; - // a `profiles:` entry would instead hide the service from the build resolver, which fails the - // `service:` reference below with "declares unknown service". scale: Some(0), ..ComposeService::default() }, - ), - ( - "ws-server".to_string(), - ComposeService { - build: Some(ComposeBuild { - context: workspace_rel, - dockerfile: scenario_dockerfile_rel, - additional_contexts: vec![("hub".to_string(), "service:ws-server-hub".to_string())], - }), - network_mode: Some("host".to_string()), - // Carries `OTLP_AUTH_PASSWORD` and `OTLP_AUTH_USERNAME`: the server authenticates its OTLP - // exports against the same root credential the collector above was started with. - env_file: vec![SECRETS_ENV_FILE.to_string()], - environment: vec![ - ( - "MODULES_PATHS".to_string(), - ComposeValue::WrappedDoubleQuoted(module_paths), - ), - ( - "OTLP_COLLECTOR_URL".to_string(), - ComposeValue::Plain("http://127.0.0.1:5080/api/default/v1".to_string()), - ), - ( - "STORAGE_URL".to_string(), - ComposeValue::Plain("file:///app/storage".to_string()), - ), + )); + "service:ws-server-hub".to_string() + }; + services.push(( + "ws-server".to_string(), + ComposeService { + build: Some(ComposeBuild { + context: workspace_rel, + dockerfile: scenario_dockerfile_rel, + additional_contexts: vec![("hub".to_string(), hub_context)], + }), + network_mode: Some("host".to_string()), + // Carries `OTLP_AUTH_PASSWORD`: the server authenticates its OTLP exports against the same root + // credential the collector above was started with. The account it authenticates as is below. + env_file: vec![SECRETS_ENV_FILE.to_string()], + environment: vec![ + ( + "MODULES_PATHS".to_string(), + ComposeValue::WrappedDoubleQuoted(module_paths), + ), + ( + "OTLP_AUTH_USERNAME".to_string(), + ComposeValue::Plain(COLLECTOR_USERNAME.to_string()), + ), + ( + "OTLP_COLLECTOR_URL".to_string(), + ComposeValue::Plain("http://127.0.0.1:5080/api/default/v1".to_string()), + ), + ( + "STORAGE_URL".to_string(), + ComposeValue::Plain("file:///app/storage".to_string()), + ), + ], + volumes: vec!["ws-server-storage:/app/storage".to_string()], + depends_on: vec![( + "openobserve".to_string(), + ComposeDependsOnCondition { + condition: "service_healthy".to_string(), + }, + )], + // The hub reports its own readiness so the runners have something to gate on. + // A runner resolves its module by fetching `/modules//package.json`, which fails + // outright instead of retrying, so "container started" is not a strong enough edge -- + // `service_started` would let a runner ask before the listener exists. + healthcheck: Some(ComposeHealthcheck { + test: vec![ + "CMD".to_string(), + "curl".to_string(), + "-fsS".to_string(), + format!("{}/health", hub_http_base()), ], - volumes: vec!["ws-server-storage:/app/storage".to_string()], - depends_on: vec![( - "openobserve".to_string(), - ComposeDependsOnCondition { - condition: "service_healthy".to_string(), - }, - )], - // The hub reports its own readiness so the runners have something to gate on. - // A runner resolves its module by fetching `/modules//package.json`, which fails - // outright instead of retrying, so "container started" is not a strong enough edge -- - // `service_started` would let a runner ask before the listener exists. - healthcheck: Some(ComposeHealthcheck { - test: vec![ - "CMD".to_string(), - "curl".to_string(), - "-fsS".to_string(), - format!("{}/health", hub_http_base()), - ], - interval: "5s".to_string(), - timeout: "3s".to_string(), - retries: 20, - start_period: "10s".to_string(), - }), - ..ComposeService::default() - }, - ), - ]; + interval: "5s".to_string(), + timeout: "3s".to_string(), + retries: 20, + start_period: "10s".to_string(), + }), + ..ComposeService::default() + }, + )); services.extend(runner_services( &resolve_cluster_runners( &module_registry(&workspace_root, &workspace_root.join("services/ws-server")), cluster, )?, &relative_path_from(&output_abs, &workspace_root), + artifacts, )); let compose = ComposeFile { services, @@ -121,21 +131,28 @@ pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &P /// /// The dependency is on the hub being HEALTHY, not merely started -- a runner resolves its module over HTTP and /// fails outright rather than retrying, so an early start is a lost run rather than a slow one. -fn runner_services(runners: &[RunnerInstance], context: &str) -> Vec<(String, ComposeService)> { +/// +/// Nothing about a runner image varies by scenario, so a published one is named rather than built. That is the +/// same image the Kubernetes manifests name, and the same binary the `mise` deployment installs from crates.io. +fn runner_services( + runners: &[RunnerInstance], + context: &str, + artifacts: ArtifactSource, +) -> Vec<(String, ComposeService)> { + let published = matches!(artifacts, ArtifactSource::Published); runners .iter() .map(|runner| { + let kind = &runner.runner; let service = ComposeService { - build: Some(ComposeBuild { + build: (!published).then(|| ComposeBuild { context: context.to_string(), - dockerfile: format!("services/ws-{}-runner/Dockerfile", runner.runner), + dockerfile: format!("services/ws-{kind}-runner/Dockerfile"), additional_contexts: Vec::new(), }), + image: published.then(|| format!("{IMAGE_REGISTRY}/et-ws-{kind}-runner:latest")), network_mode: Some("host".to_string()), - environment: vec![ - ("RUNNER_MODULE".to_string(), ComposeValue::Plain(runner.module.clone())), - ("WS_SERVER_URL".to_string(), ComposeValue::Plain(hub_ws_url())), - ], + environment: runner_environment(runner), depends_on: vec![( "ws-server".to_string(), ComposeDependsOnCondition { @@ -149,8 +166,35 @@ fn runner_services(runners: &[RunnerInstance], context: &str) -> Vec<(String, Co .collect() } +/// 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)> { + let mut environment = vec![ + ("RUNNER_MODULE".to_string(), ComposeValue::Plain(runner.module.clone())), + ("WS_SERVER_URL".to_string(), ComposeValue::Plain(hub_ws_url())), + ]; + environment.extend( + runner + .env + .iter() + .map(|(name, value)| (name.clone(), ComposeValue::Plain(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 + .iter() + .map(|(name, value)| ((*name).to_string(), ComposeValue::Plain((*value).to_string()))) + .collect(); + environment.push(("ZO_DATA_DIR".to_string(), ComposeValue::Plain("/data".to_string()))); + environment +} + /// Build the `OpenObserve` collector service the scenario's traces are exported to. -fn openobserve_service(env_file: String) -> ComposeService { +fn openobserve_service() -> ComposeService { ComposeService { image: Some("openobserve/openobserve:v0.91.5".to_string()), healthcheck: Some(ComposeHealthcheck { @@ -170,11 +214,11 @@ fn openobserve_service(env_file: String) -> ComposeService { // the scenario's generated env file. Nothing outside the developer's machine needs to talk to it: the // ws-server exports to 127.0.0.1:5080 and the UI is opened locally. ports: vec!["127.0.0.1:5080:5080".to_string()], - // The scenario's own file comes second, so its password overrides the repo-wide one in `config/o2.env` - // -- compose applies the list in order. Per-scenario credentials mean two stacks running side by side - // cannot authenticate against each other's collector. - env_file: vec![env_file, SECRETS_ENV_FILE.to_string()], - environment: vec![("ZO_DATA_DIR".to_string(), ComposeValue::Plain("/data".to_string()))], + // The scenario's own file is the only one, and it carries only the credential. + // Per-scenario credentials mean two stacks running side by side cannot authenticate against each + // other's collector. + env_file: vec![SECRETS_ENV_FILE.to_string()], + environment: collector_environment(), volumes: vec!["openobserve-data:/data".to_string()], ..ComposeService::default() } @@ -341,12 +385,17 @@ impl ComposeRenderer { fn render_environment_value(&mut self, key: &str, value: &ComposeValue) { match value { ComposeValue::Plain(value) => self.push_line(3, &format!("{key}: {value}")), + // 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 Some((first, rest)) = parts.split_first() { - self.push_line(3, &format!("{key}: \"{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 { ",\\" }; + let suffix = if index == last_index { "\"" } else { "," }; self.push_line(4, &format!("{part}{suffix}")); } } else { diff --git a/utilities/cli/src/deployment_types/k3s.rs b/utilities/cli/src/deployment_types/k3s.rs index 983340e9..9101400a 100644 --- a/utilities/cli/src/deployment_types/k3s.rs +++ b/utilities/cli/src/deployment_types/k3s.rs @@ -25,7 +25,6 @@ use std::collections::BTreeMap; use std::path::Path; -use edge_toolkit::input::ClusterInput; use edge_toolkit::ports::Services; use fs_err as fs; use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec}; @@ -42,15 +41,20 @@ use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use serde::Serialize; use crate::error::CliError; +use crate::input::{ArtifactSource, ClusterInput}; use crate::{ - HUB_SERVICE, OutputType, RunnerInstance, cluster_module_names, docker_image_module_paths, hub_service_ws_url, - module_registry, resolve_cluster_runners, + COLLECTOR_SETTINGS, COLLECTOR_USERNAME, HUB_SERVICE, OutputType, RunnerInstance, cluster_module_names, + docker_image_module_paths, hub_service_ws_url, image_prefix, module_registry, resolve_cluster_runners, }; /// Pull policy that lets an image built from this repository and imported into the node satisfy a manifest. /// -/// `IfNotPresent` rather than the `Always` a `:latest` tag defaults to: the scenario images never reach a -/// registry, so an `Always` pull would send k3s looking for them in one that does not have them. +/// `IfNotPresent` rather than the `Always` a `:latest` tag defaults to, and it is the right answer whichever +/// `artifact_source` a scenario asks for. A locally built image never reaches a registry at all, so an `Always` +/// pull would send k3s looking for it in one that does not have it. A published image is there to be found, +/// and `IfNotPresent` is what stops every restart re-pulling a tag the node already holds. It also lets a +/// published-image scenario be tested against a local build of the same tag, which is how CI checks a change +/// to an image before it is published. const IMAGE_PULL_POLICY: &str = "IfNotPresent"; /// Tag the generated manifests reference, matching what the README's build-and-import step produces. @@ -107,7 +111,11 @@ pub fn generate_k3s_deployment(cluster: &ClusterInput, output_dir: &Path) -> Res let mut docs = fixed_documents(&namespace, &cluster.cluster_name, &module_paths)?; for runner in &runners { - docs.push(document(&runner_deployment(&namespace, runner))?); + docs.push(document(&runner_deployment( + &namespace, + runner, + cluster.artifact_source, + ))?); } fs::write(output_dir.join(OutputType::K3s.output_file_name()), docs.join("---\n"))?; @@ -197,12 +205,15 @@ fn namespace_object(namespace: &str) -> Namespace { /// Its root password is the other half and is deliberately absent: that value reaches the pod from the /// operator-created `Secret`, so nothing here has to be redacted before the file is committed. fn collector_config(namespace: &str) -> ConfigMap { + let mut data: BTreeMap = COLLECTOR_SETTINGS + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect(); + // The claim this deployment mounts is what makes a data directory worth naming, so it is set here rather + // than in the shared list. + let _previous: Option = data.insert("ZO_DATA_DIR".to_string(), "/data".to_string()); ConfigMap { - data: Some(BTreeMap::from([ - ("RUST_LOG".to_string(), "warn".to_string()), - ("ZO_DATA_DIR".to_string(), "/data".to_string()), - ("ZO_ROOT_USER_EMAIL".to_string(), "root@example.com".to_string()), - ])), + data: Some(data), metadata: plain_meta(namespace, "openobserve-config"), ..ConfigMap::default() } @@ -454,6 +465,8 @@ fn collector_service(namespace: &str) -> Service { fn hub_env(module_paths: &[String]) -> Vec { vec![ env("MODULES_PATHS", module_paths.join(",")), + // The account, stated here; only the password it presents comes from the `Secret`. + env("OTLP_AUTH_USERNAME", COLLECTOR_USERNAME.to_string()), env( "OTLP_COLLECTOR_URL", format!( @@ -529,18 +542,31 @@ fn hub_service(namespace: &str) -> Service { ) } +/// 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_env(runner: &RunnerInstance) -> Vec { + let mut vars = vec![ + env("RUNNER_MODULE", runner.module.clone()), + env("WS_SERVER_URL", hub_service_ws_url()), + ]; + vars.extend(runner.env.iter().map(|(name, value)| env(name, value.clone()))); + vars +} + /// One deployment per runner, each hosting a single module exactly as the other two formats arrange it. -fn runner_deployment(namespace: &str, runner: &RunnerInstance) -> Deployment { +fn runner_deployment(namespace: &str, runner: &RunnerInstance, images: ArtifactSource) -> Deployment { // A runner fetches its module to a scratch directory and, for the web runner, lets Deno cache there, so a // read-only root needs somewhere writable even though nothing is meant to persist. // skipcq: RS-S1003 let (runtime_mount, runtime_volume) = scratch("runner-tmp", "/tmp"); let container = Container { - env: Some(vec![ - env("RUNNER_MODULE", runner.module.clone()), - env("WS_SERVER_URL", hub_service_ws_url()), - ]), - image: Some(format!("et-ws-{}-runner:{IMAGE_TAG}", runner.runner)), + env: Some(runner_env(runner)), + image: Some(format!( + "{}et-ws-{}-runner:{IMAGE_TAG}", + image_prefix(images), + runner.runner + )), image_pull_policy: Some(IMAGE_PULL_POLICY.to_string()), name: runner.name.clone(), resources: Some(container_resources()), diff --git a/utilities/cli/src/deployment_types/mise.rs b/utilities/cli/src/deployment_types/mise.rs index 5491e77f..c630b87f 100644 --- a/utilities/cli/src/deployment_types/mise.rs +++ b/utilities/cli/src/deployment_types/mise.rs @@ -1,56 +1,68 @@ use std::fmt::Write as _; use std::path::Path; -use edge_toolkit::input::ClusterInput; use et_path::{absolute_from, relative_path_from}; use fs_err as fs; use toml::{Table, Value}; use crate::error::CliError; +use crate::input::{ArtifactSource, ClusterInput}; use crate::{ - RunnerInstance, cluster_module_names, hub_ws_url, module_registry, resolve_cluster_runners, resolve_module_paths, - runner_crate, + COLLECTOR_SETTINGS, COLLECTOR_USERNAME, RunnerInstance, cluster_module_names, hub_ws_url, module_registry, + resolve_cluster_runners, resolve_module_paths, runner_crate, }; +/// Crate whose binary serves the hub, which a published deployment installs in place of building it. +const HUB_CRATE: &str = "et-ws-server"; + +/// Version every `cargo:` tool the generated deployment declares is requested at. +const LATEST: &str = "latest"; + pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<(), CliError> { let output_path = output_dir.join("mise.toml"); let workspace_root = edge_toolkit::config::get_project_root(); let output_abs = absolute_from(&workspace_root, output_dir); let ws_server_dir = workspace_root.join("services/ws-server"); let workspace_rel = relative_path_from(&output_abs, &workspace_root); - let openobserve_env_file_rel = "config/o2.env"; - // The image is lifted into a shell variable, not folded with a continuation: inlining it would put the - // `docker run` past the editorconfig line length, and a wrapped copy is what once silently dropped `-it`. - // `-e ZO_ROOT_USER_PASSWORD` passes the name only, so Docker forwards the value from the task environment - // that `[env] _.file` loaded -- and being after `--env-file`, the scenario's password still wins over the - // repo-wide one that file carries. + // The image and the settings are lifted into shell variables rather than folded with continuations: + // inlining them would put the `docker run` past the editorconfig line length, and a wrapped copy is what + // once silently dropped `-it`. `-e ZO_ROOT_USER_PASSWORD` passes the name only, so Docker forwards the + // value from the task environment that `[env] _.file` loaded; the settings before it carry their values. + let collector_flags = COLLECTOR_SETTINGS + .iter() + .map(|(name, value)| format!("-e {name}={value}")) + .collect::>() + .join(" "); let openobserve_run = format!( concat!( "image=openobserve/openobserve:v0.91.5\n", - "docker run --rm --name openobserve -p 127.0.0.1:5080:5080 --env-file {} ", + "settings=\"{}\"\n", + "# $settings is a word-split flag list by design; do not quote it.\n", + "docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings ", "-e ZO_ROOT_USER_PASSWORD \"$image\"\n", ), - openobserve_env_file_rel + collector_flags ); let module_names = cluster_module_names(cluster); let module_paths = scenario_module_paths(&ws_server_dir, &module_names)?; let module_paths_lines = wrap_module_paths(&module_paths); - let ws_server_run = format!("{module_paths_lines}export MODULES_PATHS\ncargo run\n"); + let artifacts = cluster.artifact_source; + let hub_command = if matches!(artifacts, ArtifactSource::Published) { + HUB_CRATE + } else { + "cargo run" + }; + let ws_server_run = format!("{module_paths_lines}export MODULES_PATHS\n{hub_command}\n"); let ws_server_rel = relative_path_from(&output_abs, &ws_server_dir); let mut root = Table::new(); let mut tasks = Table::new(); + // No working directory: the collector is a container started from values this file carries, so unlike the + // hub it has nothing to resolve against the repository. let _previous: Option = tasks.insert( "openobserve".to_string(), - Value::Table(mise_task( - Some("o2"), - None, - Some(&workspace_rel), - Some(&openobserve_run), - None, - None, - )), + Value::Table(mise_task(Some("o2"), None, None, Some(&openobserve_run), None, None)), ); let _previous: Option = tasks.insert( "ws-server".to_string(), @@ -74,8 +86,8 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re Value::Table(mise_task( None, Some(&format!("Run {} in the {} runner", runner.module, runner.runner)), - Some(&workspace_rel), - Some(&runner_run_body(runner)), + workspace_dir(&workspace_rel, artifacts), + Some(&runner_run_body(runner, artifacts)), None, Some(runner_env(runner)), )), @@ -110,10 +122,7 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re let _previous: Option = root.insert("env".to_string(), Value::Table(mise_env())); let _previous: Option = root.insert("tasks".to_string(), Value::Table(tasks)); - let mut tools = Table::new(); - let _previous: Option = tools.insert("cargo:open".to_string(), Value::String("latest".to_string())); - // No extra tools for the runner tasks: `runner_run_body` calls only `cargo`, which the hub task needs anyway. - let _previous: Option = root.insert("tools".to_string(), Value::Table(tools)); + let _previous: Option = root.insert("tools".to_string(), Value::Table(mise_tools(&runners, artifacts))); fs::write(&output_path, toml::to_string(&Value::Table(root))?)?; @@ -231,6 +240,15 @@ fn mise_env() -> Table { let _previous: Option = file.insert("file".to_string(), Value::String(crate::SECRETS_ENV_FILE.to_string())); let mut env = Table::new(); let _previous: Option = env.insert("_".to_string(), Value::Table(file)); + // The account beside the password, at the same scope, because whatever reads one has to read the other. + // The hub does, and so does the WASI runner -- the only runner carrying an `OtlpConfig`; the web and + // pyo3 runners ignore `OTLP_*` entirely. Handed a password with no account to present it as, the WASI + // runner fails at startup with `missing field `username``. File scope rather than per task because the + // password already arrives there, from the credential file, and splitting the pair is what broke it. + let _previous: Option = env.insert( + "OTLP_AUTH_USERNAME".to_string(), + Value::String(COLLECTOR_USERNAME.to_string()), + ); env } @@ -263,13 +281,50 @@ fn mise_depends(depends: &[String]) -> Table { /// off, so nothing installed it and the task silently spun out its whole timeout). Replacing that with /// `et-cli wait-for-module` fixed the tool problem but added a second `cargo run` to every runner task, which /// under the coverage profile rebuilt the CLI before it could poll. -fn runner_run_body(runner: &RunnerInstance) -> String { +fn runner_run_body(runner: &RunnerInstance, artifacts: ArtifactSource) -> String { let crate_name = runner_crate(&runner.runner); let mut body = String::default(); + if matches!(artifacts, ArtifactSource::Published) { + // The released binary is on `PATH` as a mise shim, so it needs no working directory of its own. + let _write_result = writeln!(body, "{crate_name}"); + return body; + } let _write_result = writeln!(body, "cargo run --quiet -p {crate_name}"); body } +/// The `[tools]` a generated deployment declares. +/// +/// A local deployment declares nothing for what it runs, because `runner_run_body` and the hub task call only +/// `cargo`, which anyone building this repository already has. A published one names each released binary as a +/// `cargo:` tool, which is what puts it on `PATH`. `task.run_auto_install` is off, so these arrive through the +/// `mise install` the README asks for rather than on first use. +fn mise_tools(runners: &[RunnerInstance], artifacts: ArtifactSource) -> Table { + let mut tools = Table::new(); + let _previous: Option = tools.insert("cargo:open".to_string(), Value::String(LATEST.to_string())); + if matches!(artifacts, ArtifactSource::Published) { + let latest = Value::String(LATEST.to_string()); + let _previous: Option = tools.insert(format!("cargo:{HUB_CRATE}"), latest.clone()); + for runner in runners { + let crate_name = runner_crate(&runner.runner); + let _previous: Option = tools.insert(format!("cargo:{crate_name}"), latest.clone()); + } + } + tools +} + +/// Working directory a task needs, or `None` when the command carries no dependence on where it runs. +/// +/// A local deployment compiles out of the cargo workspace, so every task has to start there. A published one +/// runs a binary off `PATH` and only the hub still needs a directory, because the module paths it is handed are +/// relative to the server's own. +const fn workspace_dir(workspace_rel: &str, artifacts: ArtifactSource) -> Option<&str> { + if matches!(artifacts, ArtifactSource::Published) { + return None; + } + Some(workspace_rel) +} + /// The `RUNNER_*`/`WS_*` environment a runner task needs. /// /// `WS_SERVER_URL` is spelled out rather than left to the runner's default so the generated task keeps working @@ -278,5 +333,10 @@ fn runner_env(runner: &RunnerInstance) -> Table { let mut env = Table::new(); let _previous: Option = env.insert("RUNNER_MODULE".to_string(), Value::String(runner.module.clone())); let _previous: Option = env.insert("WS_SERVER_URL".to_string(), Value::String(hub_ws_url())); + // Whatever the scenario declared for this agent. It cannot collide with the two above: the resolver + // rejects an `env:` naming either, so there is nothing here to decide between. + for (name, value) in &runner.env { + let _previous: Option = env.insert(name.clone(), Value::String(value.clone())); + } env } diff --git a/utilities/cli/src/deployment_types/scenario_image.rs b/utilities/cli/src/deployment_types/scenario_image.rs index 7e04c488..13a16d52 100644 --- a/utilities/cli/src/deployment_types/scenario_image.rs +++ b/utilities/cli/src/deployment_types/scenario_image.rs @@ -2,10 +2,10 @@ use std::collections::BTreeSet; use std::fmt::Write as _; use std::path::Path; -use edge_toolkit::input::ClusterInput; use fs_err as fs; use crate::error::CliError; +use crate::input::ClusterInput; use crate::{ModuleSource, cluster_module_names, module_registry, resolve_module_sources}; /// Build context path of the hub image's Dockerfile, which the scenario image layers on top of. @@ -34,10 +34,11 @@ pub fn generate_scenario_image(cluster: &ClusterInput, output_dir: &Path) -> Res } } - fs::write( - output_dir.join("Dockerfile"), - render_dockerfile(&cluster.cluster_name, &repo_paths, &mise_tools), - )?; + // 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(&cluster.cluster_name, &repo_paths, &mise_tools); + fs::write(output_dir.join("Dockerfile"), format!("{}\n", dockerfile.trim_end()))?; fs::write( output_dir.join("Dockerfile.dockerignore"), render_dockerignore(&project_root, &cluster.cluster_name, &repo_paths)?, @@ -89,6 +90,8 @@ fn render_dockerfile( "# hadolint ignore=DL3006\n", "FROM hub\n", "\n", + "LABEL org.opencontainers.image.source=\"https://github.com/edge-toolkit/core\"\n", + "\n", )); // `pkg/` unconditionally, never probed for on disk. diff --git a/utilities/cli/src/error.rs b/utilities/cli/src/error.rs index 4be0bfe2..a252c8ab 100644 --- a/utilities/cli/src/error.rs +++ b/utilities/cli/src/error.rs @@ -13,7 +13,10 @@ pub enum CliError { #[error(transparent)] Io(#[from] std::io::Error), - #[error("Failed to parse cluster input YAML")] + // The underlying message carries the whole of what is actionable -- which field, which line, and for a + // rejected enum the values that would have been accepted -- and a `source` nobody prints is a message + // that stops at "it did not parse". + #[error("Failed to parse cluster input YAML: {0}")] ParseClusterYaml(#[from] serde_yaml::Error), #[error("Failed to parse {path}")] @@ -75,9 +78,6 @@ pub enum CliError { #[error("Verification root {0:?} does not contain any scenario files under */input/*.yaml")] NoScenarios(PathBuf), - #[error("Unsupported deployment_type {0:?}. Supported values are currently: mise, docker-compose")] - UnsupportedDeploymentType(String), - #[error("No local module or runtime package found for dependency {0:?}")] UnknownDependency(String), @@ -99,6 +99,9 @@ pub enum CliError { #[error("Runner {name:?} is declared more than once; each runner needs its own name")] DuplicateRunnerName { name: String }, + + #[error("Agent {agent:?} sets {variable}, which the generated deployment derives and must own")] + ReservedRunnerEnv { agent: String, variable: String }, } /// Parse `src` as TOML into `T`, attaching `path` to the error on failure. diff --git a/utilities/cli/src/input.rs b/utilities/cli/src/input.rs new file mode 100644 index 00000000..c8110607 --- /dev/null +++ b/utilities/cli/src/input.rs @@ -0,0 +1,186 @@ +use std::collections::BTreeMap; + +use clap::ValueEnum; +use edge_toolkit::config::get_project_root; +use fs_err as fs; +use serde::{Deserialize, Serialize}; + +/// This repository, as its own workspace manifest states it. +/// +/// Compared against rather than merely looked for, because the question it answers is identity: whether the +/// tree this process resolved is the one that builds these artifacts, not whether some Rust project is nearby. +const REPOSITORY_URL: &str = "https://github.com/edge-toolkit/core"; + +/// The deployment format a scenario is rendered into. +/// +/// An enum rather than the string it is written as, because the set is closed and every consumer switches on +/// it: as a string, a misspelling reaches the generator, and the three writers of these files each have to +/// re-decide what an unrecognised one means. Deserialization rejects anything outside the set instead, naming +/// the alternatives, at the point the input is read. +#[expect( + clippy::exhaustive_enums, + reason = "OutputType enumerates the supported deployment formats; downstream code matches exhaustively" +)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum OutputType { + #[default] + Mise, + #[serde(rename = "docker-compose", alias = "docker_compose")] + DockerCompose, + K3s, +} + +impl OutputType { + pub const ALL: &'static [Self] = &[Self::Mise, Self::DockerCompose, Self::K3s]; + + #[must_use] + pub const fn output_file_name(self) -> &'static str { + match self { + Self::Mise => "mise.toml", + Self::DockerCompose => "compose.yaml", + Self::K3s => "k3s.yaml", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ClusterInput { + pub cluster_name: String, + /// The format this scenario is rendered into unless `--output-type` overrides it, from `deployment_type:`. + #[serde(default)] + pub deployment_type: OutputType, + /// Where the artifacts a generated deployment consumes come from, from `artifact_source:`. + /// + /// Stated, it is honoured as written, in both directions. Left out, it is settled while the input is read + /// -- see [`infer_artifact_source`] -- so that by the time anything holds a `ClusterInput` the question is + /// answered and there is no second, later notion of "unset" for a consumer to re-resolve. + #[serde(default = "infer_artifact_source")] + pub artifact_source: ArtifactSource, + /// The agents this cluster runs, from `agents:`. + /// + /// Defaults to none, which is a cluster of the hub and its collector and nothing else. That is a real + /// deployment rather than a degenerate one -- it serves the UI and accepts agents that connect to it -- + /// and it is what a scenario stating nothing but its name describes. + #[serde(default)] + pub agents: Vec, +} + +/// The name a cluster takes when its input does not choose one. +pub const DEFAULT_CLUSTER_NAME: &str = "default"; + +impl Default for ClusterInput { + /// The smallest cluster that is still valid: named, no agents, rendered the way an unstated input is. + /// + /// Not derived, because a derived `cluster_name` is the empty string and an empty name is one of the + /// things scenario validation exists to reject -- a `Default` that cannot be generated from would be a + /// trap rather than a starting point. `artifact_source` is the enum's own default rather than the + /// inferred one, because this is the value with nothing to infer from; reading an input is where a tree + /// gets a say. + fn default() -> Self { + Self { + cluster_name: DEFAULT_CLUSTER_NAME.to_string(), + deployment_type: OutputType::default(), + artifact_source: ArtifactSource::default(), + agents: Vec::new(), + } + } +} + +/// Which copy of this project's own artifacts a generated deployment addresses. +/// +/// `Local` builds everything from the working tree: a Kubernetes manifest names image tags the deploying +/// machine has to produce and import into the node, and a `mise` deployment runs its binaries out of the cargo +/// workspace. `Published` addresses what has been released instead -- container images from the project's +/// registry, binaries from the crates.io releases -- so a deployment consumes the project rather than +/// rebuilding it. What a scenario cannot get either way is its own module set, which is built from the tree +/// because it is particular to that deployment. +/// +/// `Published` is the default because it is the only one of the two that always works. Building from the +/// working tree needs a working tree, so `Local` is an answer available to almost nobody: everyone generating +/// a deployment for their own cluster has the releases and not this repository. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(rename_all = "lowercase")] +pub enum ArtifactSource { + Local, + #[default] + Published, +} + +/// Settle where an input that did not say gets its artifacts from. +/// +/// `Published`, except where there is proof it need not be: inside this repository every artifact can be +/// built from the tree, and building what you are working on is the whole point of generating a deployment +/// there. Anywhere else the releases are the only thing that exists. +#[must_use] +pub fn infer_artifact_source() -> ArtifactSource { + if running_in_this_repository() { + ArtifactSource::Local + } else { + ArtifactSource::default() + } +} + +/// Whether this process is running inside the repository that builds these artifacts. +/// +/// Two decisions turn on it, and they are the same question asked twice: whether a deployment can be built +/// from the tree, and whether a generated credential belongs to a committed fixture or to someone's real +/// deployment. A root with no readable manifest is not this repository, which is the same answer as a root +/// whose manifest belongs to something else, so the read failing needs no handling of its own. +#[must_use] +pub fn running_in_this_repository() -> bool { + let manifest = get_project_root().join("Cargo.toml"); + fs::read_to_string(&manifest).is_ok_and(|text| manifest_declares_this_repository(&text)) +} + +/// Whether a workspace manifest is this repository's. +/// +/// Split from the file read so the decision is a pure function of the text and can be exercised over every +/// shape a manifest takes -- another project's, one with no `[workspace.package]`, one that is not TOML at +/// all -- none of which is reachable through the caller, since the caller can only ever see whichever +/// manifest the running process happens to sit under. Public for that reason and no other. +#[must_use] +pub fn manifest_declares_this_repository(manifest: &str) -> bool { + let Ok(parsed) = manifest.parse::() else { + return false; + }; + parsed + .get("workspace") + .and_then(|workspace| workspace.get("package")) + .and_then(|package| package.get("repository")) + .and_then(toml::Value::as_str) + == Some(REPOSITORY_URL) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Agent { + pub name: String, + /// Runner that executes this agent's modules, from `runner:`. + /// + /// Unset means the modules are only served, for a browser to load and run itself -- which is what every + /// deployment did before this field existed. Set, the generated deployment also starts a runner process per + /// resource, so the cluster runs headless. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runner: Option, + /// Extra environment for this agent's runner processes, from `env:`. + /// + /// Every deployment format has somewhere to put these -- a `mise` task's `[env]`, a compose service's + /// `environment:`, a Kubernetes container's `env:` -- so a scenario that needs a runner configured says + /// so once, here, rather than the operator editing three generated files that a regeneration overwrites. + /// + /// A `BTreeMap` so the rendering is ordered: the outputs are committed and diffed, and a hash map would + /// reorder them between runs for no reason anyone could act on. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + pub resources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Resource { + #[serde(rename = "type")] + pub resource_type: String, +} diff --git a/utilities/cli/src/lib.rs b/utilities/cli/src/lib.rs index af4ec86f..6ed74f87 100644 --- a/utilities/cli/src/lib.rs +++ b/utilities/cli/src/lib.rs @@ -7,8 +7,6 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt::Write as _; use std::path::{Path, PathBuf}; -use clap::ValueEnum; -use edge_toolkit::input::ClusterInput; use edge_toolkit::ports::Services; use et_path::relative_path_from; use fs_err as fs; @@ -17,44 +15,29 @@ use serde::Deserialize; mod deployment_types; mod error; mod hub_ws_url; +mod input; mod module_package_json; mod scenario_password; -pub use self::deployment_types::{ - docker_image_module_paths, generate_docker_compose_deployment, generate_k3s_deployment, generate_mise_deployment, - generate_scenario_image, scenario_module_paths, +// `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; no consumer outside this directory builds on it, and nothing exported is a promise. Everything the +// generators share among themselves is `pub(crate)`, so what remains below is the whole of the surface anyone +// could depend on -- short enough to read, which is what makes an accidental addition to it visible. +pub use self::deployment_types::{docker_image_module_paths, scenario_module_paths}; +pub(crate) use self::deployment_types::{ + generate_docker_compose_deployment, generate_k3s_deployment, generate_mise_deployment, generate_scenario_image, }; pub use self::error::CliError; -pub use self::hub_ws_url::{HUB_SERVICE, hub_service_ws_url, hub_ws_url}; +pub(crate) use self::hub_ws_url::HUB_SERVICE; +pub use self::hub_ws_url::{hub_service_ws_url, hub_ws_url}; +pub(crate) use self::input::running_in_this_repository; +pub use self::input::{ + ArtifactSource, ClusterInput, DEFAULT_CLUSTER_NAME, OutputType, infer_artifact_source, + manifest_declares_this_repository, +}; pub use self::module_package_json::generate_module_package_json; -pub use self::scenario_password::{scenario_password, scenario_seed}; - -#[expect( - clippy::exhaustive_enums, - reason = "OutputType enumerates the supported deployment formats; downstream code matches exhaustively" -)] -#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq, ValueEnum)] -#[serde(rename_all = "lowercase")] -pub enum OutputType { - #[default] - Mise, - #[serde(rename = "docker-compose", alias = "docker_compose")] - DockerCompose, - K3s, -} - -impl OutputType { - pub const ALL: &'static [Self] = &[Self::Mise, Self::DockerCompose, Self::K3s]; - - #[must_use] - pub const fn output_file_name(self) -> &'static str { - match self { - Self::Mise => "mise.toml", - Self::DockerCompose => "compose.yaml", - Self::K3s => "k3s.yaml", - } - } -} +pub(crate) use self::scenario_password::{scenario_password, scenario_seed}; fn generated_output_files(output_types: &[OutputType]) -> Vec<&'static str> { let mut files = Vec::new(); @@ -141,7 +124,7 @@ struct CargoWsModule { /// install dir and has to be installed before it can be staged. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] -pub enum ModuleSource { +pub(crate) enum ModuleSource { /// A directory in this repository, as a path relative to the repository root. Repo(String), /// A package staged by a mise tool. @@ -160,17 +143,59 @@ const PYODIDE_DOCKER_PATH: &str = "/app/node_modules/pyodide"; /// /// The password is derived from the scenario input so a deployment is reproducible from it, which used to mean /// writing the literal into `mise.toml` and `compose.yaml` -- both committed under `verification/`, where every -/// secret scanner duly found it. Holding it in one uncommitted file instead keeps the deployment reproducible -/// (regenerating the scenario rewrites this file too) while leaving no credential in a tracked file to suppress. +/// secret scanner duly found it. Collecting it into one file keeps the deployment reproducible while leaving +/// the rest of the generated output free of anything a scanner reads as a credential. /// -/// An inline suppression marker is not an alternative here. Each scanner reads only its own marker syntax, some -/// read none at all from a committed file, and whether a given password is reported at all comes down to the -/// separator it happens to draw -- so the same marker holds for one scenario and not the next. -pub const SECRETS_ENV_FILE: &str = "secrets.env"; +/// Whether that one file is committed depends on where it was generated, and [`write_deployment_gitignore`] +/// decides: under `verification/` it is a fixture the drift check reads, and anywhere else it is a real +/// credential that gets an ignore file written beside it. +pub(crate) const SECRETS_ENV_FILE: &str = "secrets.env"; + +/// Account the collector is created with, and the one the hub authenticates its OTLP exports as. +/// +/// One constant because the two are the same account seen from either end: the collector is created with it as +/// `ZO_ROOT_USER_EMAIL` and the hub presents it as `OTLP_AUTH_USERNAME`, so a deployment where they disagree +/// comes up healthy and then rejects every export. Written in two files before this existed, with nothing +/// checking that they matched. +pub const COLLECTOR_USERNAME: &str = "root@example.com"; + +/// The collector's non-secret settings, which every generated deployment carries rather than sourcing. +/// +/// Three formats render this -- a `ConfigMap`, a compose `environment:` block, a `docker run` flag list -- so +/// it is one list and a setting cannot reach some deployments and not others. Reading it from a file in this +/// repository instead is what made a generated deployment unable to leave the tree, for the sake of two values +/// neither secret nor scenario-specific. +/// +/// Two things are deliberately absent. The root password is derived per scenario and reaches each format from +/// the generated env file. `ZO_DATA_DIR` is per format rather than shared, because it only means anything +/// alongside the storage that format declares -- a named volume, a claim, or nothing at all. +pub(crate) const COLLECTOR_SETTINGS: [(&str, &str); 2] = + [("RUST_LOG", "warn"), ("ZO_ROOT_USER_EMAIL", COLLECTOR_USERNAME)]; + +/// Registry path the repository's own images are published under. +/// +/// A runner image is the same for every deployment -- nothing in one varies by scenario -- so a scenario that +/// asks for published images names it here and the cluster pulls it, leaving the node with nothing to build or +/// import. The hub image is published alongside them but no manifest names it: it is the base a scenario image +/// is layered onto, so it reaches a deployment as a build context rather than as something a pod runs. +pub(crate) const IMAGE_REGISTRY: &str = "ghcr.io/edge-toolkit/core"; + +/// Prefix that turns a bare image name into the one a scenario's `artifact_source` asks for. +/// +/// A prefix rather than two parallel name builders, because that is the whole of the difference: the tags are +/// identical, and an unqualified one is what a container runtime treats as local-or-Docker-Hub. +#[must_use] +pub(crate) fn image_prefix(images: ArtifactSource) -> String { + if matches!(images, ArtifactSource::Published) { + format!("{IMAGE_REGISTRY}/") + } else { + String::default() + } +} #[derive(Debug, Clone)] #[non_exhaustive] -pub struct ModuleRegistryEntry { +pub(crate) struct ModuleRegistryEntry { pub mise_path: String, pub docker_path: String, pub dependencies: BTreeSet, @@ -189,10 +214,8 @@ pub fn generate_deployment( output_type: Option, ) -> Result { let (cluster, seed) = load_cluster_input(input_file)?; - let output_type = output_type - .map(Ok) - .or_else(|| cluster.deployment_type.as_deref().map(output_type_from_input)) - .unwrap_or(Ok(OutputType::Mise))?; + // The flag wins where it is given; otherwise the input's own choice, which defaults to `Mise`. + let output_type = output_type.unwrap_or(cluster.deployment_type); let module_names = cluster_module_names(&cluster); generate_deployment_outputs(&cluster, output_dir, &[output_type], seed)?; @@ -208,7 +231,7 @@ pub fn generate_deployment( /// /// Read once and hashed here so the seed covers the exact file the deployment was generated from, comments and /// formatting included, rather than a re-serialization of the parsed struct. -pub fn load_cluster_input(input_file: &Path) -> Result<(ClusterInput, u64), CliError> { +pub(crate) fn load_cluster_input(input_file: &Path) -> Result<(ClusterInput, u64), CliError> { let content = fs::read(input_file)?; let cluster: ClusterInput = serde_yaml::from_slice(&content)?; validate_cluster_name(&cluster.cluster_name)?; @@ -289,18 +312,6 @@ pub fn regenerate_verification( Ok(regenerated) } -pub fn output_type_from_input(value: &str) -> Result { - if value.eq_ignore_ascii_case("mise") { - Ok(OutputType::Mise) - } else if matches!(value.to_ascii_lowercase().as_str(), "docker-compose" | "docker_compose") { - Ok(OutputType::DockerCompose) - } else if value.eq_ignore_ascii_case("k3s") { - Ok(OutputType::K3s) - } else { - Err(CliError::UnsupportedDeploymentType(value.to_string())) - } -} - const fn deployment_summary( cluster_name: String, agent_templates: usize, @@ -328,6 +339,7 @@ fn generate_deployment_outputs( // same root credentials the collector was started with. let password = scenario_password(seed); fs::write(output_dir.join(SECRETS_ENV_FILE), secrets_env(&password))?; + write_deployment_gitignore(output_dir)?; for output_type in output_types { match output_type { OutputType::Mise => generate_mise_deployment(cluster, output_dir)?, @@ -348,23 +360,91 @@ fn generate_deployment_outputs( let readme_path = output_dir.join("README.md"); let module_names = cluster_module_names(cluster); - fs::write(&readme_path, generated_readme(cluster, &module_names, output_types))?; + let dockerfile = scenario_dockerfile_path(output_dir); + fs::write( + &readme_path, + generated_readme(cluster, &module_names, output_types, &dockerfile), + )?; Ok(()) } -/// Render the env file both deployment formats read the scenario credential from. +/// 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 test 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 out of whatever repository it was generated into. +/// +/// Written beside the file it covers rather than left to the operator, because the failure is silent and +/// permanent: a credential committed once stays in the history after it is deleted. A nested `.gitignore` is +/// what makes the deployment directory safe to drop anywhere, which is the point of generating it. +/// +/// Not written inside this repository. Here the verification outputs are committed evidence -- their whole +/// purpose is to be diffed when a generator changes -- and the password is derived from an input this +/// repository also carries, so it is reproducible from what is already public rather than a secret the file +/// is keeping. An ignore file here would only hide them from the drift check that exists to read them. +fn write_deployment_gitignore(output_dir: &Path) -> Result<(), CliError> { + if running_in_this_repository() { + return Ok(()); + } + let body = format!( + concat!( + "# Written by `et-cli` beside the credential it covers.\n", + "# 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", + ), + file = SECRETS_ENV_FILE + ); + fs::write(output_dir.join(".gitignore"), body)?; + Ok(()) +} + +/// Render the env file every deployment format reads the scenario credential from. /// /// Two names for the one password because the services that share it read different variables: `OpenObserve` /// takes `ZO_ROOT_USER_PASSWORD` as its root credential, and the ws-server authenticates its OTLP exports with -/// `OTLP_AUTH_PASSWORD`. The username is here too so everything the pair needs to agree on lives in one file. +/// `OTLP_AUTH_PASSWORD`. Nothing else belongs here. The account those two authenticate as is not a secret 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) -> String { format!( concat!( - "# Generated, and deliberately not committed -- regenerating the scenario rewrites it.\n", + "# 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", - "OTLP_AUTH_USERNAME=root@example.com\n", ), password = password ) @@ -422,7 +502,12 @@ fn discover_verification_scenarios(verification_root: &Path) -> Result String { +fn generated_readme( + cluster: &ClusterInput, + module_names: &[String], + output_types: &[OutputType], + dockerfile: &str, +) -> String { let module_summary = if module_names.is_empty() { "No workflow modules were selected in the scenario input.".to_string() } else { @@ -454,7 +539,15 @@ fn generated_readme(cluster: &ClusterInput, module_names: &[String], output_type }; let run_instructions = output_types .iter() - .map(|output_type| generated_run_instructions(*output_type, &cluster.cluster_name, &runner_kinds(cluster))) + .map(|output_type| { + generated_run_instructions( + *output_type, + &cluster.cluster_name, + dockerfile, + &runner_kinds(cluster), + cluster.artifact_source, + ) + }) .collect::>() .join("\n"); @@ -463,10 +556,12 @@ fn generated_readme(cluster: &ClusterInput, module_names: &[String], output_type "# {name}\n\n", "{output_summary}\n\n", "{module_summary}\n\n", + "{artifact_note}", "{secrets_note}\n\n", "{run_instructions}", ), name = cluster.cluster_name, + artifact_note = artifact_source_note(cluster.artifact_source), output_summary = output_summary, module_summary = module_summary, secrets_note = secrets_note(), @@ -474,17 +569,37 @@ fn generated_readme(cluster: &ClusterInput, module_names: &[String], output_type ) } -/// Explain the credential file, since it is the one generated file the repository does not carry. +/// 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 checkout without it starts 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() -> String { format!( concat!( - "`{file}` holds the scenario's derived OpenObserve and OTLP credentials, and is deliberately not\n", - "committed. Regenerating this scenario writes it; if it is missing, run\n", - "`mise run regen-verification` (or `et-cli generate-deployment`) before starting the stack.", + "`{file}` holds the scenario's derived OpenObserve and OTLP 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.", ), file = SECRETS_ENV_FILE ) @@ -507,56 +622,121 @@ fn runner_kinds(cluster: &ClusterInput) -> Vec { kinds } -fn generated_run_instructions(output_type: OutputType, cluster_name: &str, runners: &[String]) -> String { - 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::DockerCompose => concat!( - "## Run With Docker Compose\n\n", - "From this directory, start the scenario with:\n\n", - "```bash\n", - "docker compose up --build\n", - "```\n\n", - "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. The hub is build-only and never runs as a\n", - "container of its own.\n", - "`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", - "Stop the scenario with:\n\n", +/// Render the step that installs a published deployment's binaries, and nothing at all for a local one. +/// +/// A local deployment compiles what it runs from the working tree, so `mise run` is the whole of it. A +/// published one declares its binaries as `cargo:` tools, and `task.run_auto_install` is off, so without this +/// step the first task dies 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. +const fn mise_install_note(artifacts: ArtifactSource) -> &'static str { + if matches!(artifacts, ArtifactSource::Published) { + return concat!( + "Fetch the binaries the tasks below name before the first run:\n\n", "```bash\n", - "docker compose down\n", - "```\n" - ) - .to_string(), - OutputType::K3s => k3s_run_instructions(cluster_name, runners), + "mise install\n", + "```\n\n" + ); + } + "" +} + +fn generated_run_instructions( + output_type: OutputType, + cluster_name: &str, + dockerfile: &str, + runners: &[String], + artifacts: ArtifactSource, +) -> String { + match output_type { + OutputType::Mise => format!( + concat!( + "## Run With Mise\n\n", + "{install}", + "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" + ), + install = mise_install_note(artifacts) + ), + OutputType::DockerCompose => format!( + concat!( + "## Run With Docker Compose\n\n", + "From this directory, start the scenario with:\n\n", + "```bash\n", + "docker compose up --build\n", + "```\n\n", + "{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", + "Stop the scenario with:\n\n", + "```bash\n", + "docker compose down\n", + "```\n" + ), + hub = compose_hub_note(artifacts) + ), + OutputType::K3s => k3s_run_instructions(cluster_name, dockerfile, runners, artifacts), + } +} + +/// 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 declares a +/// build-only service for the hub and takes that service as the named build context, so `docker compose up` +/// builds two images; a published one points the context straight at the released image and builds one. A +/// reader who does not know which shape they have is reading a `compose.yaml` with a service in it they +/// cannot account for, or missing one the other scenarios have. +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 as a build context.\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. The hub is build-only and never runs as a\n", + "container of its own.\n" + ) } -/// Render the build-and-import section for each runner image the scenario's manifests name. +/// Render the section covering each runner image the scenario's manifests name. /// -/// Emitted as commands rather than described in prose, because a runner image the node does not have leaves -/// its pod in `ErrImagePull` indefinitely -- the manifests name `et-ws--runner:latest` and nothing in -/// the deployment builds it. 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_commands(runners: &[String]) -> String { +/// 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"); @@ -577,33 +757,62 @@ fn runner_image_commands(runners: &[String]) -> String { ) } +/// Render the paragraph and the hub reference 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 what supplies `FROM hub`, and whether that hub is +/// a build of its own, actually change. +fn k3s_image_preamble(images: ArtifactSource) -> (&'static str, String, &'static str) { + if matches!(images, ArtifactSource::Published) { + return ( + 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 module-less hub image and takes that\n", + "hub as a _named build context_ rather than building it, so a plain `docker build` of the\n", + "scenario Dockerfile fails on `FROM hub` -- pointing the context at the published hub is what\n", + "supplies it. From the repository root:\n\n" + ), + format!("{IMAGE_REGISTRY}/et-ws-server:latest"), + "", + ); + } + ( + 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`. From the repository root:\n\n" + ), + "et-ws-server-hub:latest".to_string(), + "docker build -t \"$hub\" -f services/ws-server/Dockerfile .\n", + ) +} + /// 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_name: &str, runners: &[String]) -> String { +fn k3s_run_instructions(cluster_name: &str, dockerfile: &str, runners: &[String], images: ArtifactSource) -> String { + let (preamble, hub, hub_build) = k3s_image_preamble(images); format!( concat!( "## Run With k3s\n\n", - "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`. From the repository root:\n\n", + "{preamble}", "```bash\n", "scenario={name}\n", - "hub=et-ws-server-hub:latest\n", + "hub={hub}\n", "image=\"et-ws-server-$scenario:latest\"\n", - "dockerfile=\"verification/local/output/$scenario/Dockerfile\"\n", - "docker build -t \"$hub\" -f services/ws-server/Dockerfile .\n", + "dockerfile=\"{dockerfile}\"\n", + "{hub_build}", "docker build --build-context \"hub=docker-image://$hub\" -t \"$image\" -f \"$dockerfile\" .\n", "docker save \"$image\" | sudo k3s ctr images import -\n", "```\n\n", - "{runner_commands}", + "{runner_note}", "### Load The Credential\n\n", - "`{file}` is generated but deliberately not committed, so the `Secret` is created from it rather\n", - "than shipped inside `k3s.yaml`. From this directory:\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", @@ -619,14 +828,18 @@ fn k3s_run_instructions(cluster_name: &str, runners: &[String]) -> String { "kubectl get pods -n \"$ns\" --watch\n", "```\n" ), + dockerfile = dockerfile, file = SECRETS_ENV_FILE, + hub = hub, + hub_build = hub_build, name = cluster_name, - runner_commands = runner_image_commands(runners) + preamble = preamble, + runner_note = runner_image_note(runners, images) ) } #[must_use] -pub fn module_registry(project_root: &Path, ws_server_dir: &Path) -> BTreeMap { +pub(crate) fn module_registry(project_root: &Path, ws_server_dir: &Path) -> BTreeMap { let mut registry = BTreeMap::new(); register_modules_under( @@ -870,7 +1083,7 @@ fn resolve_module_entries<'registry>( Ok(entries) } -pub fn resolve_module_paths( +pub(crate) fn resolve_module_paths( registry: &BTreeMap, module_names: &[String], path_for: F, @@ -885,7 +1098,7 @@ where } /// Resolve the cluster's modules to the docker path each is served from and how it is provisioned. -pub fn resolve_module_sources( +pub(crate) fn resolve_module_sources( registry: &BTreeMap, module_names: &[String], ) -> Result, CliError> { @@ -900,7 +1113,7 @@ pub fn resolve_module_sources( /// Everything is taken from the registry as-is except pyodide, whose distribution depends on the cluster: the /// registry cannot decide that, because pyodide arrives as a dependency of whichever Python modules the cluster /// happens to declare. -pub fn resolve_cluster_modules( +pub(crate) fn resolve_cluster_modules( registry: &BTreeMap, module_names: &[String], ) -> Result, CliError> { @@ -953,16 +1166,21 @@ pub fn npm_module_path(package: &str) -> Result { .ok_or_else(|| CliError::UnresolvedNpmModule(package.to_string())) } +/// Environment a runner gets from the deployment rather than from the scenario, and so cannot be overridden. +pub(crate) const DERIVED_RUNNER_ENV: [&str; 2] = ["RUNNER_MODULE", "WS_SERVER_URL"]; + /// One runner process the generated deployment starts, resolved from an agent that names a `runner:`. #[derive(Debug, Clone)] #[non_exhaustive] -pub struct RunnerInstance { +pub(crate) struct RunnerInstance { /// Deployment-unique name for the process: the task name in `mise.toml`, the service name in `compose.yaml`. pub name: String, /// Runner kind the agent asked for, already validated against [`SUPPORTED_RUNNERS`]. pub runner: String, /// Value for the runner's `RUNNER_MODULE`, i.e. the module's published package name. pub module: String, + /// Extra environment the scenario declared for this agent, rendered by every deployment format. + pub env: BTreeMap, } /// Runner kinds a scenario may name, mapped to the crate that runs them. @@ -972,7 +1190,7 @@ pub struct RunnerInstance { /// `services/ws--runner/Dockerfile`. Nothing else distinguishes a runner here, so a fourth is this line /// plus an image. Rejecting a kind by name is what stops a scenario from asking for one and silently getting /// nothing. -pub const SUPPORTED_RUNNERS: [(&str, &str); 3] = [ +pub(crate) const SUPPORTED_RUNNERS: [(&str, &str); 3] = [ ("pyo3", "et-ws-pyo3-runner"), ("wasi", "et-ws-wasi-runner"), ("web", "et-ws-web-runner"), @@ -984,7 +1202,7 @@ pub const SUPPORTED_RUNNERS: [(&str, &str); 3] = [ /// task into a table and compose writes each service as a mapping key. Either way a collision replaces rather /// than reports -- an agent called `ws-server` would quietly take the hub's place, and the deployment would come /// up missing the thing it was meant to talk to. Rejecting the name is the only way that surfaces. -pub const RESERVED_RUNNER_NAMES: [&str; 6] = [ +pub(crate) const RESERVED_RUNNER_NAMES: [&str; 6] = [ "generated-scenario", "o2", "open-o2", @@ -998,7 +1216,7 @@ pub const RESERVED_RUNNER_NAMES: [&str; 6] = [ /// One process per resource rather than per agent, because a runner hosts exactly one module -- `RUNNER_MODULE` /// is a single name. An agent with one resource (the usual shape) therefore keeps the agent's own name, and only /// a multi-resource agent gets the resource suffixed, so the common case reads as the scenario wrote it. -pub fn resolve_cluster_runners( +pub(crate) fn resolve_cluster_runners( registry: &BTreeMap, cluster: &ClusterInput, ) -> Result, CliError> { @@ -1019,6 +1237,19 @@ pub fn resolve_cluster_runners( supported, }); } + // A scenario cannot set the two the deployment derives for it. + // `RUNNER_MODULE` and `WS_SERVER_URL` are what wire a runner to its module and its hub, and both are + // computed from the rest of the input. Letting `env:` win would mean a scenario whose generated files + // describe one deployment and whose runners join another; letting the derived value win would mean an + // `env:` entry that is silently ignored. Neither is worth allowing, so it is an error to write one. + for variable in DERIVED_RUNNER_ENV { + if agent.env.contains_key(variable) { + return Err(CliError::ReservedRunnerEnv { + agent: agent.name.clone(), + variable: (*variable).to_string(), + }); + } + } let multi_resource = agent.resources.len() > 1; for resource in &agent.resources { let module_name = resource.resource_type.trim(); @@ -1035,6 +1266,9 @@ pub fn resolve_cluster_runners( name, runner: runner.to_string(), module, + // Every resource of a multi-resource agent gets its own runner process, and the agent's + // environment describes the agent, so each of them carries it. + env: agent.env.clone(), }); } } @@ -1047,7 +1281,7 @@ pub fn resolve_cluster_runners( /// meant writing the same format string more than once. One definition here serves the compose services, the /// mise tasks and whatever is added next, and it is the only place that has to change if the port moves. #[must_use] -pub fn hub_http_base() -> String { +pub(crate) fn hub_http_base() -> String { format!("http://localhost:{}", Services::InsecureWebSocketServer.port()) } @@ -1088,7 +1322,7 @@ fn runner_name( /// The crate whose binary runs `runner`, which [`resolve_cluster_runners`] has already validated. #[must_use] -pub fn runner_crate(runner: &str) -> &'static str { +pub(crate) fn runner_crate(runner: &str) -> &'static str { SUPPORTED_RUNNERS .iter() .find(|(kind, _)| *kind == runner) @@ -1096,7 +1330,7 @@ pub fn runner_crate(runner: &str) -> &'static str { } #[must_use] -pub fn cluster_module_names(cluster: &ClusterInput) -> Vec { +pub(crate) fn cluster_module_names(cluster: &ClusterInput) -> Vec { cluster .agents .iter() diff --git a/utilities/cli/tests/collector_identity.rs b/utilities/cli/tests/collector_identity.rs new file mode 100644 index 00000000..a6e095e2 --- /dev/null +++ b/utilities/cli/tests/collector_identity.rs @@ -0,0 +1,74 @@ +//! Holds the repository's own collector credentials to the values the generated deployments are built from. +//! +//! A generated deployment is self-consistent on its own: the collector's account and the hub's OTLP account +//! both come from `COLLECTOR_USERNAME`, and the password is derived per scenario into one file both halves +//! read. The repository's own dev stack is not -- `config/o2.env` starts the collector and +//! `.mise/config.toml` configures the server, and the two agree only because someone typed the same strings +//! into both. Disagreeing costs nothing at startup and everything afterwards: the collector comes up healthy, +//! the server exports happily, and every span is rejected with a 401 nothing surfaces. +//! +//! So these read the files rather than restating the values, and a rotation in one place fails here instead +//! of in whatever is being debugged a week later. +#![cfg(test)] + +use std::path::PathBuf; + +use edge_toolkit::config::get_project_root; +use et_cli::COLLECTOR_USERNAME; +use fs_err as fs; + +/// Read `config/o2.env` the way docker's `--env-file` does: everything after the first `=` is the value. +/// +/// Deliberately not a dotenv parser. That file is consumed by `--env-file` and by `.`-sourcing it in a task +/// body, neither of which strips quotes or honours inline comments, so a parser that did either would accept +/// a file the collector then reads differently. +fn o2_env_value(key: &str) -> String { + let path = get_project_root().join("config/o2.env"); + let text = fs::read_to_string(&path).unwrap(); + text.lines() + .find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) + .unwrap_or_else(|| panic!("no {key} in {}", path.display())) + .to_string() +} + +/// Read one `[tasks.ws-server.env]` value, which is how the repository's own server is configured. +fn ws_server_env_value(key: &str) -> String { + let path: PathBuf = get_project_root().join(".mise/config.toml"); + let text = fs::read_to_string(&path).unwrap(); + let config: toml::Table = toml::from_str(&text).unwrap(); + config + .get("tasks") + .and_then(|tasks| tasks.get("ws-server")) + .and_then(|task| task.get("env")) + .and_then(|env| env.get(key)) + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("no {key} under [tasks.ws-server.env] in {}", path.display())) + .to_string() +} + +#[test] +fn the_dev_collector_account_is_the_one_generated_deployments_use() { + // One account seen from both ends: `ZO_ROOT_USER_EMAIL` creates it, `OTLP_AUTH_USERNAME` presents it. + assert_eq!( + o2_env_value("ZO_ROOT_USER_EMAIL"), + COLLECTOR_USERNAME, + "the collector this repository starts must use the account the generated deployments name" + ); + assert_eq!( + ws_server_env_value("OTLP_AUTH_USERNAME"), + COLLECTOR_USERNAME, + "and the server that exports to it must present that same account" + ); +} + +#[test] +fn the_dev_collector_password_matches_on_both_sides() { + // The other half of the same credential, which has no constant to hang from: it is a committed dev-only + // password rather than anything a generated deployment reuses, so the two files are its only statement of + // it and agreeing with each other is the whole of what can be checked. + assert_eq!( + ws_server_env_value("OTLP_AUTH_PASSWORD"), + o2_env_value("ZO_ROOT_USER_PASSWORD"), + "the server authenticates as the collector's root user, so the two passwords are one value" + ); +} diff --git a/utilities/cli/tests/input.rs b/utilities/cli/tests/input.rs new file mode 100644 index 00000000..8b9c0666 --- /dev/null +++ b/utilities/cli/tests/input.rs @@ -0,0 +1,166 @@ +//! Covers how a scenario input deserializes, and what each field answers when the input leaves it out. +//! +//! Every default here is load-bearing, because `verification/*/input/default.yaml` is an input that states +//! nothing but its name and whose committed output is whatever these produce. The one that most repays a +//! test is `artifact_source`, which is not a fixed default at all: it is settled against the tree the +//! process is running in, so the same input means "build it" in this repository and "pull it" anywhere else. +#![cfg(test)] + +use et_cli::{ + ArtifactSource, ClusterInput, DEFAULT_CLUSTER_NAME, OutputType, infer_artifact_source, + manifest_declares_this_repository, +}; + +/// A scenario stating only what has no default, which is what the `default` verification scenario is. +const ONLY_A_NAME: &str = "cluster_name: default\n"; + +/// A scenario with agents, for the fields that only mean something once there are some. +const WITH_AN_AGENT: &str = " +cluster_name: math1 +agents: + - name: math1-twin + runner: web + resources: + - type: math1 +"; + +fn parse(yaml: &str) -> ClusterInput { + serde_yaml::from_str(yaml).unwrap() +} + +#[test] +fn an_input_stating_only_a_name_takes_every_other_field_from_its_default() { + let cluster = parse(ONLY_A_NAME); + + assert_eq!(cluster.cluster_name, DEFAULT_CLUSTER_NAME); + assert_eq!(cluster.deployment_type, OutputType::Mise); + assert!(cluster.agents.is_empty(), "a hub and its collector, and nothing else"); +} + +#[test] +fn the_default_cluster_input_is_one_that_could_be_generated_from() { + // A derived `Default` would give it the empty name that scenario validation rejects, so the value would + // exist only to be invalid. This is the same cluster `ONLY_A_NAME` parses to, apart from the source. + let cluster = ClusterInput::default(); + + assert_eq!(cluster.cluster_name, DEFAULT_CLUSTER_NAME); + assert_eq!(cluster.deployment_type, OutputType::Mise); + assert_eq!(cluster.artifact_source, ArtifactSource::Published); + assert!(cluster.agents.is_empty()); +} + +#[test] +fn published_is_the_artifact_source_with_nothing_to_infer_from() { + // The fallback for anywhere that cannot build these artifacts, which is everywhere but this repository. + assert_eq!(ArtifactSource::default(), ArtifactSource::Published); +} + +#[test] +fn an_unstated_artifact_source_infers_local_inside_this_repository() { + // These tests run from a checkout, which is the proof the inference looks for. Were this to answer + // `Published`, every scenario under `verification/local` would quietly start naming the registry. + assert_eq!(infer_artifact_source(), ArtifactSource::Local); + assert_eq!(parse(ONLY_A_NAME).artifact_source, ArtifactSource::Local); +} + +#[test] +fn a_stated_artifact_source_is_honoured_over_what_the_tree_would_infer() { + // In both directions, including a `published` stated here, where the tree could have supplied + // everything: that input describes a deployment somewhere else. + let local = parse(&format!("{ONLY_A_NAME}artifact_source: local\n")); + let published = parse(&format!("{ONLY_A_NAME}artifact_source: published\n")); + + assert_eq!(local.artifact_source, ArtifactSource::Local); + assert_eq!(published.artifact_source, ArtifactSource::Published); +} + +#[test] +fn only_this_repositories_own_manifest_counts_as_proof() { + // The cases the caller cannot reach, since it only ever sees the manifest it happens to sit under. + let ours = "[workspace.package]\nrepository = \"https://github.com/edge-toolkit/core\"\n"; + assert!(manifest_declares_this_repository(ours)); + + let theirs = "[workspace.package]\nrepository = \"https://github.com/edge-toolkit/research\"\n"; + assert!( + !manifest_declares_this_repository(theirs), + "another project's workspace" + ); + assert!( + !manifest_declares_this_repository("[workspace]\nmembers = []\n"), + "a workspace that declares no package metadata" + ); + assert!( + !manifest_declares_this_repository("[package]\nname = \"solo\"\n"), + "a manifest that is not a workspace at all" + ); + assert!( + !manifest_declares_this_repository("this is not toml { ["), + "and something that does not parse" + ); +} + +#[test] +fn every_deployment_type_spelling_deserializes_to_its_own_output_file() { + for (spelling, expected, file) in [ + ("mise", OutputType::Mise, "mise.toml"), + ("docker-compose", OutputType::DockerCompose, "compose.yaml"), + ("docker_compose", OutputType::DockerCompose, "compose.yaml"), + ("k3s", OutputType::K3s, "k3s.yaml"), + ] { + let cluster = parse(&format!("{ONLY_A_NAME}deployment_type: {spelling}\n")); + + assert_eq!(cluster.deployment_type, expected, "for {spelling}"); + assert_eq!(cluster.deployment_type.output_file_name(), file); + } + assert_eq!(OutputType::ALL.len(), 3, "and the list of them is complete"); +} + +#[test] +fn an_unknown_deployment_type_is_rejected() { + // The reason it is an enum: as a string, a misspelling reached the generator instead of the reader. + let parsed: Result = serde_yaml::from_str(&format!("{ONLY_A_NAME}deployment_type: yaml\n")); + + let rejected = parsed.unwrap_err().to_string(); + + assert!(rejected.contains("yaml"), "names what was written: {rejected}"); + assert!(rejected.contains("mise"), "and what it could have been: {rejected}"); +} + +#[test] +fn an_unknown_artifact_source_is_rejected() { + // Rather than falling back to a default, which would turn a typo into a deployment addressing the wrong + // artifacts and report nothing at generation time. + let parsed: Result = serde_yaml::from_str(&format!("{ONLY_A_NAME}artifact_source: registry\n")); + + let _rejected = parsed.unwrap_err(); +} + +#[test] +fn an_agent_keeps_its_runner_and_resources() { + let cluster = parse(WITH_AN_AGENT); + + let [agent] = cluster.agents.as_slice() else { + panic!("expected exactly one agent"); + }; + assert_eq!(agent.name, "math1-twin"); + assert_eq!(agent.runner.as_deref(), Some("web")); + let [resource] = agent.resources.as_slice() else { + panic!("expected exactly one resource"); + }; + assert_eq!(resource.resource_type, "math1"); +} + +#[test] +fn a_cluster_input_round_trips_through_yaml() { + // Serialization is a public surface of its own: `et-cli` writes a scenario back out, and a `skip` + // attribute on the wrong field would drop it silently rather than failing to compile. + let cluster = parse(&format!("{WITH_AN_AGENT}artifact_source: published\n")); + + let rendered = serde_yaml::to_string(&cluster).unwrap(); + let reparsed = parse(&rendered); + + assert_eq!(reparsed.artifact_source, ArtifactSource::Published); + assert_eq!(reparsed.deployment_type, cluster.deployment_type); + assert_eq!(reparsed.cluster_name, cluster.cluster_name); + assert_eq!(reparsed.agents.len(), cluster.agents.len()); +} diff --git a/utilities/cli/tests/scenario_generation.rs b/utilities/cli/tests/scenario_generation.rs index feddaf70..2f7d07ce 100644 --- a/utilities/cli/tests/scenario_generation.rs +++ b/utilities/cli/tests/scenario_generation.rs @@ -2,7 +2,7 @@ use et_cli::{ docker_image_module_paths, generate_deployment, hub_service_ws_url, hub_ws_url, module_package_json, - regenerate_verification, scenario_module_paths, + regenerate_verification, scenario_dockerfile_path, scenario_module_paths, }; use fs_err as fs; use serde::Deserialize as _; @@ -43,6 +43,8 @@ fn deployment_error_for(input: &str) -> String { #[test] fn generate_deployment_rejects_unsupported_deployment_type() { + // Rejected while the input is read rather than by a check further in, which is what makes the error name + // the field and the line it is on instead of describing a value the generator could not use. let error = deployment_error_for( r#"cluster_name: "test-cluster" deployment_type: yaml @@ -50,7 +52,7 @@ agents: [] "#, ); - assert!(error.contains("Unsupported deployment_type"), "got: {error}"); + assert!(error.contains("yaml") && error.contains("mise"), "got: {error}"); } #[test] @@ -270,19 +272,244 @@ agents: /// The two tests below assert on different halves of the same manifest, so the generation lives here once. The /// temp root comes back alongside the directory because dropping it deletes the tree. fn k3s_scenario() -> (tempfile::TempDir, std::path::PathBuf) { - let (test_root, verification_root, output_dir) = scenario_tree( + k3s_scenario_with("") +} + +/// Generate the k3s scenario above with `extra` spliced into its input, for the tests that vary one field. +/// +/// Split from `k3s_scenario` rather than written out a second time, so the two image sources are generated +/// from the same scenario and any difference the tests below assert on is the field and nothing else. +fn k3s_scenario_with(extra: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let (test_root, verification_root, output_dir) = scenario_tree(&format!( r#"cluster_name: "k3s-cluster" deployment_type: "k3s" +{extra}agents: + - name: "math1-twin" + runner: "wasi" + resources: + - type: "wasi-math1" +"# + )); + + let _regenerated = regenerate_verification(&verification_root, None).unwrap(); + (test_root, output_dir) +} + +/// Generate the k3s scenario with `extra` spliced in and return the two files a rendering is judged by. +/// +/// The temp root is dropped here rather than handed back, because both files are read before it goes. +/// The one input line that switches a scenario from building what it runs to addressing the releases. +const PUBLISHED: &str = "artifact_source: \"published\"\n"; + +fn k3s_manifest_and_readme(extra: &str) -> (String, String) { + let (_test_root, output_dir) = k3s_scenario_with(extra); + let manifest = fs::read_to_string(output_dir.join("k3s.yaml")).unwrap(); + let readme = fs::read_to_string(output_dir.join("README.md")).unwrap(); + (manifest, readme) +} + +#[test] +fn the_artifact_source_decides_whether_runner_images_are_pulled() { + // Both renderings in one test, because what matters is the contrast: the same scenario has to come out + // naming a runner the node holds under one source and one the cluster fetches under the other. + let (local_manifest, local_readme) = k3s_manifest_and_readme(""); + let (published_manifest, published_readme) = k3s_manifest_and_readme(PUBLISHED); + + assert!( + local_manifest.contains("image: et-ws-wasi-runner:latest"), + "an unqualified name is what a node resolves from its own images: {local_manifest}" + ); + assert!( + !local_manifest.contains("ghcr.io"), + "nothing is pulled: {local_manifest}" + ); + assert!( + local_readme.contains("docker build -t et-ws-wasi-runner:latest"), + "the README has to say how to produce it: {local_readme}" + ); + + assert!( + published_manifest.contains("image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest"), + "the cluster pulls the runner: {published_manifest}" + ); + // The scenario image stays unqualified whichever source is asked for: it carries this deployment's own + // module set, so there is no published copy of it to name. + assert!( + published_manifest.contains("image: et-ws-server-k3s-cluster:latest"), + "the scenario image is still local: {published_manifest}" + ); + assert!( + !published_readme.contains("docker build -t et-ws-wasi-runner:latest"), + "nothing is built for a pulled image: {published_readme}" + ); + assert!( + published_readme.contains("hub=ghcr.io/edge-toolkit/core/et-ws-server:latest"), + "the hub reaches the scenario build from the registry: {published_readme}" + ); +} + +/// A scenario whose agent declares extra runner environment, which every format has to carry. +const WITH_RUNNER_ENV: &str = r#"cluster_name: "runner-env" agents: - name: "math1-twin" runner: "wasi" + env: + OTLP_COLLECTOR_URL: "http://host:5080/api/default/v1" + RUST_LOG: debug resources: - type: "wasi-math1" +"#; + +#[test] +fn an_agents_declared_env_reaches_every_deployment_format() { + // One declaration in the input, three renderings. A format that dropped it would leave a runner + // configured in two deployments out of three, which only shows up when that deployment is run. + // `env:` belongs to the agent, not the cluster, so this is a whole input rather than a spliced line. + let (_test_root, verification_root, output_dir) = scenario_tree(WITH_RUNNER_ENV); + let _regenerated = regenerate_verification(&verification_root, None).unwrap(); + let mise = fs::read_to_string(output_dir.join("mise.toml")).unwrap(); + let compose = fs::read_to_string(output_dir.join("compose.yaml")).unwrap(); + let manifest = fs::read_to_string(output_dir.join("k3s.yaml")).unwrap(); + + assert!(mise.contains("RUST_LOG = \"debug\""), "mise task env: {mise}"); + assert!(compose.contains("RUST_LOG: debug"), "compose service env: {compose}"); + assert!(manifest.contains("- name: RUST_LOG"), "k3s container env: {manifest}"); + + // The derived pair is still there, and still first, so the declared entries add rather than replace. + for rendered in [&mise, &compose, &manifest] { + assert!( + rendered.contains("RUNNER_MODULE"), + "still wired to its module: {rendered}" + ); + assert!(rendered.contains("WS_SERVER_URL"), "still wired to its hub: {rendered}"); + assert!(rendered.contains("http://host:5080/api/default/v1"), "{rendered}"); + } +} + +#[test] +fn generate_deployment_rejects_an_agent_overriding_derived_runner_env() { + // Letting it win would generate files describing one deployment whose runners join another; letting the + // derived value win would silently ignore what the scenario asked for. Neither is worth allowing. + let error = deployment_error_for( + r#"cluster_name: "derived-env" +agents: + - name: "twin" + runner: "web" + env: + WS_SERVER_URL: "ws://elsewhere:8080/ws" + resources: + - type: "math1" "#, ); - let _regenerated = regenerate_verification(&verification_root, None).unwrap(); - (test_root, output_dir) + assert!( + error.contains("WS_SERVER_URL") && error.contains("must own"), + "expected a reserved-env error, got: {error}" + ); +} + +#[test] +fn the_scenario_dockerfile_path_stays_relative_however_shallow_the_output_dir() { + use std::path::Path; + + // A single-component directory has a parent, and it is the empty path rather than `None`. Prefixing it + // blindly yields `/$scenario/Dockerfile`, an absolute path to a directory nobody has -- and the README + // hands that straight to `docker build -f`. + assert_eq!( + scenario_dockerfile_path(Path::new("my-scenario")), + "$scenario/Dockerfile" + ); + assert_eq!( + scenario_dockerfile_path(Path::new("verification/local/output/math1")), + "verification/local/output/$scenario/Dockerfile" + ); +} + +#[test] +fn the_wrapped_module_list_folds_back_into_one_comma_separated_value() { + // The list is wrapped to stay inside the line limit, and it is wrapped by YAML folding rather than by a + // trailing `\`, which the repository bans. Folding is only correct if the breaks come back as separators + // the server accepts, so this parses the generated file rather than trusting the spelling. + let (_test_root, output_dir) = k3s_scenario_with(""); + let text = fs::read_to_string(output_dir.join("compose.yaml")).unwrap(); + + assert!(!text.contains('\\'), "no line continuations survive: {text}"); + + let compose: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); + let paths = compose["services"]["ws-server"]["environment"]["MODULES_PATHS"] + .as_str() + .unwrap(); + + assert!(!paths.contains('\n'), "folded to a single line: {paths}"); + let segments: Vec<&str> = paths.split(',').map(str::trim).collect(); + assert_eq!(segments.first().copied(), Some("/app/services/ws-server/static")); + assert!( + segments.iter().all(|segment| segment.starts_with("/app/")), + "every segment is a path once trimmed: {segments:?}" + ); +} + +#[test] +fn the_artifact_source_decides_what_the_compose_stack_builds() { + let (_local_root, local_dir) = k3s_scenario_with(""); + let (_published_root, published_dir) = k3s_scenario_with(PUBLISHED); + let local = fs::read_to_string(local_dir.join("compose.yaml")).unwrap(); + let published = fs::read_to_string(published_dir.join("compose.yaml")).unwrap(); + + // The build-only hub service exists solely to be a named build context, so it goes when nothing is built. + assert!( + local.contains("ws-server-hub:") && local.contains("hub: service:ws-server-hub"), + "a local stack builds the hub it layers onto: {local}" + ); + assert!( + local.contains("dockerfile: services/ws-wasi-runner/Dockerfile"), + "and builds its runners: {local}" + ); + + assert!( + !published.contains("ws-server-hub"), + "a published stack has no hub to build: {published}" + ); + let hub = "hub: docker-image://ghcr.io/edge-toolkit/core/et-ws-server:latest"; + assert!(published.contains(hub), "it layers onto the released hub: {published}"); + assert!( + published.contains("image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest"), + "and pulls its runners: {published}" + ); + // The scenario's own image is the one thing still built, because no release can carry its module set. + assert!( + published.contains("dockerfile: ") && !published.contains("services/ws-wasi-runner/Dockerfile"), + "leaving only the scenario image to build: {published}" + ); +} + +#[test] +fn the_artifact_source_decides_whether_the_mise_deployment_builds_what_it_runs() { + let (_local_root, local_dir) = k3s_scenario_with(""); + let (_published_root, published_dir) = k3s_scenario_with(PUBLISHED); + let local = fs::read_to_string(local_dir.join("mise.toml")).unwrap(); + let published = fs::read_to_string(published_dir.join("mise.toml")).unwrap(); + + assert!( + local.contains("cargo run --quiet -p et-ws-wasi-runner"), + "a local deployment compiles the runner from the workspace: {local}" + ); + assert!( + !local.contains("cargo:et-ws-wasi-runner"), + "and so declares no released binary: {local}" + ); + + // The released binary is named bare, as a line of its own, so a leftover `cargo run` cannot satisfy this. + assert!( + published.contains("\net-ws-wasi-runner\n"), + "a published deployment runs the released runner: {published}" + ); + assert!(!published.contains("cargo run"), "and builds nothing: {published}"); + // Each released binary is declared as a tool, which is what puts it on `PATH` for the task above. + for crate_name in ["et-ws-server", "et-ws-wasi-runner"] { + let declared = format!("\"cargo:{crate_name}\" = \"latest\""); + assert!(published.contains(&declared), "expected {declared} in: {published}"); + } } #[test] diff --git a/uv.lock b/uv.lock index 2c9ce782..bc3e8f95 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ members = [ "et-ws-pydemo1", "et-ws-pyeye1", "et-ws-pyface1", + "et-ws-pymath1", "et-ws-pyspeech1", "et-ws-wasi-graphics-info", ] @@ -25,16 +26,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -283,6 +284,11 @@ dev = [ { name = "pytest-cov" }, ] +[[package]] +name = "et-ws-pymath1" +version = "0.1.0" +source = { editable = "services/ws-modules/pymath1" } + [[package]] name = "et-ws-pyspeech1" version = "0.1.0" diff --git a/verification/local/input/default.yaml b/verification/local/input/default.yaml new file mode 100644 index 00000000..27d4ae16 --- /dev/null +++ b/verification/local/input/default.yaml @@ -0,0 +1,6 @@ +# Every field but the name left out, so this scenario is whatever the defaults make it. +# It is here to hold those defaults still: the committed output beside it is what an input that asks for +# nothing produces, so a change to any default shows up as a diff rather than as a surprise to whoever +# generates a deployment next. Inside this repository an unstated `artifact_source` infers `local`, which is +# what distinguishes this from its twin under `verification/published`. +cluster_name: "default" diff --git a/verification/local/output/default/Dockerfile b/verification/local/output/default/Dockerfile new file mode 100644 index 00000000..bc0b3989 --- /dev/null +++ b/verification/local/output/default/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +# AUTO-GENERATED by `et-cli` for the `default` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# The hub image (services/ws-server/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. +# +# 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. + +# `hub` is a named build context, not a stage defined here. +# The generated compose file wires it to the hub service with `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 + +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" diff --git a/verification/local/output/default/Dockerfile.dockerignore b/verification/local/output/default/Dockerfile.dockerignore new file mode 100644 index 00000000..193a7d03 --- /dev/null +++ b/verification/local/output/default/Dockerfile.dockerignore @@ -0,0 +1,73 @@ +# AUTO-GENERATED by `et-cli` for the `default` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# 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. + +# AUTO-GENERATED from .gitignore by 'mise run gen:dockerignore' -- do not edit. +# Docker reads only this file; patterns are **/-prefixed to match at any depth +# like .gitignore. Edit .gitignore and regenerate. + +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. +**/.claude/ +**/*.wasm +**/*.onnx +**/target/ +**/.DS_Store +services/ws-wasm-agent/pkg/ +services/ws-modules/pywasm1/pkg/ +services/ws-modules/rdata1/pkg/webr/ +services/ws-modules/rcomm1/pkg/webr/ +services/ws-modules/rmath1/pkg/webr/ +services/ws-modules/js-data1/pkg/et_ws_js_data1.js +services/ws-server/static/models/ +**/.zig-cache/ +**/zig-out/ +**/*.o +**/*.pem +**/mprocs.log +**/__pycache__/ +**/.pytest_cache/ +**/.python-version +**/node_modules/ +**/.venv/ +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). +**/obj/ +services/ws-modules/dotnet-data1/bin/ +# Gradle project caches and build output plus the Kotlin session dir, all regenerated by any Gradle run. +**/.gradle/ +**/.kotlin/ +services/ws-modules/kotlin-data1/build/ +services/ws-modules/kotlin-math1/build/ +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +lcov.info +**/coverage-python.xml +**/.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +**/*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). +.vscode/* +!.vscode/extensions.json +**/.ruff_cache/ +**/.lycheecache +# Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). +**/.dart_tool/ +services/ws-server/storage/ +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. +**/.git/ +**/Dockerfile* +README.md + +# 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. diff --git a/verification/local/output/default/README.md b/verification/local/output/default/README.md new file mode 100644 index 00000000..ecd9b7d6 --- /dev/null +++ b/verification/local/output/default/README.md @@ -0,0 +1,95 @@ +# default + +This directory contains generated deployment configs for the `default` scenario. +Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. + +No workflow modules were selected in the scenario input. + +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. + +## 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 +``` + +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose up --build +``` + +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. The hub is build-only and never runs as a +container of its own. +`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 . + +Stop the scenario with: + +```bash +docker compose down +``` + +## Run With k3s + +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`. From the repository root: + +```bash +scenario=default +hub=et-ws-server-hub:latest +image="et-ws-server-$scenario:latest" +dockerfile="verification/local/output/$scenario/Dockerfile" +docker build -t "$hub" -f services/ws-server/Dockerfile . +docker build --build-context "hub=docker-image://$hub" -t "$image" -f "$dockerfile" . +docker save "$image" | sudo k3s ctr images import - +``` + +### Load The Credential + +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: + +```bash +ns=et-default +kubectl create namespace "$ns" --save-config +kubectl create secret generic "$ns-secrets" --from-env-file=secrets.env -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 +``` diff --git a/verification/local/output/default/compose.yaml b/verification/local/output/default/compose.yaml new file mode 100644 index 00000000..33b1e13d --- /dev/null +++ b/verification/local/output/default/compose.yaml @@ -0,0 +1,61 @@ +services: + openobserve: + image: openobserve/openobserve:v0.91.5 + healthcheck: + test: + - CMD + - /openobserve + - node + - status + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + ports: + - 127.0.0.1:5080:5080 + env_file: + - secrets.env + environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com + ZO_DATA_DIR: /data + volumes: + - openobserve-data:/data + ws-server-hub: + build: + context: ../../../.. + dockerfile: services/ws-server/Dockerfile + scale: 0 + ws-server: + healthcheck: + test: + - CMD + - curl + - -fsS + - http://localhost:8080/health + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + env_file: + - secrets.env + build: + context: ../../../.. + dockerfile: verification/local/output/default/Dockerfile + additional_contexts: + hub: service:ws-server-hub + network_mode: host + environment: + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent" + OTLP_AUTH_USERNAME: root@example.com + OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 + STORAGE_URL: file:///app/storage + volumes: + - ws-server-storage:/app/storage + depends_on: + openobserve: + condition: service_healthy +volumes: + openobserve-data: {} + ws-server-storage: {} diff --git a/verification/local/output/default/k3s.yaml b/verification/local/output/default/k3s.yaml new file mode 100644 index 00000000..7bd27618 --- /dev/null +++ b/verification/local/output/default/k3s.yaml @@ -0,0 +1,224 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: et-default +--- +apiVersion: v1 +kind: ConfigMap +data: + RUST_LOG: warn + ZO_DATA_DIR: /data + ZO_ROOT_USER_EMAIL: root@example.com +metadata: + name: openobserve-config + namespace: et-default +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openobserve-data + namespace: et-default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-default +spec: + replicas: 1 + selector: + matchLabels: + app: openobserve + template: + metadata: + labels: + app: openobserve + spec: + containers: + - envFrom: + - configMapRef: + name: openobserve-config + - secretRef: + name: et-default-secrets + image: openobserve/openobserve:v0.91.5 + imagePullPolicy: IfNotPresent + name: openobserve + ports: + - containerPort: 5080 + readinessProbe: + exec: + command: + - /openobserve + - node + - status + failureThreshold: 20 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /data + name: openobserve-data + - mountPath: /tmp + name: openobserve-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: openobserve-data + persistentVolumeClaim: + claimName: openobserve-data + - emptyDir: {} + name: openobserve-tmp +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-default +spec: + ports: + - name: http + port: 5080 + targetPort: 5080 + selector: + app: openobserve +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ws-server-storage + namespace: et-default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-default +spec: + replicas: 1 + selector: + matchLabels: + app: ws-server + template: + metadata: + labels: + app: ws-server + spec: + containers: + - args: + - --agent-registry + - /app/runtime/registry.yaml + command: + - et-ws-server + env: + - name: MODULES_PATHS + value: /app/services/ws-server/static,/app/services/ws-wasm-agent + - name: OTLP_AUTH_USERNAME + value: root@example.com + - name: OTLP_COLLECTOR_URL + value: http://openobserve:5080/api/default/v1 + - name: STORAGE_URL + value: file:///app/storage + - name: TLS_CERT_FILE + value: /app/runtime/cert.pem + - name: TLS_KEY_FILE + value: /app/runtime/key.pem + envFrom: + - secretRef: + name: et-default-secrets + image: et-ws-server-default:latest + imagePullPolicy: IfNotPresent + name: ws-server + ports: + - containerPort: 8080 + - containerPort: 8443 + readinessProbe: + failureThreshold: 20 + httpGet: + path: /health + port: 8080 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /app/storage + name: ws-server-storage + - mountPath: /app/runtime + name: ws-server-runtime + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: ws-server-storage + persistentVolumeClaim: + claimName: ws-server-storage + - emptyDir: {} + name: ws-server-runtime +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-default +spec: + ports: + - name: ws + port: 8080 + targetPort: 8080 + - name: wss + port: 8443 + targetPort: 8443 + selector: + app: ws-server diff --git a/verification/local/output/default/mise.toml b/verification/local/output/default/mise.toml new file mode 100644 index 00000000..a91a920d --- /dev/null +++ b/verification/local/output/default/mise.toml @@ -0,0 +1,34 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + +[env._] +file = "secrets.env" + +[tasks.generated-scenario] +depends = ["openobserve", "ws-server"] +description = "Run generated scenario for default" + +[tasks.open-o2] +description = "Open the OpenObserve UI" +run = "open http://localhost:5080/" + +[tasks.openobserve] +alias = "o2" +run = """ +image=openobserve/openobserve:v0.91.5 +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" +""" + +[tasks.ws-server] +description = "Run the WebSocket server" +dir = "../../../../services/ws-server" +run = """ +MODULES_PATHS="static, ../ws-wasm-agent" +export MODULES_PATHS +cargo run +""" + +[tools] +"cargo:open" = "latest" diff --git a/verification/local/output/default/secrets.env b/verification/local/output/default/secrets.env new file mode 100644 index 00000000..490e8182 --- /dev/null +++ b/verification/local/output/default/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Doloribus_illum3779 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Doloribus_illum3779 diff --git a/verification/local/output/facility-security-scenario/Dockerfile b/verification/local/output/facility-security-scenario/Dockerfile index a5636363..a80d4661 100644 --- a/verification/local/output/facility-security-scenario/Dockerfile +++ b/verification/local/output/facility-security-scenario/Dockerfile @@ -128,6 +128,8 @@ EOF # hadolint ignore=DL3006 FROM hub +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + COPY --chown=10001:10001 services/ws-modules/face-detection/pkg /app/services/ws-modules/face-detection/pkg COPY --chown=10001:10001 services/ws-modules/har1/pkg /app/services/ws-modules/har1/pkg COPY --chown=10001:10001 services/ws-modules/pyface1/pkg /app/services/ws-modules/pyface1/pkg diff --git a/verification/local/output/facility-security-scenario/Dockerfile.dockerignore b/verification/local/output/facility-security-scenario/Dockerfile.dockerignore index 4ab4f1ea..d05c76be 100644 --- a/verification/local/output/facility-security-scenario/Dockerfile.dockerignore +++ b/verification/local/output/facility-security-scenario/Dockerfile.dockerignore @@ -59,10 +59,11 @@ lcov.info # Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). **/.dart_tool/ services/ws-server/storage/ -# Per-scenario credential file written by `et-cli generate-deployment` beside its mise.toml / compose.yaml. -# Derived from the scenario input, so regenerating a deployment rewrites it; it is the one place the password -# lives, which is what keeps it out of the generated files that ARE committed under verification/. -**/secrets.env +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. **/.git/ **/Dockerfile* README.md diff --git a/verification/local/output/facility-security-scenario/README.md b/verification/local/output/facility-security-scenario/README.md index 86d9443e..7cde9801 100644 --- a/verification/local/output/facility-security-scenario/README.md +++ b/verification/local/output/facility-security-scenario/README.md @@ -5,9 +5,10 @@ Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. The scenario exposes these workflow modules: face-detection, har1, pyface1. -`secrets.env` holds the scenario's derived OpenObserve and OTLP credentials, and is deliberately not -committed. Regenerating this scenario writes it; if it is missing, run -`mise run regen-verification` (or `et-cli generate-deployment`) before starting the stack. +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. ## Run With Mise @@ -71,8 +72,8 @@ docker save "$image" | sudo k3s ctr images import - ### Load The Credential -`secrets.env` is generated but deliberately not committed, so the `Secret` is created from it rather -than shipped inside `k3s.yaml`. From this directory: +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: ```bash ns=et-facility-security-scenario diff --git a/verification/local/output/facility-security-scenario/compose.yaml b/verification/local/output/facility-security-scenario/compose.yaml index a1051771..3fcf2ea2 100644 --- a/verification/local/output/facility-security-scenario/compose.yaml +++ b/verification/local/output/facility-security-scenario/compose.yaml @@ -14,9 +14,10 @@ services: ports: - 127.0.0.1:5080:5080 env_file: - - ../../../../config/o2.env - secrets.env environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com ZO_DATA_DIR: /data volumes: - openobserve-data:/data @@ -45,16 +46,17 @@ services: hub: service:ws-server-hub network_mode: host environment: - MODULES_PATHS: "/app/services/ws-server/static,\ - /app/services/ws-wasm-agent,\ - /app/services/ws-modules/face-detection,\ - /app/services/ws-modules/har1,\ - /app/services/ws-modules/pyface1,\ - /app/data/model-modules/model-face1,\ - /app/node_modules/onnxruntime-web,\ - /app/data/model-modules/model-har-motion1,\ - /app/generated/python-ws,\ + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent, + /app/services/ws-modules/face-detection, + /app/services/ws-modules/har1, + /app/services/ws-modules/pyface1, + /app/data/model-modules/model-face1, + /app/node_modules/onnxruntime-web, + /app/data/model-modules/model-har-motion1, + /app/generated/python-ws, /app/node_modules/pyodide" + OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 STORAGE_URL: file:///app/storage volumes: diff --git a/verification/local/output/facility-security-scenario/k3s.yaml b/verification/local/output/facility-security-scenario/k3s.yaml index bde8e1d6..69c1a38f 100644 --- a/verification/local/output/facility-security-scenario/k3s.yaml +++ b/verification/local/output/facility-security-scenario/k3s.yaml @@ -147,6 +147,8 @@ spec: env: - name: MODULES_PATHS value: /app/services/ws-server/static,/app/services/ws-wasm-agent,/app/services/ws-modules/face-detection,/app/services/ws-modules/har1,/app/services/ws-modules/pyface1,/app/data/model-modules/model-face1,/app/node_modules/onnxruntime-web,/app/data/model-modules/model-har-motion1,/app/generated/python-ws,/app/node_modules/pyodide + - name: OTLP_AUTH_USERNAME + value: root@example.com - name: OTLP_COLLECTOR_URL value: http://openobserve:5080/api/default/v1 - name: STORAGE_URL diff --git a/verification/local/output/facility-security-scenario/mise.toml b/verification/local/output/facility-security-scenario/mise.toml index b4cdf9f6..e229ef66 100644 --- a/verification/local/output/facility-security-scenario/mise.toml +++ b/verification/local/output/facility-security-scenario/mise.toml @@ -1,3 +1,6 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + [env._] file = "secrets.env" @@ -11,10 +14,11 @@ run = "open http://localhost:5080/" [tasks.openobserve] alias = "o2" -dir = "../../../.." run = """ image=openobserve/openobserve:v0.91.5 -docker run --rm --name openobserve -p 127.0.0.1:5080:5080 --env-file config/o2.env -e ZO_ROOT_USER_PASSWORD "$image" +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" """ [tasks.ws-server] diff --git a/verification/local/output/facility-security-scenario/secrets.env b/verification/local/output/facility-security-scenario/secrets.env new file mode 100644 index 00000000..cb3d92b9 --- /dev/null +++ b/verification/local/output/facility-security-scenario/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Et+quis8784 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Et+quis8784 diff --git a/verification/local/output/math1/Dockerfile b/verification/local/output/math1/Dockerfile index c02a87ba..f3b1dc34 100644 --- a/verification/local/output/math1/Dockerfile +++ b/verification/local/output/math1/Dockerfile @@ -21,5 +21,7 @@ # hadolint ignore=DL3006 FROM hub +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + COPY --chown=10001:10001 services/ws-modules/math1/pkg /app/services/ws-modules/math1/pkg COPY --chown=10001:10001 services/ws-modules/math1-sender/pkg /app/services/ws-modules/math1-sender/pkg diff --git a/verification/local/output/math1/Dockerfile.dockerignore b/verification/local/output/math1/Dockerfile.dockerignore index 6a428e28..57eef0b2 100644 --- a/verification/local/output/math1/Dockerfile.dockerignore +++ b/verification/local/output/math1/Dockerfile.dockerignore @@ -59,10 +59,11 @@ lcov.info # Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). **/.dart_tool/ services/ws-server/storage/ -# Per-scenario credential file written by `et-cli generate-deployment` beside its mise.toml / compose.yaml. -# Derived from the scenario input, so regenerating a deployment rewrites it; it is the one place the password -# lives, which is what keeps it out of the generated files that ARE committed under verification/. -**/secrets.env +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. **/.git/ **/Dockerfile* README.md diff --git a/verification/local/output/math1/README.md b/verification/local/output/math1/README.md index 90305c91..1b7ea5e6 100644 --- a/verification/local/output/math1/README.md +++ b/verification/local/output/math1/README.md @@ -5,9 +5,10 @@ Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. The scenario exposes these workflow modules: math1, math1-sender. -`secrets.env` holds the scenario's derived OpenObserve and OTLP credentials, and is deliberately not -committed. Regenerating this scenario writes it; if it is missing, run -`mise run regen-verification` (or `et-cli generate-deployment`) before starting the stack. +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. ## Run With Mise @@ -78,8 +79,8 @@ docker save et-ws-web-runner:latest | sudo k3s ctr images import - ### Load The Credential -`secrets.env` is generated but deliberately not committed, so the `Secret` is created from it rather -than shipped inside `k3s.yaml`. From this directory: +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: ```bash ns=et-math1 diff --git a/verification/local/output/math1/compose.yaml b/verification/local/output/math1/compose.yaml index 1c2c892e..1d456054 100644 --- a/verification/local/output/math1/compose.yaml +++ b/verification/local/output/math1/compose.yaml @@ -14,9 +14,10 @@ services: ports: - 127.0.0.1:5080:5080 env_file: - - ../../../../config/o2.env - secrets.env environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com ZO_DATA_DIR: /data volumes: - openobserve-data:/data @@ -45,10 +46,11 @@ services: hub: service:ws-server-hub network_mode: host environment: - MODULES_PATHS: "/app/services/ws-server/static,\ - /app/services/ws-wasm-agent,\ - /app/services/ws-modules/math1,\ + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent, + /app/services/ws-modules/math1, /app/services/ws-modules/math1-sender" + OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 STORAGE_URL: file:///app/storage volumes: diff --git a/verification/local/output/math1/k3s.yaml b/verification/local/output/math1/k3s.yaml index d7e034ef..135dc038 100644 --- a/verification/local/output/math1/k3s.yaml +++ b/verification/local/output/math1/k3s.yaml @@ -147,6 +147,8 @@ spec: env: - name: MODULES_PATHS value: /app/services/ws-server/static,/app/services/ws-wasm-agent,/app/services/ws-modules/math1,/app/services/ws-modules/math1-sender + - name: OTLP_AUTH_USERNAME + value: root@example.com - name: OTLP_COLLECTOR_URL value: http://openobserve:5080/api/default/v1 - name: STORAGE_URL diff --git a/verification/local/output/math1/mise.toml b/verification/local/output/math1/mise.toml index 3232bfa4..86573f24 100644 --- a/verification/local/output/math1/mise.toml +++ b/verification/local/output/math1/mise.toml @@ -1,3 +1,6 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + [env._] file = "secrets.env" @@ -33,10 +36,11 @@ run = "open http://localhost:5080/" [tasks.openobserve] alias = "o2" -dir = "../../../.." run = """ image=openobserve/openobserve:v0.91.5 -docker run --rm --name openobserve -p 127.0.0.1:5080:5080 --env-file config/o2.env -e ZO_ROOT_USER_PASSWORD "$image" +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" """ [tasks.ws-server] diff --git a/verification/local/output/math1/secrets.env b/verification/local/output/math1/secrets.env new file mode 100644 index 00000000..3b2de047 --- /dev/null +++ b/verification/local/output/math1/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Qui%recusandae6594 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Qui%recusandae6594 diff --git a/verification/local/output/pyo3-math1/Dockerfile b/verification/local/output/pyo3-math1/Dockerfile index 75889d00..2ba9ac72 100644 --- a/verification/local/output/pyo3-math1/Dockerfile +++ b/verification/local/output/pyo3-math1/Dockerfile @@ -21,5 +21,7 @@ # hadolint ignore=DL3006 FROM hub +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + COPY --chown=10001:10001 services/ws-modules/pyo3-math1/pkg /app/services/ws-modules/pyo3-math1/pkg COPY --chown=10001:10001 services/ws-modules/wasi-math1-sender/pkg /app/services/ws-modules/wasi-math1-sender/pkg diff --git a/verification/local/output/pyo3-math1/Dockerfile.dockerignore b/verification/local/output/pyo3-math1/Dockerfile.dockerignore index 03030966..aecaba84 100644 --- a/verification/local/output/pyo3-math1/Dockerfile.dockerignore +++ b/verification/local/output/pyo3-math1/Dockerfile.dockerignore @@ -59,10 +59,11 @@ lcov.info # Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). **/.dart_tool/ services/ws-server/storage/ -# Per-scenario credential file written by `et-cli generate-deployment` beside its mise.toml / compose.yaml. -# Derived from the scenario input, so regenerating a deployment rewrites it; it is the one place the password -# lives, which is what keeps it out of the generated files that ARE committed under verification/. -**/secrets.env +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. **/.git/ **/Dockerfile* README.md diff --git a/verification/local/output/pyo3-math1/README.md b/verification/local/output/pyo3-math1/README.md index fb314f0d..a95e793f 100644 --- a/verification/local/output/pyo3-math1/README.md +++ b/verification/local/output/pyo3-math1/README.md @@ -5,9 +5,10 @@ Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. The scenario exposes these workflow modules: pyo3-math1, wasi-math1-sender. -`secrets.env` holds the scenario's derived OpenObserve and OTLP credentials, and is deliberately not -committed. Regenerating this scenario writes it; if it is missing, run -`mise run regen-verification` (or `et-cli generate-deployment`) before starting the stack. +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. ## Run With Mise @@ -80,8 +81,8 @@ docker save et-ws-pyo3-runner:latest | sudo k3s ctr images import - ### Load The Credential -`secrets.env` is generated but deliberately not committed, so the `Secret` is created from it rather -than shipped inside `k3s.yaml`. From this directory: +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: ```bash ns=et-pyo3-math1 diff --git a/verification/local/output/pyo3-math1/compose.yaml b/verification/local/output/pyo3-math1/compose.yaml index 8a3ad58a..079f9374 100644 --- a/verification/local/output/pyo3-math1/compose.yaml +++ b/verification/local/output/pyo3-math1/compose.yaml @@ -14,9 +14,10 @@ services: ports: - 127.0.0.1:5080:5080 env_file: - - ../../../../config/o2.env - secrets.env environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com ZO_DATA_DIR: /data volumes: - openobserve-data:/data @@ -45,10 +46,11 @@ services: hub: service:ws-server-hub network_mode: host environment: - MODULES_PATHS: "/app/services/ws-server/static,\ - /app/services/ws-wasm-agent,\ - /app/services/ws-modules/pyo3-math1,\ + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent, + /app/services/ws-modules/pyo3-math1, /app/services/ws-modules/wasi-math1-sender" + OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 STORAGE_URL: file:///app/storage volumes: diff --git a/verification/local/output/pyo3-math1/k3s.yaml b/verification/local/output/pyo3-math1/k3s.yaml index 6162afad..a6d8b651 100644 --- a/verification/local/output/pyo3-math1/k3s.yaml +++ b/verification/local/output/pyo3-math1/k3s.yaml @@ -147,6 +147,8 @@ spec: env: - name: MODULES_PATHS value: /app/services/ws-server/static,/app/services/ws-wasm-agent,/app/services/ws-modules/pyo3-math1,/app/services/ws-modules/wasi-math1-sender + - name: OTLP_AUTH_USERNAME + value: root@example.com - name: OTLP_COLLECTOR_URL value: http://openobserve:5080/api/default/v1 - name: STORAGE_URL diff --git a/verification/local/output/pyo3-math1/mise.toml b/verification/local/output/pyo3-math1/mise.toml index 8aa95260..de014d24 100644 --- a/verification/local/output/pyo3-math1/mise.toml +++ b/verification/local/output/pyo3-math1/mise.toml @@ -1,3 +1,6 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + [env._] file = "secrets.env" @@ -22,10 +25,11 @@ run = "open http://localhost:5080/" [tasks.openobserve] alias = "o2" -dir = "../../../.." run = """ image=openobserve/openobserve:v0.91.5 -docker run --rm --name openobserve -p 127.0.0.1:5080:5080 --env-file config/o2.env -e ZO_ROOT_USER_PASSWORD "$image" +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" """ [tasks.pyo3-math1-twin] diff --git a/verification/local/output/pyo3-math1/secrets.env b/verification/local/output/pyo3-math1/secrets.env new file mode 100644 index 00000000..c1053cda --- /dev/null +++ b/verification/local/output/pyo3-math1/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Voluptatem_ut8447 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Voluptatem_ut8447 diff --git a/verification/local/output/wasi-math1/Dockerfile b/verification/local/output/wasi-math1/Dockerfile index 1403c6a5..afea0828 100644 --- a/verification/local/output/wasi-math1/Dockerfile +++ b/verification/local/output/wasi-math1/Dockerfile @@ -21,5 +21,7 @@ # hadolint ignore=DL3006 FROM hub +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + COPY --chown=10001:10001 services/ws-modules/wasi-math1/pkg /app/services/ws-modules/wasi-math1/pkg COPY --chown=10001:10001 services/ws-modules/wasi-math1-sender/pkg /app/services/ws-modules/wasi-math1-sender/pkg diff --git a/verification/local/output/wasi-math1/Dockerfile.dockerignore b/verification/local/output/wasi-math1/Dockerfile.dockerignore index 389fcf5d..318d9b0a 100644 --- a/verification/local/output/wasi-math1/Dockerfile.dockerignore +++ b/verification/local/output/wasi-math1/Dockerfile.dockerignore @@ -59,10 +59,11 @@ lcov.info # Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). **/.dart_tool/ services/ws-server/storage/ -# Per-scenario credential file written by `et-cli generate-deployment` beside its mise.toml / compose.yaml. -# Derived from the scenario input, so regenerating a deployment rewrites it; it is the one place the password -# lives, which is what keeps it out of the generated files that ARE committed under verification/. -**/secrets.env +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. **/.git/ **/Dockerfile* README.md diff --git a/verification/local/output/wasi-math1/README.md b/verification/local/output/wasi-math1/README.md index b513b6c3..23dc88e9 100644 --- a/verification/local/output/wasi-math1/README.md +++ b/verification/local/output/wasi-math1/README.md @@ -5,9 +5,10 @@ Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. The scenario exposes these workflow modules: wasi-math1, wasi-math1-sender. -`secrets.env` holds the scenario's derived OpenObserve and OTLP credentials, and is deliberately not -committed. Regenerating this scenario writes it; if it is missing, run -`mise run regen-verification` (or `et-cli generate-deployment`) before starting the stack. +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. ## Run With Mise @@ -78,8 +79,8 @@ docker save et-ws-wasi-runner:latest | sudo k3s ctr images import - ### Load The Credential -`secrets.env` is generated but deliberately not committed, so the `Secret` is created from it rather -than shipped inside `k3s.yaml`. From this directory: +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: ```bash ns=et-wasi-math1 diff --git a/verification/local/output/wasi-math1/compose.yaml b/verification/local/output/wasi-math1/compose.yaml index 21f9a62d..10188337 100644 --- a/verification/local/output/wasi-math1/compose.yaml +++ b/verification/local/output/wasi-math1/compose.yaml @@ -14,9 +14,10 @@ services: ports: - 127.0.0.1:5080:5080 env_file: - - ../../../../config/o2.env - secrets.env environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com ZO_DATA_DIR: /data volumes: - openobserve-data:/data @@ -45,10 +46,11 @@ services: hub: service:ws-server-hub network_mode: host environment: - MODULES_PATHS: "/app/services/ws-server/static,\ - /app/services/ws-wasm-agent,\ - /app/services/ws-modules/wasi-math1,\ + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent, + /app/services/ws-modules/wasi-math1, /app/services/ws-modules/wasi-math1-sender" + OTLP_AUTH_USERNAME: root@example.com OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 STORAGE_URL: file:///app/storage volumes: diff --git a/verification/local/output/wasi-math1/k3s.yaml b/verification/local/output/wasi-math1/k3s.yaml index d5eb20f0..5f38b74b 100644 --- a/verification/local/output/wasi-math1/k3s.yaml +++ b/verification/local/output/wasi-math1/k3s.yaml @@ -147,6 +147,8 @@ spec: env: - name: MODULES_PATHS value: /app/services/ws-server/static,/app/services/ws-wasm-agent,/app/services/ws-modules/wasi-math1,/app/services/ws-modules/wasi-math1-sender + - name: OTLP_AUTH_USERNAME + value: root@example.com - name: OTLP_COLLECTOR_URL value: http://openobserve:5080/api/default/v1 - name: STORAGE_URL diff --git a/verification/local/output/wasi-math1/mise.toml b/verification/local/output/wasi-math1/mise.toml index 5b5adef1..077b2b0c 100644 --- a/verification/local/output/wasi-math1/mise.toml +++ b/verification/local/output/wasi-math1/mise.toml @@ -1,3 +1,6 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + [env._] file = "secrets.env" @@ -22,10 +25,11 @@ run = "open http://localhost:5080/" [tasks.openobserve] alias = "o2" -dir = "../../../.." run = """ image=openobserve/openobserve:v0.91.5 -docker run --rm --name openobserve -p 127.0.0.1:5080:5080 --env-file config/o2.env -e ZO_ROOT_USER_PASSWORD "$image" +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" """ [tasks.wasi-math1-twin] diff --git a/verification/local/output/wasi-math1/secrets.env b/verification/local/output/wasi-math1/secrets.env new file mode 100644 index 00000000..dafea23b --- /dev/null +++ b/verification/local/output/wasi-math1/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Aspernatur%in2445 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Aspernatur%in2445 diff --git a/verification/published/input/default.yaml b/verification/published/input/default.yaml new file mode 100644 index 00000000..e1be31b6 --- /dev/null +++ b/verification/published/input/default.yaml @@ -0,0 +1,5 @@ +# The `local` tree's `default` scenario, rendered against released artifacts rather than builds of this tree. +# `artifact_source` is the one field stated, and it has to be: generating here is itself the proof that an +# unstated one infers `local`, so the released rendering can only be asked for outright. +cluster_name: "default" +artifact_source: "published" diff --git a/verification/published/input/math1.yaml b/verification/published/input/math1.yaml new file mode 100644 index 00000000..3a655024 --- /dev/null +++ b/verification/published/input/math1.yaml @@ -0,0 +1,16 @@ +cluster_name: "math1" +deployment_type: "mise" + +# The `local` tree's scenario of the same name, rendered against released artifacts rather than built ones. +artifact_source: "published" + +agents: + - name: "math1-twin" + runner: "web" + resources: + - type: "math1" + + - name: "math1-trigger" + runner: "web" + resources: + - type: "math1-sender" diff --git a/verification/published/input/pyo3-math1.yaml b/verification/published/input/pyo3-math1.yaml new file mode 100644 index 00000000..a4a1de31 --- /dev/null +++ b/verification/published/input/pyo3-math1.yaml @@ -0,0 +1,16 @@ +cluster_name: "pyo3-math1" +deployment_type: "mise" + +# The `local` tree's scenario of the same name, rendered against released artifacts rather than built ones. +artifact_source: "published" + +agents: + - name: "math1-trigger" + runner: "wasi" + resources: + - type: "wasi-math1-sender" + + - name: "pyo3-math1-twin" + runner: "pyo3" + resources: + - type: "pyo3-math1" diff --git a/verification/published/input/wasi-math1.yaml b/verification/published/input/wasi-math1.yaml new file mode 100644 index 00000000..f3986451 --- /dev/null +++ b/verification/published/input/wasi-math1.yaml @@ -0,0 +1,16 @@ +cluster_name: "wasi-math1" +deployment_type: "mise" + +# The `local` tree's scenario of the same name, rendered against released artifacts rather than built ones. +artifact_source: "published" + +agents: + - name: "math1-trigger" + runner: "wasi" + resources: + - type: "wasi-math1-sender" + + - name: "wasi-math1-twin" + runner: "wasi" + resources: + - type: "wasi-math1" diff --git a/verification/published/output/default/Dockerfile b/verification/published/output/default/Dockerfile new file mode 100644 index 00000000..bc0b3989 --- /dev/null +++ b/verification/published/output/default/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +# AUTO-GENERATED by `et-cli` for the `default` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# The hub image (services/ws-server/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. +# +# 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. + +# `hub` is a named build context, not a stage defined here. +# The generated compose file wires it to the hub service with `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 + +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" diff --git a/verification/published/output/default/Dockerfile.dockerignore b/verification/published/output/default/Dockerfile.dockerignore new file mode 100644 index 00000000..193a7d03 --- /dev/null +++ b/verification/published/output/default/Dockerfile.dockerignore @@ -0,0 +1,73 @@ +# AUTO-GENERATED by `et-cli` for the `default` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# 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. + +# AUTO-GENERATED from .gitignore by 'mise run gen:dockerignore' -- do not edit. +# Docker reads only this file; patterns are **/-prefixed to match at any depth +# like .gitignore. Edit .gitignore and regenerate. + +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. +**/.claude/ +**/*.wasm +**/*.onnx +**/target/ +**/.DS_Store +services/ws-wasm-agent/pkg/ +services/ws-modules/pywasm1/pkg/ +services/ws-modules/rdata1/pkg/webr/ +services/ws-modules/rcomm1/pkg/webr/ +services/ws-modules/rmath1/pkg/webr/ +services/ws-modules/js-data1/pkg/et_ws_js_data1.js +services/ws-server/static/models/ +**/.zig-cache/ +**/zig-out/ +**/*.o +**/*.pem +**/mprocs.log +**/__pycache__/ +**/.pytest_cache/ +**/.python-version +**/node_modules/ +**/.venv/ +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). +**/obj/ +services/ws-modules/dotnet-data1/bin/ +# Gradle project caches and build output plus the Kotlin session dir, all regenerated by any Gradle run. +**/.gradle/ +**/.kotlin/ +services/ws-modules/kotlin-data1/build/ +services/ws-modules/kotlin-math1/build/ +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +lcov.info +**/coverage-python.xml +**/.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +**/*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). +.vscode/* +!.vscode/extensions.json +**/.ruff_cache/ +**/.lycheecache +# Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). +**/.dart_tool/ +services/ws-server/storage/ +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. +**/.git/ +**/Dockerfile* +README.md + +# 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. diff --git a/verification/published/output/default/README.md b/verification/published/output/default/README.md new file mode 100644 index 00000000..3fdc9a72 --- /dev/null +++ b/verification/published/output/default/README.md @@ -0,0 +1,104 @@ +# default + +This directory contains generated deployment configs for the `default` scenario. +Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. + +No workflow modules were selected in the scenario input. + +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. + +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. + +## Run With Mise + +Fetch the binaries the tasks below name before the first run: + +```bash +mise install +``` + +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 +``` + +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose up --build +``` + +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 as a build context. +`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 . + +Stop the scenario with: + +```bash +docker compose down +``` + +## Run With k3s + +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 module-less hub image and takes that +hub as a _named build context_ rather than building it, so a plain `docker build` of the +scenario Dockerfile fails on `FROM hub` -- pointing the context at the published hub is what +supplies it. From the repository root: + +```bash +scenario=default +hub=ghcr.io/edge-toolkit/core/et-ws-server:latest +image="et-ws-server-$scenario:latest" +dockerfile="verification/published/output/$scenario/Dockerfile" +docker build --build-context "hub=docker-image://$hub" -t "$image" -f "$dockerfile" . +docker save "$image" | sudo k3s ctr images import - +``` + +### Load The Credential + +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: + +```bash +ns=et-default +kubectl create namespace "$ns" --save-config +kubectl create secret generic "$ns-secrets" --from-env-file=secrets.env -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 +``` diff --git a/verification/published/output/default/compose.yaml b/verification/published/output/default/compose.yaml new file mode 100644 index 00000000..1ce1ab76 --- /dev/null +++ b/verification/published/output/default/compose.yaml @@ -0,0 +1,56 @@ +services: + openobserve: + image: openobserve/openobserve:v0.91.5 + healthcheck: + test: + - CMD + - /openobserve + - node + - status + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + ports: + - 127.0.0.1:5080:5080 + env_file: + - secrets.env + environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com + ZO_DATA_DIR: /data + volumes: + - openobserve-data:/data + ws-server: + healthcheck: + test: + - CMD + - curl + - -fsS + - http://localhost:8080/health + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + env_file: + - secrets.env + build: + context: ../../../.. + dockerfile: verification/published/output/default/Dockerfile + additional_contexts: + hub: docker-image://ghcr.io/edge-toolkit/core/et-ws-server:latest + network_mode: host + environment: + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent" + OTLP_AUTH_USERNAME: root@example.com + OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 + STORAGE_URL: file:///app/storage + volumes: + - ws-server-storage:/app/storage + depends_on: + openobserve: + condition: service_healthy +volumes: + openobserve-data: {} + ws-server-storage: {} diff --git a/verification/published/output/default/k3s.yaml b/verification/published/output/default/k3s.yaml new file mode 100644 index 00000000..7bd27618 --- /dev/null +++ b/verification/published/output/default/k3s.yaml @@ -0,0 +1,224 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: et-default +--- +apiVersion: v1 +kind: ConfigMap +data: + RUST_LOG: warn + ZO_DATA_DIR: /data + ZO_ROOT_USER_EMAIL: root@example.com +metadata: + name: openobserve-config + namespace: et-default +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openobserve-data + namespace: et-default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-default +spec: + replicas: 1 + selector: + matchLabels: + app: openobserve + template: + metadata: + labels: + app: openobserve + spec: + containers: + - envFrom: + - configMapRef: + name: openobserve-config + - secretRef: + name: et-default-secrets + image: openobserve/openobserve:v0.91.5 + imagePullPolicy: IfNotPresent + name: openobserve + ports: + - containerPort: 5080 + readinessProbe: + exec: + command: + - /openobserve + - node + - status + failureThreshold: 20 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /data + name: openobserve-data + - mountPath: /tmp + name: openobserve-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: openobserve-data + persistentVolumeClaim: + claimName: openobserve-data + - emptyDir: {} + name: openobserve-tmp +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-default +spec: + ports: + - name: http + port: 5080 + targetPort: 5080 + selector: + app: openobserve +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ws-server-storage + namespace: et-default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-default +spec: + replicas: 1 + selector: + matchLabels: + app: ws-server + template: + metadata: + labels: + app: ws-server + spec: + containers: + - args: + - --agent-registry + - /app/runtime/registry.yaml + command: + - et-ws-server + env: + - name: MODULES_PATHS + value: /app/services/ws-server/static,/app/services/ws-wasm-agent + - name: OTLP_AUTH_USERNAME + value: root@example.com + - name: OTLP_COLLECTOR_URL + value: http://openobserve:5080/api/default/v1 + - name: STORAGE_URL + value: file:///app/storage + - name: TLS_CERT_FILE + value: /app/runtime/cert.pem + - name: TLS_KEY_FILE + value: /app/runtime/key.pem + envFrom: + - secretRef: + name: et-default-secrets + image: et-ws-server-default:latest + imagePullPolicy: IfNotPresent + name: ws-server + ports: + - containerPort: 8080 + - containerPort: 8443 + readinessProbe: + failureThreshold: 20 + httpGet: + path: /health + port: 8080 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /app/storage + name: ws-server-storage + - mountPath: /app/runtime + name: ws-server-runtime + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: ws-server-storage + persistentVolumeClaim: + claimName: ws-server-storage + - emptyDir: {} + name: ws-server-runtime +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-default +spec: + ports: + - name: ws + port: 8080 + targetPort: 8080 + - name: wss + port: 8443 + targetPort: 8443 + selector: + app: ws-server diff --git a/verification/published/output/default/mise.toml b/verification/published/output/default/mise.toml new file mode 100644 index 00000000..37125eae --- /dev/null +++ b/verification/published/output/default/mise.toml @@ -0,0 +1,35 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + +[env._] +file = "secrets.env" + +[tasks.generated-scenario] +depends = ["openobserve", "ws-server"] +description = "Run generated scenario for default" + +[tasks.open-o2] +description = "Open the OpenObserve UI" +run = "open http://localhost:5080/" + +[tasks.openobserve] +alias = "o2" +run = """ +image=openobserve/openobserve:v0.91.5 +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" +""" + +[tasks.ws-server] +description = "Run the WebSocket server" +dir = "../../../../services/ws-server" +run = """ +MODULES_PATHS="static, ../ws-wasm-agent" +export MODULES_PATHS +et-ws-server +""" + +[tools] +"cargo:et-ws-server" = "latest" +"cargo:open" = "latest" diff --git a/verification/published/output/default/secrets.env b/verification/published/output/default/secrets.env new file mode 100644 index 00000000..09973d76 --- /dev/null +++ b/verification/published/output/default/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Libero%recusandae5191 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Libero%recusandae5191 diff --git a/verification/published/output/math1/Dockerfile b/verification/published/output/math1/Dockerfile new file mode 100644 index 00000000..f3b1dc34 --- /dev/null +++ b/verification/published/output/math1/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +# AUTO-GENERATED by `et-cli` for the `math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# The hub image (services/ws-server/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. +# +# 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. + +# `hub` is a named build context, not a stage defined here. +# The generated compose file wires it to the hub service with `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 + +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + +COPY --chown=10001:10001 services/ws-modules/math1/pkg /app/services/ws-modules/math1/pkg +COPY --chown=10001:10001 services/ws-modules/math1-sender/pkg /app/services/ws-modules/math1-sender/pkg diff --git a/verification/published/output/math1/Dockerfile.dockerignore b/verification/published/output/math1/Dockerfile.dockerignore new file mode 100644 index 00000000..57eef0b2 --- /dev/null +++ b/verification/published/output/math1/Dockerfile.dockerignore @@ -0,0 +1,75 @@ +# AUTO-GENERATED by `et-cli` for the `math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# 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. + +# AUTO-GENERATED from .gitignore by 'mise run gen:dockerignore' -- do not edit. +# Docker reads only this file; patterns are **/-prefixed to match at any depth +# like .gitignore. Edit .gitignore and regenerate. + +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. +**/.claude/ +**/*.wasm +**/*.onnx +**/target/ +**/.DS_Store +services/ws-wasm-agent/pkg/ +services/ws-modules/pywasm1/pkg/ +services/ws-modules/rdata1/pkg/webr/ +services/ws-modules/rcomm1/pkg/webr/ +services/ws-modules/rmath1/pkg/webr/ +services/ws-modules/js-data1/pkg/et_ws_js_data1.js +services/ws-server/static/models/ +**/.zig-cache/ +**/zig-out/ +**/*.o +**/*.pem +**/mprocs.log +**/__pycache__/ +**/.pytest_cache/ +**/.python-version +**/node_modules/ +**/.venv/ +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). +**/obj/ +services/ws-modules/dotnet-data1/bin/ +# Gradle project caches and build output plus the Kotlin session dir, all regenerated by any Gradle run. +**/.gradle/ +**/.kotlin/ +services/ws-modules/kotlin-data1/build/ +services/ws-modules/kotlin-math1/build/ +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +lcov.info +**/coverage-python.xml +**/.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +**/*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). +.vscode/* +!.vscode/extensions.json +**/.ruff_cache/ +**/.lycheecache +# Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). +**/.dart_tool/ +services/ws-server/storage/ +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. +**/.git/ +**/Dockerfile* +README.md + +# 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. +!services/ws-modules/math1-sender/pkg/** +!services/ws-modules/math1/pkg/** diff --git a/verification/published/output/math1/README.md b/verification/published/output/math1/README.md new file mode 100644 index 00000000..8a548a7f --- /dev/null +++ b/verification/published/output/math1/README.md @@ -0,0 +1,109 @@ +# math1 + +This directory contains generated deployment configs for the `math1` scenario. +Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. + +The scenario exposes these workflow modules: math1, math1-sender. + +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. + +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. + +## Run With Mise + +Fetch the binaries the tasks below name before the first run: + +```bash +mise install +``` + +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 +``` + +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose up --build +``` + +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 as a build context. +`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 . + +Stop the scenario with: + +```bash +docker compose down +``` + +## Run With k3s + +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 module-less hub image and takes that +hub as a _named build context_ rather than building it, so a plain `docker build` of the +scenario Dockerfile fails on `FROM hub` -- pointing the context at the published hub is what +supplies it. From the repository root: + +```bash +scenario=math1 +hub=ghcr.io/edge-toolkit/core/et-ws-server:latest +image="et-ws-server-$scenario:latest" +dockerfile="verification/published/output/$scenario/Dockerfile" +docker build --build-context "hub=docker-image://$hub" -t "$image" -f "$dockerfile" . +docker save "$image" | sudo k3s ctr images import - +``` + +The cluster pulls this scenario's runner images, so nothing has to be built or imported for +them: + +- `ghcr.io/edge-toolkit/core/et-ws-web-runner:latest` + +### Load The Credential + +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: + +```bash +ns=et-math1 +kubectl create namespace "$ns" --save-config +kubectl create secret generic "$ns-secrets" --from-env-file=secrets.env -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 +``` diff --git a/verification/published/output/math1/compose.yaml b/verification/published/output/math1/compose.yaml new file mode 100644 index 00000000..97f0ace4 --- /dev/null +++ b/verification/published/output/math1/compose.yaml @@ -0,0 +1,76 @@ +services: + openobserve: + image: openobserve/openobserve:v0.91.5 + healthcheck: + test: + - CMD + - /openobserve + - node + - status + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + ports: + - 127.0.0.1:5080:5080 + env_file: + - secrets.env + environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com + ZO_DATA_DIR: /data + volumes: + - openobserve-data:/data + ws-server: + healthcheck: + test: + - CMD + - curl + - -fsS + - http://localhost:8080/health + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + env_file: + - secrets.env + build: + context: ../../../.. + dockerfile: verification/published/output/math1/Dockerfile + additional_contexts: + hub: docker-image://ghcr.io/edge-toolkit/core/et-ws-server:latest + network_mode: host + environment: + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent, + /app/services/ws-modules/math1, + /app/services/ws-modules/math1-sender" + OTLP_AUTH_USERNAME: root@example.com + OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 + STORAGE_URL: file:///app/storage + volumes: + - ws-server-storage:/app/storage + depends_on: + openobserve: + condition: service_healthy + math1-twin: + image: ghcr.io/edge-toolkit/core/et-ws-web-runner:latest + network_mode: host + environment: + RUNNER_MODULE: et-ws-math1 + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy + math1-trigger: + image: ghcr.io/edge-toolkit/core/et-ws-web-runner:latest + network_mode: host + environment: + RUNNER_MODULE: et-ws-math1-sender + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy +volumes: + openobserve-data: {} + ws-server-storage: {} diff --git a/verification/published/output/math1/k3s.yaml b/verification/published/output/math1/k3s.yaml new file mode 100644 index 00000000..306275ad --- /dev/null +++ b/verification/published/output/math1/k3s.yaml @@ -0,0 +1,332 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: et-math1 +--- +apiVersion: v1 +kind: ConfigMap +data: + RUST_LOG: warn + ZO_DATA_DIR: /data + ZO_ROOT_USER_EMAIL: root@example.com +metadata: + name: openobserve-config + namespace: et-math1 +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openobserve-data + namespace: et-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: openobserve + template: + metadata: + labels: + app: openobserve + spec: + containers: + - envFrom: + - configMapRef: + name: openobserve-config + - secretRef: + name: et-math1-secrets + image: openobserve/openobserve:v0.91.5 + imagePullPolicy: IfNotPresent + name: openobserve + ports: + - containerPort: 5080 + readinessProbe: + exec: + command: + - /openobserve + - node + - status + failureThreshold: 20 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /data + name: openobserve-data + - mountPath: /tmp + name: openobserve-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: openobserve-data + persistentVolumeClaim: + claimName: openobserve-data + - emptyDir: {} + name: openobserve-tmp +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-math1 +spec: + ports: + - name: http + port: 5080 + targetPort: 5080 + selector: + app: openobserve +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ws-server-storage + namespace: et-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: ws-server + template: + metadata: + labels: + app: ws-server + spec: + containers: + - args: + - --agent-registry + - /app/runtime/registry.yaml + command: + - et-ws-server + env: + - name: MODULES_PATHS + value: /app/services/ws-server/static,/app/services/ws-wasm-agent,/app/services/ws-modules/math1,/app/services/ws-modules/math1-sender + - name: OTLP_AUTH_USERNAME + value: root@example.com + - name: OTLP_COLLECTOR_URL + value: http://openobserve:5080/api/default/v1 + - name: STORAGE_URL + value: file:///app/storage + - name: TLS_CERT_FILE + value: /app/runtime/cert.pem + - name: TLS_KEY_FILE + value: /app/runtime/key.pem + envFrom: + - secretRef: + name: et-math1-secrets + image: et-ws-server-math1:latest + imagePullPolicy: IfNotPresent + name: ws-server + ports: + - containerPort: 8080 + - containerPort: 8443 + readinessProbe: + failureThreshold: 20 + httpGet: + path: /health + port: 8080 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /app/storage + name: ws-server-storage + - mountPath: /app/runtime + name: ws-server-runtime + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: ws-server-storage + persistentVolumeClaim: + claimName: ws-server-storage + - emptyDir: {} + name: ws-server-runtime +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-math1 +spec: + ports: + - name: ws + port: 8080 + targetPort: 8080 + - name: wss + port: 8443 + targetPort: 8443 + selector: + app: ws-server +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: math1-twin + name: math1-twin + namespace: et-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: math1-twin + template: + metadata: + labels: + app: math1-twin + spec: + containers: + - env: + - name: RUNNER_MODULE + value: et-ws-math1 + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: ghcr.io/edge-toolkit/core/et-ws-web-runner:latest + imagePullPolicy: IfNotPresent + name: math1-twin + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: math1-trigger + name: math1-trigger + namespace: et-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: math1-trigger + template: + metadata: + labels: + app: math1-trigger + spec: + containers: + - env: + - name: RUNNER_MODULE + value: et-ws-math1-sender + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: ghcr.io/edge-toolkit/core/et-ws-web-runner:latest + imagePullPolicy: IfNotPresent + name: math1-trigger + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp diff --git a/verification/published/output/math1/mise.toml b/verification/published/output/math1/mise.toml new file mode 100644 index 00000000..f5fd4340 --- /dev/null +++ b/verification/published/output/math1/mise.toml @@ -0,0 +1,56 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + +[env._] +file = "secrets.env" + +[tasks.generated-scenario] +depends = ["math1-trigger", "math1-twin", "openobserve", "ws-server"] +description = "Run generated scenario for math1" + +[tasks.math1-trigger] +description = "Run et-ws-math1-sender in the web runner" +run = """ +et-ws-web-runner +""" + +[tasks.math1-trigger.env] +RUNNER_MODULE = "et-ws-math1-sender" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.math1-twin] +description = "Run et-ws-math1 in the web runner" +run = """ +et-ws-web-runner +""" + +[tasks.math1-twin.env] +RUNNER_MODULE = "et-ws-math1" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.open-o2] +description = "Open the OpenObserve UI" +run = "open http://localhost:5080/" + +[tasks.openobserve] +alias = "o2" +run = """ +image=openobserve/openobserve:v0.91.5 +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" +""" + +[tasks.ws-server] +description = "Run the WebSocket server" +dir = "../../../../services/ws-server" +run = """ +MODULES_PATHS="static, ../ws-wasm-agent, ../ws-modules/math1, ../ws-modules/math1-sender" +export MODULES_PATHS +et-ws-server +""" + +[tools] +"cargo:et-ws-server" = "latest" +"cargo:et-ws-web-runner" = "latest" +"cargo:open" = "latest" diff --git a/verification/published/output/math1/secrets.env b/verification/published/output/math1/secrets.env new file mode 100644 index 00000000..41866ccb --- /dev/null +++ b/verification/published/output/math1/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Ea_totam2987 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Ea_totam2987 diff --git a/verification/published/output/pyo3-math1/Dockerfile b/verification/published/output/pyo3-math1/Dockerfile new file mode 100644 index 00000000..2ba9ac72 --- /dev/null +++ b/verification/published/output/pyo3-math1/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +# AUTO-GENERATED by `et-cli` for the `pyo3-math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# The hub image (services/ws-server/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. +# +# 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. + +# `hub` is a named build context, not a stage defined here. +# The generated compose file wires it to the hub service with `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 + +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + +COPY --chown=10001:10001 services/ws-modules/pyo3-math1/pkg /app/services/ws-modules/pyo3-math1/pkg +COPY --chown=10001:10001 services/ws-modules/wasi-math1-sender/pkg /app/services/ws-modules/wasi-math1-sender/pkg diff --git a/verification/published/output/pyo3-math1/Dockerfile.dockerignore b/verification/published/output/pyo3-math1/Dockerfile.dockerignore new file mode 100644 index 00000000..aecaba84 --- /dev/null +++ b/verification/published/output/pyo3-math1/Dockerfile.dockerignore @@ -0,0 +1,75 @@ +# AUTO-GENERATED by `et-cli` for the `pyo3-math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# 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. + +# AUTO-GENERATED from .gitignore by 'mise run gen:dockerignore' -- do not edit. +# Docker reads only this file; patterns are **/-prefixed to match at any depth +# like .gitignore. Edit .gitignore and regenerate. + +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. +**/.claude/ +**/*.wasm +**/*.onnx +**/target/ +**/.DS_Store +services/ws-wasm-agent/pkg/ +services/ws-modules/pywasm1/pkg/ +services/ws-modules/rdata1/pkg/webr/ +services/ws-modules/rcomm1/pkg/webr/ +services/ws-modules/rmath1/pkg/webr/ +services/ws-modules/js-data1/pkg/et_ws_js_data1.js +services/ws-server/static/models/ +**/.zig-cache/ +**/zig-out/ +**/*.o +**/*.pem +**/mprocs.log +**/__pycache__/ +**/.pytest_cache/ +**/.python-version +**/node_modules/ +**/.venv/ +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). +**/obj/ +services/ws-modules/dotnet-data1/bin/ +# Gradle project caches and build output plus the Kotlin session dir, all regenerated by any Gradle run. +**/.gradle/ +**/.kotlin/ +services/ws-modules/kotlin-data1/build/ +services/ws-modules/kotlin-math1/build/ +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +lcov.info +**/coverage-python.xml +**/.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +**/*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). +.vscode/* +!.vscode/extensions.json +**/.ruff_cache/ +**/.lycheecache +# Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). +**/.dart_tool/ +services/ws-server/storage/ +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. +**/.git/ +**/Dockerfile* +README.md + +# 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. +!services/ws-modules/pyo3-math1/pkg/** +!services/ws-modules/wasi-math1-sender/pkg/** diff --git a/verification/published/output/pyo3-math1/README.md b/verification/published/output/pyo3-math1/README.md new file mode 100644 index 00000000..bfd43e22 --- /dev/null +++ b/verification/published/output/pyo3-math1/README.md @@ -0,0 +1,110 @@ +# pyo3-math1 + +This directory contains generated deployment configs for the `pyo3-math1` scenario. +Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. + +The scenario exposes these workflow modules: pyo3-math1, wasi-math1-sender. + +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. + +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. + +## Run With Mise + +Fetch the binaries the tasks below name before the first run: + +```bash +mise install +``` + +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 +``` + +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose up --build +``` + +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 as a build context. +`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 . + +Stop the scenario with: + +```bash +docker compose down +``` + +## Run With k3s + +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 module-less hub image and takes that +hub as a _named build context_ rather than building it, so a plain `docker build` of the +scenario Dockerfile fails on `FROM hub` -- pointing the context at the published hub is what +supplies it. From the repository root: + +```bash +scenario=pyo3-math1 +hub=ghcr.io/edge-toolkit/core/et-ws-server:latest +image="et-ws-server-$scenario:latest" +dockerfile="verification/published/output/$scenario/Dockerfile" +docker build --build-context "hub=docker-image://$hub" -t "$image" -f "$dockerfile" . +docker save "$image" | sudo k3s ctr images import - +``` + +The cluster pulls this scenario's runner images, so nothing has to be built or imported for +them: + +- `ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest` +- `ghcr.io/edge-toolkit/core/et-ws-pyo3-runner:latest` + +### Load The Credential + +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: + +```bash +ns=et-pyo3-math1 +kubectl create namespace "$ns" --save-config +kubectl create secret generic "$ns-secrets" --from-env-file=secrets.env -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 +``` diff --git a/verification/published/output/pyo3-math1/compose.yaml b/verification/published/output/pyo3-math1/compose.yaml new file mode 100644 index 00000000..91673949 --- /dev/null +++ b/verification/published/output/pyo3-math1/compose.yaml @@ -0,0 +1,76 @@ +services: + openobserve: + image: openobserve/openobserve:v0.91.5 + healthcheck: + test: + - CMD + - /openobserve + - node + - status + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + ports: + - 127.0.0.1:5080:5080 + env_file: + - secrets.env + environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com + ZO_DATA_DIR: /data + volumes: + - openobserve-data:/data + ws-server: + healthcheck: + test: + - CMD + - curl + - -fsS + - http://localhost:8080/health + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + env_file: + - secrets.env + build: + context: ../../../.. + dockerfile: verification/published/output/pyo3-math1/Dockerfile + additional_contexts: + hub: docker-image://ghcr.io/edge-toolkit/core/et-ws-server:latest + network_mode: host + environment: + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent, + /app/services/ws-modules/pyo3-math1, + /app/services/ws-modules/wasi-math1-sender" + OTLP_AUTH_USERNAME: root@example.com + OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 + STORAGE_URL: file:///app/storage + volumes: + - ws-server-storage:/app/storage + depends_on: + openobserve: + condition: service_healthy + math1-trigger: + image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest + network_mode: host + environment: + RUNNER_MODULE: et-ws-wasi-math1-sender + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy + pyo3-math1-twin: + image: ghcr.io/edge-toolkit/core/et-ws-pyo3-runner:latest + network_mode: host + environment: + RUNNER_MODULE: et-ws-pyo3-math1 + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy +volumes: + openobserve-data: {} + ws-server-storage: {} diff --git a/verification/published/output/pyo3-math1/k3s.yaml b/verification/published/output/pyo3-math1/k3s.yaml new file mode 100644 index 00000000..8ea1752c --- /dev/null +++ b/verification/published/output/pyo3-math1/k3s.yaml @@ -0,0 +1,332 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: et-pyo3-math1 +--- +apiVersion: v1 +kind: ConfigMap +data: + RUST_LOG: warn + ZO_DATA_DIR: /data + ZO_ROOT_USER_EMAIL: root@example.com +metadata: + name: openobserve-config + namespace: et-pyo3-math1 +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openobserve-data + namespace: et-pyo3-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-pyo3-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: openobserve + template: + metadata: + labels: + app: openobserve + spec: + containers: + - envFrom: + - configMapRef: + name: openobserve-config + - secretRef: + name: et-pyo3-math1-secrets + image: openobserve/openobserve:v0.91.5 + imagePullPolicy: IfNotPresent + name: openobserve + ports: + - containerPort: 5080 + readinessProbe: + exec: + command: + - /openobserve + - node + - status + failureThreshold: 20 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /data + name: openobserve-data + - mountPath: /tmp + name: openobserve-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: openobserve-data + persistentVolumeClaim: + claimName: openobserve-data + - emptyDir: {} + name: openobserve-tmp +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-pyo3-math1 +spec: + ports: + - name: http + port: 5080 + targetPort: 5080 + selector: + app: openobserve +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ws-server-storage + namespace: et-pyo3-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-pyo3-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: ws-server + template: + metadata: + labels: + app: ws-server + spec: + containers: + - args: + - --agent-registry + - /app/runtime/registry.yaml + command: + - et-ws-server + env: + - name: MODULES_PATHS + value: /app/services/ws-server/static,/app/services/ws-wasm-agent,/app/services/ws-modules/pyo3-math1,/app/services/ws-modules/wasi-math1-sender + - name: OTLP_AUTH_USERNAME + value: root@example.com + - name: OTLP_COLLECTOR_URL + value: http://openobserve:5080/api/default/v1 + - name: STORAGE_URL + value: file:///app/storage + - name: TLS_CERT_FILE + value: /app/runtime/cert.pem + - name: TLS_KEY_FILE + value: /app/runtime/key.pem + envFrom: + - secretRef: + name: et-pyo3-math1-secrets + image: et-ws-server-pyo3-math1:latest + imagePullPolicy: IfNotPresent + name: ws-server + ports: + - containerPort: 8080 + - containerPort: 8443 + readinessProbe: + failureThreshold: 20 + httpGet: + path: /health + port: 8080 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /app/storage + name: ws-server-storage + - mountPath: /app/runtime + name: ws-server-runtime + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: ws-server-storage + persistentVolumeClaim: + claimName: ws-server-storage + - emptyDir: {} + name: ws-server-runtime +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-pyo3-math1 +spec: + ports: + - name: ws + port: 8080 + targetPort: 8080 + - name: wss + port: 8443 + targetPort: 8443 + selector: + app: ws-server +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: math1-trigger + name: math1-trigger + namespace: et-pyo3-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: math1-trigger + template: + metadata: + labels: + app: math1-trigger + spec: + containers: + - env: + - name: RUNNER_MODULE + value: et-ws-wasi-math1-sender + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest + imagePullPolicy: IfNotPresent + name: math1-trigger + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: pyo3-math1-twin + name: pyo3-math1-twin + namespace: et-pyo3-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: pyo3-math1-twin + template: + metadata: + labels: + app: pyo3-math1-twin + spec: + containers: + - env: + - name: RUNNER_MODULE + value: et-ws-pyo3-math1 + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: ghcr.io/edge-toolkit/core/et-ws-pyo3-runner:latest + imagePullPolicy: IfNotPresent + name: pyo3-math1-twin + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp diff --git a/verification/published/output/pyo3-math1/mise.toml b/verification/published/output/pyo3-math1/mise.toml new file mode 100644 index 00000000..041077ed --- /dev/null +++ b/verification/published/output/pyo3-math1/mise.toml @@ -0,0 +1,57 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + +[env._] +file = "secrets.env" + +[tasks.generated-scenario] +depends = ["math1-trigger", "openobserve", "pyo3-math1-twin", "ws-server"] +description = "Run generated scenario for pyo3-math1" + +[tasks.math1-trigger] +description = "Run et-ws-wasi-math1-sender in the wasi runner" +run = """ +et-ws-wasi-runner +""" + +[tasks.math1-trigger.env] +RUNNER_MODULE = "et-ws-wasi-math1-sender" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.open-o2] +description = "Open the OpenObserve UI" +run = "open http://localhost:5080/" + +[tasks.openobserve] +alias = "o2" +run = """ +image=openobserve/openobserve:v0.91.5 +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" +""" + +[tasks.pyo3-math1-twin] +description = "Run et-ws-pyo3-math1 in the pyo3 runner" +run = """ +et-ws-pyo3-runner +""" + +[tasks.pyo3-math1-twin.env] +RUNNER_MODULE = "et-ws-pyo3-math1" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.ws-server] +description = "Run the WebSocket server" +dir = "../../../../services/ws-server" +run = """ +MODULES_PATHS="static, ../ws-wasm-agent, ../ws-modules/pyo3-math1, ../ws-modules/wasi-math1-sender" +export MODULES_PATHS +et-ws-server +""" + +[tools] +"cargo:et-ws-pyo3-runner" = "latest" +"cargo:et-ws-server" = "latest" +"cargo:et-ws-wasi-runner" = "latest" +"cargo:open" = "latest" diff --git a/verification/published/output/pyo3-math1/secrets.env b/verification/published/output/pyo3-math1/secrets.env new file mode 100644 index 00000000..03d414c7 --- /dev/null +++ b/verification/published/output/pyo3-math1/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Nisi%quis3506 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Nisi%quis3506 diff --git a/verification/published/output/wasi-math1/Dockerfile b/verification/published/output/wasi-math1/Dockerfile new file mode 100644 index 00000000..afea0828 --- /dev/null +++ b/verification/published/output/wasi-math1/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +# AUTO-GENERATED by `et-cli` for the `wasi-math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# The hub image (services/ws-server/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. +# +# 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. + +# `hub` is a named build context, not a stage defined here. +# The generated compose file wires it to the hub service with `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 + +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + +COPY --chown=10001:10001 services/ws-modules/wasi-math1/pkg /app/services/ws-modules/wasi-math1/pkg +COPY --chown=10001:10001 services/ws-modules/wasi-math1-sender/pkg /app/services/ws-modules/wasi-math1-sender/pkg diff --git a/verification/published/output/wasi-math1/Dockerfile.dockerignore b/verification/published/output/wasi-math1/Dockerfile.dockerignore new file mode 100644 index 00000000..318d9b0a --- /dev/null +++ b/verification/published/output/wasi-math1/Dockerfile.dockerignore @@ -0,0 +1,75 @@ +# AUTO-GENERATED by `et-cli` for the `wasi-math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# 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. + +# AUTO-GENERATED from .gitignore by 'mise run gen:dockerignore' -- do not edit. +# Docker reads only this file; patterns are **/-prefixed to match at any depth +# like .gitignore. Edit .gitignore and regenerate. + +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. +**/.claude/ +**/*.wasm +**/*.onnx +**/target/ +**/.DS_Store +services/ws-wasm-agent/pkg/ +services/ws-modules/pywasm1/pkg/ +services/ws-modules/rdata1/pkg/webr/ +services/ws-modules/rcomm1/pkg/webr/ +services/ws-modules/rmath1/pkg/webr/ +services/ws-modules/js-data1/pkg/et_ws_js_data1.js +services/ws-server/static/models/ +**/.zig-cache/ +**/zig-out/ +**/*.o +**/*.pem +**/mprocs.log +**/__pycache__/ +**/.pytest_cache/ +**/.python-version +**/node_modules/ +**/.venv/ +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). +**/obj/ +services/ws-modules/dotnet-data1/bin/ +# Gradle project caches and build output plus the Kotlin session dir, all regenerated by any Gradle run. +**/.gradle/ +**/.kotlin/ +services/ws-modules/kotlin-data1/build/ +services/ws-modules/kotlin-math1/build/ +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +lcov.info +**/coverage-python.xml +**/.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +**/*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). +.vscode/* +!.vscode/extensions.json +**/.ruff_cache/ +**/.lycheecache +# Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). +**/.dart_tool/ +services/ws-server/storage/ +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. +**/.git/ +**/Dockerfile* +README.md + +# 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. +!services/ws-modules/wasi-math1-sender/pkg/** +!services/ws-modules/wasi-math1/pkg/** diff --git a/verification/published/output/wasi-math1/README.md b/verification/published/output/wasi-math1/README.md new file mode 100644 index 00000000..9aaee78e --- /dev/null +++ b/verification/published/output/wasi-math1/README.md @@ -0,0 +1,109 @@ +# wasi-math1 + +This directory contains generated deployment configs for the `wasi-math1` scenario. +Files: `mise.toml`, `compose.yaml`, `k3s.yaml`. + +The scenario exposes these workflow modules: wasi-math1, wasi-math1-sender. + +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. + +`secrets.env` holds the scenario's derived OpenObserve and OTLP 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. + +## Run With Mise + +Fetch the binaries the tasks below name before the first run: + +```bash +mise install +``` + +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 +``` + +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose up --build +``` + +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 as a build context. +`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 . + +Stop the scenario with: + +```bash +docker compose down +``` + +## Run With k3s + +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 module-less hub image and takes that +hub as a _named build context_ rather than building it, so a plain `docker build` of the +scenario Dockerfile fails on `FROM hub` -- pointing the context at the published hub is what +supplies it. From the repository root: + +```bash +scenario=wasi-math1 +hub=ghcr.io/edge-toolkit/core/et-ws-server:latest +image="et-ws-server-$scenario:latest" +dockerfile="verification/published/output/$scenario/Dockerfile" +docker build --build-context "hub=docker-image://$hub" -t "$image" -f "$dockerfile" . +docker save "$image" | sudo k3s ctr images import - +``` + +The cluster pulls this scenario's runner images, so nothing has to be built or imported for +them: + +- `ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest` + +### Load The Credential + +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: + +```bash +ns=et-wasi-math1 +kubectl create namespace "$ns" --save-config +kubectl create secret generic "$ns-secrets" --from-env-file=secrets.env -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 +``` diff --git a/verification/published/output/wasi-math1/compose.yaml b/verification/published/output/wasi-math1/compose.yaml new file mode 100644 index 00000000..3d5aafa2 --- /dev/null +++ b/verification/published/output/wasi-math1/compose.yaml @@ -0,0 +1,76 @@ +services: + openobserve: + image: openobserve/openobserve:v0.91.5 + healthcheck: + test: + - CMD + - /openobserve + - node + - status + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + ports: + - 127.0.0.1:5080:5080 + env_file: + - secrets.env + environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com + ZO_DATA_DIR: /data + volumes: + - openobserve-data:/data + ws-server: + healthcheck: + test: + - CMD + - curl + - -fsS + - http://localhost:8080/health + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + env_file: + - secrets.env + build: + context: ../../../.. + dockerfile: verification/published/output/wasi-math1/Dockerfile + additional_contexts: + hub: docker-image://ghcr.io/edge-toolkit/core/et-ws-server:latest + network_mode: host + environment: + MODULES_PATHS: "/app/services/ws-server/static, + /app/services/ws-wasm-agent, + /app/services/ws-modules/wasi-math1, + /app/services/ws-modules/wasi-math1-sender" + OTLP_AUTH_USERNAME: root@example.com + OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 + STORAGE_URL: file:///app/storage + volumes: + - ws-server-storage:/app/storage + depends_on: + openobserve: + condition: service_healthy + math1-trigger: + image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest + network_mode: host + environment: + RUNNER_MODULE: et-ws-wasi-math1-sender + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy + wasi-math1-twin: + image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest + network_mode: host + environment: + RUNNER_MODULE: et-ws-wasi-math1 + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy +volumes: + openobserve-data: {} + ws-server-storage: {} diff --git a/verification/published/output/wasi-math1/k3s.yaml b/verification/published/output/wasi-math1/k3s.yaml new file mode 100644 index 00000000..83df6a0c --- /dev/null +++ b/verification/published/output/wasi-math1/k3s.yaml @@ -0,0 +1,332 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: et-wasi-math1 +--- +apiVersion: v1 +kind: ConfigMap +data: + RUST_LOG: warn + ZO_DATA_DIR: /data + ZO_ROOT_USER_EMAIL: root@example.com +metadata: + name: openobserve-config + namespace: et-wasi-math1 +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openobserve-data + namespace: et-wasi-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-wasi-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: openobserve + template: + metadata: + labels: + app: openobserve + spec: + containers: + - envFrom: + - configMapRef: + name: openobserve-config + - secretRef: + name: et-wasi-math1-secrets + image: openobserve/openobserve:v0.91.5 + imagePullPolicy: IfNotPresent + name: openobserve + ports: + - containerPort: 5080 + readinessProbe: + exec: + command: + - /openobserve + - node + - status + failureThreshold: 20 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /data + name: openobserve-data + - mountPath: /tmp + name: openobserve-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: openobserve-data + persistentVolumeClaim: + claimName: openobserve-data + - emptyDir: {} + name: openobserve-tmp +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-wasi-math1 +spec: + ports: + - name: http + port: 5080 + targetPort: 5080 + selector: + app: openobserve +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ws-server-storage + namespace: et-wasi-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-wasi-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: ws-server + template: + metadata: + labels: + app: ws-server + spec: + containers: + - args: + - --agent-registry + - /app/runtime/registry.yaml + command: + - et-ws-server + env: + - name: MODULES_PATHS + value: /app/services/ws-server/static,/app/services/ws-wasm-agent,/app/services/ws-modules/wasi-math1,/app/services/ws-modules/wasi-math1-sender + - name: OTLP_AUTH_USERNAME + value: root@example.com + - name: OTLP_COLLECTOR_URL + value: http://openobserve:5080/api/default/v1 + - name: STORAGE_URL + value: file:///app/storage + - name: TLS_CERT_FILE + value: /app/runtime/cert.pem + - name: TLS_KEY_FILE + value: /app/runtime/key.pem + envFrom: + - secretRef: + name: et-wasi-math1-secrets + image: et-ws-server-wasi-math1:latest + imagePullPolicy: IfNotPresent + name: ws-server + ports: + - containerPort: 8080 + - containerPort: 8443 + readinessProbe: + failureThreshold: 20 + httpGet: + path: /health + port: 8080 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /app/storage + name: ws-server-storage + - mountPath: /app/runtime + name: ws-server-runtime + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: ws-server-storage + persistentVolumeClaim: + claimName: ws-server-storage + - emptyDir: {} + name: ws-server-runtime +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-wasi-math1 +spec: + ports: + - name: ws + port: 8080 + targetPort: 8080 + - name: wss + port: 8443 + targetPort: 8443 + selector: + app: ws-server +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: math1-trigger + name: math1-trigger + namespace: et-wasi-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: math1-trigger + template: + metadata: + labels: + app: math1-trigger + spec: + containers: + - env: + - name: RUNNER_MODULE + value: et-ws-wasi-math1-sender + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest + imagePullPolicy: IfNotPresent + name: math1-trigger + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: wasi-math1-twin + name: wasi-math1-twin + namespace: et-wasi-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: wasi-math1-twin + template: + metadata: + labels: + app: wasi-math1-twin + spec: + containers: + - env: + - name: RUNNER_MODULE + value: et-ws-wasi-math1 + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: ghcr.io/edge-toolkit/core/et-ws-wasi-runner:latest + imagePullPolicy: IfNotPresent + name: wasi-math1-twin + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp diff --git a/verification/published/output/wasi-math1/mise.toml b/verification/published/output/wasi-math1/mise.toml new file mode 100644 index 00000000..ddf2d39d --- /dev/null +++ b/verification/published/output/wasi-math1/mise.toml @@ -0,0 +1,56 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + +[env._] +file = "secrets.env" + +[tasks.generated-scenario] +depends = ["math1-trigger", "openobserve", "wasi-math1-twin", "ws-server"] +description = "Run generated scenario for wasi-math1" + +[tasks.math1-trigger] +description = "Run et-ws-wasi-math1-sender in the wasi runner" +run = """ +et-ws-wasi-runner +""" + +[tasks.math1-trigger.env] +RUNNER_MODULE = "et-ws-wasi-math1-sender" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.open-o2] +description = "Open the OpenObserve UI" +run = "open http://localhost:5080/" + +[tasks.openobserve] +alias = "o2" +run = """ +image=openobserve/openobserve:v0.91.5 +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name openobserve -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" +""" + +[tasks.wasi-math1-twin] +description = "Run et-ws-wasi-math1 in the wasi runner" +run = """ +et-ws-wasi-runner +""" + +[tasks.wasi-math1-twin.env] +RUNNER_MODULE = "et-ws-wasi-math1" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.ws-server] +description = "Run the WebSocket server" +dir = "../../../../services/ws-server" +run = """ +MODULES_PATHS="static, ../ws-wasm-agent, ../ws-modules/wasi-math1, ../ws-modules/wasi-math1-sender" +export MODULES_PATHS +et-ws-server +""" + +[tools] +"cargo:et-ws-server" = "latest" +"cargo:et-ws-wasi-runner" = "latest" +"cargo:open" = "latest" diff --git a/verification/published/output/wasi-math1/secrets.env b/verification/published/output/wasi-math1/secrets.env new file mode 100644 index 00000000..a2a31639 --- /dev/null +++ b/verification/published/output/wasi-math1/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Eum_quo8731 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Eum_quo8731