Author SHA1 Message Date
eSlider 302de4024d docs: move live demo links to PR #3
CI & Release / Verify documentation (push) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify documentation (pull_request) Successful in 4s
CI & Release / Verify simulator (pull_request) Successful in 11s
CI & Release / Trivy scan (pull_request) Successful in 9s
CI & Release / Semantic Release (pull_request) Skipped
Drop duplicate external URLs from design journey; small PR #3 owns them.
2026-07-10 12:18:50 +01:00
eSlider 826cef0c3e fix: drop PyYAML dep so verify job pytest passes
CI & Release / Verify documentation (push) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify documentation (pull_request) Successful in 4s
CI & Release / Verify simulator (pull_request) Successful in 11s
CI & Release / Trivy scan (pull_request) Successful in 8s
CI & Release / Semantic Release (pull_request) Skipped
Store doc rules in bin/docs_rules.json (stdlib json). test_check_docs no
longer fails in the simulator job that only installs simulator/requirements.txt.
2026-07-10 11:02:55 +01:00
eSlider df04736336 chore: drop unrelated files from bin/ move commit
CI & Release / Verify documentation (push) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify documentation (pull_request) Successful in 6s
CI & Release / Verify simulator (pull_request) Failing after 9s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
2026-07-10 10:59:29 +01:00
eSlider b4de3ed076 refactor: move scripts/ to bin/
CI & Release / Verify documentation (push) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify documentation (pull_request) Successful in 5s
CI & Release / Verify simulator (pull_request) Failing after 10s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Relocate check_docs, check_adrs, and docs_rules; update CI, tests, and docs.
2026-07-10 10:59:22 +01:00
eSlider 10a18f1321 fix: revert ADR edits and enforce immutable accepted records
CI & Release / Verify documentation (push) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify documentation (pull_request) Successful in 6s
CI & Release / Verify simulator (pull_request) Failing after 10s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Restore docs/adr to main; add check_adrs.py CI gate so existing ADR-*.md
files cannot be modified — supersede with a new numbered ADR instead.
2026-07-10 10:57:39 +01:00
eSlider bed6f93626 docs: default caveman style for issues, PRs, and reviews
CI & Release / Verify documentation (push) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify documentation (pull_request) Successful in 5s
CI & Release / Verify simulator (pull_request) Failing after 9s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Document caveman-full as the collaboration voice in AGENTS and CONTRIBUTING,
and add Gitea issue/PR templates that pre-fill terse structure.
2026-07-10 10:56:13 +01:00
eSlider e6e57843cb feat: add CI documentation rules checker and docs-verify job
CI & Release / Verify documentation (push) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify documentation (pull_request) Successful in 5s
CI & Release / Verify simulator (pull_request) Failing after 9s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Enforce link resolution, glossary anchors, and readability rules on every
push and PR; docs-only pushes to main still run docs-verify while skipping
the simulator gate.
2026-07-10 10:54:28 +01:00
eSlider 9bec50e8f7 fix(ci): fetch PR ref on checkout instead of shallow main clone
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Successful in 11s
CI & Release / Trivy scan (pull_request) Successful in 9s
CI & Release / Semantic Release (pull_request) Skipped
Shallow clone of default branch did not contain commits on feature
branches, causing pull_request verify jobs to fail at checkout.
2026-07-10 10:48:27 +01:00
eSlider 5f6fd8462c docs: audit links, glossary T0-T4, readability pass
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Failing after 4s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Extend glossary with storage floors, source of truth, and linkable anchors.
Fix broken links, pose frame drift, rsync/offload wording, and README tone.
Add docs/improvement audit records and live demo links in design journey.
2026-07-10 10:46:59 +01:00
eSlider ed531f9fb6 Link license to readme.md
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
2026-07-09 15:55:04 +01:00
eSlider 336f9c0428 Add license
CI & Release / Verify simulator (push) Successful in 11s
CI & Release / Trivy scan (push) Successful in 8s
CI & Release / Semantic Release (push) Successful in 5s
2026-07-09 15:52:16 +01:00
eSlider 3d13f744e6 docs(journey): link the live visual prototype at swarm.produktor.io
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
2026-07-09 12:47:12 +01:00
eSlider dfb0556eda docs: add design journey, a narrative walk-through linking into the code
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
A chronological companion to the ADRs: how the design came together, from
the first mental model and 2D prototype through the three data tiers, the
relative-pose broadcast, the layered WireGuard/SSH trust model, SQL as the
peer contract, offload and replication, and the end-to-end proof of concept.
Each chapter links straight into the relevant source and decision records.
Flagged as the recommended first read in the README.
2026-07-09 12:30:40 +01:00
eSlider d3ccd6f94e docs: record architecture decisions (ADR-0001..0009) and operations knowledge
CI & Release / Verify simulator (push) Successful in 11s
CI & Release / Trivy scan (push) Successful in 8s
CI & Release / Semantic Release (push) Successful in 5s
Add docs/adr/ with a chronological ADR set reconstructed from the project
history (no orchestrator, one storage format, derived-state sync,
SQL-over-SSH contract, WireGuard beneath SSH, IaC boundaries, non-root
image, runtime data paths, bounded lake scans), an index README, and a
template. Distinguish repo-local ADRs from ecosystem-wide ASRs.

Expand AGENTS.md with a decision-records section and a runtime/operations
guide (service URLs, rebuild workflow, CPU budget knobs). Reference the
ADRs from README, CONTRIBUTING, and the affected design docs, and add an
open question for T1 partition pruning.
2026-07-09 11:41:05 +01:00
eSlider ea062af86a perf: bound lake scans and add platform Grafana dashboard
CI & Release / Verify simulator (push) Successful in 11s
CI & Release / Trivy scan (push) Successful in 9s
CI & Release / Semantic Release (push) Successful in 5s
Explorer and exporter were scanning the full Parquet lake every 1–5s
(~2000 files, 200MB+), driving ~2.2 CPU cores. Limit metrics to the
last N flights, cache DuckDB views and the partition tree, slow live
polls to 2s, and keep drones alive after seal to stop restart churn.

