diff --git a/docs/plan.md b/docs/plan.md index 980721c0..2dda3e1a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -6,6 +6,7 @@ **As of:** 2026-06-11 **Current phase:** Phases 0–5 complete; on the Phase 6/7 horizon. The AX.25 v2.2 Data-Link engine (Phase 2) is conformance-complete — mod-8 **and mod-128** connected-mode data transfer, REJ/SREJ recovery, segmentation, Timer Recovery, all green against the conformance + property harnesses (the on-air 10 kB lossy bench loop, #214, is the one residual, gated on TNC hardware not code). KISS hardening (Phase 3), the node host (Phase 4 — `Packet.Node`/`Packet.Node.Core`, deployable `.deb`), and the React web control panel (Phase 5) are all shipped and **live on the lab** (`pdn.m0lte.uk`): NET/ROM L3+L4 + INP3 routing, beacons, and a complete auth story (TLS · refresh-token rotation · WebAuthn passkeys · over-RF sysop TOTP) reachable over a real trusted cert with passkeys working on phone + laptop. A 2026-06-10 correctness sweep reconciled the issue tracker (it had drifted well behind the code) — see §17. **Next:** Phase 6 (AGW/RHPv2 external app surfaces) or Phase 7 (apt-repo distribution + signed one-click update); the deferred link-tuner is parked in Phase 8; per-frame RSSI/SNR (Tait 8100/8200, #363) is the Phase 10 adaptive-RF seed. +**Latest amendment:** [§17 entry 2026-06-11 — **DAPPS bundle re-pinned to v0.34.0** — manifest now fetched from the release (the interim copy deleted, completing the 5b design); the release carries `DAPPS_ENV_MANAGED` + the node-derived callsign with SSID probe-walk, so enabling dapps on a pdn node is now zero-config](#17-amendment-log) **Latest amendment:** [§17 entry 2026-06-11 — App packages: the supervisor injects **`PDN_NODE_CALLSIGN`**/`PDN_NODE_ALIAS` so an app can derive its identity from the host node (the an-app-lives-at-an-SSID-of-the-node-callsign convention; first consumer: DAPPS's `-7` default)](#17-amendment-log) **Latest amendment:** [§17 entry 2026-06-11 — App platform **Slice 5b: DAPPS bundled with pdn, public interfaces only** — `packaging/dapps/pdn-app.yaml` (service: the **published release binary** + the rhpv2-bearer environment; ui: its dashboard proxied under `/apps/dapps/`; capabilities network+web; no session verb — DAPPS binds its own callsigns over RHP) staged by build-deb from the **pinned m0lte/dapps release v0.33.12** (per-RID sha256 pins; cached; a failed pin hard-fails, an unreachable release skips the package and still builds); manifest upstreamed as m0lte/dapps#148. First-start seeding documented (owner supplies `DAPPS_CALLSIGN` via the `apps:` override before first start; placeholder mode is benign). Deb 39.8→73.4 MB](#17-amendment-log) **Latest amendment:** [§17 entry 2026-06-11 — App platform **Slice 5a: the app-package model** — `pdn-app.yaml` manifests **authored by the app** and discovered under `/usr/share/packetnet/apps` + `/var/lib/packetnet/apps`; **discovered = disabled until the owner enables** (the trust grant — an `apps:` override entry or the new control-panel toggle, which shows the manifest's declared capabilities before flipping); a **pdn-supervised `service:` lifecycle** (start/stop with enablement, SIGTERM→grace→kill-tree, backoff + crash-loop breaker → Faulted, `managed: external` escape hatch); `GET/POST /api/v1/apps/packages[...]` + the Manage-apps UI; WALL + LOBBY migrated to real packages (LOBBY's daemon now pdn-supervised — no more hand-run systemd unit). Contract: docs/app-packages.md. +91 tests; 725 Node + 22 web green](#17-amendment-log) @@ -1057,6 +1058,10 @@ What changed, why, where to look for details. ``` +### 2026-06-11 — DAPPS bundle re-pinned to v0.34.0 — manifest now fetched from the release + +DAPPS bundle re-pinned to v0.34.0 — manifest now fetched from the release (the interim copy deleted, completing the 5b design); the release carries `DAPPS_ENV_MANAGED` + the node-derived callsign with SSID probe-walk, so enabling dapps on a pdn node is now zero-config. Mechanics: m0lte/dapps v0.34.0 is the first release shipping `pdn-app.yaml` as a release asset (version-stamped to the tag — the m0lte/dapps#148 upstreaming landing), so build-deb.sh now pins FOUR assets (the three per-RID binaries + the manifest, each sha256-pinned) and fetches the manifest through the same version-keyed cache/verify/skip machinery as the binaries (`dapps_fetch`: a fresh download failing its pin hard-fails; unreachable with no valid cache warns and skips the dapps package entirely — all-or-nothing, never a binary without its manifest or vice versa); `packaging/dapps/pdn-app.yaml` is deleted as the 5b design always intended. `ShippedManifestsTests` re-pointed at the cached release asset (`artifacts/cache/dapps-v-pdn-app.yaml`, the path derived from the script pin so the cache key always tracks it): the dapps tests are now `[SkippableFact]`s (skip with "run scripts/build-deb.sh first" when the cache is absent), assert version 0.34.0 + the new `DAPPS_ENV_MANAGED=true` deployment-managed env mode, keep the manifest↔script version-drift guard, and the catalog test split into an unconditional examples/ (wall+lobby) leg + a skippable full-shipped-layout leg staging the cached manifest under a temp `dapps/` dir. Zero-config story: `DAPPS_ENV_MANAGED` makes the manifest's env deployment-managed (re-applied every start, badged read-only in the dashboard), and DAPPS derives its identity from the injected `PDN_NODE_CALLSIGN` as `-7`, probe-walking to the next free SSID on a duplicate-socket refusal — no more must-set-`DAPPS_CALLSIGN`-before-first-enable wrinkle (an explicit `DAPPS_CALLSIGN` override still wins). Deb 73.3 MB; 729 Node tests green (9 ShippedManifests). + ### 2026-06-11 — App packages: PDN_NODE_CALLSIGN/PDN_NODE_ALIAS injected into supervised services A one-line widening of the package contract (docs/app-packages.md § The manifest): the service supervisor now injects the node's own identity (`PDN_NODE_CALLSIGN`, plus `PDN_NODE_ALIAS` when set) alongside the existing `PDN_APP_*`/`PDN_RHP_*` variables — so an app can derive its on-air identity per Tom's convention that an app lives at an SSID of the node callsign (the first consumer: DAPPS, which gains a `-` default in m0lte/dapps' config-env-precedence PR, removing the must-set-DAPPS_CALLSIGN-before-first-enable wrinkle). diff --git a/packaging/dapps/pdn-app.yaml b/packaging/dapps/pdn-app.yaml deleted file mode 100644 index 094ac7a6..00000000 --- a/packaging/dapps/pdn-app.yaml +++ /dev/null @@ -1,56 +0,0 @@ -# pdn-app.yaml — the DAPPS app package manifest (docs/app-packages.md). -# -# INTERIM LOCATION: pdn carries this manifest until a m0lte/dapps release ships -# its own pdn-app.yaml as a release asset — then build-deb.sh fetches manifest + -# binary together from the same release and this copy is deleted. The manifest -# is authored by the app, not the node owner; pdn discovers it under -# /usr/share/packetnet/apps/dapps and never special-cases the app id. -# -# The binary alongside this manifest is the PUBLISHED release artifact -# (github.com/m0lte/dapps, self-contained single-file per RID), fetched and -# sha256-verified by scripts/build-deb.sh — never built from or vendored source. -manifest: 1 -id: dapps # must equal the package directory name -name: DAPPS -version: "0.33.12" # the pinned m0lte/dapps release (DAPPS_VERSION in build-deb.sh) -description: Distributed Asynchronous Packet Pub/Sub — store-and-forward messaging over packet radio. -icon: inbox -capabilities: [network, web] # binds its own callsigns over RHPv2; serves its own web UI - -# The .NET daemon pdn supervises while the app is enabled. No `session:` block — -# DAPPS is not a console verb; it speaks RHPv2 to the node and binds its own -# callsigns (docs/rhp2-server.md). -# -# FIRST-START SEEDING — read before enabling: DAPPS seeds its config from -# DAPPS_* env vars ONLY on first start (a fresh data/dapps.db in the working -# directory). Once the db exists, env vars stop mattering — later changes go -# through the DAPPS dashboard's /Setup // /Config pages, or by deleting the db -# to re-seed. In particular the owner MUST supply their callsign via the -# `apps:` override environment BEFORE the first start (there is no sensible -# default — DAPPS boots in setup-required mode with the N0CALL placeholder and -# refuses to bind bearers until a real callsign is configured): -# -# apps: -# - id: dapps -# enabled: true -# environment: -# DAPPS_CALLSIGN: M0XYZ-7 -# -# The working directory defaults to the app's state dir, so the database lands -# in /var/lib/packetnet/apps/dapps (DAPPS resolves it cwd-relative). -service: - command: ./dapps # relative — resolves to the staged binary in this package dir - environment: - DAPPS_NODE_BEARER: rhpv2 # speak RHPv2 to pdn (not AGW) - DAPPS_NODE_HOST: "127.0.0.1" - DAPPS_RHP_PORT: "9000" # pdn's RHP server loopback port - DAPPS_DEFAULT_BEARER_PORT: "0" - DAPPS_UPDATE_CHECK_ENABLED: "false" # pdn's deb owns updates — no self-update nag - ASPNETCORE_URLS: http://127.0.0.1:18080 # loopback only; must match ui.upstream - DAPPS_MQTT_PORT: "18831" # off the well-known 1883 — don't fight a real broker - -# pdn reverse-proxies /apps/dapps/ to the loopback upstream and shows a launcher tile. -ui: - upstream: http://127.0.0.1:18080 - name: DAPPS - icon: inbox diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index ff22e1e0..ef62e350 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -23,17 +23,20 @@ esac # DAPPS — the bundled store-and-forward messaging app (docs/app-packages.md # § Distribution). Public interfaces only: we FETCH the published self-contained -# release binary from m0lte/dapps (never build from or vendor its source) and -# pin it by version + per-RID sha256. To bump: download each dapps-linux-* asset -# from the new release ONCE, sha256sum it, and update all four pins together -# (plus packaging/dapps/pdn-app.yaml's version — ShippedManifestsTests asserts -# the manifest tracks dapps_version). -dapps_version="v0.33.12" +# release binary AND its app-authored pdn-app.yaml manifest from the SAME +# m0lte/dapps release (never build from or vendor its source) and pin every +# asset by version + sha256. To bump: download all FOUR assets from the new +# release ONCE (dapps-linux-{x64,arm64,arm} + pdn-app.yaml), sha256sum them, +# and update dapps_version + the four pins together (ShippedManifestsTests +# asserts the cached release manifest tracks dapps_version). +dapps_version="v0.34.0" case "$rid" in - linux-x64) dapps_sha256="5040b79d2bd11cebead93445bc4a12a89a597e403a0c2ec2a7401600b9e5a95d" ;; - linux-arm64) dapps_sha256="835c0691a450730b52b915d7610c10ba5203f85eb0fa67f7303f631c7d82d609" ;; - linux-arm) dapps_sha256="02812ff9afcaf89b46a3b8739dc5ba556a4f817a5aa3dfa9bb372b710c4a035e" ;; + linux-x64) dapps_sha256="74677abcaa29aa6300416b7741236a12a061d915629653ce3bef3001e78f3e3c" ;; + linux-arm64) dapps_sha256="89f18f9e2344ade06bae07957c4de00a97bcd03bd8d95a06e1a0353b7b390b6e" ;; + linux-arm) dapps_sha256="49ff594ec7b54250f836178571b19fed86252b65474c03f2e1e23690815c9ef6" ;; esac +# The manifest is RID-independent: one asset, one pin, version-stamped to the tag. +dapps_manifest_sha256="fabbd5675386bf2e3532413089f61a4268d5798acf1aba7d4a8ae71ef27fc85e" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" proj="$root/src/Packet.Node/Packet.Node.csproj" @@ -95,40 +98,52 @@ install -d "$stage/usr/share/packetnet/apps/lobby" install -m 0644 "$root/examples/lobby/pdn-app.yaml" "$stage/usr/share/packetnet/apps/lobby/pdn-app.yaml" install -m 0755 "$root/examples/lobby/lobby.py" "$stage/usr/share/packetnet/apps/lobby/lobby.py" install -m 0644 "$root/examples/lobby/README.md" "$stage/usr/share/packetnet/apps/lobby/README.md" -# DAPPS — staged from the m0lte/dapps PUBLISHED release artifact (pins above; the -# "only public interfaces" rule — docs/app-packages.md § Distribution). The asset is -# cached in artifacts/cache and re-downloaded only when the cached file's hash no -# longer matches the pin. A downloaded file that fails the pin is a hard build error -# (wrong artifact / tampering — never package it). If the download fails AND no valid -# cache exists (network-less build), the dapps package is SKIPPED with a warning and -# the deb still builds: DAPPS is bundled, not load-bearing. +# DAPPS — staged from the m0lte/dapps PUBLISHED release: the per-RID binary AND the +# app-authored pdn-app.yaml manifest, both assets of the SAME pinned release (pins +# above; the "only public interfaces" rule — docs/app-packages.md § Distribution). +# Each asset is cached in artifacts/cache (key includes the version) and re-downloaded +# only when the cached file's hash no longer matches its pin. A downloaded file that +# fails its pin is a hard build error (wrong artifact / tampering — never package it). +# If a download fails AND no valid cache exists (network-less build), the dapps +# package is SKIPPED with a warning and the deb still builds — all-or-nothing: a +# binary is never staged without its manifest, nor a manifest without its binary. +# DAPPS is bundled, not load-bearing. dapps_cache="$root/artifacts/cache/dapps-${dapps_version}-${rid}" -dapps_url="https://github.com/m0lte/dapps/releases/download/${dapps_version}/dapps-${rid}" -dapps_cache_ok() { - [ -f "$dapps_cache" ] && echo "${dapps_sha256} ${dapps_cache}" | sha256sum --check --status - +dapps_manifest_cache="$root/artifacts/cache/dapps-${dapps_version}-pdn-app.yaml" +# dapps_pin_ok — the file exists and matches its pin. +dapps_pin_ok() { + [ -f "$1" ] && echo "$2 $1" | sha256sum --check --status - } -if ! dapps_cache_ok; then - echo "==> fetch DAPPS ${dapps_version} (${rid}) from the published release" - mkdir -p "$(dirname "$dapps_cache")" - if curl -fSL --retry 3 -o "${dapps_cache}.tmp" "$dapps_url"; then - mv "${dapps_cache}.tmp" "$dapps_cache" - if ! dapps_cache_ok; then - echo "ERROR: $dapps_url does not match the pinned sha256 (${dapps_sha256})." >&2 - echo " Refusing to package an unverified binary. If the dapps release was" >&2 - echo " re-cut, re-pin dapps_version + all three hashes together." >&2 +# dapps_fetch — cached, pin-verified fetch of one release +# asset. Returns 0 with a verified file at ; returns 1 only when the release +# is unreachable and no valid cache exists; a FRESH download failing its pin exits. +dapps_fetch() { + local asset="$1" cache="$2" sha="$3" + local url="https://github.com/m0lte/dapps/releases/download/${dapps_version}/${asset}" + if dapps_pin_ok "$cache" "$sha"; then return 0; fi + echo "==> fetch DAPPS ${dapps_version} asset ${asset} from the published release" + mkdir -p "$(dirname "$cache")" + if curl -fSL --retry 3 -o "${cache}.tmp" "$url"; then + mv "${cache}.tmp" "$cache" + if ! dapps_pin_ok "$cache" "$sha"; then + echo "ERROR: $url does not match the pinned sha256 (${sha})." >&2 + echo " Refusing to package an unverified artifact. If the dapps release was" >&2 + echo " re-cut, re-pin dapps_version + all four hashes together." >&2 exit 1 fi - else - rm -f "${dapps_cache}.tmp" + return 0 fi -fi -if dapps_cache_ok; then + rm -f "${cache}.tmp" + return 1 +} +if dapps_fetch "dapps-${rid}" "$dapps_cache" "$dapps_sha256" && + dapps_fetch "pdn-app.yaml" "$dapps_manifest_cache" "$dapps_manifest_sha256"; then install -d "$stage/usr/share/packetnet/apps/dapps" - install -m 0644 "$root/packaging/dapps/pdn-app.yaml" "$stage/usr/share/packetnet/apps/dapps/pdn-app.yaml" + install -m 0644 "$dapps_manifest_cache" "$stage/usr/share/packetnet/apps/dapps/pdn-app.yaml" install -m 0755 "$dapps_cache" "$stage/usr/share/packetnet/apps/dapps/dapps" else - echo "WARNING: could not fetch DAPPS ${dapps_version} (${rid}) and no valid cached" >&2 - echo " copy exists — building the deb WITHOUT the bundled dapps package." >&2 + echo "WARNING: could not fetch DAPPS ${dapps_version} (${rid} binary + manifest) and no" >&2 + echo " valid cached copy exists — building the deb WITHOUT the bundled dapps package." >&2 fi sed -e "s/@ARCH@/$arch/" -e "s/@VERSION@/$version/" \ "$root/packaging/control.in" > "$stage/DEBIAN/control" diff --git a/tests/Packet.Node.Tests/Applications/Packages/ShippedManifestsTests.cs b/tests/Packet.Node.Tests/Applications/Packages/ShippedManifestsTests.cs index 8035a119..8fc0ca7c 100644 --- a/tests/Packet.Node.Tests/Applications/Packages/ShippedManifestsTests.cs +++ b/tests/Packet.Node.Tests/Applications/Packages/ShippedManifestsTests.cs @@ -7,10 +7,13 @@ namespace Packet.Node.Tests.Applications.Packages; /// /// The SHIPPED manifests — examples/wall/pdn-app.yaml, examples/lobby/pdn-app.yaml, -/// and packaging/dapps/pdn-app.yaml, staged by scripts/build-deb.sh into +/// and the DAPPS manifest fetched from the pinned m0lte/dapps release (cached, pin-verified, +/// under artifacts/cache/), staged by scripts/build-deb.sh into /// /usr/share/packetnet/apps/<id> — parsed and validated mechanically, so an /// edit that breaks parsing (or drifts the wiring the two halves of an app agree on) -/// fails CI instead of failing on a deployed node. +/// fails CI instead of failing on a deployed node. The DAPPS tests are +/// s: they assert against the release asset itself, +/// which exists locally only after a build-deb.sh run has populated the cache. /// public class ShippedManifestsTests { @@ -102,19 +105,22 @@ public void Lobby_service_listen_path_matches_the_session_socket_path() } // ---- DAPPS ---------------------------------------------------------------------- - - [Fact] + // + // The DAPPS manifest is app-authored and lives UPSTREAM: build-deb.sh fetches + // pdn-app.yaml + the binary from the same pinned m0lte/dapps release and stages + // them together (docs/app-packages.md § Distribution — the interim in-repo copy is + // gone). These tests assert against the pin-verified cached release asset, so they + // skip until a build-deb.sh run has populated artifacts/cache. + + [SkippableFact] public void Dapps_manifest_parses_with_the_expected_blocks() { - // packaging/dapps/pdn-app.yaml — pdn carries it interim; the binary beside it is - // the published m0lte/dapps release artifact, fetched + sha256-pinned by - // build-deb.sh (never built from source — public interfaces only). var m = AppPackageManifestYaml.Parse(ReadDappsManifest()); m.Manifest.Should().Be(1); m.Id.Should().Be("dapps"); m.Name.Should().Be("DAPPS"); - m.Version.Should().NotBeNullOrWhiteSpace(); + m.Version.Should().Be("0.34.0"); m.Description.Should().Contain("Distributed Asynchronous Packet Pub/Sub"); m.Icon.Should().Be("inbox"); m.Capabilities.Should().Equal("network", "web"); @@ -130,8 +136,10 @@ public void Dapps_manifest_parses_with_the_expected_blocks() m.Service.WorkingDirectory.Should().BeNull( "cwd must default to the state dir so the cwd-relative dapps.db lands in /var/lib/packetnet/apps/dapps"); - // The env map seeds DAPPS's first-start config: RHPv2 to the local node, no - // self-update (pdn's deb owns updates), MQTT off the well-known 1883. + // The env map is deployment-managed config (DAPPS_ENV_MANAGED — re-applied at + // every start): RHPv2 to the local node, no self-update (pdn's deb owns + // updates), MQTT off the well-known 1883. + m.Service.Environment.Should().Contain("DAPPS_ENV_MANAGED", "true"); m.Service.Environment.Should().Contain("DAPPS_NODE_BEARER", "rhpv2"); m.Service.Environment.Should().Contain("DAPPS_NODE_HOST", "127.0.0.1"); m.Service.Environment.Should().Contain("DAPPS_RHP_PORT", "9000"); @@ -139,14 +147,16 @@ public void Dapps_manifest_parses_with_the_expected_blocks() m.Service.Environment.Should().Contain("DAPPS_UPDATE_CHECK_ENABLED", "false"); m.Service.Environment.Should().Contain("DAPPS_MQTT_PORT", "18831"); m.Service.Environment.Should().NotContainKey("DAPPS_CALLSIGN", - "there is no sensible default callsign — the owner supplies it via the apps: override"); + "no callsign pre-set — DAPPS derives - from the injected " + + "PDN_NODE_CALLSIGN (probing the next free SSID on a clash); an explicit " + + "DAPPS_CALLSIGN via the apps: override still wins"); m.Ui.Should().NotBeNull(); m.Ui!.Name.Should().Be("DAPPS"); m.Ui.Icon.Should().Be("inbox"); } - [Fact] + [SkippableFact] public void Dapps_aspnetcore_urls_matches_the_ui_upstream() { // DAPPS serves its web UI where ASPNETCORE_URLS says; the gateway proxies to @@ -159,44 +169,37 @@ public void Dapps_aspnetcore_urls_matches_the_ui_upstream() $"{upstream.Scheme}://{upstream.Host}:{upstream.Port}"); } - [Fact] + [SkippableFact] public void Dapps_manifest_version_matches_the_build_deb_pin() { - // build-deb.sh stages the release binary pinned as dapps_version=vX.Y.Z; the - // manifest's informational version must be the same release or the UI lies. - var script = File.ReadAllText(Path.Combine(RepoRoot(), "scripts", "build-deb.sh")); - var match = System.Text.RegularExpressions.Regex.Match( - script, @"^dapps_version=""v([^""]+)""", System.Text.RegularExpressions.RegexOptions.Multiline); - match.Success.Should().BeTrue("build-deb.sh must pin dapps_version=\"vX.Y.Z\""); - + // build-deb.sh fetches manifest + binary from the same release pinned as + // dapps_version="vX.Y.Z"; the manifest's release-stamped version: must name + // that release too, or the pin and the cached asset have drifted (a re-cut + // release, or a cache key that stopped tracking the version). var m = AppPackageManifestYaml.Parse(ReadDappsManifest()); - m.Version.Should().Be(match.Groups[1].Value); + + m.Version.Should().Be(DappsVersionPin()); } // ---- the catalog's view ------------------------------------------------------------ [Fact] - public void Catalog_discovers_all_shipped_packages_as_valid_and_disabled_by_default() + public void Catalog_discovers_the_example_packages_as_valid_and_disabled_by_default() { - // examples/ + packaging/ together are exactly the layout build-deb.sh stages into - // /usr/share/packetnet/apps (packaging/ holds only the dapps package dir — the - // catalog skips directories without a manifest), so pointing the catalog at them - // exercises the full contract validation (id = dir name, per-kind required fields, - // verb collisions) against what we actually ship. + // examples/ is the in-repo half of the layout build-deb.sh stages into + // /usr/share/packetnet/apps (wall + lobby; the catalog skips directories + // without a manifest). Unconditional — the dapps leg, which needs the cached + // release manifest, is the SkippableFact below. var catalog = new AppPackageCatalog(NullLoggerFactory.Instance); var config = new NodeConfig { Identity = new Identity { Callsign = "M0LTE-1" }, - AppPackageRoots = - [ - Path.Combine(RepoRoot(), "examples"), - Path.Combine(RepoRoot(), "packaging"), - ], + AppPackageRoots = [Path.Combine(RepoRoot(), "examples")], }; var found = catalog.Discover(config); - found.Select(p => p.Id).Should().BeEquivalentTo(["wall", "lobby", "dapps"]); + found.Select(p => p.Id).Should().BeEquivalentTo(["wall", "lobby"]); foreach (var package in found) { package.Error.Should().BeNull("a shipped package must validate clean"); @@ -204,13 +207,73 @@ public void Catalog_discovers_all_shipped_packages_as_valid_and_disabled_by_defa } } + [SkippableFact] + public void Catalog_discovers_the_full_shipped_layout_including_the_dapps_release_package() + { + // The full shipped layout: examples/ (wall, lobby) + a dapps package dir staged + // from the cached release manifest — exactly what build-deb.sh puts under + // /usr/share/packetnet/apps, so this exercises the full contract validation + // (id = dir name, per-kind required fields, verb collisions) against what we + // actually ship. The manifest must sit under a directory named `dapps` (id = + // dir name), so it's copied from the cache into a temp root. + var manifest = ReadDappsManifest(); + var tempRoot = Directory.CreateTempSubdirectory("pdn-shipped-dapps-").FullName; + try + { + Directory.CreateDirectory(Path.Combine(tempRoot, "dapps")); + File.WriteAllText( + Path.Combine(tempRoot, "dapps", AppPackageCatalog.ManifestFileName), manifest); + + var catalog = new AppPackageCatalog(NullLoggerFactory.Instance); + var config = new NodeConfig + { + Identity = new Identity { Callsign = "M0LTE-1" }, + AppPackageRoots = [Path.Combine(RepoRoot(), "examples"), tempRoot], + }; + + var found = catalog.Discover(config); + + found.Select(p => p.Id).Should().BeEquivalentTo(["wall", "lobby", "dapps"]); + foreach (var package in found) + { + package.Error.Should().BeNull("a shipped package must validate clean"); + package.Enabled.Should().BeFalse("discovered is not enabled — the owner opts in"); + } + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + } + // ---- plumbing ---------------------------------------------------------------------- private static string ReadManifest(string id) => File.ReadAllText(Path.Combine(RepoRoot(), "examples", id, AppPackageCatalog.ManifestFileName)); - private static string ReadDappsManifest() => - File.ReadAllText(Path.Combine(RepoRoot(), "packaging", "dapps", AppPackageCatalog.ManifestFileName)); + /// The release the build bundles, parsed from scripts/build-deb.sh's + /// dapps_version="vX.Y.Z" pin — the single source of truth. + private static string DappsVersionPin() + { + var script = File.ReadAllText(Path.Combine(RepoRoot(), "scripts", "build-deb.sh")); + var match = System.Text.RegularExpressions.Regex.Match( + script, @"^dapps_version=""v([^""]+)""", System.Text.RegularExpressions.RegexOptions.Multiline); + match.Success.Should().BeTrue("build-deb.sh must pin dapps_version=\"vX.Y.Z\""); + return match.Groups[1].Value; + } + + /// Where build-deb.sh caches the fetched release manifest — must mirror the + /// script's dapps_manifest_cache="$root/artifacts/cache/dapps-${dapps_version}-pdn-app.yaml" + /// exactly (the cache key includes the version, so a stale cache can never satisfy a new pin). + private static string DappsManifestCachePath() => + Path.Combine(RepoRoot(), "artifacts", "cache", $"dapps-v{DappsVersionPin()}-pdn-app.yaml"); + + private static string ReadDappsManifest() + { + var path = DappsManifestCachePath(); + Skip.If(!File.Exists(path), "dapps release manifest not cached — run scripts/build-deb.sh first"); + return File.ReadAllText(path); + } /// Walk up from the test assembly to the repo root (the directory that has /// examples/wall/pdn-app.yaml) — same approach as WallAppIntegrationTests. diff --git a/tests/Packet.Node.Tests/Packet.Node.Tests.csproj b/tests/Packet.Node.Tests/Packet.Node.Tests.csproj index cbcaad28..31de678b 100644 --- a/tests/Packet.Node.Tests/Packet.Node.Tests.csproj +++ b/tests/Packet.Node.Tests/Packet.Node.Tests.csproj @@ -5,6 +5,9 @@ + +