Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
302de4024d | ||
|
|
826cef0c3e | ||
|
|
df04736336 | ||
|
|
b4de3ed076 | ||
|
|
10a18f1321 | ||
|
|
bed6f93626 | ||
|
|
e6e57843cb | ||
|
|
9bec50e8f7 | ||
|
|
5f6fd8462c | ||
|
|
ed531f9fb6 | ||
|
|
336f9c0428 | ||
|
|
3d13f744e6 | ||
|
|
dfb0556eda | ||
|
|
d3ccd6f94e | ||
|
|
ea062af86a | ||
|
|
bcd956d11a | ||
|
|
9f99d132e3 |
@@ -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.
|
||||||
@@ -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. -->
|
||||||
@@ -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. -->
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 T0–T4, 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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 (T0–T4), 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
Terms and abbreviations used throughout this proposal.
|
Terms and abbreviations used throughout this proposal.
|
||||||
|
|
||||||
|
**Vocabulary guide:** **architecture layers** (1–3) = on-board software stack; **storage floors** (T0–T4) = 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 T0–T4 ([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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) T0–T4 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
@@ -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 T0–T4 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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 T0–T2. Nothing here requires ground contact during a mission.
|
- Storage floors [T0–T2](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 |
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) (T0–T4)
|
||||||
|
|
||||||
|
## 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) T0–T4:
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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`).
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Documentation audit — 2026-07-10
|
||||||
|
|
||||||
|
Manual pass over `docs/`, all READMEs, and glossary. Goal: correct links, unify T0–T4 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 (T0–T4, 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 T0–T4, 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`.
|
||||||
@@ -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 T0–T4; 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.
|
||||||
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -323,7 +369,91 @@ resource "kubernetes_config_map" "grafana_dashboard" {
|
|||||||
namespace = kubernetes_namespace.swarm.metadata[0].name
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
}
|
}
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
+14
-15
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,27 +102,50 @@ 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}"}
|
||||||
con = connect()
|
started = time.monotonic()
|
||||||
try:
|
with _lock:
|
||||||
cur = con.sql(sql)
|
con = connect()
|
||||||
columns = cur.columns
|
try:
|
||||||
rows = cur.fetchmany(ROW_LIMIT)
|
cur = con.sql(sql)
|
||||||
return {
|
columns = cur.columns
|
||||||
"columns": columns,
|
rows = cur.fetchmany(ROW_LIMIT)
|
||||||
"rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows],
|
_query_count += 1
|
||||||
"truncated": len(rows) == ROW_LIMIT,
|
_last_query_s = time.monotonic() - started
|
||||||
}
|
return {
|
||||||
except duckdb.Error as exc:
|
"columns": columns,
|
||||||
return {"error": str(exc)}
|
"rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows],
|
||||||
finally:
|
"truncated": len(rows) == ROW_LIMIT,
|
||||||
con.close()
|
}
|
||||||
|
except duckdb.Error as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
metric(
|
for ds, name in (
|
||||||
"swarm_rows_total", "Telemetry rows written per drone and sensor", "gauge",
|
("telemetry", "swarm_rows_total"),
|
||||||
[f'swarm_rows_total{{drone="{d}",sensor="{s}"}} {n}'
|
("detections", "swarm_detections_total"),
|
||||||
for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({glob('telemetry')}) GROUP BY 1,2")],
|
("state", "swarm_state_frames_total"),
|
||||||
)
|
):
|
||||||
metric(
|
reader = parquet_reader(DATA_DIR, ds)
|
||||||
"swarm_detections_total", "Detection events per drone and class", "gauge",
|
if ds == "telemetry":
|
||||||
[f'swarm_detections_total{{drone="{d}",cls="{c}"}} {n}'
|
metric(
|
||||||
for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({glob('detections')}) GROUP BY 1,2")],
|
name, f"Rows in dataset={ds} (recent {flight_window()} flights)", "gauge",
|
||||||
)
|
[f'{name}{{drone="{d}",sensor="{s}"}} {n}'
|
||||||
metric(
|
for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
|
||||||
"swarm_state_frames_total", "Pose broadcast frames per drone and direction", "gauge",
|
)
|
||||||
[f'swarm_state_frames_total{{drone="{d}",direction="{dr}"}} {n}'
|
elif ds == "detections":
|
||||||
for d, dr, n in _q(con, f"SELECT drone, direction, count(*) FROM read_parquet({glob('state')}) GROUP BY 1,2")],
|
metric(
|
||||||
)
|
name, "Detection events per drone and class", "gauge",
|
||||||
|
[f'{name}{{drone="{d}",cls="{c}"}} {n}'
|
||||||
|
for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metric(
|
||||||
|
name, "Pose broadcast frames per drone and direction", "gauge",
|
||||||
|
[f'{name}{{drone="{d}",direction="{dr}"}} {n}'
|
||||||
|
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": [] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user