Add node-exporter, scan-duration metrics, and a Swarm Platform Grafana
dashboard for node CPU/memory and scan health.
2026-07-08 21:19:53 +01:00
eSlider bcd956d11a fix: wire live mode and Grafana to the generated data stream
CI & Release / Verify simulator (push) Successful in 11s
CI & Release / Trivy scan (push) Successful in 9s
CI & Release / Semantic Release (push) Successful in 5s
Use the real hive column name (`drone`) in the prototype's live query
and pin the Grafana datasource uid to `swarm-prom` so provisioned
dashboards resolve their Prometheus source in k3d.
2026-07-08 20:34:37 +01:00
eSlider 9f99d132e3 fix: chown var/t1 and var/t3 to simulator uid 10001 for k3d
CI & Release / Verify simulator (push) Successful in 12s
CI & Release / Trivy scan (push) Successful in 9s
CI & Release / Semantic Release (push) Successful in 4s
Non-root drone pods need write access on the hostPath lake. Ansible now
creates var/t1 and var/t3 owned by uid 10001 instead of relying on 0777
alone after legacy root-owned parquet trees.
2026-07-08 20:27:25 +01:00
eSlider 669e8ff005 fix: run simulator image as non-root user (Trivy DS-0002)
CI & Release / Verify simulator (push) Successful in 11s
CI & Release / Trivy scan (push) Successful in 9s
CI & Release / Semantic Release (push) Successful in 4s
Add swarm uid 10001 in both build stages so Trivy config scan passes
and containers do not run as root. Mounted /data volumes should be
world-writable or owned by uid 10001 (var/t1 from Ansible is 0777).
2026-07-08 20:20:19 +01:00
60 changed files with 2211 additions and 270 deletions
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Design docs
url: https://git.produktor.io/eSlider/swarm-house/src/branch/main/docs/12-design-journey.md
about: Start with the design journey, then open an issue here.
- name: Open questions
url: https://git.produktor.io/eSlider/swarm-house/src/branch/main/docs/09-open-questions.md
about: Known unknowns — check before duplicating.
+24
View File
@@ -0,0 +1,24 @@
---
name: Change / task
about: Default issue template — caveman style
title: ""
labels: []
---
Write **caveman-full**: short lines, fragments OK, no filler. See [`CONTRIBUTING.md`](../../CONTRIBUTING.md#caveman-style-default).
## What
<!-- One line. What need fix or build. -->
## Why
<!-- Problem or goal. Skip if obvious from title. -->
## Done when
- [ ]
## Notes
<!-- Links, ADR, open question §. Optional. -->
+19
View File
@@ -0,0 +1,19 @@
Write **caveman-full** by default. See [`CONTRIBUTING.md`](../CONTRIBUTING.md#caveman-style-default).
## What
<!-- What changed. Bullets OK. -->
## Why
<!-- Problem this fixes or design gap closed. -->
## Test
- [ ] `pytest tests/ -q` (if code touched)
- [ ] `python bin/check_docs.py` (if docs touched)
- [ ] ADR added if boundary/contract changed
## Links
<!-- Closes / Relates to #issue. Optional demo URL. -->
+31 -5
View File
@@ -1,4 +1,5 @@
# Swarm House - Gitea Actions # Swarm House - Gitea Actions
# docs-verify: markdown links, glossary anchors, readability rules (every push/PR)
# verify: compile check, virtual-drone smoke flight, SQL gate tests (every push/PR) # verify: compile check, virtual-drone smoke flight, SQL gate tests (every push/PR)
# scan: Trivy vulnerability + misconfiguration scan # scan: Trivy vulnerability + misconfiguration scan
# release: semantic version bump (conventional commits), tag, Gitea release # release: semantic version bump (conventional commits), tag, Gitea release
@@ -17,6 +18,27 @@ on:
branches: [main] branches: [main]
jobs: jobs:
docs-verify:
name: Verify documentation
runs-on: ubuntu-latest
container: python:3.12-bookworm
steps:
- name: Checkout
run: |
git init .
git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
git fetch --depth=50 origin "${{ gitea.ref }}"
git checkout FETCH_HEAD
- name: Fetch base branch for ADR immutability
run: git fetch --depth=1 origin main
- name: ADR immutability (no edits to accepted records)
run: python bin/check_adrs.py --base origin/main
- name: Documentation rules (links, glossary, readability)
run: python bin/check_docs.py
verify: verify:
name: Verify simulator name: Verify simulator
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -24,8 +46,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
git clone --depth=50 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git . git init .
git checkout ${{ gitea.sha }} git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
git fetch --depth=50 origin "${{ gitea.ref }}"
git checkout FETCH_HEAD
- name: Install dependencies - name: Install dependencies
run: pip install --quiet -r simulator/requirements.txt run: pip install --quiet -r simulator/requirements.txt
@@ -61,8 +85,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
git clone --depth=1 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git . git init .
git checkout ${{ gitea.sha }} git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
git fetch --depth=1 origin "${{ gitea.ref }}"
git checkout FETCH_HEAD
- name: Install Trivy - name: Install Trivy
run: | run: |
@@ -81,7 +107,7 @@ jobs:
name: Semantic Release name: Semantic Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: python:3.12-bookworm container: python:3.12-bookworm
needs: [verify, scan] needs: [docs-verify, verify, scan]
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
permissions: permissions:
contents: write contents: write
+71
View File
@@ -24,8 +24,79 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development workflow and TDD re
## Conventions ## Conventions
### Caveman style (default for collaboration)
**Issues, pull requests, and code reviews** use **caveman-full** by default:
- Short sentences or fragments; drop articles and filler when clear
- Lead with fact, verdict, or ask — not preamble
- Bullets over paragraphs; one idea per line
- Keep technical terms, acronyms, paths, and code exact
- English only; no identifying references (same as all repo prose)
README tiers: root [`README.md`](README.md) = **caveman-lite** (full sentences, less filler);
component READMEs = **caveman-full**. Design docs in `docs/` stay readable prose unless
editing for consistency.
Templates: [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/),
[`.github/pull_request_template.md`](.github/pull_request_template.md).
- Markdown: ATX headings, fenced code blocks with language tags, pipe tables. - Markdown: ATX headings, fenced code blocks with language tags, pipe tables.
- Python: PEP 8, type hints, pure functions where possible, no unnecessary classes. - Python: PEP 8, type hints, pure functions where possible, no unnecessary classes.
- TypeScript: strict mode, functional components and hooks, no extra UI libraries. - TypeScript: strict mode, functional components and hooks, no extra UI libraries.
- YAML: 2-space indent. - YAML: 2-space indent.
- Diagrams: Mermaid inside Markdown. - Diagrams: Mermaid inside Markdown.
## Decision records
Significant architectural decisions are recorded chronologically as **ADRs** in
[`docs/adr/`](docs/adr/) — one immutable file per decision, in the
Status / Context / Options / Decision / Consequences format. Start from
[`docs/adr/README.md`](docs/adr/README.md).
- **ADR** = a point-in-time decision for *this* repo → `docs/adr/`.
- **ASR** = a standing, ecosystem-wide standard (Go-first, secrets, layout) →
lives in the `inventar` repo, **not** here.
When a change alters a boundary, contract, or trade-off, add an ADR (copy
[`docs/adr/TEMPLATE.md`](docs/adr/TEMPLATE.md), take the next number, update the
index) and cross-link it from the affected design doc. A later reversal gets a
new ADR that supersedes the old one — **never edit an Accepted record**.
**Immutable:** every `docs/adr/ADR-*.md` file is frozen once on `main`. CI rejects
any change that modifies an existing ADR. Updates go in a new numbered ADR only.
The index ([`docs/adr/README.md`](docs/adr/README.md)) and [`TEMPLATE.md`](docs/adr/TEMPLATE.md)
may still change; decision bodies do not.
## Runtime & operations (sim environment)
The k3d ground miniature is `swarm-sim`. Services are exposed as NodePorts:
| Service | URL |
| --- | --- |
| Explorer (T1 live lake) | `http://localhost:30088` |
| Warehouse explorer (T3) | `http://localhost:30089` |
| Grafana (dashboards `swarm-fleet`, `swarm-platform`) | `http://localhost:30300` |
| Prometheus | `http://localhost:30990` |
| MinIO console | `http://localhost:30901` |
| Prototype dev server (`npm run dev`) | `http://localhost:5173` |
Rebuild the simulator image and roll it out:
```bash
cd simulator && docker build -t swarm-house/simulator:dev .
k3d image import swarm-house/simulator:dev -c swarm-sim
kubectl rollout restart -n swarm statefulset/drone deployment/explorer deployment/exporter
cd infra/terraform/sim-env && terraform apply
```
**CPU budget knobs** (see [ADR-0009](docs/adr/ADR-0009-bounded-lake-scans.md)):
`METRIC_FLIGHT_WINDOW` (scan only recent flights), `SCAN_INTERVAL_S`,
`VIEW_REFRESH_S`, `TREE_CACHE_S`, `KEEP_ALIVE=1` (drones idle after seal instead
of restarting), `IMU_HZ`. Never scan the full lake on a hot path — it grows with
pod churn. Long-horizon analysis is a T3 warehouse query, not an exporter job.
Runtime data lives under `var/t1` / `var/t3`, owned by uid **10001**
([ADR-0007](docs/adr/ADR-0007-non-root-image.md),
[ADR-0008](docs/adr/ADR-0008-runtime-data-paths.md)); both are gitignored.
Terraform state is local and never committed.
+45
View File
@@ -17,6 +17,7 @@ The CI pipeline enforces this:
| Gate | When it runs | | Gate | When it runs |
| --- | --- | | --- | --- |
| `python bin/check_docs.py` | Every push/PR (`docs-verify` job) — links, glossary anchors, readability rules ([ci-rules](docs/improvement/ci-rules.md)) |
| `pytest tests/` | Every push/PR (`verify` job) | | `pytest tests/` | Every push/PR (`verify` job) |
| `RUN pytest tests/` in the simulator Docker build | Every `docker build` — a red test blocks the image | | `RUN pytest tests/` in the simulator Docker build | Every `docker build` — a red test blocks the image |
| Smoke flight + DuckDB assertion | Every push/PR | | Smoke flight + DuckDB assertion | Every push/PR |
@@ -76,14 +77,58 @@ test: cover broadcast frame roundtrip
Never include AI tool references, `Co-authored-by` trailers for assistants, or Never include AI tool references, `Co-authored-by` trailers for assistants, or
identifying names of people, companies, or locations. identifying names of people, companies, or locations.
## Caveman style (default)
Use **caveman-full** in **issues**, **pull requests**, and **reviews** unless the
author explicitly asks otherwise.
| Do | Skip |
| --- | --- |
| Short lines, fragments OK | Long preamble ("I wanted to start by saying…") |
| Lead with what / why / verdict | Filler ("just", "basically", "kind of") |
| Bullets, checklists | Repeated context already in diff or issue |
| Exact terms, paths, code | Invented abbreviations or vague "the thing" |
**Examples**
| Fluffy | Caveman |
| --- | --- |
| I think we should probably consider adding a link checker to CI because links might break. | Add CI link checker. Broken relative links fail merge. |
| This pull request implements the documentation improvements that were discussed in the audit. | Docs audit: glossary T0T4, fix ground README link, rsync wording. |
| LGTM but maybe we could think about whether the anchor format is consistent? | Merge OK. Fix glossary anchors in follow-up (#1). |
README intensity: root = caveman-lite; `simulator/`, `prototype/`, `infra/`, `docs/adr/` READMEs =
caveman-full. Numbered design docs in `docs/` stay full prose.
Gitea pre-fills [issue](.github/ISSUE_TEMPLATE/default.md) and
[PR](.github/pull_request_template.md) templates with this style.
### Reviews
Default review voice = caveman-full:
1. **Verdict first** — merge / wait / no, or approve / request changes
2. **Findings** — bullet per blocker or nit; file + line when useful
3. **Scope** — say if issue AC met, partial, or out of scope
4. No essay; link ADR, issue, or doc section instead of restating design
## Pull request checklist ## Pull request checklist
- [ ] Tests added or updated; `pytest tests/ -q` passes locally - [ ] Tests added or updated; `pytest tests/ -q` passes locally
- [ ] `docker build` in `simulator/` succeeds (test stage included) - [ ] `docker build` in `simulator/` succeeds (test stage included)
- [ ] Documentation updated if behaviour or boundaries changed - [ ] Documentation updated if behaviour or boundaries changed
- [ ] **ADR added** if the change alters an architectural boundary, contract, or trade-off (see [`docs/adr/`](docs/adr/)) — never edit an existing `ADR-*.md`; supersede with a new number
- [ ] English only; no identifying references - [ ] English only; no identifying references
- [ ] Conventional commit message - [ ] Conventional commit message
## Decisions
Architectural decisions are recorded chronologically as ADRs in
[`docs/adr/`](docs/adr/). Read [`docs/adr/README.md`](docs/adr/README.md) before
changing a boundary or contract; add a new ADR (from
[`docs/adr/TEMPLATE.md`](docs/adr/TEMPLATE.md)) rather than editing an Accepted
one when you reverse course.
## Questions ## Questions
Open design questions live in [`docs/09-open-questions.md`](docs/09-open-questions.md). Open design questions live in [`docs/09-open-questions.md`](docs/09-open-questions.md).
+19
View File
@@ -0,0 +1,19 @@
PROPRIETARY LICENSE
Copyright (c) 2026 Andriy Oblivantsev. All rights reserved.
This material is proprietary and confidential.
No person or organization may, without prior written permission from the copyright holder:
- Publish or republish this material, in whole or in part.
- Copy, reproduce, distribute, transmit, forward, or share this material by any means.
- Use, modify, adapt, translate, create derivative works from, or incorporate this material into any other work.
- Make this material available to any third party, whether publicly or privately.
- Store this material in any repository, archive, or knowledge base accessible to others.
Permission must be obtained in writing before any of the above activities are undertaken.
Unauthorized use, publication, forwarding, reproduction, distribution, or disclosure of this material is strictly prohibited and may result in civil and criminal penalties under applicable copyright and intellectual property laws.
This license grants no rights except the right to view the material for its intended purpose by an authorized recipient.
+16 -5
View File
@@ -2,9 +2,14 @@
**An on-prem data platform for autonomous drone swarms** — a design proposal from a DevOps perspective. **An on-prem data platform for autonomous drone swarms** — a design proposal from a DevOps perspective.
A fleet of drones flies fully autonomously: no internet uplink, no external access, all data stays inside the swarm. Each drone collects high-rate sensor telemetry and runs on-board video object detection. The swarm must exchange just enough state to coordinate flight, store everything else locally, and hand its data over to a ground warehouse after landing — reproducibly, securely, and at scale. Fleet flies autonomous. No internet uplink, no external access. Data stays in the swarm. Each drone collects high-rate sensor telemetry and runs on-board video object detection. Swarm exchanges just enough state to coordinate; everything else stays local until a ground warehouse receives it after landing — reproducibly, securely, at scale.
This repository describes how to build, deliver, test, and operate that platform: the storage layout, the sync strategy, the network trust model, the CI/CD chain, and the simulation environment that makes it all testable without touching real hardware. This repository covers storage layout, sync strategy, network trust model, CI/CD chain, and the simulation environment that tests it without real hardware.
> **Restricted use.** Third parties may not use, copy, modify, or distribute this
> source code, documentation, or related materials without **explicit written
> permission** from the copyright holder. No license is granted by default. See
> [LICENSE](LICENSE) for the full terms.
## Design principles ## Design principles
@@ -24,7 +29,7 @@ This repository describes how to build, deliver, test, and operate that platform
| [00 — Glossary](docs/00-glossary.md) | Terms and abbreviations used throughout | | [00 — Glossary](docs/00-glossary.md) | Terms and abbreviations used throughout |
| [01 — Problem statement](docs/01-problem-statement.md) | Goal, constraints, knowns vs assumptions | | [01 — Problem statement](docs/01-problem-statement.md) | Goal, constraints, knowns vs assumptions |
| [02 — Architecture](docs/02-architecture.md) | On-board layers, service composition, communication planes | | [02 — Architecture](docs/02-architecture.md) | On-board layers, service composition, communication planes |
| [03 — Data platform](docs/03-data-platform.md) | Parquet/DuckDB storage tiers, partitioning, hot-write path | | [03 — Data platform](docs/03-data-platform.md) | Parquet/DuckDB storage floors (T0T4), partitioning, hot-write path |
| [04 — Swarm sync](docs/04-swarm-sync.md) | What syncs, what does not, pub/sub transport, query standard | | [04 — Swarm sync](docs/04-swarm-sync.md) | What syncs, what does not, pub/sub transport, query standard |
| [05 — Network & security](docs/05-network-security.md) | Zero-trust mesh, key provisioning, encrypted transport | | [05 — Network & security](docs/05-network-security.md) | Zero-trust mesh, key provisioning, encrypted transport |
| [06 — Environments](docs/06-environments.md) | Drone / ground warehouse / dev-simulation infrastructures | | [06 — Environments](docs/06-environments.md) | Drone / ground warehouse / dev-simulation infrastructures |
@@ -33,6 +38,10 @@ This repository describes how to build, deliver, test, and operate that platform
| [09 — Open questions](docs/09-open-questions.md) | Known unknowns and proposed answers | | [09 — Open questions](docs/09-open-questions.md) | Known unknowns and proposed answers |
| [10 — Domain context](docs/10-domain-context.md) | Swarm autonomy principles this design builds on | | [10 — Domain context](docs/10-domain-context.md) | Swarm autonomy principles this design builds on |
| [11 — CI/CD & delivery](docs/11-cicd-delivery.md) | Pipelines, registry, dev containers, fleet releases | | [11 — CI/CD & delivery](docs/11-cicd-delivery.md) | Pipelines, registry, dev containers, fleet releases |
| [12 — Design journey](docs/12-design-journey.md) | **Start here** — a narrative walk-through of how the design came together, linking into the code |
| [ADRs](docs/adr/) | Architecture Decision Records — the decisions behind the above, in the order they were made |
New? Start with the [design journey](docs/12-design-journey.md) — story plus code links. Principles = *what*; [ADRs](docs/adr/) = *why and when*.
## Runnable parts ## Runnable parts
@@ -41,10 +50,10 @@ This repository describes how to build, deliver, test, and operate that platform
| [`simulator/`](simulator/) | Virtual drone fleet: generates realistic telemetry through the same Parquet/DuckDB pipeline a real drone would use; N drones via Docker Compose | Python, pyarrow, DuckDB | | [`simulator/`](simulator/) | Virtual drone fleet: generates realistic telemetry through the same Parquet/DuckDB pipeline a real drone would use; N drones via Docker Compose | Python, pyarrow, DuckDB |
| [`prototype/`](prototype/) | 2D top-down swarm visualization: drones, obstacles, inter-drone links with live transfer metrics; **live mode** renders real positions from the k3d fleet | React, TypeScript, Vite, SVG | | [`prototype/`](prototype/) | 2D top-down swarm visualization: drones, obstacles, inter-drone links with live transfer metrics; **live mode** renders real positions from the k3d fleet | React, TypeScript, Vite, SVG |
| [`infra/ansible/`](infra/ansible/) | Host preparation: drone bench provisioning (keys, forced commands, WireGuard, Compose bundle) and local k3d simulation cluster | Ansible | | [`infra/ansible/`](infra/ansible/) | Host preparation: drone bench provisioning (keys, forced commands, WireGuard, Compose bundle) and local k3d simulation cluster | Ansible |
| [`infra/terraform/`](infra/terraform/) | Declared workloads on the sim cluster: virtual fleet + observability ([`sim-env`](infra/terraform/sim-env/)), warehouse + T1→T3 offload ([`ground`](infra/terraform/ground/)) | Terraform, kubernetes provider | | [`infra/terraform/`](infra/terraform/) | Declared workloads on the sim cluster: virtual fleet + observability ([`sim-env`](infra/terraform/sim-env/)), warehouse + [T1→T3](docs/03-data-platform.md#storage-floors-tiers) offload ([`ground`](infra/terraform/ground/)) | Terraform, kubernetes provider |
| [`infra/gitops/`](infra/gitops/) | Flux overlays for the ground segment — policy labels, dashboard bundles; drones stay on the fleet manifest | Flux, Kustomize | | [`infra/gitops/`](infra/gitops/) | Flux overlays for the ground segment — policy labels, dashboard bundles; drones stay on the fleet manifest | Flux, Kustomize |
Shared runtime data (gitignored): `var/t1/` (live lake), `var/t3/` (warehouse). See [`CONTRIBUTING.md`](CONTRIBUTING.md). Shared runtime data (gitignored): [`var/t1/`](docs/00-glossary.md#t1--warm) (live lake, [T1](docs/00-glossary.md#t1--warm)), [`var/t3/`](docs/00-glossary.md#t3--warehouse) (warehouse, [T3](docs/00-glossary.md#t3--warehouse)). See [`CONTRIBUTING.md`](CONTRIBUTING.md).
## Quick start ## Quick start
@@ -73,3 +82,5 @@ Contributing (TDD-first workflow): [`CONTRIBUTING.md`](CONTRIBUTING.md).
## CI & releases ## CI & releases
Every push runs the pipeline in [`.github/workflows/release.yaml`](.github/workflows/release.yaml): **pytest unit tests**, a compile check, a 10-second virtual smoke flight verified with DuckDB, and a Trivy vulnerability/misconfiguration scan. The simulator Docker build also runs `pytest` — a failing test blocks the image. Pushes to `main` then cut a semantic version from the conventional-commit type (`feat:` → minor, `!`/`BREAKING CHANGE` → major, else patch), tag the commit, and publish a release with a **fleet release manifest** (versioned artifact list with checksums — the delivery unit described in [11 — CI/CD & delivery](docs/11-cicd-delivery.md)) plus a source bundle. Every push runs the pipeline in [`.github/workflows/release.yaml`](.github/workflows/release.yaml): **pytest unit tests**, a compile check, a 10-second virtual smoke flight verified with DuckDB, and a Trivy vulnerability/misconfiguration scan. The simulator Docker build also runs `pytest` — a failing test blocks the image. Pushes to `main` then cut a semantic version from the conventional-commit type (`feat:` → minor, `!`/`BREAKING CHANGE` → major, else patch), tag the commit, and publish a release with a **fleet release manifest** (versioned artifact list with checksums — the delivery unit described in [11 — CI/CD & delivery](docs/11-cicd-delivery.md)) plus a source bundle.
[LICENSE](LICENSE)
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Reject modifications to accepted Architecture Decision Records."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ADR_PREFIX = "docs/adr/ADR-"
def _git(*args: str) -> str:
return subprocess.check_output(
["git", *args],
cwd=ROOT,
text=True,
stderr=subprocess.DEVNULL,
).strip()
def _exists_on(base: str, path: str) -> bool:
try:
_git("cat-file", "-e", f"{base}:{path}")
return True
except subprocess.CalledProcessError:
return False
def find_modified_adrs(base: str) -> list[str]:
try:
diff_out = _git("diff", "--name-only", f"{base}...HEAD", "--", "docs/adr/")
except subprocess.CalledProcessError:
return []
modified: list[str] = []
for rel in diff_out.splitlines():
if not rel.startswith(ADR_PREFIX) or not rel.endswith(".md"):
continue
if _exists_on(base, rel):
modified.append(rel)
return sorted(modified)
def run_check(base: str = "origin/main") -> int:
modified = find_modified_adrs(base)
if modified:
print("Immutable ADR violation — accepted records cannot be edited:")
for path in modified:
print(f" error: {path}")
print(
"\nReversal or refinement → new ADR-NNNN file. "
"Never edit an existing ADR-*.md."
)
return 1
print(f"ADR immutability OK (base {base})")
return 0
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--base",
default="origin/main",
help="Git ref to compare against (default: origin/main)",
)
args = parser.parse_args()
sys.exit(run_check(base=args.base))
if __name__ == "__main__":
main()
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Validate markdown links, glossary anchors, and readability rules."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RULES_PATH = Path(__file__).with_name("docs_rules.json")
LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
BARE_SEE_RE = re.compile(r"(?<![\[(/])see\s+(\d{2})(?![\w-])", re.IGNORECASE)
SKIP_SCHEMES = ("http://", "https://", "mailto:", "tel:", "#")
def load_rules(path: Path = RULES_PATH) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def slug_heading(text: str) -> str:
"""Anchor slug used in this repository's glossary links."""
text = text.strip()
text = re.sub(r"\s&\s", "", text)
if "" in text:
left, right = text.split("", 1)
return f"{_slug_part(left)}--{_slug_part(right)}"
return _slug_part(text)
def _slug_part(text: str) -> str:
slug = text.strip().lower()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"\s+", "-", slug)
return slug
def collect_anchors(md_path: Path) -> set[str]:
anchors: set[str] = set()
for line in md_path.read_text(encoding="utf-8").splitlines():
if line.startswith("##"):
anchors.add(slug_heading(line.lstrip("#").strip()))
return anchors
def expand_markdown_paths(patterns: list[str]) -> list[Path]:
files: set[Path] = set()
for pattern in patterns:
for path in ROOT.glob(pattern):
if path.is_file() and path.suffix == ".md":
files.add(path)
return sorted(files)
def split_link_target(raw: str) -> tuple[str, str | None]:
target = raw.strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
if "#" in target:
path_part, fragment = target.split("#", 1)
return path_part, fragment or None
return target, None
def resolve_path(source: Path, link_path: str) -> Path | None:
if not link_path or link_path.startswith(SKIP_SCHEMES):
return None
candidate = (source.parent / link_path).resolve()
try:
candidate.relative_to(ROOT.resolve())
except ValueError:
return None
return candidate
def is_glossary_link(link_path: str, rules: dict) -> bool:
glossary = rules["glossary"]["file"].replace("\\", "/")
normalized = link_path.replace("\\", "/")
return (
normalized == glossary
or normalized.endswith("/" + glossary)
or normalized.endswith(glossary.split("/")[-1])
)
def check_file(
md_path: Path,
rules: dict,
anchor_cache: dict[Path, set[str]],
) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
rel = md_path.relative_to(ROOT)
text = md_path.read_text(encoding="utf-8")
if rules.get("readability", {}).get("forbid_bare_see_refs"):
prose = re.sub(r"`[^`]*`", "", text)
for match in BARE_SEE_RE.finditer(prose):
errors.append(
f"{rel}:{_line_no(text, match.start())}: bare cross-ref "
f"'see {match.group(1)}' — use a markdown link"
)
for match in LINK_RE.finditer(text):
raw = match.group(1)
if any(raw.startswith(s) for s in SKIP_SCHEMES):
continue
link_path, fragment = split_link_target(raw)
if not link_path.endswith(".md"):
continue
for banned in rules.get("banned_link_targets", []):
if banned in link_path.replace("\\", "/"):
errors.append(
f"{rel}:{_line_no(text, match.start())}: banned link target "
f"'{link_path}'"
)
target = resolve_path(md_path, link_path)
if target is None:
errors.append(
f"{rel}:{_line_no(text, match.start())}: link escapes repo or "
f"is invalid: {raw}"
)
continue
if not target.is_file():
errors.append(
f"{rel}:{_line_no(text, match.start())}: broken link "
f"{link_path} -> {target.relative_to(ROOT)}"
)
continue
if fragment:
if target not in anchor_cache:
anchor_cache[target] = collect_anchors(target)
if fragment not in anchor_cache[target]:
errors.append(
f"{rel}:{_line_no(text, match.start())}: unknown anchor "
f"#{fragment} in {target.relative_to(ROOT)}"
)
if fragment and is_glossary_link(link_path, rules):
section_anchors = set(rules.get("glossary_section_anchors", []))
if fragment in section_anchors:
warnings.append(
f"{rel}:{_line_no(text, match.start())}: glossary section "
f"link #{fragment} — prefer a ### term anchor where one exists"
)
return errors, warnings
def _line_no(text: str, index: int) -> int:
return text.count("\n", 0, index) + 1
def run_checks(warnings_as_errors: bool = False) -> int:
rules = load_rules(RULES_PATH)
anchor_cache: dict[Path, set[str]] = {}
all_errors: list[str] = []
all_warnings: list[str] = []
glossary_path = ROOT / rules["glossary"]["file"]
anchor_cache[glossary_path] = collect_anchors(glossary_path)
for md_path in expand_markdown_paths(rules["markdown_paths"]):
errors, warnings = check_file(md_path, rules, anchor_cache)
all_errors.extend(errors)
all_warnings.extend(warnings)
if all_warnings:
print("Documentation warnings:")
for msg in sorted(all_warnings):
print(f" warning: {msg}")
print()
if all_errors:
print("Documentation errors:")
for msg in sorted(all_errors):
print(f" error: {msg}")
print(f"\n{len(all_errors)} error(s), {len(all_warnings)} warning(s)")
return 1
if warnings_as_errors and all_warnings:
print(f"\n{len(all_warnings)} warning(s) treated as errors")
return 1
print(
f"Documentation checks passed "
f"({len(expand_markdown_paths(rules['markdown_paths']))} files, "
f"{len(all_warnings)} warning(s))"
)
return 0
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--warnings-as-errors",
action="store_true",
help="Exit non-zero when section-link warnings are present",
)
args = parser.parse_args()
sys.exit(run_checks(warnings_as_errors=args.warnings_as_errors))
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
{
"markdown_paths": [
"docs/**/*.md",
"README.md",
"CONTRIBUTING.md",
"AGENTS.md",
"simulator/README.md",
"prototype/README.md",
"infra/**/README.md"
],
"glossary": {
"file": "docs/00-glossary.md"
},
"banned_link_targets": [
"03-storage-design.md"
],
"glossary_section_anchors": [
"data--storage",
"swarm--robotics",
"infrastructure--delivery"
],
"preferred_term_anchors": [
"t0--hot",
"t1--warm",
"t2--shared",
"t3--warehouse",
"t4--dev",
"source-of-truth",
"architecture-layer-1--ingestion",
"architecture-layer-2--storage-and-transform",
"architecture-layer-3--serving-and-sync",
"pipeline-stage",
"bulk-sync",
"offload",
"rclone",
"adr",
"asr"
],
"readability": {
"forbid_bare_see_refs": true
}
}
+66 -2
View File
@@ -2,6 +2,70 @@
Terms and abbreviations used throughout this proposal. Terms and abbreviations used throughout this proposal.
**Vocabulary guide:** **architecture layers** (13) = on-board software stack; **storage floors** (T0T4) = retention/latency ladder; **pipeline stages** = narrative capture → reduction → sync only ([12 — Design journey](12-design-journey.md) §4). Do not mix these three.
## Project vocabulary
### T0 — hot
RAM / DuckDB in-process sliding window of the last minutes; what mission logic queries in flight. Retention: minutes. Maps to **hot** in the classic three-tier model.
### T1 — warm
Drone NVMe: full raw `telemetry` + `detections` + `state` of the current flight; never leaves the drone in flight. Retention: current flight plus quota-based headroom. Maps to **warm**. Runtime path: [`var/t1/`](../var/t1/) ([ADR-0008](adr/ADR-0008-runtime-data-paths.md)).
### T2 — shared
On-board MinIO bucket: derived data only (`detections`, `state`), replicated opportunistically across the swarm. Retention: current mission.
### T3 — warehouse
Ground on-prem object store + Parquet lakehouse: every flight of every drone, forever; replay, analytics, model training. Retention: years. Maps to **cold** / **DWH**. Runtime path: [`var/t3/`](../var/t3/) ([ADR-0008](adr/ADR-0008-runtime-data-paths.md)). After offload and integrity audit, T3 is the [source of truth](#source-of-truth) for historical flight data.
### T4 — dev
Engineer laptop or sim farm: slices pulled from T3, or synthetic data from the simulator. Retention: ephemeral.
### Source of truth
The authoritative copy of flight data after [offload](#offload) and integrity audit: the [T3](#t3--warehouse) warehouse. Each drone holds its own partial record in flight ([T1](#t1--warm)); peers exchange derived state only ([04 — Swarm sync](04-swarm-sync.md)). Replay, post-flight fusion, training, and audit read from T3 — not from any single drone's local store. See [06 — Environments](06-environments.md), [ADR-0003](adr/ADR-0003-sync-derived-state-only.md).
### Architecture layer 1 — ingestion
On-board services that capture sensor streams and video analytics ([02 — Architecture](02-architecture.md)): `sensor-ingest`, `video-analytics`.
### Architecture layer 2 — storage and transform
On-board Parquet write path, sealer, DuckDB, NVMe ([02 — Architecture](02-architecture.md)).
### Architecture layer 3 — serving and sync
On-board event hook, pub/sub broadcast, MinIO derived bucket, peer query API ([02 — Architecture](02-architecture.md)).
### Pipeline stage
Narrative inside each drone: (1) raw capture, (2) ETL/reduction, (3) bidirectional sync interface. Not the same as storage floors T0T4 ([12 — Design journey](12-design-journey.md) §4).
### Bulk sync
In-flight pull of sealed `detections`/`state` Parquet partitions between peers via **rsync** delta over persistent SSH forced commands ([04 — Swarm sync](04-swarm-sync.md)).
### Offload
Post-flight mirror of sealed T1 partitions into T3 at the dock: **rsync** or MinIO **`mc mirror`**, then integrity audit before local prune ([03 — Data platform](03-data-platform.md)).
### rclone
Optional rsync-class tool when the far end is object storage and rsync is awkward; not the default in-flight mesh path ([09 — Open questions](09-open-questions.md) §12).
### ADR
Architecture Decision Record — a point-in-time decision for this repository ([adr/README.md](adr/README.md)).
### ASR
Architecture Standard Record — a standing cross-repository policy (lives in the ecosystem `inventar` repo, not here). See [adr/README.md](adr/README.md).
## Data & storage ## Data & storage
| Term | Meaning | | Term | Meaning |
@@ -10,8 +74,8 @@ Terms and abbreviations used throughout this proposal.
| **Hive partitioning** | Directory layout `key=value/…` understood by most engines; enables partition pruning (only relevant directories are read) | | **Hive partitioning** | Directory layout `key=value/…` understood by most engines; enables partition pruning (only relevant directories are read) |
| **DuckDB** | In-process analytical SQL engine; queries Parquet globs directly, no server to operate | | **DuckDB** | In-process analytical SQL engine; queries Parquet globs directly, no server to operate |
| **ZSTD** | Zstandard compression; the default codec for all Parquet files here | | **ZSTD** | Zstandard compression; the default codec for all Parquet files here |
| **DWH** | Data warehouse — here: the on-prem ground store holding every flight ever flown | | **DWH** | Data warehouse — here: the on-prem ground store holding every flight ever flown; same role as [source of truth](#source-of-truth) |
| **Hot / warm / cold tiers** | Storage floors ordered by access latency and retention: in-memory window → local NVMe → ground warehouse | | **Hot / warm / cold tiers** | Classic three-tier model: in-memory window → local NVMe → ground warehouse. Here mapped to [T0](#t0--hot), [T1](#t1--warm), and [T3](#t3--warehouse); [T2](#t2--shared) and [T4](#t4--dev) are named explicitly |
| **Sealing** | Compacting many small streamed Parquet files into one final file per partition once a time window closes | | **Sealing** | Compacting many small streamed Parquet files into one final file per partition once a time window closes |
| **UUIDv7** | Time-ordered UUID; sortable by creation time, used as the canonical flight identifier | | **UUIDv7** | Time-ordered UUID; sortable by creation time, used as the canonical flight identifier |
| **DuckDB spatial** | Extension adding a GEOMETRY type and `ST_*` functions; used for separation audits, perimeter predicates, and trajectory analysis in the local planar frame | | **DuckDB spatial** | Extension adding a GEOMETRY type and `ST_*` functions; used for separation audits, perimeter predicates, and trajectory analysis in the local planar frame |
+3 -3
View File
@@ -15,7 +15,7 @@ Plus everything a platform needs around that: reproducible builds and deployment
| Constraint | Consequence | | Constraint | Consequence |
| --- | --- | | --- | --- |
| **No internet, no external access, ever** | All infrastructure self-hosted; updates through an in-air-gap registry mirror; no cloud services of any kind | | **No internet, no external access, ever** | All infrastructure self-hosted; updates through an in-air-gap registry mirror; no cloud services of any kind |
| **Autonomous operation** | No human in the control loop during flight; the platform must not require operator intervention | | **Autonomous operation** | No human in the control loop during flight; the platform must not require operator intervention. A [supervising operator](00-glossary.md#swarm--robotics) may set mission intent over [C2](00-glossary.md#swarm--robotics) before and during flight, but does not steer individual vehicles |
| **Intermittent mesh connectivity** | No component may assume a stable link between any two drones; no swarm-wide orchestrator | | **Intermittent mesh connectivity** | No component may assume a stable link between any two drones; no swarm-wide orchestrator |
| **Mixed hardware** | ARM64 and AMD64 Linux targets; some nodes carry GPUs for inference; all images built multi-arch | | **Mixed hardware** | ARM64 and AMD64 Linux targets; some nodes carry GPUs for inference; all images built multi-arch |
| **Bounded on-board resources** | Multi-TB NVMe but finite; high-rate ingest must be compressed and retention-managed in flight | | **Bounded on-board resources** | Multi-TB NVMe but finite; high-rate ingest must be compressed and retention-managed in flight |
@@ -30,8 +30,8 @@ Plus everything a platform needs around that: reproducible builds and deployment
- **Parquet** adoption is at the evaluation stage — the storage layout in this proposal is the core of what is being asked. - **Parquet** adoption is at the evaluation stage — the storage layout in this proposal is the core of what is being asked.
- **MinIO** is available and considered as the inter-drone sync mechanism. - **MinIO** is available and considered as the inter-drone sync mechanism.
- A **lightweight Kubernetes** exists in the ecosystem; this proposal scopes it to ground infrastructure only (see [02 — Architecture](02-architecture.md)). - A **lightweight Kubernetes** exists in the ecosystem; this proposal scopes it to ground infrastructure only (see [02 — Architecture](02-architecture.md)).
- After landing, each drone's data is **offloaded to an on-prem warehouse** for replay and iterative model training. - After landing, each drone's data is **offloaded to the on-prem [source of truth](00-glossary.md#source-of-truth)** for replay and iterative model training.
- Mission intent (declarative goals) reaches the swarm over a narrow **C2 channel**; there is no continuous ground link in flight. - Mission intent (declarative goals) reaches the swarm over a narrow **[C2](00-glossary.md#swarm--robotics) channel**; there is no continuous ground link in flight.
## What is assumed (explicitly marked as assumptions) ## What is assumed (explicitly marked as assumptions)
+6 -6
View File
@@ -2,7 +2,7 @@
## On-board: three layers, one Compose file ## On-board: three layers, one Compose file
Every drone runs the same three-layer data plane. **Docker Compose under systemd is the only on-board orchestrator.** Cluster orchestrators (Kubernetes, Swarm mode) assume a stable control plane and continuous connectivity — in an ad-hoc mesh with intermittent links that is an anti-pattern. Each drone is fully autonomous; coordination happens through data exchange, not through a shared control plane. Lightweight Kubernetes (k3s) is used on the ground only (warehouse, CI runners, simulation farm — see [06 — Environments](06-environments.md)). Every drone runs the same [three-layer](00-glossary.md#architecture-layer-1--ingestion) data plane ([storage floors](00-glossary.md#t0--hot) T0T4 are a separate concept — see [03 — Data platform](03-data-platform.md#storage-floors-tiers)). **Docker Compose under systemd is the only on-board orchestrator.** Cluster orchestrators (Kubernetes, Swarm mode) assume a stable control plane and continuous connectivity — in an ad-hoc mesh with intermittent links that is an anti-pattern. Each drone is fully autonomous; coordination happens through data exchange, not through a shared control plane. Lightweight Kubernetes (k3s) is used on the ground only (warehouse, CI runners, simulation farm — see [06 — Environments](06-environments.md)).
```mermaid ```mermaid
graph TB graph TB
@@ -42,7 +42,7 @@ graph TB
### Layer 1 — Ingestion ### Layer 1 — Ingestion
- `sensor-ingest` subscribes to sensor sources (ROS 2 topics where available, raw drivers otherwise) and normalizes them into typed streams: IMU, barometer, temperature, LiDAR, RSSI, power, and so on. - `sensor-ingest` subscribes to sensor sources (ROS 2 topics where available, raw drivers otherwise) and normalizes them into typed streams: IMU, barometer, temperature, LiDAR, RSSI, power, and so on.
- `video-analytics` runs a **YOLO-like object detector** on the camera stream. The model is deliberately treated as a swappable, versioned artifact (weights differ per mission and improve over time — see [11 — CI/CD & delivery](11-cicd-delivery.md)). GPU access via nvidia-container-toolkit; ARM64 builds use the vendor's L4T-class base images. - `video-analytics` runs a [**YOLO-like**](00-glossary.md#swarm--robotics) object detector on the camera stream. The model is deliberately treated as a swappable, versioned artifact (weights differ per mission and improve over time — see [11 — CI/CD & delivery](11-cicd-delivery.md)). GPU access via nvidia-container-toolkit; ARM64 builds use the vendor's L4T-class base images.
- Both services emit rows, not files. Timestamps are `int64` epoch **nanoseconds** end to end (native for ROS 2; IMU-class rates make milliseconds insufficient). - Both services emit rows, not files. Timestamps are `int64` epoch **nanoseconds** end to end (native for ROS 2; IMU-class rates make milliseconds insufficient).
### Layer 2 — Storage and transform ### Layer 2 — Storage and transform
@@ -55,7 +55,7 @@ graph TB
- The **event hook** is the on-board "lambda": when the writer lands new *derived* rows (state, detections), it triggers registered actions — broadcast, MinIO upload, or a local mission-logic callback. Nothing polls. - The **event hook** is the on-board "lambda": when the writer lands new *derived* rows (state, detections), it triggers registered actions — broadcast, MinIO upload, or a local mission-logic callback. Nothing polls.
- The **state publisher** broadcasts compact position/attitude/detection payloads over the mesh pub/sub (transport analysis in [04 — Swarm sync](04-swarm-sync.md)). - The **state publisher** broadcasts compact position/attitude/detection payloads over the mesh pub/sub (transport analysis in [04 — Swarm sync](04-swarm-sync.md)).
- **Bulk sync** pulls sealed derived partitions from peers over persistent SSH (rsync delta transfer); MinIO remains optional where an S3 API is wanted. - **Bulk sync** pulls sealed derived partitions from peers over persistent SSH ([rsync](00-glossary.md#bulk-sync) delta transfer); MinIO remains optional where an S3 API is wanted.
- **Peer queries** are read-only DuckDB SQL over SSH forced commands — SELECT-only gate, read-only OS user, columnar responses (the design decision and its reasoning are in [04](04-swarm-sync.md)). - **Peer queries** are read-only DuckDB SQL over SSH forced commands — SELECT-only gate, read-only OS user, columnar responses (the design decision and its reasoning are in [04](04-swarm-sync.md)).
## Communication planes ## Communication planes
@@ -65,11 +65,11 @@ Three isolated planes with different lifecycles:
```mermaid ```mermaid
graph LR graph LR
subgraph planes [Communication planes] subgraph planes [Communication planes]
C2["C2 control plane<br/>mission intent, narrow, in production"] C2["[C2](00-glossary.md#swarm--robotics) control plane<br/>mission intent, narrow, in production"]
DATA["swarm data plane<br/>state broadcast + sync, in production"] DATA["swarm data plane<br/>state broadcast + sync, in production"]
DEV["dev/debug plane<br/>live telemetry, bench only"] DEV["dev/debug plane<br/>live telemetry, bench only"]
end end
OPERATOR["supervising operator<br/>(man-in-the-loop)"] --> C2 OPERATOR["supervising operator<br/>([man-in-the-loop](00-glossary.md#swarm--robotics))"] --> C2
C2 --> SWARM["swarm"] C2 --> SWARM["swarm"]
SWARM <--> DATA SWARM <--> DATA
DEV -.->|absent from production builds| SWARM DEV -.->|absent from production builds| SWARM
@@ -79,7 +79,7 @@ graph LR
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **C2 control plane** | Deliver declarative mission goals and boundaries; receive high-level status. Human supervises intent, not actions | Very low | Yes | | **C2 control plane** | Deliver declarative mission goals and boundaries; receive high-level status. Human supervises intent, not actions | Very low | Yes |
| **Swarm data plane** | State broadcast, derived-data replication, peer queries | The scarce resource; budget per message class | Yes | | **Swarm data plane** | State broadcast, derived-data replication, peer queries | The scarce resource; budget per message class | Yes |
| **Dev/debug plane** | Full live telemetry for bench development and HIL tests | High | **No — physically absent from production builds** | | **Dev/debug plane** | Full live telemetry for bench development and [HIL](00-glossary.md#infrastructure--delivery) tests | High | **No — physically absent from production builds** |
The dev plane is not "disabled by config": production images and radio profiles simply do not contain it. That removes an entire attack surface instead of guarding it. The dev plane is not "disabled by config": production images and radio profiles simply do not contain it. That removes an entire attack surface instead of guarding it.
+15 -13
View File
@@ -1,6 +1,6 @@
# 03 — Data platform # 03 — Data platform
The heart of the proposal: how raw sensor streams become a compressed, queryable, offloadable store — with **one format and one layout on every floor** of the system. The heart of the proposal: how raw sensor streams become a compressed, queryable, offloadable store — with **one format and one layout on every [storage floor](00-glossary.md#t0--hot)** of the system.
## Datasets ## Datasets
@@ -14,7 +14,7 @@ Three logical datasets, distinct because their consumers and sync policies diffe
## Partitioning layout ## Partitioning layout
Identity dimensions first, calendar time last, everything Hive-style `key=value`: Identity dimensions first, calendar time last, everything [Hive-style](00-glossary.md#data--storage) `key=value`:
``` ```
dataset=telemetry/flight=20260708T1100Z-a3f2/drone=dr-017/sensor=imu/year=2026/month=07/day=08/hour=11/ dataset=telemetry/flight=20260708T1100Z-a3f2/drone=dr-017/sensor=imu/year=2026/month=07/day=08/hour=11/
@@ -29,7 +29,7 @@ Design decisions and their reasons:
- **`flight` before `drone`.** The dominant warehouse query is *replay of one flight across all drones*. With `flight` on top, a replay reads one subtree. (On board, `drone=` is constant and costs nothing — but keeping it means the on-board layout is byte-identical to the warehouse layout.) - **`flight` before `drone`.** The dominant warehouse query is *replay of one flight across all drones*. With `flight` on top, a replay reads one subtree. (On board, `drone=` is constant and costs nothing — but keeping it means the on-board layout is byte-identical to the warehouse layout.)
- **`sensor` before time.** Each sensor has its own schema, so sensors need separate leaves anyway; and "one sensor over a period" is the most common analytical scan. Partition pruning handles both. - **`sensor` before time.** Each sensor has its own schema, so sensors need separate leaves anyway; and "one sensor over a period" is the most common analytical scan. Partition pruning handles both.
- **Flight identifiers.** The canonical flight id is a **UUIDv7** (time-ordered, globally unique, embedded timestamp — sortable by design). Partition paths use a short human-readable time-sortable alias (`20260708T1100Z-a3f2`) so directory listings stay debuggable; the full UUIDv7 lives in the data and in the flight manifest. - **Flight identifiers.** The canonical flight id is a [**UUIDv7**](00-glossary.md#data--storage) (time-ordered, globally unique, embedded timestamp — sortable by design). Partition paths use a short human-readable time-sortable alias (`20260708T1100Z-a3f2`) so directory listings stay debuggable; the full UUIDv7 lives in the data and in the flight manifest.
- **Timestamps: `int64` epoch nanoseconds.** Same 8 bytes as milliseconds, native resolution of ROS 2, sufficient for IMU-class rates, valid until year 2262. One time type everywhere — no unit confusion at merge time. - **Timestamps: `int64` epoch nanoseconds.** Same 8 bytes as milliseconds, native resolution of ROS 2, sufficient for IMU-class rates, valid until year 2262. One time type everywhere — no unit confusion at merge time.
- **No rounding in raw data.** Coordinates and measurements are stored exactly as the sensor emits them (float64/float32 per sensor spec). ZSTD plus Parquet delta encoding makes rounding-for-size unnecessary. Quantization (millimeters as int32) is applied **only** to broadcast payloads, where every byte of air time counts ([04 — Swarm sync](04-swarm-sync.md)). - **No rounding in raw data.** Coordinates and measurements are stored exactly as the sensor emits them (float64/float32 per sensor spec). ZSTD plus Parquet delta encoding makes rounding-for-size unnecessary. Quantization (millimeters as int32) is applied **only** to broadcast payloads, where every byte of air time counts ([04 — Swarm sync](04-swarm-sync.md)).
@@ -50,7 +50,7 @@ sequenceDiagram
S->>W: rows (int64 ns, typed) S->>W: rows (int64 ns, typed)
W->>C: flush min_NN.parquet (small, ZSTD-fast) W->>C: flush min_NN.parquet (small, ZSTD-fast)
end end
Note over Z: window closes (hour sealed<br/>or flight ends) Note over Z: window closes (hour [sealed](00-glossary.md#data--storage)<br/>or flight ends)
Z->>C: read all min_*.parquet Z->>C: read all min_*.parquet
Z->>P: write one compact file (ZSTD-high) Z->>P: write one compact file (ZSTD-high)
Z->>C: remove blocks after verify Z->>C: remove blocks after verify
@@ -63,26 +63,28 @@ sequenceDiagram
## Storage floors (tiers) ## Storage floors (tiers)
Same format on every floor; only volume, retention, and location change: Same [Parquet](00-glossary.md#data--storage) + [DuckDB](00-glossary.md#data--storage) format on every floor; only volume, retention, and location change. See [glossary](00-glossary.md#t0--hot) for the full T0T4 definitions.
| Floor | Where | Contents | Retention | | Floor | Where | Contents | Retention |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **T0 — hot** | RAM / DuckDB in-process | Sliding window of the last minutes; what mission logic queries in flight | Minutes | | **[T0 — hot](00-glossary.md#t0--hot)** | RAM / DuckDB in-process | Sliding window of the last minutes; what mission logic queries in flight | Minutes |
| **T1 — warm** | Drone NVMe | Full raw `telemetry` + `detections` + `state` of the current flight; never leaves the drone in flight | Current flight (+ quota-based headroom) | | **[T1 — warm](00-glossary.md#t1--warm)** | Drone NVMe | Full raw `telemetry` + `detections` + `state` of the current flight; never leaves the drone in flight | Current flight (+ quota-based headroom) |
| **T2 — shared** | On-board MinIO bucket | Derived data only (`detections`, `state`), replicated opportunistically across the swarm | Current mission | | **[T2 — shared](00-glossary.md#t2--shared)** | On-board MinIO bucket | Derived data only (`detections`, `state`), replicated opportunistically across the swarm | Current mission |
| **T3 — warehouse** | Ground on-prem object store + Parquet lakehouse | Every flight of every drone, forever; replay, analytics, model training | Years | | **[T3 — warehouse](00-glossary.md#t3--warehouse)** | Ground on-prem object store + Parquet lakehouse | Every flight of every drone, forever; [source of truth](00-glossary.md#source-of-truth) for replay, analytics, model training | Years |
| **T4 — dev** | Engineer laptop / sim farm | Slices pulled from T3, or synthetic data from the simulator | Ephemeral | | **[T4 — dev](00-glossary.md#t4--dev)** | Engineer laptop / sim farm | Slices pulled from T3, or synthetic data from the simulator | Ephemeral |
## Flight offload: a mirror, not a migration ## Flight offload: a mirror, not a migration
Because T1 and T3 share the identical layout, offload after landing is: Because [T1](00-glossary.md#t1--warm) and [T3](00-glossary.md#t3--warehouse) share the identical layout, [offload](00-glossary.md#offload) after landing is:
```bash ```bash
# per drone, at the base station # per drone, at the base station
mc mirror drone-nvme/flights/ warehouse/flights/ # or rsync over the wired dock rsync -a drone-nvme/flights/ warehouse/flights/ # or mc mirror to object store
duckdb -c "…integrity audit: row counts, time coverage, gap scan per partition…" duckdb -c "…integrity audit: row counts, time coverage, gap scan per partition…"
``` ```
For S3-only backends where rsync is awkward, [rclone](00-glossary.md#rclone) is an optional rsync-class alternative — not the default mesh path ([09 — Open questions](09-open-questions.md) §12).
The audit compares expected vs actual coverage per partition (row counts, min/max timestamps, gap detection) before the drone's local copy is released for cleanup. Missing or corrupt partitions are re-pulled — a lazy gap-repair pass rather than a failed batch job. The audit compares expected vs actual coverage per partition (row counts, min/max timestamps, gap detection) before the drone's local copy is released for cleanup. Missing or corrupt partitions are re-pulled — a lazy gap-repair pass rather than a failed batch job.
## Retention and quotas on board ## Retention and quotas on board
@@ -138,6 +140,6 @@ On the ground this powers replay overlays, coverage analysis (did ubiquitous sen
## Schema management ## Schema management
- Every dataset schema is versioned in-repo (`schemas/` SemVer, one file per dataset version) and referenced by the fleet release manifest ([11](11-cicd-delivery.md)). - Every dataset schema is versioned in-repo (planned `schemas/` directory — [roadmap stage 2](08-roadmap.md), [09 — Open questions](09-open-questions.md) §14; SemVer, one file per dataset version) and referenced by the fleet release manifest ([11](11-cicd-delivery.md)).
- Parquet files carry the schema version in file metadata; readers select the matching deserializer. - Parquet files carry the schema version in file metadata; readers select the matching deserializer.
- Additive evolution only within a MAJOR version (new nullable columns); breaking changes bump MAJOR and require a manifest release. - Additive evolution only within a MAJOR version (new nullable columns); breaking changes bump MAJOR and require a manifest release.
+13 -11
View File
@@ -16,19 +16,21 @@ Bandwidth is the scarcest resource in the system. Every message class gets an ex
## Broadcast payload: small on the wire, precise at rest ## Broadcast payload: small on the wire, precise at rest
The `state` broadcast is a fixed compact frame: The `state` broadcast is a fixed compact frame (46 bytes, little-endian):
| Field | Type | Notes | | Field | Type | Notes |
| --- | --- | --- | | --- | --- | --- |
| `drone_id` | uint16 | Fleet-scoped registry | | `magic` | 2 bytes | `b"SH"` |
| `version` | uint8 | Protocol version |
| `drone_id` | 8 bytes | Zero-padded ASCII fleet id |
| `ts_ns` | int64 | Epoch nanoseconds, same clock domain as storage | | `ts_ns` | int64 | Epoch nanoseconds, same clock domain as storage |
| `pos_x/y/z` | int32 | **Millimeters** in the mission frame — quantized only here, storage keeps full float precision | | `pos_x/y/z` | int32 | **Millimeters** in the mission frame — quantized only here, storage keeps full float precision |
| `att_roll/pitch/yaw` | int16 | Centi-degrees | | `att_roll/pitch/yaw` | int16 | Centi-degrees |
| `vel_x/y/z` | int16 | cm/s | | `vel_x/y/z` | int16 | cm/s |
| `frame_ref` | uint8 | Frame of reference id (GPS-denied: local/visual-odometry frames must be explicit) | | `frame_ref` | uint8 | Frame of reference id ([GPS-denied](00-glossary.md#swarm--robotics): local/visual-odometry frames must be explicit) |
| `flags` | uint8 | Battery-low, returning, degraded-sensors, … | | `flags` | uint8 | Battery-low, returning, degraded-sensors, … |
~40 bytes per frame → a 50-drone swarm at 5 Hz is ~10 KB/s of pose traffic before transport overhead. Trivial even on a congested mesh. 46 bytes per frame → a 50-drone swarm at 5 Hz is ~11.5 KB/s of pose traffic before transport overhead. Trivial even on a congested mesh.
`detections` events are slightly larger (class, confidence, bounding volume, ego-pose) but event-shaped and rare by comparison. `detections` events are slightly larger (class, confidence, bounding volume, ego-pose) but event-shaped and rare by comparison.
@@ -51,7 +53,7 @@ graph LR
SB -->|"store-and-forward relay"| SC SB -->|"store-and-forward relay"| SC
``` ```
### Recommended: Zenoh ### Recommended: [Zenoh](00-glossary.md#swarm--robotics)
- Designed exactly for constrained, dynamic networks: built-in peer discovery, brokerless peer-to-peer mode, store-and-forward, and a query layer on top of pub/sub. - Designed exactly for constrained, dynamic networks: built-in peer discovery, brokerless peer-to-peer mode, store-and-forward, and a query layer on top of pub/sub.
- First-class robotics citizenship: an official ROS 2 RMW implementation exists, so the ingestion side and the sync side can share one middleware. - First-class robotics citizenship: an official ROS 2 RMW implementation exists, so the ingestion side and the sync side can share one middleware.
@@ -68,13 +70,13 @@ graph LR
## Two sync mechanisms, deliberately separate ## Two sync mechanisms, deliberately separate
1. **Fast path — pub/sub (Zenoh):** pose frames and detection events. Fire-and-forget with bounded staleness; consumers keep a peer-state cache. 1. **Fast path — pub/sub ([Zenoh](00-glossary.md#swarm--robotics)):** pose frames and detection events. Fire-and-forget with bounded staleness; consumers keep a peer-state cache.
2. **Bulk path — rsync over persistent SSH:** sealed `detections`/`state` Parquet partitions are pulled opportunistically between drones when links allow. This is how a drone that was out of range catches up on mission history without anyone re-sending events. 2. **Bulk path — [rsync](00-glossary.md#bulk-sync) over persistent SSH:** sealed `detections`/`state` Parquet partitions are pulled opportunistically between drones when links allow. This is how a drone that was out of range catches up on mission history without anyone re-sending events.
Why SSH-based bulk sync over object-store replication: Why SSH-based bulk sync over object-store replication:
- **Identity is already there.** Every drone holds pre-provisioned ed25519 keys and a fixed `known_hosts`/`authorized_keys` set from ground provisioning ([05](05-network-security.md)) — the trust model needs no new machinery. - **Identity is already there.** Every drone holds pre-provisioned [ed25519](00-glossary.md#infrastructure--delivery) keys and a fixed `known_hosts`/`authorized_keys` set from ground provisioning ([05](05-network-security.md)) — the trust model needs no new machinery.
- **One persistent multiplexed session** (`ControlMaster`) per peer costs almost nothing at idle and survives as a single TCP stream; every transfer rides it without new handshakes. - **One persistent multiplexed session** ([`ControlMaster`](00-glossary.md#infrastructure--delivery)) per peer costs almost nothing at idle and survives as a single TCP stream; every transfer rides it without new handshakes.
- **rsync delta transfer is resumable** across link drops — exactly the failure mode of an ad-hoc mesh — and the shared partition layout makes it trivially incremental: same paths, same files, pull only what is missing. - **rsync delta transfer is resumable** across link drops — exactly the failure mode of an ad-hoc mesh — and the shared partition layout makes it trivially incremental: same paths, same files, pull only what is missing.
- **Zero extra services** on the flight-critical node. MinIO remains available where an S3 API is genuinely wanted (ground warehouse, and optionally on board), but the in-flight bulk path does not depend on it. - **Zero extra services** on the flight-critical node. MinIO remains available where an S3 API is genuinely wanted (ground warehouse, and optionally on board), but the in-flight bulk path does not depend on it.
@@ -83,7 +85,7 @@ Partition healing is automatic: replication is pull-based, addressed by partitio
Two deliberate non-features of the SSH channel: Two deliberate non-features of the SSH channel:
- **No remote filesystem mounts in flight.** FUSE/sshfs over a lossy mesh inherits NFS hang semantics — processes block uninterruptibly when the link drops. Mounts are fine on the bench and at the dock; in flight, data moves by pull, never by mount. - **No remote filesystem mounts in flight.** FUSE/sshfs over a lossy mesh inherits NFS hang semantics — processes block uninterruptibly when the link drops. Mounts are fine on the bench and at the dock; in flight, data moves by pull, never by mount.
- **No free-form remote execution.** Arbitrary drone-to-drone exec would mean one compromised unit owns the fleet. Instead, every permitted operation is an **SSH forced command** (`command="…"` in `authorized_keys`, one key pair per operation): the key *is* the API. Flexible like a CLI, auditable like an RPC, least-privilege by construction. - **No free-form remote execution.** Arbitrary drone-to-drone exec would mean one compromised unit owns the fleet. Instead, every permitted operation is an **SSH [forced command](00-glossary.md#infrastructure--delivery)** (`command="…"` in `authorized_keys`, one key pair per operation): the key *is* the API. Flexible like a CLI, auditable like an RPC, least-privilege by construction.
## Design decision: SQL-over-SSH as the peer query channel ## Design decision: SQL-over-SSH as the peer query channel
@@ -114,7 +116,7 @@ SQL access must not become a write channel. A single "read-only connection" flag
- **Schema-first:** all datasets share the versioned schemas from [03](03-data-platform.md); a query addresses `dataset + partition predicates + column projection + time range`. Schema version is negotiated from the fleet release manifest — peers on one release speak one schema by construction. - **Schema-first:** all datasets share the versioned schemas from [03](03-data-platform.md); a query addresses `dataset + partition predicates + column projection + time range`. Schema version is negotiated from the fleet release manifest — peers on one release speak one schema by construction.
- **Read-only SELECT over the local Parquet store**, streamed back as Parquet/Arrow batches over the multiplexed SSH session. Never JSON blobs. - **Read-only SELECT over the local Parquet store**, streamed back as Parquet/Arrow batches over the multiplexed SSH session. Never JSON blobs.
- **On the ground — GraphQL as the integration standard:** the warehouse exposes the same schemas through GraphQL for the multi-team surface, where flexibility and introspection matter more than grams and milliwatts. **Arrow Flight** is the upgrade path for heavy columnar serving from T3 if result sizes outgrow it. - **On the ground — GraphQL as the integration standard:** the warehouse exposes the same schemas through GraphQL for the multi-team surface, where flexibility and introspection matter more than grams and milliwatts. **Arrow Flight** is the upgrade path for heavy columnar serving from [T3](00-glossary.md#t3--warehouse) if result sizes outgrow it.
What this buys operationally: the dev/bench experience and the flight protocol are the same thing. An engineer debugging on the bench runs the identical query a peer drone would run — same wrapper, same permissions, same output format. There is no "debug API" that behaves differently from the real one. What this buys operationally: the dev/bench experience and the flight protocol are the same thing. An engineer debugging on the bench runs the identical query a peer drone would run — same wrapper, same permissions, same output format. There is no "debug API" that behaves differently from the real one.
+6 -4
View File
@@ -2,6 +2,8 @@
Zero trust, fully air-gapped, everything provisioned before wheels-up. Zero trust, fully air-gapped, everything provisioned before wheels-up.
> **Decision:** [ADR-0005 — WireGuard beneath SSH](adr/ADR-0005-wireguard-beneath-ssh.md).
## Trust model ## Trust model
- **Nothing joins the swarm at runtime.** Every device receives its identity (key pair + certificate) during ground provisioning, signed by the fleet's offline CA. There is no trust-on-first-use, no runtime enrollment endpoint, no exception path. - **Nothing joins the swarm at runtime.** Every device receives its identity (key pair + certificate) during ground provisioning, signed by the fleet's offline CA. There is no trust-on-first-use, no runtime enrollment endpoint, no exception path.
@@ -21,9 +23,9 @@ graph TB
A["drone A<br/>wg key + certs"] A["drone A<br/>wg key + certs"]
B["drone B<br/>wg key + certs"] B["drone B<br/>wg key + certs"]
C["drone C<br/>wg key + certs"] C["drone C<br/>wg key + certs"]
A <-->|"WireGuard + mTLS"| B A <-->|"WireGuard tunnel"| B
B <-->|"WireGuard + mTLS"| C B <-->|"WireGuard tunnel"| C
A <-->|"WireGuard + mTLS"| C A <-->|"WireGuard tunnel"| C
end end
PROV -->|"per-device identity, peer list"| A PROV -->|"per-device identity, peer list"| A
PROV --> B PROV --> B
@@ -63,7 +65,7 @@ Keep both layers: WireGuard for *who is on the network and whether the wire is r
## Data at rest ## Data at rest
- On-board NVMe is encrypted (LUKS) with keys held in the device's secure element / TPM where the hardware provides one; the disk alone is unreadable. - On-board NVMe is encrypted (LUKS) with keys held in the device's secure element / TPM where the hardware provides one; the disk alone is unreadable.
- The ground warehouse applies standard at-rest encryption plus role-based access; it is the single most valuable asset in the system (every flight ever flown). - The ground warehouse applies standard at-rest encryption plus role-based access; it is the [source of truth](00-glossary.md#source-of-truth) and the single most valuable asset in the system (every flight ever flown).
## Attack surface, deliberately shortened ## Attack surface, deliberately shortened
+11 -9
View File
@@ -2,20 +2,22 @@
Three infrastructures, one data contract. The layout, schemas, and pipelines are identical everywhere; only scale and lifetime differ. Three infrastructures, one data contract. The layout, schemas, and pipelines are identical everywhere; only scale and lifetime differ.
> **Decisions:** [ADR-0006 — IaC boundaries](adr/ADR-0006-iac-boundaries.md), [ADR-0008 — runtime data paths](adr/ADR-0008-runtime-data-paths.md).
```mermaid ```mermaid
graph LR graph LR
subgraph dev [3 — Dev and simulation] subgraph dev [3 — Dev and simulation]
SIM["virtual drone fleet<br/>(simulator, Compose)"] SIM["virtual drone fleet<br/>(simulator, Compose)"]
LAP["engineer laptop<br/>Dev Container"] LAP["engineer laptop<br/>Dev Container"]
SLICE["T4: data slices"] SLICE["[T4](00-glossary.md#t4--dev): data slices"]
end end
subgraph fleet [1 — Fleet, in flight] subgraph fleet [1 — Fleet, in flight]
D1["drone: Compose data plane<br/>T0 hot + T1 NVMe + T2 MinIO"] D1["drone: Compose data plane<br/>[T0](00-glossary.md#t0--hot) hot + [T1](00-glossary.md#t1--warm) NVMe + [T2](00-glossary.md#t2--shared) MinIO"]
D2["drone …"] D2["drone …"]
end end
subgraph ground [2 — Ground, on-prem] subgraph ground [2 — Ground, on-prem]
DOCK["base station docks"] DOCK["base station docks"]
DWH["T3 warehouse<br/>object store + Parquet/DuckDB"] DWH["[T3](00-glossary.md#t3--warehouse) warehouse<br/>object store + Parquet/DuckDB"]
K3S["k3s: CI runners, registry mirror,<br/>sim farm, dashboards"] K3S["k3s: CI runners, registry mirror,<br/>sim farm, dashboards"]
TRAIN["model training (out of scope)<br/>reads T3, ships weights"] TRAIN["model training (out of scope)<br/>reads T3, ships weights"]
end end
@@ -32,7 +34,7 @@ graph LR
## 1 — Fleet infrastructure (on the drones) ## 1 — Fleet infrastructure (on the drones)
- Docker Compose under systemd; the full stack from [02 — Architecture](02-architecture.md). - Docker Compose under systemd; the full stack from [02 — Architecture](02-architecture.md).
- Storage floors T0T2. Nothing here requires ground contact during a mission. - Storage floors [T0T2](00-glossary.md#t0--hot). Nothing here requires ground contact during a mission.
- Receives new software only while docked, from the registry mirror, pinned by the fleet release manifest. - Receives new software only while docked, from the registry mirror, pinned by the fleet release manifest.
## 2 — Ground infrastructure (on-prem) ## 2 — Ground infrastructure (on-prem)
@@ -41,7 +43,7 @@ The permanent installation. This **is** allowed to be a cluster — links are wi
| Component | Runs on | Role | | Component | Runs on | Role |
| --- | --- | --- | | --- | --- | --- |
| **Warehouse (T3)** | Object store (MinIO or equivalent) + Parquet | Every flight of every drone; the system's long-term memory | | **Warehouse ([T3](00-glossary.md#t3--warehouse))** | Object store (MinIO or equivalent) + Parquet | Every flight of every drone; the system's [source of truth](00-glossary.md#source-of-truth) and long-term memory |
| **Base station docks** | Bare metal | Wired offload + integrity audit + drone provisioning ([03](03-data-platform.md), [05](05-network-security.md)) | | **Base station docks** | Bare metal | Wired offload + integrity audit + drone provisioning ([03](03-data-platform.md), [05](05-network-security.md)) |
| **Registry mirror** | k3s | In-air-gap container registry + artifact storage; the only software source drones ever see | | **Registry mirror** | k3s | In-air-gap container registry + artifact storage; the only software source drones ever see |
| **CI runners** | k3s | Multi-arch builds, tests, scans ([11](11-cicd-delivery.md)) | | **CI runners** | k3s | Multi-arch builds, tests, scans ([11](11-cicd-delivery.md)) |
@@ -58,7 +60,7 @@ The environment engineers live in daily — and deliberately the first thing to
- **No debug API** ([04](04-swarm-sync.md)): bench access to a drone's data goes through the same read-only SQL-over-SSH wrapper the peers use in flight — same permissions, same statement gate, same columnar output. Debugging exercises the production path instead of a parallel one, so the query channel is regression-tested every working day for free. - **No debug API** ([04](04-swarm-sync.md)): bench access to a drone's data goes through the same read-only SQL-over-SSH wrapper the peers use in flight — same permissions, same statement gate, same columnar output. Debugging exercises the production path instead of a parallel one, so the query channel is regression-tested every working day for free.
- **The virtual drone fleet** ([`simulator/`](../simulator/)) runs the *identical* data plane: same writer, same partitioning, same sealing, same broadcast schema. `DRONE_COUNT=10 docker compose up` is a swarm on a laptop. - **The virtual drone fleet** ([`simulator/`](../simulator/)) runs the *identical* data plane: same writer, same partitioning, same sealing, same broadcast schema. `DRONE_COUNT=10 docker compose up` is a swarm on a laptop.
- **Scenarios are reproducible by seed:** route shapes, sensor noise, drone dropouts, link losses are all parameterized — a bug report is a seed + config, not a war story. - **Scenarios are reproducible by seed:** route shapes, sensor noise, drone dropouts, link losses are all parameterized — a bug report is a seed + config, not a war story.
- **T4 data slices**: a thin tool pulls partition subsets from T3 (`flight=X, drone=Y, hour=Z`) for local work — layout-identical, so every query and pipeline runs unmodified. - **[T4](00-glossary.md#t4--dev) data slices**: a thin tool pulls partition subsets from [T3](00-glossary.md#t3--warehouse) (`flight=X, drone=Y, hour=Z`) for local work — layout-identical, so every query and pipeline runs unmodified.
- The same simulator images scale out on the ground k3s farm for CI regression runs: every merge request replays canonical scenarios and asserts on the resulting Parquet output (row counts, coverage, staleness budgets). - The same simulator images scale out on the ground k3s farm for CI regression runs: every merge request replays canonical scenarios and asserts on the resulting Parquet output (row counts, coverage, staleness budgets).
## IaC boundaries ## IaC boundaries
@@ -72,13 +74,13 @@ Infrastructure as code follows the same discipline as the data plane: each tool
| Ongoing ground configuration | **Flux** ([`infra/gitops/`](../infra/gitops/)) | Policy labels, dashboard bundles, offload knobs — reconciled from git without reprovisioning PVCs. Drones stay on the fleet manifest, not GitOps. | | Ongoing ground configuration | **Flux** ([`infra/gitops/`](../infra/gitops/)) | Policy labels, dashboard bundles, offload knobs — reconciled from git without reprovisioning PVCs. Drones stay on the fleet manifest, not GitOps. |
| On-board runtime | **Compose bundle from the fleet release manifest** | Terraform is deliberately *not* on the drone: there is no API server to reconcile against mid-flight, and the release manifest already gives atomic, versioned, rollback-able delivery ([11](11-cicd-delivery.md)). | | On-board runtime | **Compose bundle from the fleet release manifest** | Terraform is deliberately *not* on the drone: there is no API server to reconcile against mid-flight, and the release manifest already gives atomic, versioned, rollback-able delivery ([11](11-cicd-delivery.md)). |
Shared local data paths (same layout everywhere): **`var/t1/`** (live lake, T1) and **`var/t3/`** (warehouse, T3). Compose, k3d, and DuckDB on the host all read the same Parquet tree. Shared local data paths (same layout everywhere): **`var/t1/`** (live lake, [T1](00-glossary.md#t1--warm)) and **`var/t3/`** (warehouse, [T3](00-glossary.md#t3--warehouse)). Compose, k3d, and DuckDB on the host all read the same Parquet tree.
Two Terraform modules, two states, one deliberate split: [`sim-env`](../infra/terraform/sim-env/) (the disposable virtual fleet) and [`ground`](../infra/terraform/ground/) (the warehouse that must survive every `destroy` of the fleet). They never share a lifecycle. Two Terraform modules, two states, one deliberate split: [`sim-env`](../infra/terraform/sim-env/) (the disposable virtual fleet) and [`ground`](../infra/terraform/ground/) (the warehouse that must survive every `destroy` of the fleet). They never share a lifecycle.
### k3d: the ground segment as an executable miniature ### k3d: the ground segment as an executable miniature
`ansible-playbook infra/ansible/sim-cluster.yml` creates a local k3d cluster (k3s in Docker — the same distribution as the real ground segment) and `terraform apply` populates it: virtual drones as StatefulSet replicas over a shared Parquet lake, the exporter/Prometheus/Grafana stack, the data-plane explorer, MinIO, and the T1→T3 offload CronJob. The whole diagram at the top of this page runs on one workstation, and the prototype's **live mode** renders real drone positions straight from the explorer's read-only SQL API. `ansible-playbook infra/ansible/sim-cluster.yml` creates a local k3d cluster (k3s in Docker — the same distribution as the real ground segment) and `terraform apply` populates it: virtual drones as StatefulSet replicas over a shared Parquet lake, the exporter/Prometheus/Grafana stack, the data-plane explorer, MinIO, and the [T1](00-glossary.md#t1--warm)→[T3](00-glossary.md#t3--warehouse) offload CronJob. The sim CronJob uses `cp -ru` plus `mc mirror` as a shortcut; production dock [offload](00-glossary.md#offload) uses rsync or `mc mirror` with checksum audit ([03](03-data-platform.md)). The whole diagram at the top of this page runs on one workstation, and the prototype's **live mode** renders real drone positions straight from the explorer's read-only SQL API.
### Next fidelity step: microVMs ### Next fidelity step: microVMs
@@ -88,7 +90,7 @@ The k3d fleet shares one kernel and one network namespace tree — good enough t
| Operation | What it costs with one layout everywhere | | Operation | What it costs with one layout everywhere |
| --- | --- | | --- | --- |
| Flight offload | `mc mirror` / `rsync` + audit — no transform step | | Flight offload | `rsync` / `mc mirror` + audit — no transform step ([03](03-data-platform.md)) |
| Debugging a field issue | Pull the flight slice, replay locally, same queries | | Debugging a field issue | Pull the flight slice, replay locally, same queries |
| Validating a pipeline change | Run simulator, diff Parquet outputs | | Validating a pipeline change | Run simulator, diff Parquet outputs |
| Training data prep | Read T3 directly, no export pipeline | | Training data prep | Read T3 directly, no export pipeline |
+12 -3
View File
@@ -2,6 +2,8 @@
The platform watches itself with the same discipline it applies to sensor data — and largely through the same pipeline. The platform watches itself with the same discipline it applies to sensor data — and largely through the same pipeline.
> **Decision:** [ADR-0009 — bound observability lake scans to a flight window and cache](adr/ADR-0009-bounded-lake-scans.md).
## Two kinds of signals, one storage ## Two kinds of signals, one storage
| Signal | Examples | Where it goes | | Signal | Examples | Where it goes |
@@ -27,7 +29,7 @@ graph LR
LG["structured logs<br/>= Parquet partitions"] LG["structured logs<br/>= Parquet partitions"]
end end
subgraph ground [Ground k3s] subgraph ground [Ground k3s]
DWH["T3 warehouse"] DWH["[T3](00-glossary.md#t3--warehouse) warehouse"]
PR["Prometheus<br/>(live: docks, CI, sim farm)"] PR["Prometheus<br/>(live: docks, CI, sim farm)"]
LK["Loki<br/>(live logs, dev plane)"] LK["Loki<br/>(live logs, dev plane)"]
GF["Grafana<br/>dashboards + replay"] GF["Grafana<br/>dashboards + replay"]
@@ -42,7 +44,7 @@ graph LR
``` ```
- **Prometheus/Grafana/Loki** cover everything that is alive on the ground: docks, registry, CI runners, warehouse, simulation farm — plus drones on the bench through the dev plane. - **Prometheus/Grafana/Loki** cover everything that is alive on the ground: docks, registry, CI runners, warehouse, simulation farm — plus drones on the bench through the dev plane.
- **Flight post-mortems query the warehouse directly** with DuckDB: staleness budgets vs actuals, link quality vs distance (RSSI), sealing backlog vs ingest rate — per flight, per drone, per minute. - **Flight post-mortems query the [source of truth](00-glossary.md#source-of-truth) warehouse directly** with DuckDB: staleness budgets vs actuals, link quality vs distance (RSSI), sealing backlog vs ingest rate — per flight, per drone, per minute.
## Health questions this design answers cheaply ## Health questions this design answers cheaply
@@ -53,6 +55,13 @@ graph LR
| Which link degraded first, and was it distance or interference? | RSSI telemetry joined with pose distance | | Which link degraded first, and was it distance or interference? | RSSI telemetry joined with pose distance |
| Is the new writer version flushing slower on ARM64? | CI simulation runs emit the same metrics; diff across fleet releases | | Is the new writer version flushing slower on ARM64? | CI simulation runs emit the same metrics; diff across fleet releases |
A working miniature of this doctrine ships in [`simulator/`](../simulator/): the `monitoring` Compose profile runs a DuckDB-based exporter over the generated Parquet plus Prometheus and a provisioned Grafana dashboard — fleet statistics derived from the data platform itself, with no agent on the (virtual) drones. A working miniature of this doctrine ships in [`simulator/`](../simulator/): the `monitoring` Compose profile runs a DuckDB-based exporter over the generated Parquet plus Prometheus and two provisioned Grafana dashboards:
| Dashboard | Focus |
| --- | --- |
| **Swarm Fleet — generated data** | Row counts, detections, pose frames, Parquet bytes, battery, RSSI |
| **Swarm Platform — CPU & scan health** | Node CPU/memory (node-exporter), exporter/explorer scan durations, flight-window gauge |
The exporter and explorer **scan only the most recent flight partitions** (`METRIC_FLIGHT_WINDOW`, default 5) and cache tree/views — otherwise CPU climbs as every pod restart appends a new `flight=` tree to the shared lake. Drones in k3d set `KEEP_ALIVE=1` after sealing so they idle instead of exiting and spawning another flight.
Alerting on the ground follows standard practice (Prometheus alert rules for infrastructure, CI gates for regression in simulated staleness/throughput budgets). In flight there is nobody to page — the platform's job is to degrade in the documented order and record everything for the post-mortem. Alerting on the ground follows standard practice (Prometheus alert rules for infrastructure, CI gates for regression in simulated staleness/throughput budgets). In flight there is nobody to page — the platform's job is to degrade in the documented order and record everything for the post-mortem.
+1 -1
View File
@@ -12,7 +12,7 @@ graph TD
S4["Stage 4<br/>Serving layer<br/>event hook, query API, local MinIO"] S4["Stage 4<br/>Serving layer<br/>event hook, query API, local MinIO"]
S5["Stage 5<br/>Virtual swarm simulation<br/>N drones in Compose, seeded scenarios, CI regression"] S5["Stage 5<br/>Virtual swarm simulation<br/>N drones in Compose, seeded scenarios, CI regression"]
S6["Stage 6<br/>Mesh network and security<br/>WireGuard overlay, PKI provisioning, mTLS"] S6["Stage 6<br/>Mesh network and security<br/>WireGuard overlay, PKI provisioning, mTLS"]
S7["Stage 7<br/>Swarm sync<br/>pose broadcast, detections pub/sub, MinIO replication"] S7["Stage 7<br/>Swarm sync<br/>pose broadcast, detections pub/sub, bulk replication (rsync over SSH)"]
S8["Stage 8<br/>Ground warehouse<br/>offload + audit, T3 store, replay tooling"] S8["Stage 8<br/>Ground warehouse<br/>offload + audit, T3 store, replay tooling"]
S9["Stage 9<br/>Observability<br/>platform metrics as sensor stream, ground dashboards"] S9["Stage 9<br/>Observability<br/>platform metrics as sensor stream, ground dashboards"]
S10["Stage 10<br/>Fleet releases<br/>manifest, mirror, dock delivery, atomic rollback"] S10["Stage 10<br/>Fleet releases<br/>manifest, mirror, dock delivery, atomic rollback"]
+26 -2
View File
@@ -18,7 +18,7 @@ Visual-odometry positions are relative; different drones may hold different loca
By construction there are no write conflicts in bulk sync (each drone writes only its own `drone=` subtree). But *derived swarm-level* products (shared maps, fused detections) may need merging. By construction there are no write conflicts in bulk sync (each drone writes only its own `drone=` subtree). But *derived swarm-level* products (shared maps, fused detections) may need merging.
*Proposal:* keep swarm-level fusion out of the storage layer entirely: every drone stores its own observations; fusion is computed, not stored, in flight — and recomputed from ground truth in the warehouse. If a persistent shared structure becomes unavoidable, use a CRDT type for it rather than inventing reconciliation. *Proposal:* keep swarm-level fusion out of the storage layer entirely: every drone stores its own observations; fusion is computed, not stored, in flight — and recomputed from the [source of truth](00-glossary.md#source-of-truth) in the warehouse. If a persistent shared structure becomes unavoidable, use a [CRDT](00-glossary.md#infrastructure--delivery) type for it rather than inventing reconciliation.
## 4 — Degradation policy under sustained link loss ## 4 — Degradation policy under sustained link loss
@@ -42,7 +42,7 @@ The degradation ladder ([03](03-data-platform.md)) needs a domain-approved order
The virtual swarm covers logic and protocol; it does not cover radio behavior, GPU thermals, or NVMe write endurance under vibration. The virtual swarm covers logic and protocol; it does not cover radio behavior, GPU thermals, or NVMe write endurance under vibration.
*Proposal:* a small HIL rig at the bench — real compute module + radio, simulated sensor feeds — as stage 5.5 of the roadmap, exercised by the same seeded scenarios as CI. *Proposal:* a small [HIL](00-glossary.md#infrastructure--delivery) rig at the bench — real compute module + radio, simulated sensor feeds — as stage 5.5 of the roadmap, exercised by the same seeded scenarios as CI.
## 8 — Video retention policy ## 8 — Video retention policy
@@ -61,3 +61,27 @@ Who owns the device registry (drone ids, key issuance, revocation) organizationa
Sub-250 g class units cannot run the full stack (no GPU, minimal CPU/storage). Sub-250 g class units cannot run the full stack (no GPU, minimal CPU/storage).
*Proposal:* define a minimal profile early — state publisher + UDP-multicast pose broadcast, no local Parquet store, no video — so the swarm protocol never assumes full-stack peers. *Proposal:* define a minimal profile early — state publisher + UDP-multicast pose broadcast, no local Parquet store, no video — so the swarm protocol never assumes full-stack peers.
## 11 — Lake retention and partition pruning
The observability path now scans only the most recent flights ([ADR-0009](adr/ADR-0009-bounded-lake-scans.md)), but old `flight=` partitions still accumulate on [T1](00-glossary.md#t1--warm) disk after offload to [T3](00-glossary.md#t3--warehouse).
*Proposal:* a scheduled prune of T1 partitions whose flights are confirmed present in the T3 warehouse (offload as the retention gate), configurable by age and free-space watermark. On the drone the same policy is bounded by the NVMe quota ladder ([03](03-data-platform.md)); in the sim environment it is a ground CronJob alongside the offload job.
## 12 — Bulk transfer tool choice
In-flight peer bulk sync, dock offload, and sim shortcuts have used different wording (rsync, rclone, `mc mirror`, `cp -ru`).
*Proposal:* **rsync over SSH** is the default for peer [bulk sync](00-glossary.md#bulk-sync) and wired dock [offload](00-glossary.md#offload) of the Hive partition tree; **MinIO `mc mirror`** when the far end is the object-store facade; **[rclone](00-glossary.md#rclone)** only when the backend is S3-compatible and rsync is awkward; sim CronJob keeps **`cp -ru` + `mc mirror`** as a documented shortcut. Pick one primary tool per path in provisioning, not all at once.
## 13 — DuckLake vs plain DuckDB + Parquet on T3
The design journey mentions DuckLake; no ADR or implementation commits to it yet.
*Proposal:* baseline remains DuckDB `read_parquet` globs over the [T3](00-glossary.md#t3--warehouse) Hive tree with MinIO underneath; evaluate DuckLake only if catalog/versioning pain appears at warehouse scale — decision gets its own ADR before any dependency lands.
## 14 — `schemas/` registry layout
[03 — Data platform](03-data-platform.md) references an in-repo `schemas/` directory that does not exist yet (roadmap stage 2).
*Proposal:* add `schemas/` as part of stage 2 with one SemVer file per dataset version, referenced from the fleet release manifest; until then treat schema versions as manifest-only fields and document the gap here rather than implying the directory already exists.
+6 -6
View File
@@ -9,14 +9,14 @@ Swarm autonomy has moved past biomimicry-driven research toward practical, opera
| **Decentralization** | No single controller; every unit perceives locally and decides autonomously; no single point of failure | No swarm-wide orchestrator; each drone runs its own full data plane ([02](02-architecture.md)) | | **Decentralization** | No single controller; every unit perceives locally and decides autonomously; no single point of failure | No swarm-wide orchestrator; each drone runs its own full data plane ([02](02-architecture.md)) |
| **Local interactions** | Behavior emerges from neighbor-to-neighbor exchange, not top-down commands | State broadcast between peers; event hooks trigger local reactions ([04](04-swarm-sync.md)) | | **Local interactions** | Behavior emerges from neighbor-to-neighbor exchange, not top-down commands | State broadcast between peers; event hooks trigger local reactions ([04](04-swarm-sync.md)) |
| **Self-organization** | The group coordinates without pre-planned orchestration and survives the loss of members | Opportunistic replication; store-and-forward through intermediate peers; automatic reconciliation after partitions ([04](04-swarm-sync.md)) | | **Self-organization** | The group coordinates without pre-planned orchestration and survives the loss of members | Opportunistic replication; store-and-forward through intermediate peers; automatic reconciliation after partitions ([04](04-swarm-sync.md)) |
| **Sustainable pulsing** | The swarm repeatedly engages a target area from multiple directions and re-forms as conditions change | Requires every unit to know peer state with bounded staleness — exactly the state-sync contract ([04](04-swarm-sync.md)) | | **[Sustainable pulsing](00-glossary.md#swarm--robotics)** | The swarm repeatedly engages a target area from multiple directions and re-forms as conditions change | Requires every unit to know peer state with bounded staleness — exactly the state-sync contract ([04](04-swarm-sync.md)) |
| **Ubiquitous sensing** | The swarm acts as one distributed sensor, fusing observations into shared "top sight" | Detections are first-class derived data: locally stored, selectively shared, fully preserved for post-flight fusion ([03](03-data-platform.md)) | | **[Ubiquitous sensing](00-glossary.md#swarm--robotics)** | The swarm acts as one distributed sensor, fusing observations into shared "top sight" | Detections are first-class derived data: locally stored, selectively shared, fully preserved for post-flight fusion ([03](03-data-platform.md)) |
| **Edge intelligence** | Detection and classification happen on the unit; a local event can autonomously cue nearby units without central validation | On-board YOLO-like inference + event hook that publishes detections to the mesh immediately ([02](02-architecture.md)) | | **Edge intelligence** | Detection and classification happen on the unit; a local event can autonomously cue nearby units without central validation | On-board YOLO-like inference + event hook that publishes detections to the mesh immediately ([02](02-architecture.md)) |
| **Mesh networking** | Units relay data for each other; loss of any single link degrades nothing | Encrypted ad-hoc mesh, no infrastructure dependency ([05](05-network-security.md)) | | **Mesh networking** | Units relay data for each other; loss of any single link degrades nothing | Encrypted ad-hoc mesh, no infrastructure dependency ([05](05-network-security.md)) |
| **GPS-denied operation** | Position comes from visual odometry and relative localization, not satellites | The state schema carries a frame-of-reference field and covariance, not just raw coordinates ([03](03-data-platform.md), [09](09-open-questions.md)) | | **[GPS-denied operation](00-glossary.md#swarm--robotics)** | Position comes from visual odometry and relative localization, not satellites | The state schema carries a frame-of-reference field and covariance, not just raw coordinates ([03](03-data-platform.md), [09](09-open-questions.md)) |
| **Man-in-the-loop** | A supervisor defines intent and boundaries; the system distributes tasks itself | Narrow C2 plane for declarative goals; no per-vehicle steering ([02](02-architecture.md)) | | **[Man-in-the-loop](00-glossary.md#swarm--robotics)** | A supervisor defines intent and boundaries; the system distributes tasks itself | Narrow C2 plane for declarative goals; no per-vehicle steering ([02](02-architecture.md)) |
| **Adaptive re-tasking** | A detected event re-prioritizes nearby units without manual replanning | Event-driven data plane: detections propagate as events, mission logic subscribes ([02](02-architecture.md)) | | **Adaptive re-tasking** | A detected event re-prioritizes nearby units without manual replanning | Event-driven data plane: detections propagate as events, mission logic subscribes ([02](02-architecture.md)) |
| **Ethical autonomy / traceability** | Autonomous actions must be explainable: event-driven sensing over indiscriminate collection, decisions logged and auditable | Every broadcast, sync, and re-tasking trigger is recorded in the flight data; the warehouse preserves the full decision trail for replay ([03](03-data-platform.md), [06](06-environments.md)) | | **Ethical autonomy / traceability** | Autonomous actions must be explainable: event-driven sensing over indiscriminate collection, decisions logged and auditable | Every broadcast, sync, and re-tasking trigger is recorded in the flight data; the [source of truth](00-glossary.md#source-of-truth) warehouse preserves the full decision trail for replay ([03](03-data-platform.md), [06](06-environments.md)) |
| **Adaptive learning** | Experience gathered by one unit improves the whole team over iterations | Complete flights land in the ground warehouse; training reads from it and ships improved model weights through the fleet release cycle ([06](06-environments.md), [11](11-cicd-delivery.md)) | | **Adaptive learning** | Experience gathered by one unit improves the whole team over iterations | Complete flights land in the ground warehouse; training reads from it and ships improved model weights through the fleet release cycle ([06](06-environments.md), [11](11-cicd-delivery.md)) |
| **Multi-domain scaling** | The same swarm concepts apply to aerial, ground, surface, and underwater units — including sub-250 g platforms | The data plane is a set of small independent services; the minimal profile (writer + publisher, no GPU stack) fits constrained units ([02](02-architecture.md)) | | **Multi-domain scaling** | The same swarm concepts apply to aerial, ground, surface, and underwater units — including sub-250 g platforms | The data plane is a set of small independent services; the minimal profile (writer + publisher, no GPU stack) fits constrained units ([02](02-architecture.md)) |
@@ -25,7 +25,7 @@ Swarm autonomy has moved past biomimicry-driven research toward practical, opera
Every one of these principles quietly assumes a working data layer underneath: Every one of these principles quietly assumes a working data layer underneath:
- *Pulsing* and *re-tasking* assume each unit **knows peer state** — that is a sync latency and staleness budget. - *Pulsing* and *re-tasking* assume each unit **knows peer state** — that is a sync latency and staleness budget.
- *Ubiquitous sensing* assumes observations are **fused later** — that is a warehouse with aligned timestamps and schemas. - *Ubiquitous sensing* assumes observations are **fused later** — that is the [source of truth](00-glossary.md#source-of-truth) warehouse with aligned timestamps and schemas.
- *Edge intelligence* assumes detection events **reach neighbors fast** — that is an event-driven publish path, not a polling loop. - *Edge intelligence* assumes detection events **reach neighbors fast** — that is an event-driven publish path, not a polling loop.
- *Traceability* assumes actions are **replayable** — that is complete, immutable, partitioned flight data. - *Traceability* assumes actions are **replayable** — that is complete, immutable, partitioned flight data.
- *Learning* assumes flights are **comparable across the fleet and across time** — that is schema versioning and a single storage format. - *Learning* assumes flights are **comparable across the fleet and across time** — that is schema versioning and a single storage format.
+3 -1
View File
@@ -2,6 +2,8 @@
How software, models, and configuration reach the fleet — reproducibly, scanned, versioned, and atomically rollback-able. Everything lives inside the air gap. How software, models, and configuration reach the fleet — reproducibly, scanned, versioned, and atomically rollback-able. Everything lives inside the air gap.
> **Decisions:** [ADR-0006 — IaC boundaries & fleet manifest](adr/ADR-0006-iac-boundaries.md), [ADR-0007 — non-root image](adr/ADR-0007-non-root-image.md).
## Source and pipelines: GitLab ## Source and pipelines: GitLab
GitLab (self-managed, on-prem) is the backbone: repositories, CI/CD, and the container registry in one system. GitLab (self-managed, on-prem) is the backbone: repositories, CI/CD, and the container registry in one system.
@@ -83,7 +85,7 @@ schemas:
config: config:
compose-bundle: 3.4.0 compose-bundle: 3.4.0
radio-profile: production-2 radio-profile: production-2
peer-registry: fleet-42-r7 # provisioning + revocations, see 05 peer-registry: fleet-42-r7 # provisioning + revocations, see [05 — Network & security](05-network-security.md)
``` ```
- Everything is **semver-versioned individually**, and the manifest itself carries the fleet version — a lockfile for the whole swarm. - Everything is **semver-versioned individually**, and the manifest itself carries the fleet version — a lockfile for the whole swarm.
+197
View File
@@ -0,0 +1,197 @@
# 12 — Design journey
How this design came together, told in the order the thinking actually happened.
It is a walk-through, not a report. The formal decisions, with options and
trade-offs, live as [Architecture Decision Records](adr/README.md). This is the
story behind them, and each chapter links straight into the code it produced.
Terms: [Glossary](00-glossary.md).
---
## 1. Starting point
At the start there was only a rough brief and a few adjacent interests to lean
on. Key rotation matters in every pipeline: a cipher that is safe today may not
be safe next year, so the infrastructure has to leave room to react, without
spending too much latency or bandwidth to do it. That balance, security against
speed and volume, is a recurring trade-off. Geospatial data is older ground:
routing, indexing, fast spatial lookups at scale. So the first instinct was that
the value might be less about new storage and more about optimizing access to
data that already exists.
## 2. From words to a model, then to a picture
Once the shape was clear, an autonomous swarm where each unit talks to any peer
in range and [no controller spans the swarm](adr/ADR-0001-no-swarm-orchestrator.md),
the next step was to make the model visible. A small 2D prototype was enough to
check it: units patrolling, static and moving obstacles, links forming and
breaking, and a rough count of how much data crosses the air. Speed was not the
question yet, only volume, because volume is what you size the infrastructure
for. Starting from a picture gives an anchor before building anything.
> **Read the code**
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L219-L287) — the tick: links form and break by range, pose broadcasts and bulk sync accumulate bytes
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L57-L63) — the pose-frame size and rate that drive the volume estimate
The picture is running, not just described: the visual prototype is live at
[swarm.produktor.io](https://swarm.produktor.io/) (same access as this
repository). It draws units patrolling, links forming and breaking by range, and
a running estimate of how much data crosses the air.
## 3. Naming the limits
Before any code, the limits had to be named. Maximum distance between units in
the air. The fidelity data is stored at. The fidelity it can be transmitted at.
Those drive volume, and volume drives the whole shape of the system. The
conclusion: constrain the transport, but do not constrain on-board capture. Keep
everything locally. Send only what peers truly need.
> **Read more**
> - [01 — Problem statement](01-problem-statement.md) and [03 — Data platform](03-data-platform.md) — the constraints and the [storage floors](00-glossary.md#t0--hot) (T0T4)
## 4. Three pipeline stages inside each unit
That gave [three architecture layers](02-architecture.md) on every drone — not the same as [storage floors](00-glossary.md#t0--hot) T0T4:
1. **Raw capture** (pipeline stage 1), never transformed.
2. **ETL / reduction** (pipeline stage 2), where data is cut down to what has to be shared:
millimeters to centimeters, thinning time series where that is enough,
dropping what nobody downstream reads.
3. **A bidirectional interface** (pipeline stage 3): on one side it broadcasts position into the
shared channel, on the other it lets peers pull data.
The ETL is deliberately not over-specified. How sensor data is transformed is a
data-engineering decision. The platform's job is the contract, meaning what gets
stored and in which structure, not to step into that work.
> **Read the code**
> - [`simulator/virtual_drone/writer.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/writer.py#L23-L80) — the Hive-partition layout and the seal step that compacts a flight
> - [`simulator/virtual_drone/sensors.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/sensors.py) — the raw capture that feeds pipeline stage 1
## 5. What actually needs to sync
The most time-critical item is where each peer is, so every unit has time to
react. The choice was to broadcast [relative pose](04-swarm-sync.md) (x, y, z and
time) instead of absolute coordinates. Relative is cheaper and enough for
coordination. To avoid sending orientation, a unit is modelled as a **sphere**
that bounds its extent. That trades a little compute for much less data, and no
per-shape encoding. If the units are identical, their 3D model can be provisioned
ahead instead of transmitted.
> **Read the code**
> - [`simulator/virtual_drone/broadcast.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/broadcast.py#L1-L40) — the 46-byte pose frame, position quantized on the wire only
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L260-L277) — pose broadcasts at 5 Hz and opportunistic bulk sync, made visible
## 6. Security as nested layers, not one wall
The assumption was an ad-hoc wireless mesh. Broadcasting positions in the clear,
even over encrypted Wi-Fi, is not acceptable: anyone who joins can reconstruct
trajectories over time, and wireless encryption has been broken by ordinary
people for two decades. Relying on that layer alone is risky, and pushing heavy
encryption there is wasteful. So the trust model is [layered](05-network-security.md):
1. Wireless transport (WPA class) as the outer shell.
2. **WireGuard over UDP** as the mesh overlay: proven, peer-authenticated by
static keys, cheap.
3. **SSH inside the tunnel** as the application layer, instead of HTTP/HTTPS. SSH
is one of the most tested remote-access technologies we have. HTTPS would pull
in a certificate authority and extra parts, and it is the first surface a
security researcher probes. [Fewer moving parts, on purpose](adr/ADR-0005-wireguard-beneath-ssh.md).
Identity and encryption solve together at provisioning time: a per-flight,
per-unit key pair recorded in a flight registry. Each unit holds its own key pair
plus the public keys of its peers. A separate key registry for SSH means that if
one WireGuard key leaks, SSH access does not follow automatically.
> **Read the code**
> - [`infra/ansible/drone-provision.yml`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/infra/ansible/drone-provision.yml#L20-L70) — ed25519 identity, peers authorized only through a forced command, WireGuard brought up
> - [05 — Network & security](05-network-security.md) and [ADR-0005](adr/ADR-0005-wireguard-beneath-ssh.md)
## 7. SQL as the contract
Beyond the broadcast, people and peers want richer access. The whole progression
is familiar: fully dynamic APIs, then JSON, OpenAPI/Swagger, GraphQL, then heavier
schemes. Each time the same pain: hard to debug in the moment, and a lot of
infrastructure to support, test, and agree on. The simpler conclusion:
[**SQL is the contract**](adr/ADR-0004-sql-over-ssh-contract.md). Everyone who
touches data already speaks SQL, and we do not know in advance which slice each
of them needs, so give them a declarative, extensible way in rather than guessing
endpoints ahead of time.
Concretely: an SSH connection whose forced command runs a read-only **DuckDB**
query directly. No extra auth handshake between the engine and the data, the way
a long-running database needs. No new port or protocol. When access control is
needed, it rides on Linux file permissions, because the data is discrete
Hive-partitioned files and permissions can be set per file. Not new invention:
coming back to foundations DevOps and sysadmins already trust.
> **Read the code**
> - [`simulator/explorer/server.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/explorer/server.py#L43-L53) — the read-only gate that rejects anything that writes
> - [`simulator/explorer/server.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/explorer/server.py#L111-L131) — the query path a peer or an engineer hits identically
> - [`simulator/tests/test_sql_gate.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/tests/test_sql_gate.py#L13-L35) — the gate is safety-critical, so it is tested first
## 8. Replication and offload
Moving accumulated data uses [rsync](00-glossary.md#bulk-sync) over the same SSH
channel for in-flight peer [bulk sync](00-glossary.md#bulk-sync) and dock
[offload](00-glossary.md#offload). It transfers diffs of the Hive tree efficiently.
This matters because a unit can be lost, and if it is, we want its data to
already exist elsewhere. When peers have a stable link the partition tree is
pulled between them, which gives redundancy and a way to cross-check later. On
the ground, when the fleet returns, the same path unifies every unit's data into
a [local warehouse](06-environments.md) ([T3](00-glossary.md#t3--warehouse)).
The warehouse is read-mostly for analytics, so transaction contention is not a
concern — plain DuckDB over Parquet with MinIO underneath is the baseline;
DuckLake remains under evaluation ([09 — Open questions](09-open-questions.md)
§13). The [same storage model on both ends](adr/ADR-0002-one-storage-format.md).
One uniform structure is what lets an analyst trust a [single picture](00-glossary.md#source-of-truth) even when a
unit's data has a gap.
> **Read the code**
> - [`infra/terraform/ground/main.tf`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/infra/terraform/ground/main.tf#L124-L160) — the post-flight T1 to T3 offload CronJob
> - [06 — Environments](06-environments.md) and [ADR-0002](adr/ADR-0002-one-storage-format.md)
## 9. A proof of concept, not slides
A concept can be handed over and be wrong. Instead there is a generator: a
virtual drone producing real sensor data into the Hive layout, a small frontend
to watch it fill, and a **Grafana** dashboard on top, not to get ahead of the
analysts, but to see the infrastructure work end to end. Closing the loop
(Terraform, connector, exporter, dashboard) is how a misconfiguration shows up
early. A panel that renders wrong points at exactly which layer broke, before an
analyst has to report that the infra is up but nothing shows. Better to exercise
every layer now and drop any layer or technology later if there is a sound
argument against it.
Watching that loop is also where the [CPU cost surfaced](adr/ADR-0009-bounded-lake-scans.md):
scanning the whole lake on every query does not scale, so the scan is
[bounded to a flight window and cached](07-observability.md).
> **Read the code**
> - [`simulator/monitoring/exporter.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/exporter.py#L38-L107) — the DuckDB scan that turns Parquet into Prometheus metrics
> - [`simulator/monitoring/lake.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/lake.py#L13-L33) — the flight-window scan that keeps CPU bounded as the lake grows
> - [`simulator/monitoring/grafana/dashboards/`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/grafana/dashboards) — the fleet and platform dashboards
> - [`.github/workflows/release.yaml`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/.github/workflows/release.yaml) — tests, smoke flight, Trivy, semantic release
## 10. Working method
A note on how this is meant to be built, not just what. Working together needs a
shared language, and when the working language is nobody's first language,
answering live is expensive. The form that scales is written-first: lay the idea
down carefully, let others read and answer in their own time, keep the history. A
new colleague, or an agent, can then load the full context of why a decision was
made by reading the discussion. That is exactly why the decisions here are
[ADRs](adr/README.md) and why the [contribution flow](../CONTRIBUTING.md) is
test-first: the reasoning is captured where the next person will look for it.
The mental image is a city planner. Draw perfectly straight roads and people
still cross the grass where it is faster. The job is not to decide for them, but
to understand the real paths, the pain points and limits of each team, and pave
those. So constraints get checked with the other DevOps, data engineers, and
analysts before infrastructure is laid, instead of building roads nobody uses.
---
The platform is air-gapped and on-prem by design: nothing leaves the swarm, and
nothing joins it at runtime. Start from the [README](../README.md) for the map,
or the [ADR index](adr/README.md) for the decisions in full.
@@ -0,0 +1,38 @@
# ADR-0001: No swarm-wide orchestrator; coordinate through data
## Status
Accepted (2026-07-08)
## Context
The fleet flies fully autonomously with no internet uplink and only
intermittent mesh connectivity between drones. A conventional control plane
(Kubernetes across the swarm, a leader election, a central scheduler) assumes
stable membership and low-latency links — exactly what a swarm does not have.
Partitions are the normal case, not the exception.
## Options
| Option | Pros | Cons |
| --- | --- | --- |
| A — Cluster orchestrator spanning the swarm | Familiar tooling | Assumes stable membership; a partition stalls coordination; single point of failure in the air |
| B — Each drone autonomous, coordinating through exchanged data | Survives partitions; no air-side control plane to fail | No global view; behaviour must be derivable from local state |
## Decision
Every drone is an autonomous node. There is **no orchestrator spanning the
swarm**. Coordination happens by exchanging derived state (see
[ADR-0003](ADR-0003-sync-derived-state-only.md)), and each drone makes its own
in-flight decisions against its local data window. Orchestration tooling
(Kubernetes, Flux) is confined to the **ground** segment
([ADR-0006](ADR-0006-iac-boundaries.md)).
## Consequences
- The platform degrades gracefully under partition: a drone that loses peers
keeps flying and recording.
- There is no single "fleet state" to query in the air; health questions are
answered from each drone's own store and reconciled on the ground.
- Design principle 1 in [`../../README.md`](../../README.md) and the on-board
model in [`../02-architecture.md`](../02-architecture.md) follow from this.
+32
View File
@@ -0,0 +1,32 @@
# ADR-0002: One storage format on every tier — Parquet + DuckDB, Hive layout
## Status
Accepted (2026-07-08)
## Context
Data lives in three places: on the drone's NVMe in flight (T1), in the ground
warehouse after landing (T3), and in the dev/simulation environment. If each
tier used a different format or layout, offload would need transformation code,
schemas would drift, and a query proven on the bench would not run unchanged on
flight data.
## Decision
Use **one storage format on every floor**: columnar **Parquet** files in an
identical **Hive-partitioned** layout (`dataset=…/flight=…/drone=…/…`), queried
with **DuckDB**. Flight offload T1 → T3 is therefore a plain **mirror**
(copy/rsync of partition directories), not an ETL step.
## Consequences
- Offload is a file operation, testable with a checksum; no format converters
to maintain or version.
- The same SQL runs against T1, T3, and simulated data — the simulator
([`../../simulator/`](../../simulator/)) generates the *real* layout, so tests
exercise the production format.
- Partitioning choices are load-bearing; changing them is a schema migration.
Detailed in [`../03-data-platform.md`](../03-data-platform.md).
- DuckDB's single-file, no-server model fits an air-gapped drone with no room
for a database daemon.
@@ -0,0 +1,33 @@
# ADR-0003: Sync derived state only; raw telemetry stays local
## Status
Accepted (2026-07-08)
## Context
Each drone produces high-rate sensor telemetry and on-board video detections —
far more than the mesh can carry. But peers only need enough to coordinate
flight: where everyone is, where they are heading, what was detected. Pushing
raw feeds across the air would saturate the radio and drain batteries for data
nobody reads in flight.
## Decision
Only **derived state** crosses the air — position, attitude, detections — as
small pub/sub broadcasts (~5 Hz). **Raw telemetry stays on local NVMe** until
the drone lands, then travels to the warehouse during offload
([ADR-0002](ADR-0002-one-storage-format.md)). The design is **event-driven**:
new derived data triggers downstream action through hooks; nothing polls asking
"anything new yet?".
## Consequences
- Air bandwidth scales with the number of peers and the broadcast rate, not
with sensor resolution.
- The warehouse is the only place with the full picture; in-flight decisions use
the local window plus peer broadcasts.
- Broadcast staleness per peer becomes a first-class health signal
([`../07-observability.md`](../07-observability.md)).
- What syncs and what does not is specified in
[`../04-swarm-sync.md`](../04-swarm-sync.md).
@@ -0,0 +1,41 @@
# ADR-0004: Read-only SQL-over-SSH as the peer query contract; no debug API
## Status
Accepted (2026-07-08). Supersedes the earlier sketch of a bespoke query service.
## Context
Beyond the 5 Hz broadcast, a peer (or an engineer on the bench) sometimes needs
to *ask* a drone a richer question: "show me your detections in the last minute
near this position". Inventing a service for this means a new port, a new
protocol, an auth layer, and a second code path that only runs during
debugging — the classic "works on the bench, untested in flight" trap.
## Options
| Option | Pros | Cons |
| --- | --- | --- |
| A — Custom query microservice + REST API | Flexible | New port/protocol/auth to secure; a debug-only path that never flies |
| B — Read-only DuckDB SQL over an SSH forced command | Reuses SSH trust + the query language already on both ends; identical path on bench and in flight | SQL must be gated to read-only |
## Decision
Peer data access is **read-only DuckDB SQL executed through an SSH forced
command**. SQL is already the query language on both ends
([ADR-0002](ADR-0002-one-storage-format.md)), and SSH already carries the trust
model ([ADR-0005](ADR-0005-wireguard-beneath-ssh.md)), so no new service, port,
or protocol is invented. A **SQL gate** rejects anything that writes
(`COPY`, `INSERT`, `ATTACH`, pragmas). There is **no separate debug API**: an
engineer debugging on the ground runs the identical query through the identical
wrapper, permissions, and output format a peer drone would use.
## Consequences
- One access path is built, secured, and tested — "what you test is what
flies".
- The SQL gate is safety-critical and is covered by unit tests
(`simulator/tests/test_sql_gate.py`).
- The explorer and the prototype's live mode are *just another read-only
consumer* of this same contract ([`../04-swarm-sync.md`](../04-swarm-sync.md)).
- Design principles 7 and 8 in [`../../README.md`](../../README.md) restate this.
@@ -0,0 +1,40 @@
# ADR-0005: WireGuard beneath SSH for the mesh transport
## Status
Accepted (2026-07-08)
## Context
Peer bulk sync and SQL-over-SSH ([ADR-0004](ADR-0004-sql-over-ssh-contract.md))
need an encrypted, authenticated channel across an ad-hoc radio mesh. SSH alone
would work at the application layer, but it leaves the drones' listening
surface (sshd, any other service port) exposed on the raw mesh network, and it
gives no cheap, uniform way to authenticate *the peer* before the application
handshake.
## Options
| Option | Pros | Cons |
| --- | --- | --- |
| A — SSH only | One daemon, familiar | Service ports exposed on the raw mesh; per-service auth; no network-layer peer identity |
| B — WireGuard tunnel, SSH inside it | Network-layer peer auth via static keys; only the WG port is exposed; SSH speaks over a private, encrypted subnet | Two layers to provision |
## Decision
Run **WireGuard as the network layer** and **SSH on top of it**. WireGuard
authenticates peers by pre-shared static public keys and presents a private
encrypted subnet; SSH forced commands then carry the SQL and bulk-sync contract
over that subnet. Keys are issued **per device before deployment** — nothing
joins the mesh at runtime (design principle 5).
## Consequences
- The only thing exposed on the raw radio is the WireGuard port; sshd and the
data services listen only on the WG interface.
- Peer identity is established once, at the network layer, and reused by every
application above it.
- Provisioning must place both WG and SSH keys during host prep — handled by
`infra/ansible/drone-provision.yml` ([ADR-0006](ADR-0006-iac-boundaries.md)).
- Rationale and the "why not SSH alone" argument live in
[`../05-network-security.md`](../05-network-security.md).
+41
View File
@@ -0,0 +1,41 @@
# ADR-0006: IaC split — Ansible hosts, Terraform ground, Flux ground-only, fleet manifest for drones
## Status
Accepted (2026-07-08)
## Context
Three very different things need provisioning: (1) drone hosts, which are
air-gapped and never reachable by a controller in flight; (2) the ground
segment (warehouse, observability, offload), a normal k3s cluster; (3) the
simulation environment that mirrors the ground segment locally. Using one tool
for all three would force a GitOps controller or a Terraform apply loop onto the
drone — impossible for an autonomous, disconnected node
([ADR-0001](ADR-0001-no-swarm-orchestrator.md)).
## Decision
Split infrastructure ownership by lifecycle:
| Layer | Tool | Scope |
| --- | --- | --- |
| Host preparation | **Ansible** | Drone bench prep (keys, forced commands, WireGuard, Compose bundle) and the local k3d sim cluster |
| Ground workloads | **Terraform** | k3s/k3d resources: virtual fleet + observability (`sim-env`), warehouse + T1→T3 offload (`ground`) |
| Ground continuous delivery | **Flux (GitOps)** | Ground segment **only** — policy labels, dashboard bundles |
| Drone delivery | **Fleet release manifest** | A versioned, digest-pinned artifact list; no controller pulls to the drone |
Drones are delivered by the **fleet release manifest** (one version for the
whole fleet, atomic rollback), never by a controller reaching into the air.
## Consequences
- No GitOps agent or `terraform apply` ever targets a drone; the air side stays
controller-free.
- Terraform state is environment-local and kept out of Git
([ADR-0008](ADR-0008-runtime-data-paths.md)).
- The sim cluster is a faithful miniature: the same Terraform describes it and
the ground segment.
- Boundaries are documented in
[`../06-environments.md`](../06-environments.md#iac-boundaries); delivery in
[`../11-cicd-delivery.md`](../11-cicd-delivery.md).
+27
View File
@@ -0,0 +1,27 @@
# ADR-0007: Run the simulator image as a non-root user
## Status
Accepted (2026-07-08)
## Context
The Trivy scan in CI flagged **DS-0002**: the simulator image ran as `root`
because no `USER` was set. A container writing the data lake as root also
produces root-owned Parquet on bind mounts, which then blocks a later non-root
process from the same files.
## Decision
Create a dedicated unprivileged user **`swarm` (uid/gid 10001)** in the
`simulator/Dockerfile` and switch to it with `USER swarm` before the entrypoint.
The uid is fixed (not auto-assigned) so host-side ownership of the shared data
paths ([ADR-0008](ADR-0008-runtime-data-paths.md)) is deterministic.
## Consequences
- Trivy DS-0002 clears; the image follows least-privilege.
- Bind-mounted data is owned by `10001:10001`; host prep (`sim-cluster.yml`) and
Ansible `chown` the `var/t1` / `var/t3` paths to this uid so k3d pods can write.
- Any future service image reusing this data must run as the same uid or share
the group.
+40
View File
@@ -0,0 +1,40 @@
# ADR-0008: Shared `var/t1` / `var/t3` runtime paths; state out of Git
## Status
Accepted (2026-07-08)
## Context
Compose, the k3d fleet, and the ground offload job all need to read and write
the same data lake, so that "go live" in the prototype shows the data the
simulator just generated. Early on, Terraform state and the provider cache were
accidentally committed (~6 MB of `*.tfstate` and a vendored provider binary),
polluting history and risking stale/secret data in Git.
## Decision
Standardise two **gitignored** runtime directories at the repo root:
| Path | Tier | Contents |
| --- | --- | --- |
| `var/t1/` | T1 live lake | In-flight Parquet from Compose or the k3d fleet |
| `var/t3/` | T3 warehouse | Offloaded historical partitions |
They are created automatically and overridable via
`SWARM_T1_DIR` / `SWARM_WAREHOUSE_DATA` / `SWARM_SIM_DATA`. Treat `var/` as
**runtime-only** per the ecosystem canonical layout. `.gitignore` excludes
`*.tfstate`, `*.tfstate.backup`, and `.terraform/` while **keeping**
`.terraform.lock.hcl` (the lock is source, the state is not).
## Consequences
- One lake feeds every component; the live prototype reflects real generated
data.
- Git history carries no machine state or large binaries; `terraform` treats
its state as local/remote-backend concern, not versioned source.
- Ownership of these paths is fixed to uid 10001
([ADR-0007](ADR-0007-non-root-image.md)).
- Recovery from earlier state drift is documented in
[`../../infra/terraform/README.md`](../../infra/terraform/README.md)
(`terraform import`).
+52
View File
@@ -0,0 +1,52 @@
# ADR-0009: Bound observability lake scans to a flight window and cache
## Status
Accepted (2026-07-08)
## Context
The k3d sim node was running at ~245% CPU. Three causes compounded:
1. **Explorer** rebuilt a DuckDB view over the *entire* lake on every HTTP
request (~2000 Parquet files, 200 MB+) — roughly 1.5 cores.
2. **Exporter** scanned the full lake every 5 s — roughly 0.7 cores.
3. **Drones** exited after sealing a flight; Kubernetes restarted each pod,
and every restart appended a fresh `flight=` partition tree, so the lake grew
without bound and every scan got more expensive.
The cost is inherent to scanning an append-only lake whose size grows with pod
churn, not to any single slow query. Metrics and the live view only ever need
recent flights.
## Decision
Bound and cache the scans instead of reading the whole lake:
- **Flight window.** The exporter and explorer scan only the most recent
`METRIC_FLIGHT_WINDOW` flights (default 5) via a shared helper
(`simulator/monitoring/lake.py`). Older partitions stay on disk but out of the
hot path.
- **Caching.** Explorer caches DuckDB views (`VIEW_REFRESH_S=30`) and the
partition tree (`TREE_CACHE_S=15`); the exporter scans every
`SCAN_INTERVAL_S=30` instead of 5 s.
- **Stop restart churn.** Drones set `KEEP_ALIVE=1` and idle after sealing
instead of exiting, so no new flight tree is created per restart. In k3d,
`IMU_HZ=20` and resource limits cap per-drone load; the live prototype polls
every 2 s.
- **Self-observability.** Add node-exporter, a
`swarm_exporter_scan_duration_seconds` metric, and a **Swarm Platform**
Grafana dashboard (node CPU/memory + scan health) alongside the existing fleet
dashboard.
## Consequences
- Steady state dropped to explorer ~29 m, exporter ~16 m CPU; the k3d node to
~56% (from ~245%).
- Metrics reflect recent flights only; long-horizon analysis is a warehouse
(T3) query, not an exporter concern — consistent with
[ADR-0003](ADR-0003-sync-derived-state-only.md).
- Old flight partitions accumulate on disk; pruning them is a follow-up
(tracked in [`../09-open-questions.md`](../09-open-questions.md)).
- Observability design is documented in
[`../07-observability.md`](../07-observability.md).
+40
View File
@@ -0,0 +1,40 @@
# Architecture Decision Records
This directory records the significant decisions behind Swarm House, in the
order they were made. Each record is immutable once **Accepted** — a later
decision that changes course gets a new number and supersedes the old one
rather than editing it.
## ADR vs ASR
| Kind | What it captures | Where it lives |
| --- | --- | --- |
| **ADR** (Architecture Decision Record) | A point-in-time decision for *this* repository, with the options considered and their consequences | Here, in `docs/adr/` |
| **ASR** (Architecture Standard Record) | A standing, cross-repository policy every project must obey (Go-first, secrets, layout) | The ecosystem `inventar` repo, not here |
The rules in [`../../AGENTS.md`](../../AGENTS.md) are this repo's local standing
constraints; anything ecosystem-wide belongs in `inventar` as an ASR. Design
decisions specific to the platform are ADRs and belong here.
## Index
| ADR | Decision | Status |
| --- | --- | --- |
| [ADR-0001](ADR-0001-no-swarm-orchestrator.md) | No swarm-wide orchestrator; coordinate through data | Accepted |
| [ADR-0002](ADR-0002-one-storage-format.md) | One storage format on every tier — Parquet + DuckDB, Hive layout | Accepted |
| [ADR-0003](ADR-0003-sync-derived-state-only.md) | Sync derived state only; raw telemetry stays local | Accepted |
| [ADR-0004](ADR-0004-sql-over-ssh-contract.md) | Read-only SQL-over-SSH as the peer query contract; no debug API | Accepted |
| [ADR-0005](ADR-0005-wireguard-beneath-ssh.md) | WireGuard beneath SSH for the mesh transport | Accepted |
| [ADR-0006](ADR-0006-iac-boundaries.md) | IaC split: Ansible hosts, Terraform ground, Flux ground-only, fleet manifest for drones | Accepted |
| [ADR-0007](ADR-0007-non-root-image.md) | Run the simulator image as a non-root user | Accepted |
| [ADR-0008](ADR-0008-runtime-data-paths.md) | Shared `var/t1` / `var/t3` runtime paths; state out of Git | Accepted |
| [ADR-0009](ADR-0009-bounded-lake-scans.md) | Bound observability lake scans to a flight window and cache | Accepted |
## Writing a new ADR
1. Copy [`TEMPLATE.md`](TEMPLATE.md) to `ADR-NNNN-short-slug.md` (next free number).
2. Fill in Context, Options (if more than one was real), Decision, Consequences.
3. Add a row to the index above.
4. Cross-link the ADR from the design doc it affects (and vice versa).
Keep the tone laconic: state the decision and why, skip the narrative.
+29
View File
@@ -0,0 +1,29 @@
# ADR-NNNN: Short decision title
## Status
Proposed | Accepted | Superseded by ADR-XXXX
## Context
What forces are in play? What problem or constraint triggered the decision?
Keep it to the facts that actually shaped the choice.
## Options
| Option | Pros | Cons |
| --- | --- | --- |
| A — … | … | … |
| B — … | … | … |
(Drop this section when only one option was ever real.)
## Decision
The choice, stated plainly. One paragraph.
## Consequences
- What becomes easier.
- What becomes harder or is now forbidden.
- Follow-ups, links to affected docs.
+68
View File
@@ -0,0 +1,68 @@
# Documentation audit — 2026-07-10
Manual pass over `docs/`, all READMEs, and glossary. Goal: correct links, unify T0T4 vocabulary, align rsync/offload wording with code, improve readability.
## Summary
| Metric | Result |
| --- | --- |
| Markdown files checked | 33 |
| Broken relative links (after fixes) | 0 |
| Glossary `###` anchors added | 15 (T0T4, layers, bulk sync, source of truth, …) |
| Open questions added | §12–§14 (tooling, DuckLake, `schemas/`) |
## Findings fixed
### Links
- `infra/terraform/ground/README.md` pointed at non-existent `03-storage-design.md``03-data-platform.md`.
- `11-cicd-delivery.md` bare cross-reference to doc 05 → proper markdown link.
### Factual drift (code vs docs)
| Item | Was | Now (matches `broadcast.py`, `sim.ts`) |
| --- | --- | --- |
| Pose frame | ~40 bytes, uint16 `drone_id` | 46 bytes, 8-byte ASCII `drone_id` |
### Terminology
- Extended [00 — Glossary](../00-glossary.md) with T0T4, architecture layers, pipeline stages, source of truth.
- Disambiguated design journey §4: pipeline stages ≠ storage floors.
- Roadmap Stage 7: rsync bulk path, not “MinIO replication”.
### rsync / rclone / offload
| Path | Canonical tool |
| --- | --- |
| Peer bulk sync | rsync over SSH |
| Dock offload | rsync or `mc mirror` |
| S3 backends (optional) | rclone |
| Sim CronJob | `cp -ru` + `mc mirror` (documented shortcut) |
### Live demos (design journey)
Tracked in [PR #3](https://git.produktor.io/eSlider/swarm-house/pulls/3) — explorer + Grafana links in `12-design-journey.md` §7/§9. Prototype link already on `main` §2.
### README / LICENSE
- Restricted-use notice with link to [LICENSE](../../LICENSE).
- Component READMEs tightened (caveman style).
## Remaining (documented, not bugs)
| Item | Where |
| --- | --- |
| `schemas/` directory absent | OQ §14, roadmap stage 2 |
| DuckLake undecided | OQ §13 |
| rsync bulk sync not in sim code | Design only |
| Glossary table terms link to section headers, not row anchors | Optional polish |
| T4 after “Source of truth” in glossary order | Cosmetic |
| GitOps “source of truth” vs data SSOT | Different meanings in `11-cicd-delivery` |
## Suggested CI checks (for issue #1)
Implemented in [`bin/check_docs.py`](../../bin/check_docs.py) — see [ci-rules.md](ci-rules.md).
## Files touched
`docs/00``12`, `docs/adr/*`, nine READMEs, root `README.md`.
+27
View File
@@ -0,0 +1,27 @@
# Documentation improvement records
Point-in-time audits and follow-up work for links, terminology, readability, and factual alignment with code.
| Report | Date | Scope |
| --- | --- | --- |
| [2026-07-10 audit](2026-07-10-audit.md) | 2026-07-10 | Full `docs/` + README pass; glossary T0T4; live demo links |
## Collaboration style
Issues, PRs, and reviews default to **caveman-full** ([`CONTRIBUTING.md`](../../CONTRIBUTING.md#caveman-style-default),
[`AGENTS.md`](../../AGENTS.md#caveman-style-default-for-collaboration)). Gitea templates live under
[`.github/ISSUE_TEMPLATE/`](../../.github/ISSUE_TEMPLATE/) and
[`.github/pull_request_template.md`](../../.github/pull_request_template.md).
## CI automation
Enforced in Gitea Actions (`docs-verify` job). Rules live in [`bin/docs_rules.json`](../../bin/docs_rules.json); see [ci-rules.md](ci-rules.md).
Originally tracked in [issue #1](https://git.produktor.io/eSlider/swarm-house/issues/1).
## How to add a new report
1. Copy the structure from the latest audit file.
2. Name it `YYYY-MM-DD-short-topic.md`.
3. Add a row to the table above.
4. Cross-link from [09 — Open questions](../09-open-questions.md) if new ambiguities appear.
+58
View File
@@ -0,0 +1,58 @@
# CI documentation rules
Automated checks for markdown in this repository. Enforced on every **pull request** and on **push to `main`** (including docs-only pushes that skip the simulator gate).
Implementation: [`bin/check_docs.py`](../../bin/check_docs.py) reads [`bin/docs_rules.json`](../../bin/docs_rules.json).
Workflow: [`.github/workflows/release.yaml`](../../.github/workflows/release.yaml) job **`Verify documentation`**.
Tracked from [issue #1](https://git.produktor.io/eSlider/swarm-house/issues/1) and the [2026-07-10 audit](2026-07-10-audit.md).
## Gates
| Rule | Severity | What it checks |
| --- | --- | --- |
| Relative links | error | Every `[text](path)` and `[text](path#anchor)` to a local `.md` file resolves to an existing file |
| Fragment anchors | error | `#anchor` fragments match a `##` or `###` heading in the target file (GitHub-style slug) |
| Banned targets | error | Known-bad paths (e.g. deleted `03-storage-design.md`) are not linked |
| Glossary fragments | error | All `00-glossary.md#…` links use anchors that exist in the glossary |
| Section vs term links | warning | Links to glossary *section* headers (`#swarm--robotics`, …) are listed; prefer `###` term anchors where they exist |
| Bare cross-refs | error | Prose cross-references to numbered docs without a proper markdown link |
| ADR immutability | error | No modifications to existing `docs/adr/ADR-*.md` (compare `origin/main`); add new ADR to supersede |
Warnings are printed in the job log but do not fail CI. Errors exit non-zero and block merge.
## PR workflow
```mermaid
flowchart LR
PR[Pull request] --> DV[docs-verify]
PR --> V[verify simulator]
V --> S[Trivy scan]
DV --> OK{All required green?}
V --> OK
S --> OK
OK -->|yes| Merge[Mergeable]
```
| Event | `docs-verify` | `verify` + `scan` |
| --- | --- | --- |
| PR → `main` | always | always |
| Push → `main` (code paths) | always | runs |
| Push → `main` (docs/README/AGENTS only) | always | skipped (`paths-ignore`) |
Docs-only changes therefore still get link and glossary validation on `main`; they do not trigger a semantic release.
## Local run
```bash
python bin/check_docs.py
python bin/check_adrs.py --base origin/main
python bin/check_docs.py --warnings-as-errors # treat section-link warnings as failures
```
## Changing rules
1. Edit `bin/docs_rules.json` (paths, banned targets, preferred anchors).
2. Adjust `bin/check_docs.py` only when adding a new rule *type*.
3. Add a row to the table above and mention it in the audit/improvement record if the change is significant.
+9 -18
View File
@@ -1,14 +1,11 @@
# Ansible — host preparation # Ansible — host preparation
Ansible owns everything that happens **on a host before workloads run**. Ansible = host prep before workloads run. Terraform ([`../terraform/`](../terraform/)) owns workloads. Boundary: [06 — IaC boundaries](../../docs/06-environments.md#iac-boundaries).
Terraform ([`../terraform/`](../terraform/)) owns the workloads themselves.
The boundary is deliberate and documented in
[06 — Environments](../../docs/06-environments.md#iac-boundaries).
| Playbook | Target | What it does | | Playbook | Target | What it does |
| --- | --- | --- | | --- | --- | --- |
| `drone-provision.yml` | Drone (on the bench, before a mission) | Identity keys, peer trust with forced commands, WireGuard, Compose bundle from the fleet release manifest | | `drone-provision.yml` | Drone (bench, before mission) | Identity keys, peer trust with forced commands, WireGuard, Compose bundle from fleet manifest |
| `sim-cluster.yml` | Local workstation / CI host | k3d cluster that miniatures the ground segment; shared data volume; simulator image import | | `sim-cluster.yml` | Local workstation / CI host | k3d cluster miniaturing ground segment; shared data volume; simulator image import |
## Provision a drone ## Provision a drone
@@ -16,16 +13,12 @@ The boundary is deliberate and documented in
ansible-playbook -i inventory.example.yml drone-provision.yml ansible-playbook -i inventory.example.yml drone-provision.yml
``` ```
The playbook is idempotent and runs only over the bench network — drones Idempotent. Bench network only. Never provision in flight ([05 — Network & security](../../docs/05-network-security.md)):
are never provisioned in flight (see [05 — Network & security](../../docs/05-network-security.md)):
1. **Identity**: generate the drone's ed25519 keypair if absent. 1. **Identity**: generate drone ed25519 keypair if absent.
2. **Peer trust**: install every fleet member's public key into 2. **Peer trust**: fleet public keys in `authorized_keys`, each pinned to read-only SQL forced command.
`authorized_keys`, each pinned to the read-only SQL forced command — 3. **WireGuard**: render `wg0.conf` with drone address and peers.
the only thing a peer can execute. 4. **Data plane**: Compose bundle from fleet manifest, systemd unit enabled.
3. **WireGuard**: render `wg0.conf` with the drone's address and peers.
4. **Data plane**: lay down the Compose bundle referenced by the fleet
release manifest and enable it as a systemd unit.
## Create the simulation cluster ## Create the simulation cluster
@@ -34,6 +27,4 @@ ansible-playbook sim-cluster.yml # creates k3d cluster 'swarm-sim'
cd ../terraform/sim-env && terraform init && terraform apply cd ../terraform/sim-env && terraform init && terraform apply
``` ```
The cluster mounts a shared host directory as `/data` on the (single) Shared host directory mounted as `/data` — drone pods, exporter, explorer see one Parquet lake (same contract as on-board NVMe).
node, so drone pods, the exporter, and the explorer see one Parquet lake —
the same contract as the on-board NVMe layout.
+4
View File
@@ -29,12 +29,16 @@
path: "{{ data_dir }}" path: "{{ data_dir }}"
state: directory state: directory
mode: "0777" mode: "0777"
owner: "10001"
group: "10001"
- name: Create warehouse directory (T3 host path inside the k3d node) - name: Create warehouse directory (T3 host path inside the k3d node)
ansible.builtin.file: ansible.builtin.file:
path: "{{ warehouse_dir }}" path: "{{ warehouse_dir }}"
state: directory state: directory
mode: "0777" mode: "0777"
owner: "10001"
group: "10001"
- name: List existing clusters - name: List existing clusters
ansible.builtin.command: k3d cluster list -o json ansible.builtin.command: k3d cluster list -o json
+9 -19
View File
@@ -1,20 +1,16 @@
# GitOps — ground segment (Flux) # GitOps — ground segment (Flux)
Flux reconciles **long-lived ground configuration** from this repository. It does Flux = long-lived ground config from git. Not on drones. Not fleet manifest. Different lifecycles ([11 — CI/CD](../../docs/11-cicd-delivery.md)).
not run on drones and does not replace the fleet release manifest — those are
different lifecycles by design ([11 — CI/CD](../../docs/11-cicd-delivery.md)).
## Boundary: Terraform vs Flux vs fleet manifest ## Boundary: Terraform vs Flux vs fleet manifest
| Layer | Tool | Owns | | Layer | Tool | Owns |
| --- | --- | --- | | --- | --- | --- |
| Cluster + first boot | **Terraform** ([`../terraform/`](../terraform/)) | Namespaces, PVCs, Deployments, CronJobs, NodePorts — the shape of the simulation | | Cluster + first boot | **Terraform** ([`../terraform/`](../terraform/)) | Namespaces, PVCs, Deployments, CronJobs, NodePorts |
| Ongoing ground config | **Flux** (this directory) | Policy labels, dashboard bundles, offload knobs — things that change without reprovisioning PVCs | | Ongoing ground config | **Flux** (this directory) | Policy labels, dashboard bundles, offload knobs |
| Drones | **Fleet release manifest** | Compose bundle digests, model weights, peer registry — atomic, dock-only delivery | | Drones | **[Fleet release manifest](../../docs/11-cicd-delivery.md)** | Compose digests, model weights, peer registry — dock-only |
Drones are **outside GitOps**: there is no reconciler in flight. A dock applies Drones outside GitOps. No reconciler in flight. Dock applies pinned manifest once. No mid-mission drift — no update endpoint in radio profile.
the pinned manifest once; mid-mission drift is impossible because the update
endpoint does not exist in the radio profile.
## Layout ## Layout
@@ -26,9 +22,7 @@ infra/gitops/
## Bootstrap on the k3d simulation cluster ## Bootstrap on the k3d simulation cluster
`infra/ansible/sim-cluster.yml` installs Flux controllers and applies the CRs `infra/ansible/sim-cluster.yml` installs Flux and applies CRs. After first `git push`, Flux reconciles `infra/gitops/ground/`.
below. After the first `git push`, Flux polls `origin` and reconciles
`infra/gitops/ground/` into the `ground` namespace.
```bash ```bash
# Manual bootstrap (if you skipped Ansible): # Manual bootstrap (if you skipped Ansible):
@@ -36,17 +30,13 @@ flux install --namespace=flux-system
kubectl apply -k infra/gitops/flux kubectl apply -k infra/gitops/flux
``` ```
To reconcile immediately without waiting for the poll interval: Reconcile immediately:
```bash ```bash
flux reconcile source git swarm-house -n flux-system flux reconcile source git swarm-house -n flux-system
flux reconcile kustomization ground-segment -n flux-system flux reconcile kustomization ground-segment -n flux-system
``` ```
## What Flux manages here (example) ## What Flux manages
The [`ground/`](ground/) overlay currently carries a **GitOps-managed ConfigMap** [`ground/`](ground/) overlay: GitOps-managed ConfigMap with fleet policy metadata. Production extends to Grafana dashboards, Prometheus rules, offload-schedule ConfigMaps — git-versioned, no `terraform apply` for label tweaks.
that tags the warehouse segment with fleet policy metadata. In production this
pattern extends to Grafana dashboard bundles, Prometheus rule files, and
offload-schedule ConfigMaps — all versioned in git, all auditable, none of
them requiring a `terraform apply` to tweak a label.
+12 -1
View File
@@ -1,7 +1,18 @@
# Terraform — ground workloads
Declared workloads on the k3d simulation cluster. See [06 — IaC boundaries](../../docs/06-environments.md#iac-boundaries).
| Module | Tier | Purpose |
| --- | --- | --- |
| [`sim-env/`](sim-env/) | [T4](../../docs/00-glossary.md#t4--dev) dev/sim | Virtual fleet + observability on k3d |
| [`ground/`](ground/) | [T3](../../docs/00-glossary.md#t3--warehouse) | Warehouse + [T1→T3](../../docs/03-data-platform.md#storage-floors-tiers) offload |
Two modules, two states. Fleet disposable; warehouse survives `destroy` of sim-env.
## Recovering from partial state ## Recovering from partial state
If resources were created outside Terraform (or state was lost), import them If resources were created outside Terraform (or state was lost), import them
before `terraform apply`: before `terraform apply`. Run from the target module directory (`sim-env/` or `ground/`):
```bash ```bash
# sim-env (namespace: swarm) # sim-env (namespace: swarm)
+9 -21
View File
@@ -1,19 +1,14 @@
# Terraform — ground warehouse (T3) # Terraform — ground warehouse ([T3](../../../docs/00-glossary.md#t3--warehouse))
The stationary half of the system: the historical warehouse that receives Ground warehouse. Receives sealed partitions post-flight ([storage floors](../../../docs/03-data-platform.md#storage-floors-tiers)). Long-lived infra → Terraform.
sealed partitions after every flight ([03 — Storage tiers](../../../docs/03-storage-design.md)).
It is the longest-lived, most conventional infrastructure in the design —
and therefore the most natural Terraform territory.
Runs in the same k3d cluster as [`../sim-env`](../sim-env/) but in its own Runs in same k3d cluster as [`../sim-env`](../sim-env/) but separate namespace and state: fleet disposable, warehouse not — never share `terraform destroy`.
namespace with its own state: the fleet is disposable, the warehouse is not,
and the two lifecycles must never share a `terraform destroy`.
| Resource | Purpose | | Resource | Purpose |
| --- | --- | | --- | --- |
| Deployment `minio` + PVC + NodePort 30901 | Object-store facade of the warehouse for downstream consumers (training pipelines, replay) | | Deployment `minio` + PVC + NodePort 30901 | Object-store facade for downstream consumers |
| CronJob `offload` | Post-flight offload: copies Hive partitions verbatim from the fleet lake (T1) into the warehouse (T3), then mirrors into MinIO | | CronJob `offload` | [T1](../../../docs/00-glossary.md#t1--warm) → [T3](../../../docs/00-glossary.md#t3--warehouse) offload: Hive partitions verbatim, then `mc mirror` into MinIO |
| Deployment `warehouse-explorer` + NodePort 30089 | Second explorer instance over the warehouse path — historical read-only SQL across **all** flights | | Deployment `warehouse-explorer` + NodePort 30089 | Read-only SQL across **all** offloaded flights |
## Usage ## Usage
@@ -23,16 +18,9 @@ terraform apply
terraform output terraform output
``` ```
After a couple of offload runs, the warehouse explorer shows the same After offload runs, warehouse explorer shows same partition tree as live lake, accumulated across flights. [T1](../../../docs/00-glossary.md#t1--warm) layout **is** [T3](../../../docs/00-glossary.md#t3--warehouse) layout. No transform. No schema drift. Same DuckDB queries both ends.
partition tree as the live lake but accumulated across flights — the T1
layout **is** the T3 layout, which is the whole point: no transform step,
no schema drift, DuckDB queries work identically on both ends.
## Simulation vs production ## Simulation vs production
- The CronJob compresses "drone lands, docks, offloads, prunes" into a - Sim CronJob: `cp -ru` + `mc mirror` on a schedule — shortcut for "lands, offloads, prunes". Production: event-driven per dock, rsync or `mc mirror`, checksum audit before prune ([03 — Data platform](../../../docs/03-data-platform.md)).
periodic rsync-style copy. Production offload is event-driven per docking - MinIO credentials here are throwaway defaults; production credentials sealed per site, never in state or VCS.
and verifies checksums from the flight's partition manifest before pruning
the on-board lake.
- MinIO credentials here are throwaway defaults; production credentials are
sealed per site and never in state or VCS.
+11 -19
View File
@@ -1,17 +1,14 @@
# Terraform — simulation environment (T4) # Terraform — simulation environment ([T4](../../../docs/00-glossary.md#t4--dev))
Declares the simulated fleet and its observability stack on the k3d cluster Sim fleet + observability on k3d. Cluster from [`../../ansible/sim-cluster.yml`](../../ansible/sim-cluster.yml). Executable [T4](../../../docs/00-glossary.md#t4--dev) miniature ([06 — Environments](../../../docs/06-environments.md)).
created by [`../../ansible/sim-cluster.yml`](../../ansible/sim-cluster.yml).
This module is the executable miniature of the dev/sim environment from
[06 — Environments](../../../docs/06-environments.md).
| Resource | Purpose | | Resource | Purpose |
| --- | --- | | --- | --- |
| StatefulSet `drone` × `drone_count` | Virtual drones; stable pod names become `DRONE_ID`s; all write Hive-partitioned Parquet into the shared `/data` lake | | StatefulSet `drone` × `drone_count` | Virtual drones; pod names `DRONE_ID`; shared `/data` lake |
| Deployment `exporter` | Prometheus exporter reading the lake with DuckDB | | Deployment `exporter` | Prometheus exporter reading lake with DuckDB |
| Deployment `explorer` + NodePort 30088 | Partition tree + read-only SQL console (also feeds the prototype's live mode) | | Deployment `explorer` + NodePort 30088 | Partition tree + read-only SQL (prototype live mode) |
| Deployment `prometheus` + NodePort 30990 | Scrapes the exporter | | Deployment `prometheus` + NodePort 30990 | Scrapes exporter |
| Deployment `grafana` + NodePort 30300 | Same dashboard JSON as the Compose profile, provisioned from a ConfigMap | | Deployment `grafana` + NodePort 30300 | Dashboard JSON from ConfigMap |
## Usage ## Usage
@@ -22,7 +19,7 @@ terraform apply
terraform output # URLs terraform output # URLs
``` ```
Scale the fleet without touching YAML: Scale without YAML edits:
```bash ```bash
terraform apply -var drone_count=9 -var speedup=4 terraform apply -var drone_count=9 -var speedup=4
@@ -30,11 +27,6 @@ terraform apply -var drone_count=9 -var speedup=4
## Notes ## Notes
- `image_pull_policy = "Never"`the simulator image is imported by the - `image_pull_policy = "Never"`image imported by Ansible (`k3d image import`); no registry pull. Air-gap mirror.
Ansible playbook (`k3d image import`); the cluster never pulls from a - Pod name → identity (StatefulSet ordinal). `kubectl delete pod` = power cycle: new process, same identity, new flight ID.
registry, mirroring the air-gap doctrine. - Local state only — cluster disposable.
- Pods discover their identity from the pod name (StatefulSet ordinal), so
a flight survives `kubectl delete pod` the same way a drone survives a
power cycle: new process, same identity, new flight ID.
- State is local (`terraform.tfstate` in this directory) — the simulation
cluster is disposable; nothing here is shared infrastructure.
+131 -1
View File
@@ -63,6 +63,14 @@ resource "kubernetes_stateful_set" "drone" {
name = "DATA_DIR" name = "DATA_DIR"
value = "/data" value = "/data"
} }
env {
name = "KEEP_ALIVE"
value = "1"
}
env {
name = "IMU_HZ"
value = "20"
}
volume_mount { volume_mount {
name = "lake" name = "lake"
@@ -118,9 +126,21 @@ resource "kubernetes_deployment" "exporter" {
name = "EXPORTER_PORT" name = "EXPORTER_PORT"
value = "9105" value = "9105"
} }
env {
name = "SCAN_INTERVAL_S"
value = "30"
}
env {
name = "METRIC_FLIGHT_WINDOW"
value = "5"
}
port { port {
container_port = 9105 container_port = 9105
} }
resources {
requests = { cpu = "50m", memory = "64Mi" }
limits = { cpu = "500m", memory = "256Mi" }
}
volume_mount { volume_mount {
name = "lake" name = "lake"
mount_path = "/data" mount_path = "/data"
@@ -182,9 +202,25 @@ resource "kubernetes_deployment" "explorer" {
name = "EXPLORER_PORT" name = "EXPLORER_PORT"
value = "8088" value = "8088"
} }
env {
name = "VIEW_REFRESH_S"
value = "30"
}
env {
name = "TREE_CACHE_S"
value = "15"
}
env {
name = "METRIC_FLIGHT_WINDOW"
value = "5"
}
port { port {
container_port = 8088 container_port = 8088
} }
resources {
requests = { cpu = "50m", memory = "64Mi" }
limits = { cpu = "750m", memory = "256Mi" }
}
volume_mount { volume_mount {
name = "lake" name = "lake"
mount_path = "/data" mount_path = "/data"
@@ -226,11 +262,17 @@ resource "kubernetes_config_map" "prometheus" {
data = { data = {
"prometheus.yml" = <<-EOT "prometheus.yml" = <<-EOT
global: global:
scrape_interval: 5s scrape_interval: 15s
scrape_configs: scrape_configs:
- job_name: swarm - job_name: swarm
static_configs: static_configs:
- targets: ["exporter:9105"] - targets: ["exporter:9105"]
- job_name: explorer
static_configs:
- targets: ["explorer:8088"]
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]
EOT EOT
} }
} }
@@ -298,8 +340,12 @@ resource "kubernetes_config_map" "grafana_provisioning" {
data = { data = {
"datasource.yml" = <<-EOT "datasource.yml" = <<-EOT
apiVersion: 1 apiVersion: 1
deleteDatasources:
- name: Prometheus
orgId: 1
datasources: datasources:
- name: Prometheus - name: Prometheus
uid: swarm-prom
type: prometheus type: prometheus
access: proxy access: proxy
url: http://prometheus:9090 url: http://prometheus:9090
@@ -324,6 +370,90 @@ resource "kubernetes_config_map" "grafana_dashboard" {
} }
data = { data = {
"swarm.json" = file("${path.module}/../../../simulator/monitoring/grafana/dashboards/swarm.json") "swarm.json" = file("${path.module}/../../../simulator/monitoring/grafana/dashboards/swarm.json")
"swarm-platform.json" = file("${path.module}/../../../simulator/monitoring/grafana/dashboards/swarm-platform.json")
}
}
# Host metrics for the k3d node (CPU / memory on the platform dashboard)
resource "kubernetes_deployment" "node_exporter" {
metadata {
name = "node-exporter"
namespace = kubernetes_namespace.swarm.metadata[0].name
labels = local.labels
}
spec {
replicas = 1
selector {
match_labels = { app = "node-exporter" }
}
template {
metadata {
labels = merge(local.labels, { app = "node-exporter" })
}
spec {
host_network = true
host_pid = true
container {
name = "node-exporter"
image = "prom/node-exporter:v1.8.2"
args = [
"--path.procfs=/host/proc",
"--path.sysfs=/host/sys",
"--path.rootfs=/host/root",
"--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)",
]
port {
container_port = 9100
}
volume_mount {
name = "proc"
mount_path = "/host/proc"
read_only = true
}
volume_mount {
name = "sys"
mount_path = "/host/sys"
read_only = true
}
volume_mount {
name = "root"
mount_path = "/host/root"
read_only = true
}
resources {
requests = { cpu = "20m", memory = "32Mi" }
limits = { cpu = "200m", memory = "64Mi" }
}
}
volume {
name = "proc"
host_path { path = "/proc" }
}
volume {
name = "sys"
host_path { path = "/sys" }
}
volume {
name = "root"
host_path { path = "/" }
}
}
}
}
}
resource "kubernetes_service" "node_exporter" {
metadata {
name = "node-exporter"
namespace = kubernetes_namespace.swarm.metadata[0].name
}
spec {
selector = { app = "node-exporter" }
port {
port = 9100
target_port = 9100
}
} }
} }
+13 -6
View File
@@ -1,8 +1,8 @@
# Swarm visualization prototype # Swarm visualization prototype
A 2D top-down view of the swarm data plane from [04 — Swarm sync](../docs/04-swarm-sync.md): drones patrol an area with static and mobile obstacles, exchange 5 Hz pose broadcasts (blue link flashes), and run opportunistic bulk sync of sealed partitions (green links with live rate and cumulative up/down counters). 2D top-down view of swarm data plane from [04 — Swarm sync](../docs/04-swarm-sync.md): drones patrol with obstacles, 5 Hz pose broadcasts (blue flashes), opportunistic bulk sync (green links with rate and byte counters).
No backend — a pure client-side simulation of the same behavioral model the Python simulator implements over real Parquet and UDP. No backend. Client-side sim. Same behavioral model as Python simulator (Parquet + UDP).
## Run ## Run
@@ -11,9 +11,16 @@ npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
``` ```
## Live mode
With k3d fleet + explorer running (`ansible-playbook` + `terraform apply` — see [sim-env README](../infra/terraform/sim-env/README.md)):
- Explorer at `http://localhost:30088`
- Header → **go live** — polls read-only SQL API for real drone positions
## What to look at ## What to look at
- **Links appear and disappear** as drones move in and out of radio range; persistent links render semi-transparent, broadcast deliveries flash them briefly. - Links form and break by radio range; broadcasts flash active links
- **Busy links turn green** and show current rate plus total transferred (`↑` / `↓`) — the bandwidth budget from the sync design made visible. - Busy links turn green — rate plus `↑` / `↓` totals
- **Per-drone label**: id, current Wi-Fi channel (hopping), battery. - Per-drone label: id, Wi-Fi channel, battery
- Controls: drone count, simulation speed, pause. - Controls: drone count, speed, pause
+1 -1
View File
@@ -25,7 +25,7 @@ function displayAspect(): number {
return Math.max(1, Math.min(MAX_ASPECT, q)); return Math.max(1, Math.min(MAX_ASPECT, q));
} }
const LIVE_POLL_MS = 1000; const LIVE_POLL_MS = 2000;
export default function App(): JSX.Element { export default function App(): JSX.Element {
const [droneCount, setDroneCount] = useState(8); const [droneCount, setDroneCount] = useState(8);
+7 -7
View File
@@ -20,24 +20,24 @@ export interface LivePose {
// latest battery reading from telemetry. arg_max keeps it a single scan. // latest battery reading from telemetry. arg_max keeps it a single scan.
const LIVE_SQL = ` const LIVE_SQL = `
WITH pose AS ( WITH pose AS (
SELECT drone_id, SELECT drone,
arg_max(pos_x, ts_ns) AS x, arg_max(pos_x, ts_ns) AS x,
arg_max(pos_y, ts_ns) AS y, arg_max(pos_y, ts_ns) AS y,
arg_max(yaw, ts_ns) AS yaw, arg_max(yaw, ts_ns) AS yaw,
max(ts_ns) AS ts_ns max(ts_ns) AS ts_ns
FROM state FROM state
WHERE direction = 'sent' WHERE direction = 'sent'
GROUP BY drone_id GROUP BY drone
), ),
batt AS ( batt AS (
SELECT drone_id, arg_max(level_pct, ts_ns) AS battery SELECT drone, arg_max(level_pct, ts_ns) AS battery
FROM telemetry FROM telemetry
WHERE sensor = 'battery' WHERE sensor = 'battery'
GROUP BY drone_id GROUP BY drone
) )
SELECT p.drone_id, p.x, p.y, p.yaw, p.ts_ns, b.battery SELECT p.drone, p.x, p.y, p.yaw, p.ts_ns, b.battery
FROM pose p LEFT JOIN batt b USING (drone_id) FROM pose p LEFT JOIN batt b USING (drone)
ORDER BY p.drone_id`; ORDER BY p.drone`;
interface QueryResponse { interface QueryResponse {
columns?: string[]; columns?: string[];
+9
View File
@@ -7,6 +7,10 @@ COPY virtual_drone ./virtual_drone
COPY monitoring ./monitoring COPY monitoring ./monitoring
COPY explorer ./explorer COPY explorer ./explorer
COPY tests ./tests COPY tests ./tests
RUN groupadd --gid 10001 swarm \
&& useradd --uid 10001 --gid swarm --home-dir /app --shell /usr/sbin/nologin swarm \
&& chown -R swarm:swarm /app
USER swarm
RUN pytest tests/ -q RUN pytest tests/ -q
FROM python:3.12-slim FROM python:3.12-slim
@@ -19,6 +23,11 @@ COPY virtual_drone ./virtual_drone
# them without bind mounts (Compose overrides these with live mounts) # them without bind mounts (Compose overrides these with live mounts)
COPY monitoring ./monitoring COPY monitoring ./monitoring
COPY explorer ./explorer COPY explorer ./explorer
RUN groupadd --gid 10001 swarm \
&& useradd --uid 10001 --gid swarm --home-dir /app --shell /usr/sbin/nologin swarm \
&& mkdir -p /data \
&& chown -R swarm:swarm /app /data
ENV DATA_DIR=/data ENV DATA_DIR=/data
USER swarm
CMD ["python", "-m", "virtual_drone.main"] CMD ["python", "-m", "virtual_drone.main"]
+14 -15
View File
@@ -1,8 +1,8 @@
# Virtual drone fleet # Virtual drone fleet
A data generator that exercises the **exact on-board pipeline** described in [03 — Data platform](../docs/03-data-platform.md): seeded kinematics along a patrol route, sensor streams at realistic rates, detection events, the hot `current/` → sealed Parquet write path, and the compact UDP state broadcast between drones. Data generator. Same on-board pipeline as [03 — Data platform](../docs/03-data-platform.md): seeded kinematics, sensor streams, detection events, hot `current/` → sealed Parquet, compact UDP state broadcast.
One process = one drone. Scaling the swarm is a Compose flag. One process = one drone. Scale swarm with Compose flag.
## Run a swarm ## Run a swarm
@@ -14,7 +14,7 @@ FLIGHT_ID=$(date -u +%Y%m%dT%H%MZ)-sim docker compose up --build --scale drone=5
DRONE_COUNT=10 DURATION_S=300 SPEEDUP=4 docker compose up --build --scale drone=10 DRONE_COUNT=10 DURATION_S=300 SPEEDUP=4 docker compose up --build --scale drone=10
``` ```
Output lands in [`../var/t1/`](../var/t1/) (the shared T1 lake — same path the k3d fleet and warehouse offload use). Override with `SWARM_T1_DIR` if needed: Output lands in [`../var/t1/`](../var/t1/) ([T1](../docs/00-glossary.md#t1--warm) live lake — same path k3d fleet and warehouse offload use). Override with `SWARM_T1_DIR` if needed:
``` ```
var/t1/dataset=telemetry/flight=…/drone=…/sensor=imu/year=…/…/hour=…/data.parquet var/t1/dataset=telemetry/flight=…/drone=…/sensor=imu/year=…/…/hour=…/data.parquet
@@ -22,7 +22,7 @@ var/t1/dataset=detections/flight=…/drone=…/…
var/t1/dataset=state/flight=…/drone=…/… ← sent + received broadcasts var/t1/dataset=state/flight=…/drone=…/… ← sent + received broadcasts
``` ```
Historical flights after offload live under [`../var/t3/`](../var/t3/) (T3 warehouse). Historical flights after offload: [`../var/t3/`](../var/t3/) ([T3](../docs/00-glossary.md#t3--warehouse) warehouse).
## Run a single drone without Docker ## Run a single drone without Docker
@@ -52,33 +52,32 @@ print(con.sql("""
EOF EOF
``` ```
## Monitoring: Grafana over the generated data ## Monitoring
The `monitoring` profile spins up a small metrics chain — a DuckDB-based exporter that scans the generated Parquet every few seconds, Prometheus, and a pre-provisioned Grafana dashboard: `monitoring` profile: DuckDB exporter → Prometheus → Grafana ([07 — Observability](../docs/07-observability.md)):
```bash ```bash
docker compose --profile monitoring up -d # exporter + prometheus + grafana docker compose --profile monitoring up -d
# generate some flights in parallel or beforehand:
FLIGHT_ID=$(date -u +%Y%m%dT%H%MZ)-sim docker compose up --scale drone=5 FLIGHT_ID=$(date -u +%Y%m%dT%H%MZ)-sim docker compose up --scale drone=5
``` ```
- Grafana: `http://localhost:3000` (anonymous admin — demo only) → dashboard **Swarm Fleet — generated data** - Grafana: `http://localhost:3000` **Swarm Fleet — generated data**
- Prometheus: `http://localhost:9090` · exporter: `http://localhost:9105/metrics` - Prometheus: `http://localhost:9090` · exporter: `http://localhost:9105/metrics`
Panels: telemetry rows by drone/sensor, detections by class, pose frames sent/received, battery per drone, RSSI and estimated distance per link, Parquet bytes/files on disk. The exporter is deliberately a demonstration of the observability doctrine from [07 — Observability](../docs/07-observability.md): fleet statistics are *derived from the data platform itself* — no agent on the drone, just SQL over the same Parquet everyone else reads. Fleet stats derived from Parquet via SQL — no on-drone agent.
## Data-plane explorer ## Data-plane explorer
The `monitoring` profile also starts a **data-plane explorer** at `http://localhost:8088` — a single-page web view over the lake itself, complementing Grafana (which shows aggregates, not the structure): `monitoring` profile also starts explorer at `http://localhost:8088` (k3d: `:30088`):
- **Partition tree**, live: `dataset → flight → drone → sensor → year/…/hour → file`, with file counts and bytes rolled up at every level. You can watch `current/` files appear and get sealed while a swarm is flying. - Partition tree live: `dataset → flight → drone → sensor → hour → file`
- **Read-only SQL console** with the datasets pre-registered as views (`telemetry`, `detections`, `state`) and one-click sample queries (fleet overview, battery timeline, detections by class, peer link quality, schema). - Read-only SQL console — same [statement gate](../docs/04-swarm-sync.md) as peer query channel: `SELECT`/`WITH`/`DESCRIBE`/`SUMMARIZE`/`SHOW` only
The console enforces the **same statement gate as the peer query channel** from [04 — Swarm sync](../docs/04-swarm-sync.md): a single statement, `SELECT`/`WITH`/`DESCRIBE`/`SUMMARIZE`/`SHOW` only, write/config/extension keywords rejected. That is the point: exploring the data plane on the bench uses the same read-only SQL contract a peer drone uses in flight — the explorer is the "no debug API" principle made visible. Point: bench exploration uses same read-only SQL contract as flight peers.
## Reproducibility ## Reproducibility
Every run is deterministic per `(SEED, DRONE_ID)`: same route jitter, same sensor noise, same detection sequence. A bug report is a seed and a config, not a description. Deterministic per `(SEED, DRONE_ID)`. Bug report = seed + config.
## Knobs ## Knobs
+5 -1
View File
@@ -24,7 +24,8 @@ services:
command: ["python", "monitoring/exporter.py"] command: ["python", "monitoring/exporter.py"]
environment: environment:
DATA_DIR: /data DATA_DIR: /data
SCAN_INTERVAL_S: "5" SCAN_INTERVAL_S: "30"
METRIC_FLIGHT_WINDOW: "5"
volumes: volumes:
- ${SWARM_T1_DIR:-../var/t1}:/data:ro - ${SWARM_T1_DIR:-../var/t1}:/data:ro
- ./monitoring:/app/monitoring:ro - ./monitoring:/app/monitoring:ro
@@ -37,6 +38,9 @@ services:
command: ["python", "explorer/server.py"] command: ["python", "explorer/server.py"]
environment: environment:
DATA_DIR: /data DATA_DIR: /data
VIEW_REFRESH_S: "30"
TREE_CACHE_S: "15"
METRIC_FLIGHT_WINDOW: "5"
volumes: volumes:
- ${SWARM_T1_DIR:-../var/t1}:/data:ro - ${SWARM_T1_DIR:-../var/t1}:/data:ro
- ./explorer:/app/explorer:ro - ./explorer:/app/explorer:ro
+87 -36
View File
@@ -1,33 +1,28 @@
"""Data-plane explorer: a web view over the Hive-partitioned Parquet lake. """Data-plane explorer: a web view over the Hive-partitioned Parquet lake."""
Serves three things:
/ single-page UI (partition tree + read-only SQL console)
/api/tree partition hierarchy with file counts and bytes, live
/api/query gated read-only DuckDB SQL, same statement rules as the
peer query channel (SELECT/WITH only, single statement)
The point is doctrinal, not just convenient: the explorer reuses the exact
read-only SQL contract that drones expose to each other, so "looking at the
data plane" on the bench exercises the same path a peer would use in flight.
"""
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import re import re
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
import duckdb import duckdb
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "monitoring"))
from lake import flight_window, iter_parquet_files, parquet_reader # noqa: E402
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
PORT = int(os.environ.get("EXPLORER_PORT", "8088")) PORT = int(os.environ.get("EXPLORER_PORT", "8088"))
ROW_LIMIT = int(os.environ.get("ROW_LIMIT", "500")) ROW_LIMIT = int(os.environ.get("ROW_LIMIT", "500"))
VIEW_REFRESH_S = float(os.environ.get("VIEW_REFRESH_S", "30"))
TREE_CACHE_S = float(os.environ.get("TREE_CACHE_S", "15"))
STATIC_DIR = Path(__file__).parent STATIC_DIR = Path(__file__).parent
# Same spirit as the forced-command gate on a real drone: one statement,
# must be a read, no statement that could write, configure, or reach out.
_ALLOWED_START = re.compile(r"^\s*(SELECT|WITH|DESCRIBE|SUMMARIZE|SHOW)\b", re.IGNORECASE) _ALLOWED_START = re.compile(r"^\s*(SELECT|WITH|DESCRIBE|SUMMARIZE|SHOW)\b", re.IGNORECASE)
_FORBIDDEN = re.compile( _FORBIDDEN = re.compile(
r"\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|ATTACH|DETACH|COPY|EXPORT" r"\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|ATTACH|DETACH|COPY|EXPORT"
@@ -35,9 +30,17 @@ _FORBIDDEN = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
_lock = threading.Lock()
_con: duckdb.DuckDBPyConnection | None = None
_views_at = 0.0
_tree_cache: dict | None = None
_tree_at = 0.0
_last_tree_s = 0.0
_last_query_s = 0.0
_query_count = 0
def gate(sql: str) -> str | None: def gate(sql: str) -> str | None:
"""Return a rejection reason, or None if the statement passes."""
stripped = re.sub(r"--[^\n]*|/\*.*?\*/", " ", sql, flags=re.DOTALL).strip().rstrip(";") stripped = re.sub(r"--[^\n]*|/\*.*?\*/", " ", sql, flags=re.DOTALL).strip().rstrip(";")
if not stripped: if not stripped:
return "empty statement" return "empty statement"
@@ -50,27 +53,39 @@ def gate(sql: str) -> str | None:
return None return None
def connect() -> duckdb.DuckDBPyConnection: def _refresh_views() -> None:
"""Fresh connection with the three datasets pre-registered as views.""" global _con, _views_at
con = duckdb.connect() con = duckdb.connect()
for ds in ("telemetry", "detections", "state"): for ds in ("telemetry", "detections", "state"):
pattern = f"{DATA_DIR}/dataset={ds}/**/*.parquet" reader = parquet_reader(DATA_DIR, ds)
try: try:
con.execute( con.execute(f"CREATE OR REPLACE VIEW {ds} AS SELECT * FROM read_parquet({reader})")
f"CREATE VIEW {ds} AS SELECT * FROM read_parquet("
f"'{pattern}', hive_partitioning=true, union_by_name=true)"
)
except duckdb.Error: except duckdb.Error:
pass # dataset not written yet; view simply won't exist pass
return con with _lock:
if _con is not None:
_con.close()
_con = con
_views_at = time.monotonic()
def connect() -> duckdb.DuckDBPyConnection:
if _con is None or time.monotonic() - _views_at > VIEW_REFRESH_S:
_refresh_views()
assert _con is not None
return _con
def tree() -> dict: def tree() -> dict:
"""Partition hierarchy: dataset -> flight -> drone -> leafs, with sizes.""" global _tree_cache, _tree_at, _last_tree_s
now = time.monotonic()
if _tree_cache is not None and now - _tree_at < TREE_CACHE_S:
return _tree_cache
started = now
root: dict = {} root: dict = {}
total_bytes = 0 total_bytes = 0
total_files = 0 total_files = 0
for f in sorted(DATA_DIR.rglob("*.parquet")): for f in sorted(iter_parquet_files(DATA_DIR)):
rel = f.relative_to(DATA_DIR) rel = f.relative_to(DATA_DIR)
size = f.stat().st_size size = f.stat().st_size
total_bytes += size total_bytes += size
@@ -80,7 +95,6 @@ def tree() -> dict:
node = node.setdefault("children", {}).setdefault(part, {}) node = node.setdefault("children", {}).setdefault(part, {})
leaf = node.setdefault("children", {}).setdefault(rel.parts[-1], {}) leaf = node.setdefault("children", {}).setdefault(rel.parts[-1], {})
leaf["bytes"] = size leaf["bytes"] = size
# roll sizes up the tree
node = root node = root
node["bytes"] = node.get("bytes", 0) + size node["bytes"] = node.get("bytes", 0) + size
node["files"] = node.get("files", 0) + 1 node["files"] = node.get("files", 0) + 1
@@ -88,18 +102,26 @@ def tree() -> dict:
node = node["children"][part] node = node["children"][part]
node["bytes"] = node.get("bytes", 0) + size node["bytes"] = node.get("bytes", 0) + size
node["files"] = node.get("files", 0) + 1 node["files"] = node.get("files", 0) + 1
return {"tree": root, "total_bytes": total_bytes, "total_files": total_files} _last_tree_s = time.monotonic() - started
_tree_cache = {"tree": root, "total_bytes": total_bytes, "total_files": total_files}
_tree_at = now
return _tree_cache
def run_query(sql: str) -> dict: def run_query(sql: str) -> dict:
global _last_query_s, _query_count
reason = gate(sql) reason = gate(sql)
if reason: if reason:
return {"error": f"rejected by read-only gate: {reason}"} return {"error": f"rejected by read-only gate: {reason}"}
started = time.monotonic()
with _lock:
con = connect() con = connect()
try: try:
cur = con.sql(sql) cur = con.sql(sql)
columns = cur.columns columns = cur.columns
rows = cur.fetchmany(ROW_LIMIT) rows = cur.fetchmany(ROW_LIMIT)
_query_count += 1
_last_query_s = time.monotonic() - started
return { return {
"columns": columns, "columns": columns,
"rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows], "rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows],
@@ -107,8 +129,23 @@ def run_query(sql: str) -> dict:
} }
except duckdb.Error as exc: except duckdb.Error as exc:
return {"error": str(exc)} return {"error": str(exc)}
finally:
con.close()
def metrics_text() -> str:
return "\n".join([
"# HELP swarm_explorer_query_duration_seconds Wall time of the last SQL query",
"# TYPE swarm_explorer_query_duration_seconds gauge",
f"swarm_explorer_query_duration_seconds {_last_query_s:.4f}",
"# HELP swarm_explorer_tree_duration_seconds Wall time of the last partition tree build",
"# TYPE swarm_explorer_tree_duration_seconds gauge",
f"swarm_explorer_tree_duration_seconds {_last_tree_s:.4f}",
"# HELP swarm_explorer_queries_total Read-only queries served",
"# TYPE swarm_explorer_queries_total counter",
f"swarm_explorer_queries_total {_query_count}",
"# HELP swarm_metric_flight_window Flight partitions in DuckDB views",
"# TYPE swarm_metric_flight_window gauge",
f"swarm_metric_flight_window {flight_window()}",
]) + "\n"
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -116,13 +153,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", ctype) self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
# Dev CORS: lets the prototype's live mode poll the API from another
# origin. Everything behind this is read-only by construction.
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def do_OPTIONS(self) -> None: # noqa: N802 — http.server API def do_OPTIONS(self) -> None: # noqa: N802
self.send_response(204) self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
@@ -132,15 +167,17 @@ class Handler(BaseHTTPRequestHandler):
def _json(self, payload: dict, code: int = 200) -> None: def _json(self, payload: dict, code: int = 200) -> None:
self._send(code, json.dumps(payload, default=str).encode(), "application/json") self._send(code, json.dumps(payload, default=str).encode(), "application/json")
def do_GET(self) -> None: # noqa: N802 — http.server API def do_GET(self) -> None: # noqa: N802
if self.path in ("/", "/index.html"): if self.path in ("/", "/index.html"):
self._send(200, (STATIC_DIR / "index.html").read_bytes(), "text/html; charset=utf-8") self._send(200, (STATIC_DIR / "index.html").read_bytes(), "text/html; charset=utf-8")
elif self.path == "/api/tree": elif self.path == "/api/tree":
self._json(tree()) self._json(tree())
elif self.path == "/metrics":
self._send(200, metrics_text().encode(), "text/plain; version=0.0.4")
else: else:
self._send(404, b"not found", "text/plain") self._send(404, b"not found", "text/plain")
def do_POST(self) -> None: # noqa: N802 — http.server API def do_POST(self) -> None: # noqa: N802
if self.path != "/api/query": if self.path != "/api/query":
self._send(404, b"not found", "text/plain") self._send(404, b"not found", "text/plain")
return return
@@ -156,6 +193,20 @@ class Handler(BaseHTTPRequestHandler):
pass pass
def _view_loop() -> None:
while True:
try:
_refresh_views()
except Exception:
pass
time.sleep(VIEW_REFRESH_S)
if __name__ == "__main__": if __name__ == "__main__":
print(f"data-plane explorer on :{PORT}, reading {DATA_DIR}") _refresh_views()
threading.Thread(target=_view_loop, daemon=True).start()
print(
f"data-plane explorer on :{PORT}, views refresh every {VIEW_REFRESH_S}s, "
f"last {flight_window()} flights, reading {DATA_DIR}"
)
ThreadingHTTPServer(("", PORT), Handler).serve_forever() ThreadingHTTPServer(("", PORT), Handler).serve_forever()
+49 -34
View File
@@ -1,13 +1,14 @@
"""Prometheus exporter over the simulator's Parquet output. """Prometheus exporter over the simulator's Parquet output.
Periodically scans DATA_DIR with DuckDB and exposes fleet statistics as Periodically scans DATA_DIR with DuckDB and exposes fleet statistics as
/metrics. Zero dependencies beyond duckdb: the exposition format is plain /metrics. Scans only the most recent flight partitions by default so CPU
text, served with the standard library HTTP server. stays bounded as the lake grows across pod restarts.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import sys
import threading import threading
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -15,27 +16,29 @@ from pathlib import Path
import duckdb import duckdb
sys.path.insert(0, str(Path(__file__).resolve().parent))
from lake import flight_window, iter_parquet_files, parquet_reader # noqa: E402
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
PORT = int(os.environ.get("EXPORTER_PORT", "9105")) PORT = int(os.environ.get("EXPORTER_PORT", "9105"))
SCAN_INTERVAL_S = float(os.environ.get("SCAN_INTERVAL_S", "5")) SCAN_INTERVAL_S = float(os.environ.get("SCAN_INTERVAL_S", "15"))
_lock = threading.Lock() _lock = threading.Lock()
_payload = "# swarm exporter starting\n" _payload = "# swarm exporter starting\n"
_last_scan_s = 0.0
def _q(con: duckdb.DuckDBPyConnection, sql: str) -> list[tuple]: def _q(con: duckdb.DuckDBPyConnection, sql: str) -> list[tuple]:
try: try:
return con.sql(sql).fetchall() return con.sql(sql).fetchall()
except duckdb.Error: except duckdb.Error:
return [] # partitions may not exist yet while the swarm warms up return []
def collect() -> str: def collect() -> str:
global _last_scan_s
started = time.monotonic()
con = duckdb.connect() con = duckdb.connect()
# union_by_name: sensors have different schemas under one dataset glob
glob = lambda ds: ( # noqa: E731
f"'{DATA_DIR}/dataset={ds}/**/*.parquet', hive_partitioning=true, union_by_name=true"
)
lines: list[str] = [] lines: list[str] = []
def metric(name: str, help_text: str, mtype: str, rows: list[str]) -> None: def metric(name: str, help_text: str, mtype: str, rows: list[str]) -> None:
@@ -43,27 +46,38 @@ def collect() -> str:
lines.append(f"# TYPE {name} {mtype}") lines.append(f"# TYPE {name} {mtype}")
lines.extend(rows) lines.extend(rows)
for ds, name in (
("telemetry", "swarm_rows_total"),
("detections", "swarm_detections_total"),
("state", "swarm_state_frames_total"),
):
reader = parquet_reader(DATA_DIR, ds)
if ds == "telemetry":
metric( metric(
"swarm_rows_total", "Telemetry rows written per drone and sensor", "gauge", name, f"Rows in dataset={ds} (recent {flight_window()} flights)", "gauge",
[f'swarm_rows_total{{drone="{d}",sensor="{s}"}} {n}' [f'{name}{{drone="{d}",sensor="{s}"}} {n}'
for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({glob('telemetry')}) GROUP BY 1,2")], for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
) )
elif ds == "detections":
metric( metric(
"swarm_detections_total", "Detection events per drone and class", "gauge", name, "Detection events per drone and class", "gauge",
[f'swarm_detections_total{{drone="{d}",cls="{c}"}} {n}' [f'{name}{{drone="{d}",cls="{c}"}} {n}'
for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({glob('detections')}) GROUP BY 1,2")], for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
) )
else:
metric( metric(
"swarm_state_frames_total", "Pose broadcast frames per drone and direction", "gauge", name, "Pose broadcast frames per drone and direction", "gauge",
[f'swarm_state_frames_total{{drone="{d}",direction="{dr}"}} {n}' [f'{name}{{drone="{d}",direction="{dr}"}} {n}'
for d, dr, n in _q(con, f"SELECT drone, direction, count(*) FROM read_parquet({glob('state')}) GROUP BY 1,2")], for d, dr, n in _q(con, f"SELECT drone, direction, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
) )
telem = parquet_reader(DATA_DIR, "telemetry")
metric( metric(
"swarm_battery_pct", "Latest battery level per drone", "gauge", "swarm_battery_pct", "Latest battery level per drone", "gauge",
[f'swarm_battery_pct{{drone="{d}"}} {v}' [f'swarm_battery_pct{{drone="{d}"}} {v}'
for d, v in _q(con, f""" for d, v in _q(con, f"""
SELECT drone, arg_max(level_pct, ts_ns) SELECT drone, arg_max(level_pct, ts_ns)
FROM read_parquet({glob('telemetry')}) FROM read_parquet({telem})
WHERE sensor='battery' GROUP BY drone""")], WHERE sensor='battery' GROUP BY drone""")],
) )
metric( metric(
@@ -71,26 +85,24 @@ def collect() -> str:
[f'swarm_rssi_dbm{{drone="{d}",peer="{p}"}} {v}' [f'swarm_rssi_dbm{{drone="{d}",peer="{p}"}} {v}'
for d, p, v in _q(con, f""" for d, p, v in _q(con, f"""
SELECT drone, peer_id, arg_max(rssi_dbm, ts_ns) SELECT drone, peer_id, arg_max(rssi_dbm, ts_ns)
FROM read_parquet({glob('telemetry')}) FROM read_parquet({telem})
WHERE sensor='rssi' GROUP BY drone, peer_id""")],
)
metric(
"swarm_peer_distance_m", "Latest inter-drone distance estimate", "gauge",
[f'swarm_peer_distance_m{{drone="{d}",peer="{p}"}} {v}'
for d, p, v in _q(con, f"""
SELECT drone, peer_id, arg_max(distance_m, ts_ns)
FROM read_parquet({glob('telemetry')})
WHERE sensor='rssi' GROUP BY drone, peer_id""")], WHERE sensor='rssi' GROUP BY drone, peer_id""")],
) )
files = list(DATA_DIR.rglob("*.parquet")) files = iter_parquet_files(DATA_DIR)
metric( metric(
"swarm_parquet_bytes", "Bytes on disk per dataset", "gauge", "swarm_parquet_bytes", "Bytes on disk per dataset (recent flights)", "gauge",
[f'swarm_parquet_bytes{{dataset="{ds}"}} {sum(f.stat().st_size for f in files if f"dataset={ds}" in str(f))}' [f'swarm_parquet_bytes{{dataset="{ds}"}} {sum(f.stat().st_size for f in files if f"dataset={ds}" in str(f))}'
for ds in ("telemetry", "detections", "state")], for ds in ("telemetry", "detections", "state")],
) )
metric("swarm_parquet_files", "Parquet files on disk", "gauge", metric("swarm_parquet_files", "Parquet files scanned (recent flights)", "gauge",
[f"swarm_parquet_files {len(files)}"]) [f"swarm_parquet_files {len(files)}"])
metric("swarm_metric_flight_window", "Flight partitions included per dataset", "gauge",
[f"swarm_metric_flight_window {flight_window()}"])
_last_scan_s = time.monotonic() - started
metric("swarm_exporter_scan_duration_seconds", "Wall time of the last metrics scan", "gauge",
[f"swarm_exporter_scan_duration_seconds {_last_scan_s:.4f}"])
con.close() con.close()
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@@ -101,11 +113,11 @@ def scanner() -> None:
started = time.monotonic() started = time.monotonic()
try: try:
payload = collect() payload = collect()
except Exception as exc: # keep serving stale metrics over dying except Exception as exc:
payload = f"# collect error: {exc}\n" payload = f"# collect error: {exc}\n"
with _lock: with _lock:
_payload = payload _payload = payload
time.sleep(max(0.5, SCAN_INTERVAL_S - (time.monotonic() - started))) time.sleep(max(1.0, SCAN_INTERVAL_S - (time.monotonic() - started)))
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -123,10 +135,13 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def log_message(self, *_args: object) -> None: def log_message(self, *_args: object) -> None:
pass # scrapes every few seconds; keep the log quiet pass
if __name__ == "__main__": if __name__ == "__main__":
threading.Thread(target=scanner, daemon=True).start() threading.Thread(target=scanner, daemon=True).start()
print(f"swarm exporter on :{PORT}/metrics, scanning {DATA_DIR} every {SCAN_INTERVAL_S}s") print(
f"swarm exporter on :{PORT}/metrics, scanning last {flight_window()} flights "
f"every {SCAN_INTERVAL_S}s under {DATA_DIR}"
)
ThreadingHTTPServer(("", PORT), Handler).serve_forever() ThreadingHTTPServer(("", PORT), Handler).serve_forever()
@@ -0,0 +1,90 @@
{
"uid": "swarm-platform",
"title": "Swarm Platform — CPU & scan health",
"tags": ["swarm", "platform"],
"timezone": "utc",
"schemaVersion": 39,
"version": 1,
"refresh": "10s",
"time": { "from": "now-30m", "to": "now" },
"panels": [
{
"id": 1, "type": "timeseries", "title": "Node CPU %",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[1m])) * 100)",
"legendFormat": "{{instance}}", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
},
{
"id": 2, "type": "timeseries", "title": "Node memory used %",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"legendFormat": "used", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
},
{
"id": 3, "type": "timeseries", "title": "Exporter scan duration",
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "swarm_exporter_scan_duration_seconds",
"legendFormat": "scan seconds", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"id": 4, "type": "timeseries", "title": "Explorer query duration",
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 8 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "swarm_explorer_query_duration_seconds",
"legendFormat": "last query", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"id": 5, "type": "timeseries", "title": "Explorer tree build duration",
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 8 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "swarm_explorer_tree_duration_seconds",
"legendFormat": "tree seconds", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"id": 6, "type": "stat", "title": "Parquet files scanned",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "swarm_parquet_files", "instant": true, "refId": "A" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"id": 7, "type": "stat", "title": "Flight window",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "swarm_metric_flight_window", "instant": true, "refId": "A" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"id": 8, "type": "stat", "title": "Telemetry rows (window)",
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "sum(swarm_rows_total)", "instant": true, "refId": "A" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"id": 9, "type": "stat", "title": "Min fleet battery %",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "min(swarm_battery_pct)", "instant": true, "refId": "A" }],
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
}
]
}
+43
View File
@@ -0,0 +1,43 @@
"""Scan the Hive-partitioned lake without re-reading every historical flight."""
from __future__ import annotations
import os
from pathlib import Path
def flight_window() -> int:
return max(1, int(os.environ.get("METRIC_FLIGHT_WINDOW", "5")))
def recent_flights(data_dir: Path, dataset: str, limit: int | None = None) -> list[Path]:
"""Most recently modified flight= partitions for a dataset."""
limit = limit or flight_window()
base = data_dir / f"dataset={dataset}"
if not base.is_dir():
return []
flights = [p for p in base.iterdir() if p.is_dir() and p.name.startswith("flight=")]
flights.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return flights[:limit]
def parquet_reader(data_dir: Path, dataset: str, *, limit: int | None = None) -> str:
"""DuckDB read_parquet() source limited to recent flights."""
flights = recent_flights(data_dir, dataset, limit)
if not flights:
path = data_dir / f"dataset={dataset}" / "**" / "*.parquet"
return f"'{path}', hive_partitioning=true, union_by_name=true"
if len(flights) == 1:
return f"'{flights[0]}/**/*.parquet', hive_partitioning=true, union_by_name=true"
inner = ", ".join(f"'{f}/**/*.parquet'" for f in flights)
return f"[{inner}], hive_partitioning=true, union_by_name=true"
def iter_parquet_files(data_dir: Path, *, flight_limit: int | None = None) -> list[Path]:
"""Parquet paths under recent flights only — avoids full-lake rglob."""
limit = flight_limit or flight_window()
out: list[Path] = []
for ds in ("telemetry", "detections", "state"):
for flight in recent_flights(data_dir, ds, limit):
out.extend(flight.rglob("*.parquet"))
return out
+4 -1
View File
@@ -1,7 +1,10 @@
global: global:
scrape_interval: 5s scrape_interval: 15s
scrape_configs: scrape_configs:
- job_name: swarm - job_name: swarm
static_configs: static_configs:
- targets: ["exporter:9105"] - targets: ["exporter:9105"]
- job_name: explorer
static_configs:
- targets: ["explorer:8088"]
+70
View File
@@ -0,0 +1,70 @@
"""Tests for bin/check_adrs.py — ADR immutability gate."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPTS = Path(__file__).resolve().parents[2] / "bin"
sys.path.insert(0, str(SCRIPTS))
from check_adrs import find_modified_adrs, run_check # noqa: E402
def _git(cwd: Path, *args: str) -> None:
subprocess.check_call(["git", *args], cwd=cwd, stdout=subprocess.DEVNULL)
def test_no_modified_adrs_on_current_branch() -> None:
assert run_check(base="origin/main") == 0
def test_detects_adr_edit_in_sandbox(tmp_path: Path) -> None:
_git(tmp_path, "init")
_git(tmp_path, "config", "user.email", "ci@test")
_git(tmp_path, "config", "user.name", "ci")
adr = tmp_path / "docs/adr"
adr.mkdir(parents=True)
(adr / "ADR-0001-test.md").write_text("v1\n")
_git(tmp_path, "add", ".")
_git(tmp_path, "commit", "-m", "add adr")
_git(tmp_path, "branch", "-M", "main")
(adr / "ADR-0001-test.md").write_text("v2\n")
_git(tmp_path, "checkout", "-b", "feature")
_git(tmp_path, "add", ".")
_git(tmp_path, "commit", "-m", "edit adr")
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr("check_adrs.ROOT", tmp_path)
try:
assert find_modified_adrs("main") == ["docs/adr/ADR-0001-test.md"]
assert run_check(base="main") == 1
finally:
monkeypatch.undo()
def test_new_adr_allowed(tmp_path: Path) -> None:
_git(tmp_path, "init")
_git(tmp_path, "config", "user.email", "ci@test")
_git(tmp_path, "config", "user.name", "ci")
adr = tmp_path / "docs/adr"
adr.mkdir(parents=True)
(adr / "ADR-0001-test.md").write_text("v1\n")
_git(tmp_path, "add", ".")
_git(tmp_path, "commit", "-m", "add adr")
_git(tmp_path, "branch", "-M", "main")
(adr / "ADR-0002-new.md").write_text("new\n")
_git(tmp_path, "checkout", "-b", "feature")
_git(tmp_path, "add", ".")
_git(tmp_path, "commit", "-m", "add adr 2")
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr("check_adrs.ROOT", tmp_path)
try:
assert find_modified_adrs("main") == []
assert run_check(base="main") == 0
finally:
monkeypatch.undo()
+77
View File
@@ -0,0 +1,77 @@
"""Tests for bin/check_docs.py — documentation CI rules."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
SCRIPTS = Path(__file__).resolve().parents[2] / "bin"
sys.path.insert(0, str(SCRIPTS))
from check_docs import ( # noqa: E402
collect_anchors,
run_checks,
slug_heading,
split_link_target,
)
ROOT = Path(__file__).resolve().parents[2]
GLOSSARY = ROOT / "docs/00-glossary.md"
def test_slug_heading_matches_glossary_anchors() -> None:
assert slug_heading("T0 — hot") == "t0--hot"
assert slug_heading("T3 — warehouse") == "t3--warehouse"
assert slug_heading("Source of truth") == "source-of-truth"
assert slug_heading("Data & storage") == "data--storage"
def test_glossary_term_anchors_exist() -> None:
anchors = collect_anchors(GLOSSARY)
for term in (
"t0--hot",
"t1--warm",
"t2--shared",
"t3--warehouse",
"t4--dev",
"source-of-truth",
"bulk-sync",
"offload",
):
assert term in anchors, f"missing glossary anchor #{term}"
def test_split_link_target() -> None:
assert split_link_target("foo.md#bar") == ("foo.md", "bar")
assert split_link_target("foo.md") == ("foo.md", None)
def test_repo_docs_pass() -> None:
assert run_checks() == 0
def test_broken_link_detected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "00-glossary.md").write_text("# Glossary\n\n## Data & storage\n")
(docs / "bad.md").write_text("See [missing](missing.md).\n")
rules = tmp_path / "bin"
rules.mkdir()
(rules / "docs_rules.json").write_text(
json.dumps(
{
"markdown_paths": ["docs/**/*.md"],
"glossary": {"file": "docs/00-glossary.md"},
"banned_link_targets": [],
"glossary_section_anchors": [],
"preferred_term_anchors": [],
"readability": {"forbid_bare_see_refs": False},
}
)
)
monkeypatch.setattr("check_docs.ROOT", tmp_path)
monkeypatch.setattr("check_docs.RULES_PATH", rules / "docs_rules.json")
assert run_checks() == 1
+40
View File
@@ -0,0 +1,40 @@
"""Lake scan helpers — bounded to recent flight partitions."""
from __future__ import annotations
from pathlib import Path
from monitoring.lake import flight_window, iter_parquet_files, parquet_reader, recent_flights
def test_parquet_reader_limits_to_recent_flights(tmp_path: Path) -> None:
for i, name in enumerate(("flight=aaa", "flight=bbb", "flight=ccc")):
hour = tmp_path / f"dataset=telemetry/{name}/drone=dr-01/sensor=imu/year=2026/month=07/day=08/hour=10"
hour.mkdir(parents=True)
f = hour / "data.parquet"
f.write_bytes(b"x" * (i + 1))
# Make later names newer
import os
import time
os.utime(f, (time.time() + i, time.time() + i))
reader = parquet_reader(tmp_path, "telemetry", limit=1)
assert "flight=ccc" in reader
assert "flight=bbb" not in reader
def test_iter_parquet_files_skips_old_flights(tmp_path: Path) -> None:
old = tmp_path / "dataset=state/flight=old/drone=dr-01/year=2026/month=07/day=08/hour=09"
old.mkdir(parents=True)
(old / "data.parquet").write_bytes(b"old")
new = tmp_path / "dataset=state/flight=new/drone=dr-01/year=2026/month=07/day=08/hour=10"
new.mkdir(parents=True)
new_file = new / "data.parquet"
new_file.write_bytes(b"new")
import os
import time
os.utime(new_file, (time.time() + 10, time.time() + 10))
files = iter_parquet_files(tmp_path, flight_limit=1)
assert len(files) == 1
assert "flight=new" in str(files[0])
+5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
import random import random
import select import select
import time import time
@@ -108,6 +109,10 @@ def run(cfg: Config) -> None:
writer.seal() writer.seal()
print(f"[{cfg.drone_id}] done: {frames_sent} state frames sent, " print(f"[{cfg.drone_id}] done: {frames_sent} state frames sent, "
f"{len(peers_seen)} peers seen {sorted(peers_seen)}; sealed to {root}") f"{len(peers_seen)} peers seen {sorted(peers_seen)}; sealed to {root}")
if os.environ.get("KEEP_ALIVE", "0") == "1":
print(f"[{cfg.drone_id}] KEEP_ALIVE=1 — idle after seal (no pod restart churn)")
while True:
time.sleep(3600)
if __name__ == "__main__": if __name__ == "__main__":