Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6b50929c | ||
|
|
ed531f9fb6 | ||
|
|
336f9c0428 | ||
|
|
3d13f744e6 | ||
|
|
dfb0556eda | ||
|
|
d3ccd6f94e | ||
|
|
ea062af86a | ||
|
|
bcd956d11a | ||
|
|
9f99d132e3 | ||
|
|
669e8ff005 | ||
|
|
de1c7c9558 | ||
|
|
ad7e0a5cbe | ||
|
|
cb4664f5ee | ||
|
|
d58569b25c | ||
|
|
eef6623f39 | ||
|
|
f008da1427 | ||
|
|
407a70fef1 |
@@ -30,6 +30,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install --quiet -r simulator/requirements.txt
|
run: pip install --quiet -r simulator/requirements.txt
|
||||||
|
|
||||||
|
- name: Unit tests (TDD gate)
|
||||||
|
working-directory: simulator
|
||||||
|
run: pytest tests/ -q
|
||||||
|
|
||||||
- name: Compile check
|
- name: Compile check
|
||||||
run: python -m compileall -q simulator/virtual_drone simulator/monitoring simulator/explorer
|
run: python -m compileall -q simulator/virtual_drone simulator/monitoring simulator/explorer
|
||||||
|
|
||||||
@@ -49,26 +53,6 @@ jobs:
|
|||||||
print(f"smoke flight OK: {rows} telemetry rows")
|
print(f"smoke flight OK: {rows} telemetry rows")
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: SQL gate tests
|
|
||||||
working-directory: simulator
|
|
||||||
run: |
|
|
||||||
python - <<'EOF'
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, "explorer")
|
|
||||||
from server import gate
|
|
||||||
|
|
||||||
assert gate("SELECT 1") is None
|
|
||||||
assert gate("WITH t AS (SELECT 1) SELECT * FROM t") is None
|
|
||||||
assert gate("DESCRIBE telemetry") is None
|
|
||||||
assert gate("DROP TABLE telemetry") is not None
|
|
||||||
assert gate("SELECT 1; SELECT 2") is not None
|
|
||||||
assert gate("INSTALL httpfs") is not None
|
|
||||||
assert gate("SET memory_limit='1GB'") is not None
|
|
||||||
assert gate("/* sneaky */ COPY t TO 'x'") is not None
|
|
||||||
assert gate("") is not None
|
|
||||||
print("SQL gate OK: reads pass, writes and config are rejected")
|
|
||||||
EOF
|
|
||||||
|
|
||||||
scan:
|
scan:
|
||||||
name: Trivy scan
|
name: Trivy scan
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -141,6 +125,7 @@ jobs:
|
|||||||
TAG="${{ steps.version.outputs.new_tag }}"
|
TAG="${{ steps.version.outputs.new_tag }}"
|
||||||
mkdir -p .tmp
|
mkdir -p .tmp
|
||||||
tar --exclude='.git' --exclude='.tmp' --exclude='simulator/data' \
|
tar --exclude='.git' --exclude='.tmp' --exclude='simulator/data' \
|
||||||
|
--exclude='var/t1' --exclude='var/t3' \
|
||||||
--exclude='simulator/.venv' --exclude='prototype/node_modules' \
|
--exclude='simulator/.venv' --exclude='prototype/node_modules' \
|
||||||
-czf ".tmp/swarm-house-${TAG}.tar.gz" .
|
-czf ".tmp/swarm-house-${TAG}.tar.gz" .
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
data/
|
data/
|
||||||
|
simulator/data/
|
||||||
|
var/t1/**
|
||||||
|
var/t3/**
|
||||||
|
!var/t1/.gitkeep
|
||||||
|
!var/t3/.gitkeep
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
.terraform/
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ Rules for anyone (human or tooling) contributing to this repository.
|
|||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
docs/ Design documentation (Markdown + Mermaid)
|
docs/ Design documentation (Markdown + Mermaid)
|
||||||
simulator/ Virtual drone fleet — Python data generator (Parquet/DuckDB pipeline)
|
simulator/ Virtual drone fleet — Python data generator (Parquet/DuckDB pipeline)
|
||||||
prototype/ 2D swarm visualization — React + TypeScript (Vite)
|
prototype/ 2D swarm visualization — React + TypeScript (Vite)
|
||||||
|
infra/ Ansible host prep, Terraform workloads, Flux GitOps overlays
|
||||||
|
var/t1/ Shared T1 live lake (gitignored runtime data)
|
||||||
|
var/t3/ Shared T3 warehouse (gitignored runtime data)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development workflow and TDD requirements.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Markdown: ATX headings, fenced code blocks with language tags, pipe tables.
|
- Markdown: ATX headings, fenced code blocks with language tags, pipe tables.
|
||||||
@@ -24,3 +29,52 @@ prototype/ 2D swarm visualization — React + TypeScript (Vite)
|
|||||||
- 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thank you for helping improve Swarm House. This repository is a self-contained
|
||||||
|
design proposal for an on-prem data platform for autonomous drone swarms — all
|
||||||
|
contributions must follow the rules in [`AGENTS.md`](AGENTS.md).
|
||||||
|
|
||||||
|
## TDD first (required)
|
||||||
|
|
||||||
|
Every change to executable code ships with tests **before** or **alongside** the
|
||||||
|
implementation — never as an afterthought.
|
||||||
|
|
||||||
|
1. **Red:** write or extend a failing test that captures the behaviour you need.
|
||||||
|
2. **Green:** implement the smallest change that makes it pass.
|
||||||
|
3. **Refactor:** clean up without weakening the test.
|
||||||
|
|
||||||
|
The CI pipeline enforces this:
|
||||||
|
|
||||||
|
| Gate | When it runs |
|
||||||
|
| --- | --- |
|
||||||
|
| `pytest tests/` | Every push/PR (`verify` job) |
|
||||||
|
| `RUN pytest tests/` in the simulator Docker build | Every `docker build` — a red test blocks the image |
|
||||||
|
| Smoke flight + DuckDB assertion | Every push/PR |
|
||||||
|
| Trivy scan | After verify passes |
|
||||||
|
|
||||||
|
Test locations:
|
||||||
|
|
||||||
|
- `simulator/tests/` — Python unit tests (SQL gate, broadcast codec, writer layout, flight kinematics)
|
||||||
|
- Prototype: `npm run build` / `tsc --noEmit` before UI changes
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
### Fast inner loop (Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd simulator
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pytest tests/ -q # run tests first
|
||||||
|
DRONE_COUNT=5 docker compose up --build # writes to ../var/t1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full ground miniature (k3d + Terraform + Flux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook infra/ansible/sim-cluster.yml
|
||||||
|
cd infra/terraform/sim-env && terraform init && terraform apply
|
||||||
|
cd ../ground && terraform init && terraform apply
|
||||||
|
cd ../../prototype && npm install && npm run dev # press "go live"
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared data directories (gitignored, created automatically):
|
||||||
|
|
||||||
|
| Path | Tier | Contents |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `var/t1/` | T1 live lake | In-flight Parquet from Compose or k3d fleet |
|
||||||
|
| `var/t3/` | T3 warehouse | Offloaded historical partitions |
|
||||||
|
|
||||||
|
Override with `SWARM_T1_DIR` / `SWARM_WAREHOUSE_DATA` / `SWARM_SIM_DATA` env vars.
|
||||||
|
|
||||||
|
### Prototype only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd prototype && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Conventional Commits only:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add warehouse offload cron schedule variable
|
||||||
|
fix: reject COPY statements in the SQL gate
|
||||||
|
docs: clarify WireGuard vs SSH layering
|
||||||
|
test: cover broadcast frame roundtrip
|
||||||
|
```
|
||||||
|
|
||||||
|
Never include AI tool references, `Co-authored-by` trailers for assistants, or
|
||||||
|
identifying names of people, companies, or locations.
|
||||||
|
|
||||||
|
## Pull request checklist
|
||||||
|
|
||||||
|
- [ ] Tests added or updated; `pytest tests/ -q` passes locally
|
||||||
|
- [ ] `docker build` in `simulator/` succeeds (test stage included)
|
||||||
|
- [ ] 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/))
|
||||||
|
- [ ] English only; no identifying references
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
Open design questions live in [`docs/09-open-questions.md`](docs/09-open-questions.md).
|
||||||
|
Architecture boundaries are documented in [`docs/06-environments.md`](docs/06-environments.md#iac-boundaries).
|
||||||
@@ -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.
|
||||||
@@ -17,6 +17,25 @@ This repository describes how to build, deliver, test, and operate that platform
|
|||||||
7. **SQL is the contract.** Peer data access is read-only DuckDB SQL over SSH forced commands — the query language already lives on both ends, so no service, port, or protocol is invented for it.
|
7. **SQL is the contract.** Peer data access is read-only DuckDB SQL over SSH forced commands — the query language already lives on both ends, so no service, port, or protocol is invented for it.
|
||||||
8. **No debug API.** The bench and the flight use the same channel: an engineer debugging on the ground runs the identical query through the identical wrapper, permissions, and output format a peer drone would use. What you test is what flies.
|
8. **No debug API.** The bench and the flight use the same channel: an engineer debugging on the ground runs the identical query through the identical wrapper, permissions, and output format a peer drone would use. What you test is what flies.
|
||||||
|
|
||||||
|
None of the concrete tool picks above are mandates. This repo is a **from-scratch platform sketch**: enough structure to hire and build against, with every decision recorded so the team can replace a piece when a better fit appears.
|
||||||
|
|
||||||
|
## Implemented now vs proposed next
|
||||||
|
|
||||||
|
Honest map so a reader knows what runs today versus what is design intent.
|
||||||
|
|
||||||
|
| Area | Implemented in this repo (runnable PoC) | Proposed for a production air-gapped fleet |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| On-board layout | Hive-partitioned Parquet writer, seal step, DuckDB views | Same contract; Compose services under systemd |
|
||||||
|
| Pose path | Fixed **45-byte** UDP frame + 2D bandwidth visualisation | Zenoh pub/sub (UDP kept as degraded minimal profile) |
|
||||||
|
| Peer query | Read-only SQL **gate** over HTTP explorer (keyword allow-list) | Same gate idea via **SSH forced command** + OS/engine hardening |
|
||||||
|
| Bulk sync | Visualised opportunistic transfer volume | rsync/rclone over persistent SSH between peers |
|
||||||
|
| Mesh trust | Ansible templates for WireGuard + ed25519 forced commands | Provisioned per-device keys; nothing joins at runtime |
|
||||||
|
| Ground segment | k3d/Terraform sim: lake, Grafana, offload CronJob, optional MinIO | k3s warehouse, GitOps overlays, post-flight mirror |
|
||||||
|
| CI / delivery | GitHub Actions: pytest, smoke flight, Trivy, semver release + fleet manifest artifact | Self-hosted GitLab + registry inside the air gap (same stages) |
|
||||||
|
| Docs | Problem, architecture, ADRs, design journey, open questions | Living ADRs owned by the team |
|
||||||
|
|
||||||
|
Start from the [design journey](docs/12-design-journey.md) for the story; use the table above when reviewing scope.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Contents |
|
| Document | Contents |
|
||||||
@@ -33,24 +52,49 @@ 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 here? Read the [design journey](docs/12-design-journey.md) first: it tells the story chronologically and links straight into the code and decisions. The design principles below are the *what*; the [ADRs](docs/adr/) are the *why and when* — each principle traces to a dated, immutable decision record.
|
||||||
|
|
||||||
## Runnable parts
|
## Runnable parts
|
||||||
|
|
||||||
| Component | Purpose | Stack |
|
| Component | Purpose | Stack |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [`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 | 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/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/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).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Spin up a virtual swarm of 10 drones
|
# Spin up a virtual swarm of 10 drones (Compose, no cluster needed)
|
||||||
cd simulator && DRONE_COUNT=10 docker compose up
|
cd simulator && DRONE_COUNT=10 docker compose up
|
||||||
|
|
||||||
# Run the visualization
|
# Run the visualization
|
||||||
cd prototype && npm install && npm run dev
|
cd prototype && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or run the full miniature of the ground segment — k3d cluster, fleet as
|
||||||
|
StatefulSet, Grafana, explorer, warehouse with post-flight offload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook infra/ansible/sim-cluster.yml
|
||||||
|
cd infra/terraform/sim-env && terraform init && terraform apply
|
||||||
|
cd ../ground && terraform init && terraform apply
|
||||||
|
# then press "go live" in the prototype header
|
||||||
|
```
|
||||||
|
|
||||||
|
See [06 — Environments → IaC boundaries](docs/06-environments.md#iac-boundaries) for why Ansible owns hosts, Terraform owns the ground segment, and neither flies on the drone.
|
||||||
|
|
||||||
|
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): a compile check, a 10-second virtual smoke flight verified with DuckDB, unit tests for the read-only SQL gate, and a Trivy vulnerability/misconfiguration scan. 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)
|
||||||
|
|||||||
+11
-8
@@ -20,8 +20,9 @@ graph TB
|
|||||||
subgraph serving [Layer 3 — Serving and sync]
|
subgraph serving [Layer 3 — Serving and sync]
|
||||||
HOOK["event hook<br/>fires on new derived data"]
|
HOOK["event hook<br/>fires on new derived data"]
|
||||||
PUB["state publisher<br/>pub/sub broadcast"]
|
PUB["state publisher<br/>pub/sub broadcast"]
|
||||||
MINIO["MinIO<br/>derived datasets bucket"]
|
BULK["bulk sync<br/>rsync over SSH"]
|
||||||
QAPI["query API<br/>peer data requests"]
|
MINIO["MinIO<br/>optional derived bucket"]
|
||||||
|
QAPI["query API<br/>SQL-over-SSH"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -33,12 +34,14 @@ graph TB
|
|||||||
WRITER -->|derived rows| HOOK
|
WRITER -->|derived rows| HOOK
|
||||||
HOOK --> PUB
|
HOOK --> PUB
|
||||||
HOOK --> MINIO
|
HOOK --> MINIO
|
||||||
|
HOOK --> BULK
|
||||||
QAPI --> DUCK
|
QAPI --> DUCK
|
||||||
PUB -.->|mesh| PEERS["peer drones"]
|
PUB -.->|mesh pose| PEERS["peer drones"]
|
||||||
MINIO -.->|replication| PEERS
|
BULK -.->|sealed partitions| PEERS
|
||||||
QAPI -.->|on demand| PEERS
|
QAPI -.->|on demand| PEERS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
MinIO stays available where an S3 API helps (on-board derived datasets, ground warehouse). **In-flight peer bulk sync is SSH/rsync**, not object-store replication — see [04 — Swarm sync](04-swarm-sync.md).
|
||||||
### 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.
|
||||||
@@ -53,10 +56,10 @@ graph TB
|
|||||||
|
|
||||||
### Layer 3 — Serving and sync
|
### Layer 3 — Serving and sync
|
||||||
|
|
||||||
- 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, optional MinIO put, bulk-sync hint, 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 (UDP in the PoC; Zenoh proposed — [04](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 delta transfer). MinIO is optional where an S3 API is wanted; it is **not** the in-flight peer replication path.
|
||||||
- **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 — HTTP explorer gate in the PoC; **SSH forced commands** proposed for flight (SELECT-only gate, read-only OS user, columnar responses — [04](04-swarm-sync.md)).
|
||||||
|
|
||||||
## Communication planes
|
## Communication planes
|
||||||
|
|
||||||
|
|||||||
+23
-13
@@ -16,19 +16,27 @@ 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. The runnable PoC encodes it as
|
||||||
|
**45 bytes** little-endian (`simulator/virtual_drone/broadcast.py`); that size is
|
||||||
|
what the prototype uses for bandwidth estimates.
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Wire type (PoC) | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `drone_id` | uint16 | Fleet-scoped registry |
|
| `magic` + `version` | 2s + uint8 | `b"SH"`, version `1` |
|
||||||
|
| `drone_id` | 8s ascii | Zero-padded; a fleet `uint16` registry id is a natural production swap |
|
||||||
| `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` | 3 × int32 | **Millimeters** in the **mission frame** — quantized on the wire only; storage keeps full float precision |
|
||||||
| `att_roll/pitch/yaw` | int16 | Centi-degrees |
|
| `att_roll/pitch/yaw` | 3 × int16 | Centi-degrees — attitude stays on the wire so peers need no local shape model |
|
||||||
| `vel_x/y/z` | int16 | cm/s |
|
| `vel_x/y/z` | 3 × 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: 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.
|
45 bytes × 5 Hz × 50 drones ≈ 11 KB/s of pose traffic before transport overhead — still trivial on a congested mesh.
|
||||||
|
|
||||||
|
An earlier sketch used relative coordinates and a bounding sphere (no attitude).
|
||||||
|
It was dropped: mission-frame pose + attitude is simpler to fuse post-flight and
|
||||||
|
costs almost nothing at this frame size. Relative localization remains a
|
||||||
|
consumer concern when `frame_ref` differs across peers ([09](09-open-questions.md)).
|
||||||
|
|
||||||
`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,25 +59,27 @@ graph LR
|
|||||||
SB -->|"store-and-forward relay"| SC
|
SB -->|"store-and-forward relay"| SC
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recommended: Zenoh
|
### Recommended (proposal): Zenoh
|
||||||
|
|
||||||
- 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.
|
||||||
- Tiny footprint, ARM64-native.
|
- Tiny footprint, ARM64-native.
|
||||||
|
|
||||||
|
**PoC today:** the simulator and the 2D prototype exercise the **raw UDP** pose path only — the minimal degraded profile below. Zenoh is the proposed production pub/sub, not yet wired into the runnable stack.
|
||||||
|
|
||||||
### Alternatives considered
|
### Alternatives considered
|
||||||
|
|
||||||
| Option | Verdict |
|
| Option | Verdict |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **DDS multicast** (ROS 2 default) | Works, battle-tested; but discovery storms and tuning pain on lossy wireless meshes are well documented. Keep as fallback since ROS 2 speaks it natively |
|
| **DDS multicast** (ROS 2 default) | Works, battle-tested; but discovery storms and tuning pain on lossy wireless meshes are well documented. Keep as fallback since ROS 2 speaks it natively |
|
||||||
| **MQTT** | Needs a broker — a per-drone broker bridge is possible but adds moving parts for no gain over Zenoh |
|
| **MQTT** | Needs a broker — a per-drone broker bridge is possible but adds moving parts for no gain over Zenoh |
|
||||||
| **Raw UDP multicast** | Perfect as a last-resort minimal profile for the pose broadcast alone (fixed frame, no discovery); no query layer, no reliability — documented as the degraded mode |
|
| **Raw UDP multicast** | **Implemented in the PoC** for pose broadcast (fixed frame, no discovery); no query layer, no reliability — also the documented degraded mode |
|
||||||
| **MinIO bucket replication** | Wrong tool for the 5 Hz pose path, right tool for bulk derived datasets — see below |
|
| **MinIO bucket replication** | Wrong tool for the 5 Hz pose path; optional on board for derived datasets and primary on the ground warehouse — not the in-flight bulk path |
|
||||||
|
|
||||||
## 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 proposed; UDP in the PoC):** 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 over persistent SSH (proposed):** 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. The PoC visualises bulk volume; it does not yet run real rsync between virtual drones.
|
||||||
|
|
||||||
Why SSH-based bulk sync over object-store replication:
|
Why SSH-based bulk sync over object-store replication:
|
||||||
|
|
||||||
@@ -105,7 +115,7 @@ SQL access must not become a write channel. A single "read-only connection" flag
|
|||||||
| Layer | Mechanism | What it stops |
|
| Layer | Mechanism | What it stops |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1. Key = operation | Forced command: the query key can only invoke the query wrapper, nothing else | Arbitrary exec, lateral movement |
|
| 1. Key = operation | Forced command: the query key can only invoke the query wrapper, nothing else | Arbitrary exec, lateral movement |
|
||||||
| 2. Statement gate | Wrapper accepts a single statement, parses it, rejects anything but `SELECT` (no `COPY`, `ATTACH`, `INSTALL`, `SET`, multi-statements); parameters bound, not interpolated | SQL-as-a-write-channel, config tampering |
|
| 2. Statement gate | Wrapper accepts a single statement, rejects anything but `SELECT`/`WITH`/… (no `COPY`, `ATTACH`, `INSTALL`, `SET`, multi-statements); production should prefer a real parser + bound parameters, not keywords alone | SQL-as-a-write-channel, config tampering |
|
||||||
| 3. OS permissions | Wrapper runs as a dedicated user with **read-only filesystem access** to the data root and write access to nothing | Any write that slips past layer 2 |
|
| 3. OS permissions | Wrapper runs as a dedicated user with **read-only filesystem access** to the data root and write access to nothing | Any write that slips past layer 2 |
|
||||||
| 4. Engine hardening | `:memory:` database, external access disabled except the data-root glob, extension loading off | Reaching outside the store |
|
| 4. Engine hardening | `:memory:` database, external access disabled except the data-root glob, extension loading off | Reaching outside the store |
|
||||||
| 5. Resource caps | Timeout, memory cap, niced CPU (flight software always wins), response size budget | Denial of service via expensive queries |
|
| 5. Resource caps | Timeout, memory cap, niced CPU (flight software always wins), response size budget | Denial of service via expensive queries |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -36,6 +38,21 @@ graph TB
|
|||||||
- **RSSI is data.** Per-peer signal strength is captured into `telemetry` like any other sensor — it feeds relative-positioning estimates and post-flight link-quality analysis, and it is free.
|
- **RSSI is data.** Per-peer signal strength is captured into `telemetry` like any other sensor — it feeds relative-positioning estimates and post-flight link-quality analysis, and it is free.
|
||||||
- Electromagnetic environment sensing (whatever the radio can observe) is likewise recorded — cheap in flight, valuable in replay.
|
- Electromagnetic environment sensing (whatever the radio can observe) is likewise recorded — cheap in flight, valuable in replay.
|
||||||
|
|
||||||
|
## Why not SSH alone?
|
||||||
|
|
||||||
|
SSH and WireGuard solve different problems. SSH is the **authorization layer** for narrow, point-to-point operations (bulk sync, read-only SQL). WireGuard is the **membership and transport encryption layer** for everything on the mesh, including traffic SSH cannot carry.
|
||||||
|
|
||||||
|
| Concern | SSH alone | WireGuard + SSH (this design) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pose broadcast (UDP, ~5 Hz, compact frame) | Cannot carry subnet broadcast; would need a separate app protocol | Encrypted inside the mesh overlay; any peer on the tunnel can receive |
|
||||||
|
| Broadcast confidentiality without WG | Payloads traverse the radio **in cleartext** — any receiver in range reads swarm positions | Only provisioned fleet members complete a handshake; outsiders see noise |
|
||||||
|
| Lossy ad-hoc links | TCP head-of-line blocking; sessions stall and reconnect on packet loss | UDP transport survives loss and roaming; SSH sessions **over** WG are more stable than SSH directly on Wi-Fi |
|
||||||
|
| Network membership | Any host that reaches the port gets an SSH banner — a probe surface | Devices without a provisioned key get **no handshake response**; the port is effectively silent |
|
||||||
|
|
||||||
|
**Rejected alternative:** encrypt each broadcast frame in application code with a fleet-wide AEAD key. That reinvents a crypto layer the OS already provides, still leaves TCP head-of-line blocking for peer queries, and does not answer the membership question (who is allowed on the mesh at all).
|
||||||
|
|
||||||
|
Keep both layers: WireGuard for *who is on the network and whether the wire is readable*; SSH forced commands for *what a peer is allowed to do once connected* ([04](04-swarm-sync.md)).
|
||||||
|
|
||||||
## Key lifecycle
|
## Key lifecycle
|
||||||
|
|
||||||
| Phase | Action |
|
| Phase | Action |
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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]
|
||||||
@@ -61,6 +63,29 @@ The environment engineers live in daily — and deliberately the first thing to
|
|||||||
- **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 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.
|
||||||
- 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
|
||||||
|
|
||||||
|
Infrastructure as code follows the same discipline as the data plane: each tool owns the layer it is actually good at, and nothing owns a layer it can't reach.
|
||||||
|
|
||||||
|
| Layer | Tool | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Host preparation (drone bench provisioning, sim cluster creation) | **Ansible** ([`infra/ansible/`](../infra/ansible/)) | Idempotent, agentless, works over the same SSH channel the platform already trusts. Keys, forced commands, WireGuard, Compose bundles — all host-level state. |
|
||||||
|
| Declared workloads (simulated fleet, ground warehouse) | **Terraform** ([`infra/terraform/`](../infra/terraform/)) | The ground segment is stationary, long-lived, and cluster-shaped — classic Terraform territory. Fleet size, offload schedule, and explorer endpoints are variables, not YAML edits. |
|
||||||
|
| 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)). |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
### Next fidelity step: microVMs
|
||||||
|
|
||||||
|
The k3d fleet shares one kernel and one network namespace tree — good enough to exercise the data plane, not good enough to demo the *peer* plane (real SSH sessions between isolated machines, WireGuard handshakes, packet loss injection). The next step, when that fidelity is needed, is one **Firecracker microVM per drone**: a real kernel, a real network interface, and the actual `authorized_keys` forced-command path between virtual drones — on any KVM-capable workstation, still with no cloud dependency. The process-level simulator stays as the fast inner loop; microVMs become the pre-flight integration rig.
|
||||||
|
|
||||||
## Why identical layouts matter (the payoff)
|
## Why identical layouts matter (the payoff)
|
||||||
|
|
||||||
| Operation | What it costs with one layout everywhere |
|
| Operation | What it costs with one layout everywhere |
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -61,3 +61,9 @@ 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 disk after offload to T3.
|
||||||
|
|
||||||
|
*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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -97,6 +99,39 @@ config:
|
|||||||
2. Docked drones fetch the manifest, verify digests/signatures ([05](05-network-security.md)), stage the new Compose bundle, and switch on the next boot cycle.
|
2. Docked drones fetch the manifest, verify digests/signatures ([05](05-network-security.md)), stage the new Compose bundle, and switch on the next boot cycle.
|
||||||
3. **Never mid-flight.** Updates are a dock-only operation by construction — the update endpoint does not exist in the flight radio profile.
|
3. **Never mid-flight.** Updates are a dock-only operation by construction — the update endpoint does not exist in the flight radio profile.
|
||||||
|
|
||||||
|
## GitOps on the ground segment
|
||||||
|
|
||||||
|
The fleet release manifest handles **drones** (atomic, digest-pinned, dock-only). The **ground k3s segment** uses a different tool: [Flux](https://fluxcd.io/) reconciles long-lived configuration from git — dashboard bundles, Prometheus rules, offload schedules, policy labels — without reprovisioning PVCs or re-running full Terraform applies for every label change.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph git [Git, on-prem]
|
||||||
|
REPO["service repos"]
|
||||||
|
GITOPS["infra/gitops/<br/>ground overlay"]
|
||||||
|
end
|
||||||
|
subgraph ground_k3s [Ground k3s]
|
||||||
|
FLUX["Flux controllers"]
|
||||||
|
WH["warehouse workloads<br/>(Terraform-provisioned)"]
|
||||||
|
CFG["policy ConfigMaps<br/>dashboard bundles"]
|
||||||
|
end
|
||||||
|
subgraph drones [Fleet]
|
||||||
|
MAN["fleet release manifest"]
|
||||||
|
DOCK["dock: verify + apply"]
|
||||||
|
end
|
||||||
|
REPO --> CI
|
||||||
|
GITOPS --> FLUX
|
||||||
|
FLUX --> CFG
|
||||||
|
MAN --> DOCK
|
||||||
|
```
|
||||||
|
|
||||||
|
| Segment | Delivery mechanism | Reconciler in production? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Drones | Fleet release manifest via registry mirror | **No** — no API server mid-flight; dock applies once |
|
||||||
|
| Ground k3s (warehouse, dashboards, CI runners) | Terraform for shape + **Flux** for ongoing config | **Yes** — stationary cluster, wired network, git is the source of truth |
|
||||||
|
| Dev simulation (k3d) | Ansible creates cluster; Terraform applies workloads; Flux CRs installed from [`infra/gitops/`](../infra/gitops/) | Yes (miniature of ground) |
|
||||||
|
|
||||||
|
Boundary: **Terraform provisions** (namespaces, PVCs, Deployments, CronJobs); **Flux reconciles** policy and observability overlays on top. Neither replaces the fleet manifest on the drone.
|
||||||
|
|
||||||
## Developer experience
|
## Developer experience
|
||||||
|
|
||||||
- **Dev Containers** define the full toolchain (Python data tooling, DuckDB, compose, linters) — identical on any machine, onboarding in minutes.
|
- **Dev Containers** define the full toolchain (Python data tooling, DuckDB, compose, linters) — identical on any machine, onboarding in minutes.
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# 12 — Design journey
|
||||||
|
|
||||||
|
How this design came together, told in the order the thinking actually happened.
|
||||||
|
It is a walk-through, not a report — and **not a mandate**. The goal is a
|
||||||
|
from-scratch platform sketch that shows how the pieces fit; every concrete
|
||||||
|
choice is an option with trade-offs recorded as
|
||||||
|
[Architecture Decision Records](adr/README.md). Replace any piece if a better
|
||||||
|
fit shows up. This is the story behind the decisions, and each chapter links
|
||||||
|
straight into the code it produced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 tiers
|
||||||
|
|
||||||
|
## 4. Three tiers inside each unit
|
||||||
|
|
||||||
|
That gave [three layers](03-data-platform.md) on every drone:
|
||||||
|
|
||||||
|
1. **Raw capture**, never transformed.
|
||||||
|
2. **ETL / reduction**, where data is cut down to what has to be shared:
|
||||||
|
millimetres to centimetres, thinning time series where that is enough,
|
||||||
|
dropping what nobody downstream reads.
|
||||||
|
3. **A bidirectional interface**: 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 tier one
|
||||||
|
|
||||||
|
## 5. What actually needs to sync
|
||||||
|
|
||||||
|
The most time-critical item is where each peer is, so every unit has time to
|
||||||
|
react. The wire frame carries **mission-frame position** (millimetres on the
|
||||||
|
wire, full float at rest), **attitude**, **velocity**, a `frame_ref`, and flags —
|
||||||
|
45 bytes at 5 Hz. That is cheap enough that shrinking further is not worth the
|
||||||
|
fusion pain. An earlier sketch used relative coordinates and a bounding sphere
|
||||||
|
with no orientation; it was dropped. When peers disagree on frames,
|
||||||
|
`frame_ref` makes the mismatch explicit for consumers
|
||||||
|
([09 — Open questions](09-open-questions.md)).
|
||||||
|
|
||||||
|
> **Read the code**
|
||||||
|
> - [`simulator/virtual_drone/broadcast.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/broadcast.py#L1-L45) — the 45-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#L60-L63) — `POSE_BYTES = 45` drives the volume estimate
|
||||||
|
> - [`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-class tooling (rclone) over the same SSH
|
||||||
|
channel. 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). The warehouse is read-mostly for
|
||||||
|
analytics, so transaction contention is not a concern, which opens a
|
||||||
|
DuckDB / DuckLake approach with MinIO underneath, 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 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,46 @@
|
|||||||
|
# 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 designed for flight and bench — "what you test is what
|
||||||
|
flies".
|
||||||
|
- The statement gate in the PoC (`simulator/explorer/server.py`, covered by
|
||||||
|
`simulator/tests/test_sql_gate.py`) is a **first layer**: keyword allow/deny
|
||||||
|
over HTTP for the explorer. Production still needs the remaining layers in
|
||||||
|
[04 — Swarm sync](../04-swarm-sync.md) (forced-command key, read-only OS user,
|
||||||
|
engine hardening, resource caps) and a real SQL parser rather than keywords
|
||||||
|
alone.
|
||||||
|
- The explorer and the prototype's live mode are *stand-in consumers* of the
|
||||||
|
same read-only contract over HTTP today; the proposed flight path is
|
||||||
|
SQL-over-SSH ([`../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,39 @@
|
|||||||
|
# Ansible — host preparation
|
||||||
|
|
||||||
|
Ansible owns everything that happens **on a host before workloads run**.
|
||||||
|
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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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 |
|
||||||
|
| `sim-cluster.yml` | Local workstation / CI host | k3d cluster that miniatures the ground segment; shared data volume; simulator image import |
|
||||||
|
|
||||||
|
## Provision a drone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook -i inventory.example.yml drone-provision.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The playbook is idempotent and runs only over the bench network — drones
|
||||||
|
are never provisioned in flight (see [05 — Network & security](../../docs/05-network-security.md)):
|
||||||
|
|
||||||
|
1. **Identity**: generate the drone's ed25519 keypair if absent.
|
||||||
|
2. **Peer trust**: install every fleet member's public key into
|
||||||
|
`authorized_keys`, each pinned to the read-only SQL forced command —
|
||||||
|
the only thing a peer can execute.
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook sim-cluster.yml # creates k3d cluster 'swarm-sim'
|
||||||
|
cd ../terraform/sim-env && terraform init && terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The cluster mounts a shared host directory as `/data` on the (single)
|
||||||
|
node, so drone pods, the exporter, and the explorer see one Parquet lake —
|
||||||
|
the same contract as the on-board NVMe layout.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Provision a drone on the bench: identity, peer trust, WireGuard, data plane.
|
||||||
|
# Idempotent; never runs over the mission radio (bench network only).
|
||||||
|
---
|
||||||
|
- name: Provision drone data plane
|
||||||
|
hosts: drones
|
||||||
|
become: true
|
||||||
|
vars:
|
||||||
|
swarm_user: swarm
|
||||||
|
swarm_home: /opt/swarm
|
||||||
|
forced_command: "{{ swarm_home }}/bin/peer-query.sh"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Create swarm service user
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ swarm_user }}"
|
||||||
|
home: "{{ swarm_home }}"
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
system: true
|
||||||
|
|
||||||
|
# --- 1. Identity -----------------------------------------------------
|
||||||
|
- name: Generate drone ed25519 identity key
|
||||||
|
community.crypto.openssh_keypair:
|
||||||
|
path: "{{ swarm_home }}/.ssh/id_ed25519"
|
||||||
|
type: ed25519
|
||||||
|
comment: "{{ inventory_hostname }}"
|
||||||
|
owner: "{{ swarm_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
register: identity
|
||||||
|
|
||||||
|
- name: Collect fleet public keys
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ swarm_home }}/.ssh/id_ed25519.pub"
|
||||||
|
register: pubkey
|
||||||
|
|
||||||
|
# --- 2. Peer trust: forced command only ------------------------------
|
||||||
|
# Every peer may connect, but the only thing it can execute is the
|
||||||
|
# read-only SQL wrapper (docs/04). No shell, no forwarding, no pty.
|
||||||
|
- name: Authorize fleet peers with read-only forced command
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: "{{ swarm_user }}"
|
||||||
|
key: "{{ hostvars[item].pubkey.content | b64decode }}"
|
||||||
|
key_options: >-
|
||||||
|
command="{{ forced_command }}",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty
|
||||||
|
loop: "{{ groups['drones'] | difference([inventory_hostname]) }}"
|
||||||
|
when: hostvars[item].pubkey is defined
|
||||||
|
|
||||||
|
- name: Install peer query wrapper
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ forced_command }}"
|
||||||
|
mode: "0755"
|
||||||
|
content: |
|
||||||
|
#!/bin/sh
|
||||||
|
# Forced command: read-only DuckDB SQL over the sealed lake.
|
||||||
|
# SSH_ORIGINAL_COMMAND carries the statement; the gate rejects
|
||||||
|
# anything that is not a single read statement.
|
||||||
|
exec {{ swarm_home }}/bin/sql-gate --read-only --data /data "$SSH_ORIGINAL_COMMAND"
|
||||||
|
|
||||||
|
# --- 3. WireGuard swarm plane ----------------------------------------
|
||||||
|
- name: Render WireGuard configuration
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: templates/wg0.conf.j2
|
||||||
|
dest: /etc/wireguard/wg0.conf
|
||||||
|
mode: "0600"
|
||||||
|
notify: Restart wireguard
|
||||||
|
|
||||||
|
- name: Enable WireGuard interface
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: wg-quick@wg0
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# --- 4. Data plane from the fleet release manifest --------------------
|
||||||
|
- name: Read fleet release manifest
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ fleet_manifest }}"
|
||||||
|
name: manifest
|
||||||
|
|
||||||
|
- name: Lay down Compose bundle for release {{ manifest.release }}
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: "{{ playbook_dir }}/../../.tmp/{{ manifest.artifacts[0].name }}"
|
||||||
|
dest: "{{ swarm_home }}/release"
|
||||||
|
owner: "{{ swarm_user }}"
|
||||||
|
|
||||||
|
- name: Enable data plane unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: templates/swarm-data-plane.service.j2
|
||||||
|
dest: /etc/systemd/system/swarm-data-plane.service
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart data plane
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: Restart wireguard
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: wg-quick@wg0
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: Restart data plane
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: swarm-data-plane
|
||||||
|
state: restarted
|
||||||
|
daemon_reload: true
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Example fleet inventory. Real inventories are generated from the fleet
|
||||||
|
# release manifest and live outside the repository.
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
drones:
|
||||||
|
hosts:
|
||||||
|
dr-01:
|
||||||
|
ansible_host: 10.44.0.11
|
||||||
|
wg_address: 10.44.0.11/24
|
||||||
|
dr-02:
|
||||||
|
ansible_host: 10.44.0.12
|
||||||
|
wg_address: 10.44.0.12/24
|
||||||
|
dr-03:
|
||||||
|
ansible_host: 10.44.0.13
|
||||||
|
wg_address: 10.44.0.13/24
|
||||||
|
vars:
|
||||||
|
ansible_user: ops
|
||||||
|
wg_port: 51820
|
||||||
|
fleet_manifest: ../../.tmp/manifest.yaml
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Create the local k3d simulation cluster — an executable miniature of the
|
||||||
|
# ground segment (k3s) from docs/06. Terraform then owns the workloads.
|
||||||
|
---
|
||||||
|
- name: Simulation cluster (k3d)
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
cluster_name: swarm-sim
|
||||||
|
repo_root: "{{ playbook_dir }}/../.."
|
||||||
|
data_dir: "{{ lookup('env', 'SWARM_SIM_DATA') | default(repo_root + '/var/t1', true) }}"
|
||||||
|
warehouse_dir: "{{ lookup('env', 'SWARM_WAREHOUSE_DATA') | default(repo_root + '/var/t3', true) }}"
|
||||||
|
api_port: 6560
|
||||||
|
# Host ports for services exposed by the Terraform modules (NodePorts)
|
||||||
|
port_explorer: 30088
|
||||||
|
port_grafana: 30300
|
||||||
|
port_prometheus: 30990
|
||||||
|
port_warehouse_explorer: 30089
|
||||||
|
port_minio_console: 30901
|
||||||
|
simulator_image: swarm-house/simulator:dev
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Check k3d is installed
|
||||||
|
ansible.builtin.command: k3d version
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Create shared data directory (the pods' /data lake)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ data_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0777"
|
||||||
|
owner: "10001"
|
||||||
|
group: "10001"
|
||||||
|
|
||||||
|
- name: Create warehouse directory (T3 host path inside the k3d node)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ warehouse_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0777"
|
||||||
|
owner: "10001"
|
||||||
|
group: "10001"
|
||||||
|
|
||||||
|
- name: List existing clusters
|
||||||
|
ansible.builtin.command: k3d cluster list -o json
|
||||||
|
register: clusters
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Create cluster {{ cluster_name }}
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
k3d cluster create {{ cluster_name }}
|
||||||
|
--api-port {{ api_port }}
|
||||||
|
--servers 1 --agents 0
|
||||||
|
--volume {{ data_dir }}:/data@server:0
|
||||||
|
--volume {{ warehouse_dir }}:/warehouse@server:0
|
||||||
|
--port {{ port_explorer }}:{{ port_explorer }}@server:0
|
||||||
|
--port {{ port_grafana }}:{{ port_grafana }}@server:0
|
||||||
|
--port {{ port_prometheus }}:{{ port_prometheus }}@server:0
|
||||||
|
--port {{ port_warehouse_explorer }}:{{ port_warehouse_explorer }}@server:0
|
||||||
|
--port {{ port_minio_console }}:{{ port_minio_console }}@server:0
|
||||||
|
--k3s-arg "--disable=traefik@server:0"
|
||||||
|
--wait
|
||||||
|
when: clusters.stdout | from_json | selectattr('name', 'equalto', cluster_name) | list | length == 0
|
||||||
|
|
||||||
|
- name: Build simulator image
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker build -t {{ simulator_image }} .
|
||||||
|
chdir: "{{ playbook_dir }}/../../simulator"
|
||||||
|
register: build
|
||||||
|
changed_when: "'Using cache' not in build.stderr"
|
||||||
|
|
||||||
|
- name: Import simulator image into the cluster
|
||||||
|
ansible.builtin.command: k3d image import {{ simulator_image }} -c {{ cluster_name }}
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Merge k3d kubeconfig into the default context
|
||||||
|
ansible.builtin.command: k3d kubeconfig merge {{ cluster_name }} --kubeconfig-merge-default
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Check whether Flux CLI is available
|
||||||
|
ansible.builtin.command: flux version --client
|
||||||
|
register: flux_cli
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Install Flux CLI (local user bin)
|
||||||
|
when: flux_cli.rc != 0
|
||||||
|
block:
|
||||||
|
- name: Download Flux release archive
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://github.com/fluxcd/flux2/releases/download/v2.4.0/flux_2.4.0_linux_amd64.tar.gz
|
||||||
|
dest: /tmp/flux.tar.gz
|
||||||
|
mode: "0644"
|
||||||
|
- name: Extract flux binary
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: /tmp/flux.tar.gz
|
||||||
|
dest: "{{ lookup('env', 'HOME') }}/.local/bin"
|
||||||
|
remote_src: true
|
||||||
|
include:
|
||||||
|
- flux
|
||||||
|
extra_opts:
|
||||||
|
- --no-same-owner
|
||||||
|
ignore_errors: true
|
||||||
|
- name: Ensure flux is executable
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ lookup('env', 'HOME') }}/.local/bin/flux"
|
||||||
|
mode: "0755"
|
||||||
|
state: file
|
||||||
|
|
||||||
|
- name: Install Flux controllers into the cluster
|
||||||
|
ansible.builtin.command: flux install --namespace=flux-system --components=source-controller,kustomize-controller
|
||||||
|
environment:
|
||||||
|
PATH: "{{ lookup('env', 'HOME') }}/.local/bin:{{ lookup('env', 'PATH') }}"
|
||||||
|
KUBECONFIG: "{{ lookup('env', 'HOME') }}/.kube/config"
|
||||||
|
register: flux_install
|
||||||
|
changed_when: "'installed' in (flux_install.stdout | default(''))"
|
||||||
|
|
||||||
|
- name: Apply Flux GitOps CRs (GitRepository + Kustomization)
|
||||||
|
ansible.builtin.command: kubectl apply -k {{ repo_root }}/infra/gitops/flux
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Seed ground GitOps resources locally (works before first git push)
|
||||||
|
ansible.builtin.command: kubectl apply -k {{ repo_root }}/infra/gitops/ground
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Show kubeconfig hint
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
Cluster ready. Workloads: cd ../terraform/sim-env && terraform init && terraform apply.
|
||||||
|
kubeconfig: k3d kubeconfig get {{ cluster_name }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Swarm data plane (Compose bundle, release {{ manifest.release }})
|
||||||
|
After=docker.service wg-quick@wg0.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory={{ swarm_home }}/release
|
||||||
|
ExecStart=/usr/bin/docker compose up -d
|
||||||
|
ExecStop=/usr/bin/docker compose down
|
||||||
|
TimeoutStartSec=300
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# WireGuard swarm plane — rendered by drone-provision.yml
|
||||||
|
[Interface]
|
||||||
|
Address = {{ wg_address }}
|
||||||
|
ListenPort = {{ wg_port }}
|
||||||
|
PrivateKey = __REPLACED_FROM_HSM_AT_SEALING__
|
||||||
|
|
||||||
|
{% for peer in groups['drones'] | difference([inventory_hostname]) %}
|
||||||
|
[Peer]
|
||||||
|
# {{ peer }}
|
||||||
|
PublicKey = __PEER_{{ peer | upper | replace('-', '_') }}_PUBKEY__
|
||||||
|
AllowedIPs = {{ hostvars[peer].wg_address | ansible.utils.ipaddr('address') }}/32
|
||||||
|
{% endfor %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# GitOps — ground segment (Flux)
|
||||||
|
|
||||||
|
Flux reconciles **long-lived ground configuration** from this repository. It does
|
||||||
|
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
|
||||||
|
|
||||||
|
| Layer | Tool | Owns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Cluster + first boot | **Terraform** ([`../terraform/`](../terraform/)) | Namespaces, PVCs, Deployments, CronJobs, NodePorts — the shape of the simulation |
|
||||||
|
| Ongoing ground config | **Flux** (this directory) | Policy labels, dashboard bundles, offload knobs — things that change without reprovisioning PVCs |
|
||||||
|
| Drones | **Fleet release manifest** | Compose bundle digests, model weights, peer registry — atomic, dock-only delivery |
|
||||||
|
|
||||||
|
Drones are **outside GitOps**: there is no reconciler in flight. A dock applies
|
||||||
|
the pinned manifest once; mid-mission drift is impossible because the update
|
||||||
|
endpoint does not exist in the radio profile.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/gitops/
|
||||||
|
flux/ Flux GitRepository + Kustomization CRs (install into flux-system)
|
||||||
|
ground/ Kustomize overlay reconciled into the ground namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bootstrap on the k3d simulation cluster
|
||||||
|
|
||||||
|
`infra/ansible/sim-cluster.yml` installs Flux controllers and applies the CRs
|
||||||
|
below. After the first `git push`, Flux polls `origin` and reconciles
|
||||||
|
`infra/gitops/ground/` into the `ground` namespace.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual bootstrap (if you skipped Ansible):
|
||||||
|
flux install --namespace=flux-system
|
||||||
|
kubectl apply -k infra/gitops/flux
|
||||||
|
```
|
||||||
|
|
||||||
|
To reconcile immediately without waiting for the poll interval:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flux reconcile source git swarm-house -n flux-system
|
||||||
|
flux reconcile kustomization ground-segment -n flux-system
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Flux manages here (example)
|
||||||
|
|
||||||
|
The [`ground/`](ground/) overlay currently carries a **GitOps-managed ConfigMap**
|
||||||
|
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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: source.toolkit.fluxcd.io/v1
|
||||||
|
kind: GitRepository
|
||||||
|
metadata:
|
||||||
|
name: swarm-house
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 1m
|
||||||
|
url: https://git.produktor.io/eSlider/swarm-house.git
|
||||||
|
ref:
|
||||||
|
branch: main
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: ground-segment
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 2m
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: swarm-house
|
||||||
|
path: ./infra/gitops/ground
|
||||||
|
prune: true
|
||||||
|
wait: true
|
||||||
|
timeout: 3m
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- gitrepository.yaml
|
||||||
|
- kustomization-ground.yaml
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ground-fleet-policy
|
||||||
|
namespace: ground
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: swarm-ground
|
||||||
|
app.kubernetes.io/managed-by: flux
|
||||||
|
data:
|
||||||
|
offload_schedule: "*/2 * * * *"
|
||||||
|
retention_policy: "keep-all-flights"
|
||||||
|
explorer_mode: "read-only-sql"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
namespace: ground
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- configmap-fleet-policy.yaml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: ground
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: swarm-ground
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
## Recovering from partial state
|
||||||
|
|
||||||
|
If resources were created outside Terraform (or state was lost), import them
|
||||||
|
before `terraform apply`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sim-env (namespace: swarm)
|
||||||
|
terraform import kubernetes_namespace.swarm swarm
|
||||||
|
terraform import kubernetes_stateful_set.drone swarm/drone
|
||||||
|
terraform import kubernetes_deployment.exporter swarm/exporter
|
||||||
|
terraform import kubernetes_deployment.explorer swarm/explorer
|
||||||
|
terraform import kubernetes_deployment.prometheus swarm/prometheus
|
||||||
|
terraform import kubernetes_deployment.grafana swarm/grafana
|
||||||
|
terraform import kubernetes_service.exporter swarm/exporter
|
||||||
|
terraform import kubernetes_service.explorer swarm/explorer
|
||||||
|
terraform import kubernetes_service.prometheus swarm/prometheus
|
||||||
|
terraform import kubernetes_service.grafana swarm/grafana
|
||||||
|
terraform import kubernetes_config_map.prometheus swarm/prometheus-config
|
||||||
|
terraform import kubernetes_config_map.grafana_provisioning swarm/grafana-provisioning
|
||||||
|
terraform import kubernetes_config_map.grafana_dashboard swarm/grafana-dashboard
|
||||||
|
|
||||||
|
# ground (namespace: ground)
|
||||||
|
terraform import kubernetes_namespace.ground ground
|
||||||
|
terraform import kubernetes_persistent_volume_claim.minio ground/minio-data
|
||||||
|
terraform import kubernetes_secret.minio ground/minio-credentials
|
||||||
|
terraform import kubernetes_deployment.minio ground/minio
|
||||||
|
terraform import kubernetes_deployment.warehouse_explorer ground/warehouse-explorer
|
||||||
|
terraform import kubernetes_service.minio ground/minio
|
||||||
|
terraform import kubernetes_service.warehouse_explorer ground/warehouse-explorer
|
||||||
|
terraform import kubernetes_cron_job_v1.offload ground/offload
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `terraform apply` reconciles drift (for example pinning `EXPLORER_PORT`
|
||||||
|
and `EXPORTER_PORT` so Kubernetes service env injection does not break startup).
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||||
|
version = "2.38.0"
|
||||||
|
constraints = "~> 2.33"
|
||||||
|
hashes = [
|
||||||
|
"h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=",
|
||||||
|
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
|
||||||
|
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
|
||||||
|
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
|
||||||
|
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
|
||||||
|
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
|
||||||
|
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
|
||||||
|
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
|
||||||
|
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
|
||||||
|
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
|
||||||
|
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
|
||||||
|
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||||
|
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Terraform — ground warehouse (T3)
|
||||||
|
|
||||||
|
The stationary half of the system: the historical warehouse that receives
|
||||||
|
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
|
||||||
|
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 |
|
||||||
|
| --- | --- |
|
||||||
|
| Deployment `minio` + PVC + NodePort 30901 | Object-store facade of the warehouse for downstream consumers (training pipelines, replay) |
|
||||||
|
| CronJob `offload` | Post-flight offload: copies Hive partitions verbatim from the fleet lake (T1) into the warehouse (T3), then mirrors into MinIO |
|
||||||
|
| Deployment `warehouse-explorer` + NodePort 30089 | Second explorer instance over the warehouse path — historical read-only SQL across **all** flights |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform init
|
||||||
|
terraform apply
|
||||||
|
terraform output
|
||||||
|
```
|
||||||
|
|
||||||
|
After a couple of offload runs, the warehouse explorer shows the same
|
||||||
|
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
|
||||||
|
|
||||||
|
- The CronJob compresses "drone lands, docks, offloads, prunes" into a
|
||||||
|
periodic rsync-style copy. Production offload is event-driven per docking
|
||||||
|
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.
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Ground warehouse segment (T3, docs/03 + docs/06) — the stationary,
|
||||||
|
# long-lifecycle half of the system and therefore the most natural Terraform
|
||||||
|
# territory. Runs in the same k3d cluster as the fleet, in its own namespace
|
||||||
|
# and with its own state.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
labels = { "app.kubernetes.io/part-of" = "swarm-ground" }
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_namespace" "ground" {
|
||||||
|
metadata {
|
||||||
|
name = var.namespace
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- MinIO object store -------------------------------------------------------
|
||||||
|
|
||||||
|
resource "kubernetes_secret" "minio" {
|
||||||
|
metadata {
|
||||||
|
name = "minio-credentials"
|
||||||
|
namespace = kubernetes_namespace.ground.metadata[0].name
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
MINIO_ROOT_USER = var.minio_root_user
|
||||||
|
MINIO_ROOT_PASSWORD = var.minio_root_password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_persistent_volume_claim" "minio" {
|
||||||
|
metadata {
|
||||||
|
name = "minio-data"
|
||||||
|
namespace = kubernetes_namespace.ground.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
access_modes = ["ReadWriteOnce"]
|
||||||
|
resources {
|
||||||
|
requests = { storage = var.minio_storage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "minio" {
|
||||||
|
metadata {
|
||||||
|
name = "minio"
|
||||||
|
namespace = kubernetes_namespace.ground.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
strategy {
|
||||||
|
type = "Recreate" # RWO volume
|
||||||
|
}
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "minio" }
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = merge(local.labels, { app = "minio" })
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "minio"
|
||||||
|
image = "minio/minio:RELEASE.2024-06-13T22-53-53Z"
|
||||||
|
args = ["server", "/data", "--console-address", ":9001"]
|
||||||
|
|
||||||
|
env_from {
|
||||||
|
secret_ref {
|
||||||
|
name = kubernetes_secret.minio.metadata[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Air-gap hygiene: no update checks
|
||||||
|
env {
|
||||||
|
name = "MINIO_UPDATE"
|
||||||
|
value = "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
port {
|
||||||
|
container_port = 9000
|
||||||
|
}
|
||||||
|
port {
|
||||||
|
container_port = 9001
|
||||||
|
}
|
||||||
|
|
||||||
|
volume_mount {
|
||||||
|
name = "store"
|
||||||
|
mount_path = "/data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "store"
|
||||||
|
persistent_volume_claim {
|
||||||
|
claim_name = kubernetes_persistent_volume_claim.minio.metadata[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "minio" {
|
||||||
|
metadata {
|
||||||
|
name = "minio"
|
||||||
|
namespace = kubernetes_namespace.ground.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
type = "NodePort"
|
||||||
|
selector = { app = "minio" }
|
||||||
|
port {
|
||||||
|
name = "api"
|
||||||
|
port = 9000
|
||||||
|
target_port = 9000
|
||||||
|
}
|
||||||
|
port {
|
||||||
|
name = "console"
|
||||||
|
port = 9001
|
||||||
|
target_port = 9001
|
||||||
|
node_port = var.node_ports.minio_console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Post-flight offload: T1 lake -> T3 warehouse ------------------------------
|
||||||
|
# In production this runs on the docking station after a drone lands: only
|
||||||
|
# sealed partitions move, checksums are verified, then the on-board lake is
|
||||||
|
# pruned. The simulation compresses that into a periodic rsync-style copy
|
||||||
|
# plus an object-store mirror.
|
||||||
|
|
||||||
|
resource "kubernetes_cron_job_v1" "offload" {
|
||||||
|
metadata {
|
||||||
|
name = "offload"
|
||||||
|
namespace = kubernetes_namespace.ground.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
schedule = var.offload_schedule
|
||||||
|
concurrency_policy = "Forbid"
|
||||||
|
successful_jobs_history_limit = 3
|
||||||
|
failed_jobs_history_limit = 3
|
||||||
|
|
||||||
|
job_template {
|
||||||
|
metadata {
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
backoff_limit = 1
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
restart_policy = "Never"
|
||||||
|
|
||||||
|
# Step 1: copy Hive partitions verbatim (T1 layout == T3 layout)
|
||||||
|
init_container {
|
||||||
|
name = "copy"
|
||||||
|
image = "busybox:1.36"
|
||||||
|
command = ["sh", "-c", "cp -ru /lake/flight_id=* /warehouse/ 2>/dev/null; ls /warehouse | wc -l"]
|
||||||
|
volume_mount {
|
||||||
|
name = "lake"
|
||||||
|
mount_path = "/lake"
|
||||||
|
read_only = true
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "warehouse"
|
||||||
|
mount_path = "/warehouse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 2: mirror the warehouse into the object store for
|
||||||
|
# downstream consumers (training pipelines, replay tooling)
|
||||||
|
container {
|
||||||
|
name = "mirror"
|
||||||
|
image = "minio/mc:RELEASE.2024-06-12T14-34-03Z"
|
||||||
|
command = ["sh", "-c", "mc alias set store http://minio:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\" && mc mb -p store/warehouse && mc mirror --overwrite /warehouse store/warehouse"]
|
||||||
|
env_from {
|
||||||
|
secret_ref {
|
||||||
|
name = kubernetes_secret.minio.metadata[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "warehouse"
|
||||||
|
mount_path = "/warehouse"
|
||||||
|
read_only = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volume {
|
||||||
|
name = "lake"
|
||||||
|
host_path {
|
||||||
|
path = var.lake_host_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "warehouse"
|
||||||
|
host_path {
|
||||||
|
path = var.warehouse_host_path
|
||||||
|
type = "DirectoryOrCreate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Warehouse explorer: historical read-only SQL over all flights -------------
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "warehouse_explorer" {
|
||||||
|
metadata {
|
||||||
|
name = "warehouse-explorer"
|
||||||
|
namespace = kubernetes_namespace.ground.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "warehouse-explorer" }
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = merge(local.labels, { app = "warehouse-explorer" })
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "explorer"
|
||||||
|
image = var.simulator_image
|
||||||
|
image_pull_policy = "Never"
|
||||||
|
command = ["python", "explorer/server.py"]
|
||||||
|
env {
|
||||||
|
name = "DATA_DIR"
|
||||||
|
value = "/warehouse"
|
||||||
|
}
|
||||||
|
port {
|
||||||
|
container_port = 8088
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "warehouse"
|
||||||
|
mount_path = "/warehouse"
|
||||||
|
read_only = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "warehouse"
|
||||||
|
host_path {
|
||||||
|
path = var.warehouse_host_path
|
||||||
|
type = "DirectoryOrCreate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "warehouse_explorer" {
|
||||||
|
metadata {
|
||||||
|
name = "warehouse-explorer"
|
||||||
|
namespace = kubernetes_namespace.ground.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
type = "NodePort"
|
||||||
|
selector = { app = "warehouse-explorer" }
|
||||||
|
port {
|
||||||
|
port = 8088
|
||||||
|
target_port = 8088
|
||||||
|
node_port = var.node_ports.warehouse_explorer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
output "warehouse_explorer_url" {
|
||||||
|
description = "Historical read-only SQL over all offloaded flights"
|
||||||
|
value = "http://localhost:${var.node_ports.warehouse_explorer}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "minio_console_url" {
|
||||||
|
description = "MinIO console (object-store view of the warehouse)"
|
||||||
|
value = "http://localhost:${var.node_ports.minio_console}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "offload_schedule" {
|
||||||
|
description = "Cron schedule of the T1 -> T3 offload job"
|
||||||
|
value = var.offload_schedule
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
variable "kubeconfig" {
|
||||||
|
description = "Path to the kubeconfig for the simulation cluster"
|
||||||
|
type = string
|
||||||
|
default = "~/.kube/config"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "kube_context" {
|
||||||
|
description = "Kubeconfig context of the k3d simulation cluster"
|
||||||
|
type = string
|
||||||
|
default = "k3d-swarm-sim"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "namespace" {
|
||||||
|
description = "Namespace for the ground warehouse segment"
|
||||||
|
type = string
|
||||||
|
default = "ground"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "simulator_image" {
|
||||||
|
description = "Simulator image (provides the explorer; imported by Ansible)"
|
||||||
|
type = string
|
||||||
|
default = "swarm-house/simulator:dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "lake_host_path" {
|
||||||
|
description = "Host path of the fleet's live lake (T1) inside the k3d node"
|
||||||
|
type = string
|
||||||
|
default = "/data"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "warehouse_host_path" {
|
||||||
|
description = "Host path of the historical warehouse (T3) inside the k3d node; dot-prefixed so lake globs never match it"
|
||||||
|
type = string
|
||||||
|
default = "/data/.warehouse"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "offload_schedule" {
|
||||||
|
description = "Cron schedule of the post-flight offload job (T1 -> T3)"
|
||||||
|
type = string
|
||||||
|
default = "*/2 * * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "minio_root_user" {
|
||||||
|
description = "MinIO root user (simulation only; production uses sealed credentials)"
|
||||||
|
type = string
|
||||||
|
default = "warehouse"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "minio_root_password" {
|
||||||
|
description = "MinIO root password (simulation only)"
|
||||||
|
type = string
|
||||||
|
default = "warehouse-sim-only"
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "minio_storage" {
|
||||||
|
description = "PVC size for the MinIO object store"
|
||||||
|
type = string
|
||||||
|
default = "2Gi"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "node_ports" {
|
||||||
|
description = "Host-reachable NodePorts (must match the ports opened by sim-cluster.yml)"
|
||||||
|
type = object({
|
||||||
|
warehouse_explorer = number
|
||||||
|
minio_console = number
|
||||||
|
})
|
||||||
|
default = {
|
||||||
|
warehouse_explorer = 30089
|
||||||
|
minio_console = 30901
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.5"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
kubernetes = {
|
||||||
|
source = "hashicorp/kubernetes"
|
||||||
|
version = "~> 2.33"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "kubernetes" {
|
||||||
|
config_path = var.kubeconfig
|
||||||
|
config_context = var.kube_context
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||||
|
version = "2.38.0"
|
||||||
|
constraints = "~> 2.33"
|
||||||
|
hashes = [
|
||||||
|
"h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=",
|
||||||
|
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
|
||||||
|
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
|
||||||
|
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
|
||||||
|
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
|
||||||
|
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
|
||||||
|
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
|
||||||
|
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
|
||||||
|
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
|
||||||
|
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
|
||||||
|
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
|
||||||
|
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||||
|
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Terraform — simulation environment (T4)
|
||||||
|
|
||||||
|
Declares the simulated fleet and its observability stack on the k3d cluster
|
||||||
|
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 |
|
||||||
|
| --- | --- |
|
||||||
|
| StatefulSet `drone` × `drone_count` | Virtual drones; stable pod names become `DRONE_ID`s; all write Hive-partitioned Parquet into the shared `/data` lake |
|
||||||
|
| Deployment `exporter` | Prometheus exporter reading the lake with DuckDB |
|
||||||
|
| Deployment `explorer` + NodePort 30088 | Partition tree + read-only SQL console (also feeds the prototype's live mode) |
|
||||||
|
| Deployment `prometheus` + NodePort 30990 | Scrapes the exporter |
|
||||||
|
| Deployment `grafana` + NodePort 30300 | Same dashboard JSON as the Compose profile, provisioned from a ConfigMap |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook ../../ansible/sim-cluster.yml # once: cluster + image
|
||||||
|
terraform init
|
||||||
|
terraform apply
|
||||||
|
terraform output # URLs
|
||||||
|
```
|
||||||
|
|
||||||
|
Scale the fleet without touching YAML:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform apply -var drone_count=9 -var speedup=4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `image_pull_policy = "Never"` — the simulator image is imported by the
|
||||||
|
Ansible playbook (`k3d image import`); the cluster never pulls from a
|
||||||
|
registry, mirroring the air-gap doctrine.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
# Simulated fleet (T4 dev environment, docs/06) on the k3d cluster created
|
||||||
|
# by infra/ansible/sim-cluster.yml. Drones are StatefulSet replicas writing
|
||||||
|
# to a shared hostPath lake — the same /data contract as the on-board NVMe.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
labels = { "app.kubernetes.io/part-of" = "swarm-sim" }
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_namespace" "swarm" {
|
||||||
|
metadata {
|
||||||
|
name = var.namespace
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Fleet ------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "kubernetes_stateful_set" "drone" {
|
||||||
|
metadata {
|
||||||
|
name = "drone"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
service_name = "drone"
|
||||||
|
replicas = var.drone_count
|
||||||
|
# A landed drone powers off; the next start is the next flight
|
||||||
|
pod_management_policy = "Parallel"
|
||||||
|
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "drone" }
|
||||||
|
}
|
||||||
|
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = merge(local.labels, { app = "drone" })
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "drone"
|
||||||
|
image = var.simulator_image
|
||||||
|
image_pull_policy = "Never" # imported via k3d image import
|
||||||
|
|
||||||
|
env {
|
||||||
|
name = "DRONE_ID"
|
||||||
|
value_from {
|
||||||
|
field_ref {
|
||||||
|
field_path = "metadata.name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "DURATION_S"
|
||||||
|
value = tostring(var.flight_duration_s)
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SPEEDUP"
|
||||||
|
value = tostring(var.speedup)
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "DATA_DIR"
|
||||||
|
value = "/data"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "KEEP_ALIVE"
|
||||||
|
value = "1"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "IMU_HZ"
|
||||||
|
value = "20"
|
||||||
|
}
|
||||||
|
|
||||||
|
volume_mount {
|
||||||
|
name = "lake"
|
||||||
|
mount_path = "/data"
|
||||||
|
}
|
||||||
|
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "50m", memory = "128Mi" }
|
||||||
|
limits = { cpu = "500m", memory = "512Mi" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volume {
|
||||||
|
name = "lake"
|
||||||
|
host_path {
|
||||||
|
path = var.data_host_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Exporter + Prometheus + Grafana (observability, docs/07) ----------------
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "exporter" {
|
||||||
|
metadata {
|
||||||
|
name = "exporter"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "exporter" }
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = merge(local.labels, { app = "exporter" })
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "exporter"
|
||||||
|
image = var.simulator_image
|
||||||
|
image_pull_policy = "Never"
|
||||||
|
command = ["python", "monitoring/exporter.py"]
|
||||||
|
env {
|
||||||
|
name = "DATA_DIR"
|
||||||
|
value = "/data"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "EXPORTER_PORT"
|
||||||
|
value = "9105"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SCAN_INTERVAL_S"
|
||||||
|
value = "30"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "METRIC_FLIGHT_WINDOW"
|
||||||
|
value = "5"
|
||||||
|
}
|
||||||
|
port {
|
||||||
|
container_port = 9105
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "50m", memory = "64Mi" }
|
||||||
|
limits = { cpu = "500m", memory = "256Mi" }
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "lake"
|
||||||
|
mount_path = "/data"
|
||||||
|
read_only = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "lake"
|
||||||
|
host_path {
|
||||||
|
path = var.data_host_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "exporter" {
|
||||||
|
metadata {
|
||||||
|
name = "exporter"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
selector = { app = "exporter" }
|
||||||
|
port {
|
||||||
|
port = 9105
|
||||||
|
target_port = 9105
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "explorer" {
|
||||||
|
metadata {
|
||||||
|
name = "explorer"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "explorer" }
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = merge(local.labels, { app = "explorer" })
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "explorer"
|
||||||
|
image = var.simulator_image
|
||||||
|
image_pull_policy = "Never"
|
||||||
|
command = ["python", "explorer/server.py"]
|
||||||
|
env {
|
||||||
|
name = "DATA_DIR"
|
||||||
|
value = "/data"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "EXPLORER_PORT"
|
||||||
|
value = "8088"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "VIEW_REFRESH_S"
|
||||||
|
value = "30"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "TREE_CACHE_S"
|
||||||
|
value = "15"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "METRIC_FLIGHT_WINDOW"
|
||||||
|
value = "5"
|
||||||
|
}
|
||||||
|
port {
|
||||||
|
container_port = 8088
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "50m", memory = "64Mi" }
|
||||||
|
limits = { cpu = "750m", memory = "256Mi" }
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "lake"
|
||||||
|
mount_path = "/data"
|
||||||
|
read_only = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "lake"
|
||||||
|
host_path {
|
||||||
|
path = var.data_host_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "explorer" {
|
||||||
|
metadata {
|
||||||
|
name = "explorer"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
type = "NodePort"
|
||||||
|
selector = { app = "explorer" }
|
||||||
|
port {
|
||||||
|
port = 8088
|
||||||
|
target_port = 8088
|
||||||
|
node_port = var.node_ports.explorer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_config_map" "prometheus" {
|
||||||
|
metadata {
|
||||||
|
name = "prometheus-config"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"prometheus.yml" = <<-EOT
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: swarm
|
||||||
|
static_configs:
|
||||||
|
- targets: ["exporter:9105"]
|
||||||
|
- job_name: explorer
|
||||||
|
static_configs:
|
||||||
|
- targets: ["explorer:8088"]
|
||||||
|
- job_name: node
|
||||||
|
static_configs:
|
||||||
|
- targets: ["node-exporter:9100"]
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "prometheus" {
|
||||||
|
metadata {
|
||||||
|
name = "prometheus"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "prometheus" }
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = merge(local.labels, { app = "prometheus" })
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "prometheus"
|
||||||
|
image = "prom/prometheus:v2.53.0"
|
||||||
|
port {
|
||||||
|
container_port = 9090
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "config"
|
||||||
|
mount_path = "/etc/prometheus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "config"
|
||||||
|
config_map {
|
||||||
|
name = kubernetes_config_map.prometheus.metadata[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "prometheus" {
|
||||||
|
metadata {
|
||||||
|
name = "prometheus"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
type = "NodePort"
|
||||||
|
selector = { app = "prometheus" }
|
||||||
|
port {
|
||||||
|
port = 9090
|
||||||
|
target_port = 9090
|
||||||
|
node_port = var.node_ports.prometheus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_config_map" "grafana_provisioning" {
|
||||||
|
metadata {
|
||||||
|
name = "grafana-provisioning"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"datasource.yml" = <<-EOT
|
||||||
|
apiVersion: 1
|
||||||
|
deleteDatasources:
|
||||||
|
- name: Prometheus
|
||||||
|
orgId: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
uid: swarm-prom
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
EOT
|
||||||
|
"dashboards.yml" = <<-EOT
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: swarm
|
||||||
|
folder: ""
|
||||||
|
type: file
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_config_map" "grafana_dashboard" {
|
||||||
|
metadata {
|
||||||
|
name = "grafana-dashboard"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "grafana" {
|
||||||
|
metadata {
|
||||||
|
name = "grafana"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "grafana" }
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = merge(local.labels, { app = "grafana" })
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "grafana"
|
||||||
|
image = "grafana/grafana:11.1.0"
|
||||||
|
port {
|
||||||
|
container_port = 3000
|
||||||
|
}
|
||||||
|
|
||||||
|
env {
|
||||||
|
name = "GF_AUTH_ANONYMOUS_ENABLED"
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "GF_AUTH_ANONYMOUS_ORG_ROLE"
|
||||||
|
value = "Admin"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "GF_AUTH_DISABLE_LOGIN_FORM"
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
# Air-gap hygiene — same flags as the Compose profile
|
||||||
|
env {
|
||||||
|
name = "GF_ANALYTICS_REPORTING_ENABLED"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "GF_ANALYTICS_CHECK_FOR_UPDATES"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
volume_mount {
|
||||||
|
name = "provisioning-datasources"
|
||||||
|
mount_path = "/etc/grafana/provisioning/datasources"
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "provisioning-dashboards"
|
||||||
|
mount_path = "/etc/grafana/provisioning/dashboards"
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "dashboards"
|
||||||
|
mount_path = "/var/lib/grafana/dashboards"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volume {
|
||||||
|
name = "provisioning-datasources"
|
||||||
|
config_map {
|
||||||
|
name = kubernetes_config_map.grafana_provisioning.metadata[0].name
|
||||||
|
items {
|
||||||
|
key = "datasource.yml"
|
||||||
|
path = "datasource.yml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "provisioning-dashboards"
|
||||||
|
config_map {
|
||||||
|
name = kubernetes_config_map.grafana_provisioning.metadata[0].name
|
||||||
|
items {
|
||||||
|
key = "dashboards.yml"
|
||||||
|
path = "dashboards.yml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "dashboards"
|
||||||
|
config_map {
|
||||||
|
name = kubernetes_config_map.grafana_dashboard.metadata[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "grafana" {
|
||||||
|
metadata {
|
||||||
|
name = "grafana"
|
||||||
|
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
type = "NodePort"
|
||||||
|
selector = { app = "grafana" }
|
||||||
|
port {
|
||||||
|
port = 3000
|
||||||
|
target_port = 3000
|
||||||
|
node_port = var.node_ports.grafana
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
output "explorer_url" {
|
||||||
|
description = "Data-plane explorer (partition tree + read-only SQL)"
|
||||||
|
value = "http://localhost:${var.node_ports.explorer}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "grafana_url" {
|
||||||
|
description = "Grafana dashboards"
|
||||||
|
value = "http://localhost:${var.node_ports.grafana}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "prometheus_url" {
|
||||||
|
description = "Prometheus"
|
||||||
|
value = "http://localhost:${var.node_ports.prometheus}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "fleet" {
|
||||||
|
description = "Deployed fleet shape"
|
||||||
|
value = {
|
||||||
|
drones = var.drone_count
|
||||||
|
flight_duration_s = var.flight_duration_s
|
||||||
|
speedup = var.speedup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
variable "kubeconfig" {
|
||||||
|
description = "Path to the kubeconfig for the simulation cluster"
|
||||||
|
type = string
|
||||||
|
default = "~/.kube/config"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "kube_context" {
|
||||||
|
description = "Kubeconfig context of the k3d simulation cluster"
|
||||||
|
type = string
|
||||||
|
default = "k3d-swarm-sim"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "namespace" {
|
||||||
|
description = "Namespace for the simulated fleet"
|
||||||
|
type = string
|
||||||
|
default = "swarm"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "simulator_image" {
|
||||||
|
description = "Simulator image (imported into k3d by infra/ansible/sim-cluster.yml)"
|
||||||
|
type = string
|
||||||
|
default = "swarm-house/simulator:dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "drone_count" {
|
||||||
|
description = "Number of virtual drones in the fleet"
|
||||||
|
type = number
|
||||||
|
default = 5
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = var.drone_count >= 1 && var.drone_count <= 32
|
||||||
|
error_message = "drone_count must be between 1 and 32."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "flight_duration_s" {
|
||||||
|
description = "Simulated flight length in seconds; each pod restart begins a new flight"
|
||||||
|
type = number
|
||||||
|
default = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "speedup" {
|
||||||
|
description = "Wall-clock acceleration of the simulation"
|
||||||
|
type = number
|
||||||
|
default = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "data_host_path" {
|
||||||
|
description = "Host path inside the k3d node mounted as the shared Parquet lake"
|
||||||
|
type = string
|
||||||
|
default = "/data"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "node_ports" {
|
||||||
|
description = "Host-reachable NodePorts (must match the ports opened by sim-cluster.yml)"
|
||||||
|
type = object({
|
||||||
|
explorer = number
|
||||||
|
grafana = number
|
||||||
|
prometheus = number
|
||||||
|
})
|
||||||
|
default = {
|
||||||
|
explorer = 30088
|
||||||
|
grafana = 30300
|
||||||
|
prometheus = 30990
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.5"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
kubernetes = {
|
||||||
|
source = "hashicorp/kubernetes"
|
||||||
|
version = "~> 2.33"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "kubernetes" {
|
||||||
|
config_path = var.kubeconfig
|
||||||
|
config_context = var.kube_context
|
||||||
|
}
|
||||||
+133
-40
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
|
||||||
import {
|
import {
|
||||||
AREA,
|
AREA,
|
||||||
|
MAX_ASPECT,
|
||||||
|
areaWidth,
|
||||||
fmtBytes,
|
fmtBytes,
|
||||||
makeWorld,
|
makeWorld,
|
||||||
seed,
|
seed,
|
||||||
@@ -9,24 +12,56 @@ import {
|
|||||||
} from "./sim";
|
} from "./sim";
|
||||||
|
|
||||||
const VIEW = 1000;
|
const VIEW = 1000;
|
||||||
|
const PANEL_W = 320;
|
||||||
|
const HEADER_H = 70;
|
||||||
|
|
||||||
|
// Patrol field aspect ratio for the current window: wide monitors get a
|
||||||
|
// wider field instead of empty side margins. Quantized to 0.25 steps so
|
||||||
|
// minor resizes don't rebuild the world.
|
||||||
|
function displayAspect(): number {
|
||||||
|
const w = Math.max(320, window.innerWidth - PANEL_W);
|
||||||
|
const h = Math.max(320, window.innerHeight - HEADER_H);
|
||||||
|
const q = Math.round((w / h) * 4) / 4;
|
||||||
|
return Math.max(1, Math.min(MAX_ASPECT, q));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
const [speedup, setSpeedup] = useState(1);
|
const [speedup, setSpeedup] = useState(1);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [aspect, setAspect] = useState(displayAspect);
|
||||||
|
const [mode, setMode] = useState<"sim" | "live">("sim");
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null);
|
||||||
const [world, setWorld] = useState<World>(() => {
|
const [world, setWorld] = useState<World>(() => {
|
||||||
seed(42);
|
seed(42);
|
||||||
return makeWorld(8);
|
return makeWorld(8, displayAspect());
|
||||||
});
|
});
|
||||||
const raf = useRef(0);
|
const raf = useRef(0);
|
||||||
const last = useRef(performance.now());
|
const last = useRef(performance.now());
|
||||||
|
const liveWorld = useRef<World | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
seed(42);
|
seed(42);
|
||||||
setWorld(makeWorld(droneCount));
|
setWorld(makeWorld(droneCount, aspect));
|
||||||
}, [droneCount]);
|
}, [droneCount, aspect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let timer = 0;
|
||||||
|
const onResize = (): void => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => setAspect(displayAspect()), 250);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "sim") return;
|
||||||
const loop = (now: number): void => {
|
const loop = (now: number): void => {
|
||||||
const dt = Math.min(0.05, (now - last.current) / 1000);
|
const dt = Math.min(0.05, (now - last.current) / 1000);
|
||||||
last.current = now;
|
last.current = now;
|
||||||
@@ -37,7 +72,40 @@ export default function App(): JSX.Element {
|
|||||||
};
|
};
|
||||||
raf.current = requestAnimationFrame(loop);
|
raf.current = requestAnimationFrame(loop);
|
||||||
return () => cancelAnimationFrame(raf.current);
|
return () => cancelAnimationFrame(raf.current);
|
||||||
}, [paused, speedup]);
|
}, [paused, speedup, mode]);
|
||||||
|
|
||||||
|
// Live mode: poll the explorer's read-only SQL API for real poses
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "live") return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const poses = await fetchLivePoses(DEFAULT_EXPLORER_URL);
|
||||||
|
if (cancelled) return;
|
||||||
|
setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null);
|
||||||
|
liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000);
|
||||||
|
setWorld(liveWorld.current);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
const timer = window.setInterval(() => void poll(), LIVE_POLL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "sim") {
|
||||||
|
liveWorld.current = null;
|
||||||
|
setLiveError(null);
|
||||||
|
seed(42);
|
||||||
|
setWorld(makeWorld(droneCount, aspect));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
const activeLinks = useMemo(
|
const activeLinks = useMemo(
|
||||||
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
|
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
|
||||||
@@ -55,38 +123,54 @@ export default function App(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.controls}>
|
<div style={styles.controls}>
|
||||||
<label style={styles.label}>
|
<button
|
||||||
drones
|
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
|
||||||
<input
|
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
|
||||||
type="range"
|
title={`live source: ${DEFAULT_EXPLORER_URL}`}
|
||||||
min={3}
|
>
|
||||||
max={24}
|
{mode === "sim" ? "go live" : "back to sim"}
|
||||||
value={droneCount}
|
|
||||||
onChange={(e) => setDroneCount(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<span style={styles.value}>{droneCount}</span>
|
|
||||||
</label>
|
|
||||||
<label style={styles.label}>
|
|
||||||
speed
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0.5}
|
|
||||||
max={6}
|
|
||||||
step={0.5}
|
|
||||||
value={speedup}
|
|
||||||
onChange={(e) => setSpeedup(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<span style={styles.value}>{speedup}x</span>
|
|
||||||
</label>
|
|
||||||
<button style={styles.button} onClick={() => setPaused((p) => !p)}>
|
|
||||||
{paused ? "resume" : "pause"}
|
|
||||||
</button>
|
</button>
|
||||||
|
{mode === "live" && (
|
||||||
|
<span style={liveError ? styles.liveError : styles.liveOk}>
|
||||||
|
{liveError ?? `live · ${DEFAULT_EXPLORER_URL}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mode === "sim" && (
|
||||||
|
<>
|
||||||
|
<label style={styles.label}>
|
||||||
|
drones
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={3}
|
||||||
|
max={24}
|
||||||
|
value={droneCount}
|
||||||
|
onChange={(e) => setDroneCount(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span style={styles.value}>{droneCount}</span>
|
||||||
|
</label>
|
||||||
|
<label style={styles.label}>
|
||||||
|
speed
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={6}
|
||||||
|
step={0.5}
|
||||||
|
value={speedup}
|
||||||
|
onChange={(e) => setSpeedup(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span style={styles.value}>{speedup}x</span>
|
||||||
|
</label>
|
||||||
|
<button style={styles.button} onClick={() => setPaused((p) => !p)}>
|
||||||
|
{paused ? "resume" : "pause"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={styles.main}>
|
<div style={styles.main}>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
||||||
style={styles.canvas}
|
style={styles.canvas}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
>
|
>
|
||||||
@@ -96,7 +180,7 @@ export default function App(): JSX.Element {
|
|||||||
<stop offset="100%" stopColor="#0a0f18" />
|
<stop offset="100%" stopColor="#0a0f18" />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width={VIEW} height={VIEW} fill="url(#ground)" />
|
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
||||||
{gridLines()}
|
{gridLines()}
|
||||||
|
|
||||||
{/* Mesh links */}
|
{/* Mesh links */}
|
||||||
@@ -141,7 +225,7 @@ export default function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
{ob.moving && (
|
{ob.moving && (
|
||||||
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
||||||
mobile
|
transit
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
@@ -155,7 +239,7 @@ export default function App(): JSX.Element {
|
|||||||
>
|
>
|
||||||
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
|
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
|
||||||
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
|
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
|
||||||
dr-{String(d.id + 1).padStart(2, "0")} · ch{d.channel} · {d.battery.toFixed(0)}%
|
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
@@ -173,7 +257,8 @@ export default function App(): JSX.Element {
|
|||||||
{busiest.map((l) => (
|
{busiest.map((l) => (
|
||||||
<div key={l.key} style={styles.linkRow}>
|
<div key={l.key} style={styles.linkRow}>
|
||||||
<span style={styles.linkName}>
|
<span style={styles.linkName}>
|
||||||
dr-{String(l.a + 1).padStart(2, "0")} ↔ dr-{String(l.b + 1).padStart(2, "0")}
|
{world.drones[l.a]?.name ?? `dr-${String(l.a + 1).padStart(2, "0")}`} ↔{" "}
|
||||||
|
{world.drones[l.b]?.name ?? `dr-${String(l.b + 1).padStart(2, "0")}`}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.linkStat}>
|
<span style={styles.linkStat}>
|
||||||
{fmtBytes(l.rate)}/s · ↑{fmtBytes(l.totalUp)} ↓{fmtBytes(l.totalDown)}
|
{fmtBytes(l.rate)}/s · ↑{fmtBytes(l.totalUp)} ↓{fmtBytes(l.totalDown)}
|
||||||
@@ -183,10 +268,10 @@ export default function App(): JSX.Element {
|
|||||||
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
|
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="Legend">
|
<Section title="Legend">
|
||||||
<div style={styles.dim}>blue line — pose broadcast (5 Hz, ~46 B)</div>
|
<div style={styles.dim}>blue line — pose broadcast (5 Hz, 45 B)</div>
|
||||||
<div style={styles.dim}>green line — bulk sync (sealed partitions)</div>
|
<div style={styles.dim}>green line — bulk sync (sealed partitions)</div>
|
||||||
<div style={styles.dim}>flash — broadcast event delivered</div>
|
<div style={styles.dim}>flash — broadcast event delivered</div>
|
||||||
<div style={styles.dim}>red circle — mobile obstacle</div>
|
<div style={styles.dim}>red circle — transit object crossing the area</div>
|
||||||
</Section>
|
</Section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,12 +287,17 @@ function sy(y: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gridLines(): JSX.Element {
|
function gridLines(): JSX.Element {
|
||||||
|
const width = sx(areaWidth());
|
||||||
|
const step = VIEW / 10; // one cell per 100 m in both directions
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for (let i = 1; i < 10; i++) {
|
for (let p = step; p < width; p += step) {
|
||||||
const p = (i / 10) * VIEW;
|
|
||||||
lines.push(
|
lines.push(
|
||||||
<line key={`v${i}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
|
<line key={`v${p}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
|
||||||
<line key={`h${i}`} x1={0} y1={p} x2={VIEW} y2={p} stroke="#1a2436" strokeWidth={1} />,
|
);
|
||||||
|
}
|
||||||
|
for (let p = step; p < VIEW; p += step) {
|
||||||
|
lines.push(
|
||||||
|
<line key={`h${p}`} x1={0} y1={p} x2={width} y2={p} stroke="#1a2436" strokeWidth={1} />,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <g>{lines}</g>;
|
return <g>{lines}</g>;
|
||||||
@@ -264,6 +354,9 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
|
buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" },
|
||||||
|
liveOk: { fontSize: 11, color: "#39d98a" },
|
||||||
|
liveError: { fontSize: 11, color: "#c05a6a" },
|
||||||
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
|
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
|
||||||
canvas: { flex: 1, minWidth: 0, display: "block" },
|
canvas: { flex: 1, minWidth: 0, display: "block" },
|
||||||
panel: {
|
panel: {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Live mode: real drone poses from the explorer's read-only SQL API.
|
||||||
|
// The same gated /api/query endpoint a peer drone (or an engineer on the
|
||||||
|
// bench) would use — the prototype is just another read-only consumer.
|
||||||
|
|
||||||
|
import { AREA, LINK_RANGE, POSE_RATE_BYTES, type LinkStats, type World } from "./sim";
|
||||||
|
|
||||||
|
export const DEFAULT_EXPLORER_URL =
|
||||||
|
(import.meta.env.VITE_EXPLORER_URL as string | undefined) ?? "http://localhost:30088";
|
||||||
|
|
||||||
|
export interface LivePose {
|
||||||
|
id: string;
|
||||||
|
x: number; // meters, mission frame
|
||||||
|
y: number;
|
||||||
|
yawDeg: number;
|
||||||
|
battery: number | null;
|
||||||
|
tsNs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest pose per drone from its own 'sent' state rows, joined with the
|
||||||
|
// latest battery reading from telemetry. arg_max keeps it a single scan.
|
||||||
|
const LIVE_SQL = `
|
||||||
|
WITH pose AS (
|
||||||
|
SELECT drone,
|
||||||
|
arg_max(pos_x, ts_ns) AS x,
|
||||||
|
arg_max(pos_y, ts_ns) AS y,
|
||||||
|
arg_max(yaw, ts_ns) AS yaw,
|
||||||
|
max(ts_ns) AS ts_ns
|
||||||
|
FROM state
|
||||||
|
WHERE direction = 'sent'
|
||||||
|
GROUP BY drone
|
||||||
|
),
|
||||||
|
batt AS (
|
||||||
|
SELECT drone, arg_max(level_pct, ts_ns) AS battery
|
||||||
|
FROM telemetry
|
||||||
|
WHERE sensor = 'battery'
|
||||||
|
GROUP BY drone
|
||||||
|
)
|
||||||
|
SELECT p.drone, p.x, p.y, p.yaw, p.ts_ns, b.battery
|
||||||
|
FROM pose p LEFT JOIN batt b USING (drone)
|
||||||
|
ORDER BY p.drone`;
|
||||||
|
|
||||||
|
interface QueryResponse {
|
||||||
|
columns?: string[];
|
||||||
|
rows?: (string | number | null)[][];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLivePoses(baseUrl: string): Promise<LivePose[]> {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/query`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sql: LIVE_SQL }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`explorer responded ${res.status}`);
|
||||||
|
const data = (await res.json()) as QueryResponse;
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
return (data.rows ?? []).map((r) => ({
|
||||||
|
id: String(r[0]),
|
||||||
|
x: Number(r[1]),
|
||||||
|
y: Number(r[2]),
|
||||||
|
yawDeg: Number(r[3]),
|
||||||
|
tsNs: Number(r[4]),
|
||||||
|
battery: r[5] === null ? null : Number(r[5]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale mission-frame meters into the prototype's square view. The virtual
|
||||||
|
// drones patrol a smaller square than the sim world, so fit the observed
|
||||||
|
// extent (with padding) instead of assuming a size.
|
||||||
|
function fitScale(poses: LivePose[]): number {
|
||||||
|
const extent = Math.max(400, ...poses.map((p) => Math.max(p.x, p.y))) * 1.15;
|
||||||
|
return AREA / extent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkKey(a: number, b: number): string {
|
||||||
|
return a < b ? `${a}-${b}` : `${b}-${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert live poses into the World shape the renderer already speaks. */
|
||||||
|
export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World {
|
||||||
|
const k = fitScale(poses);
|
||||||
|
const drones = poses.map((p, i) => ({
|
||||||
|
id: i,
|
||||||
|
name: p.id,
|
||||||
|
pos: { x: p.x * k, y: p.y * k },
|
||||||
|
vel: { x: 0, y: 0 },
|
||||||
|
heading: (p.yawDeg * Math.PI) / 180,
|
||||||
|
waypoint: 0,
|
||||||
|
battery: p.battery ?? 100,
|
||||||
|
channel: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Every in-range pair exchanges pose broadcasts; accumulate totals so the
|
||||||
|
// panel keeps its meaning between polls.
|
||||||
|
const links = new Map<string, LinkStats>();
|
||||||
|
let totalBytes = prev?.totalBytes ?? 0;
|
||||||
|
for (let i = 0; i < drones.length; i++) {
|
||||||
|
for (let j = i + 1; j < drones.length; j++) {
|
||||||
|
const dist = Math.hypot(
|
||||||
|
drones[i].pos.x - drones[j].pos.x,
|
||||||
|
drones[i].pos.y - drones[j].pos.y,
|
||||||
|
);
|
||||||
|
if (dist > LINK_RANGE) continue;
|
||||||
|
const key = linkKey(i, j);
|
||||||
|
const old = prev?.links.get(key);
|
||||||
|
const bytes = POSE_RATE_BYTES * 2 * dtS;
|
||||||
|
totalBytes += bytes;
|
||||||
|
links.set(key, {
|
||||||
|
key,
|
||||||
|
a: i,
|
||||||
|
b: j,
|
||||||
|
totalUp: (old?.totalUp ?? 0) + bytes / 2,
|
||||||
|
totalDown: (old?.totalDown ?? 0) + bytes / 2,
|
||||||
|
rate: POSE_RATE_BYTES * 2,
|
||||||
|
flash: 0.6,
|
||||||
|
bulk: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
t: (prev?.t ?? 0) + dtS,
|
||||||
|
drones,
|
||||||
|
obstacles: [],
|
||||||
|
links,
|
||||||
|
totalBytes,
|
||||||
|
broadcasts: (prev?.broadcasts ?? 0) + links.size * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
+89
-31
@@ -9,6 +9,7 @@ export interface Vec {
|
|||||||
|
|
||||||
export interface Drone {
|
export interface Drone {
|
||||||
id: number;
|
id: number;
|
||||||
|
name?: string; // live mode: real drone_id from the data plane
|
||||||
pos: Vec;
|
pos: Vec;
|
||||||
vel: Vec;
|
vel: Vec;
|
||||||
heading: number; // radians
|
heading: number; // radians
|
||||||
@@ -45,12 +46,21 @@ export interface World {
|
|||||||
broadcasts: number;
|
broadcasts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AREA = 1000; // meters, square side
|
export const AREA = 1000; // meters, vertical extent of the field
|
||||||
|
export const MAX_ASPECT = 2.2;
|
||||||
|
// Horizontal extent follows the display aspect ratio (set via makeWorld),
|
||||||
|
// so wide monitors get a genuinely wider patrol area, not empty margins.
|
||||||
|
let areaX = AREA;
|
||||||
|
export function areaWidth(): number {
|
||||||
|
return areaX;
|
||||||
|
}
|
||||||
export const LINK_RANGE = 320;
|
export const LINK_RANGE = 320;
|
||||||
const CRUISE = 28;
|
const CRUISE = 28;
|
||||||
const SEPARATION = 55;
|
const SEPARATION = 55;
|
||||||
const POSE_BYTES = 46;
|
const POSE_BYTES = 45;
|
||||||
const POSE_HZ = 5;
|
const POSE_HZ = 5;
|
||||||
|
// Steady per-link broadcast throughput (both directions), bytes/s
|
||||||
|
export const POSE_RATE_BYTES = POSE_BYTES * POSE_HZ;
|
||||||
const CHANNELS = [1, 6, 11, 36, 40, 44, 149, 157];
|
const CHANNELS = [1, 6, 11, 36, 40, 44, 149, 157];
|
||||||
|
|
||||||
let seedState = 1234;
|
let seedState = 1234;
|
||||||
@@ -65,35 +75,85 @@ function rnd(): number {
|
|||||||
return ((seedState >>> 0) % 100000) / 100000;
|
return ((seedState >>> 0) % 100000) / 100000;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WAYPOINTS: Vec[] = [
|
// Two interleaved patrol routes: even drones circle the perimeter, odd
|
||||||
{ x: 120, y: 120 },
|
// drones fly an X through the center. Paths cross mid-field, so the mesh
|
||||||
{ x: AREA - 120, y: 150 },
|
// keeps bridging between the front and the back of the formation instead
|
||||||
{ x: AREA - 150, y: AREA - 120 },
|
// of stretching into disconnected segments along one loop. Routes are
|
||||||
{ x: 150, y: AREA - 150 },
|
// derived from the current field width, so they stretch on wide displays.
|
||||||
];
|
function perimeterRoute(): Vec[] {
|
||||||
|
return [
|
||||||
|
{ x: 120, y: 120 },
|
||||||
|
{ x: areaX - 120, y: 150 },
|
||||||
|
{ x: areaX - 150, y: AREA - 120 },
|
||||||
|
{ x: 150, y: AREA - 150 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function crossRoute(): Vec[] {
|
||||||
|
return [
|
||||||
|
{ x: 150, y: 150 },
|
||||||
|
{ x: areaX - 150, y: AREA - 150 },
|
||||||
|
{ x: areaX - 150, y: 150 },
|
||||||
|
{ x: 150, y: AREA - 150 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function routeOf(d: Drone): Vec[] {
|
||||||
|
return d.id % 2 === 0 ? perimeterRoute() : crossRoute();
|
||||||
|
}
|
||||||
|
|
||||||
export function makeWorld(droneCount: number): World {
|
// Transit objects enter at one edge, cross the whole area, and respawn at
|
||||||
|
// a fresh edge once they leave — a stream of through-traffic instead of
|
||||||
|
// obstacles bouncing until they wedge into a corner.
|
||||||
|
const TRANSIT_MARGIN = 120;
|
||||||
|
function spawnTransit(id: number): Obstacle {
|
||||||
|
const speed = 26 + rnd() * 30;
|
||||||
|
const alongX = 120 + rnd() * (areaX - 240);
|
||||||
|
const alongY = 120 + rnd() * (AREA - 240);
|
||||||
|
const skew = (rnd() - 0.5) * speed * 0.9; // diagonal-ish crossings
|
||||||
|
const edge = Math.floor(rnd() * 4);
|
||||||
|
const table: { pos: Vec; vel: Vec }[] = [
|
||||||
|
{ pos: { x: alongX, y: -TRANSIT_MARGIN + 20 }, vel: { x: skew, y: speed } },
|
||||||
|
{ pos: { x: alongX, y: AREA + TRANSIT_MARGIN - 20 }, vel: { x: skew, y: -speed } },
|
||||||
|
{ pos: { x: -TRANSIT_MARGIN + 20, y: alongY }, vel: { x: speed, y: skew } },
|
||||||
|
{ pos: { x: areaX + TRANSIT_MARGIN - 20, y: alongY }, vel: { x: -speed, y: skew } },
|
||||||
|
];
|
||||||
|
const { pos, vel } = table[edge];
|
||||||
|
return { id, pos, radius: 22 + rnd() * 16, moving: true, vel };
|
||||||
|
}
|
||||||
|
function outOfTransit(ob: Obstacle): boolean {
|
||||||
|
return (
|
||||||
|
ob.pos.x < -TRANSIT_MARGIN ||
|
||||||
|
ob.pos.x > areaX + TRANSIT_MARGIN ||
|
||||||
|
ob.pos.y < -TRANSIT_MARGIN ||
|
||||||
|
ob.pos.y > AREA + TRANSIT_MARGIN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeWorld(droneCount: number, aspect = 1): World {
|
||||||
|
areaX = AREA * Math.max(1, Math.min(MAX_ASPECT, aspect));
|
||||||
const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
|
const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
|
||||||
const angle = (i / droneCount) * Math.PI * 2;
|
const angle = (i / droneCount) * Math.PI * 2;
|
||||||
return {
|
return {
|
||||||
id: i,
|
id: i,
|
||||||
pos: {
|
pos: {
|
||||||
x: AREA / 2 + Math.cos(angle) * (150 + rnd() * 120),
|
x: areaX / 2 + Math.cos(angle) * (150 + rnd() * 120),
|
||||||
y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120),
|
y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120),
|
||||||
},
|
},
|
||||||
vel: { x: 0, y: 0 },
|
vel: { x: 0, y: 0 },
|
||||||
heading: angle,
|
heading: angle,
|
||||||
waypoint: i % WAYPOINTS.length,
|
waypoint: i % 4,
|
||||||
battery: 90 + rnd() * 10,
|
battery: 90 + rnd() * 10,
|
||||||
channel: CHANNELS[i % CHANNELS.length],
|
channel: CHANNELS[i % CHANNELS.length],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// Static obstacles sit at fixed fractions of the field, so they spread
|
||||||
|
// out instead of clustering left when the field widens
|
||||||
const obstacles: Obstacle[] = [
|
const obstacles: Obstacle[] = [
|
||||||
{ id: 0, pos: { x: 340, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } },
|
{ id: 0, pos: { x: areaX * 0.34, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } },
|
||||||
{ id: 1, pos: { x: 700, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } },
|
{ id: 1, pos: { x: areaX * 0.7, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } },
|
||||||
{ id: 2, pos: { x: 560, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } },
|
{ id: 2, pos: { x: areaX * 0.56, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } },
|
||||||
{ id: 3, pos: { x: 200, y: 800 }, radius: 30, moving: true, vel: { x: 14, y: -6 } },
|
spawnTransit(3),
|
||||||
{ id: 4, pos: { x: 820, y: 620 }, radius: 26, moving: true, vel: { x: -10, y: 9 } },
|
spawnTransit(4),
|
||||||
|
spawnTransit(5),
|
||||||
];
|
];
|
||||||
return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 };
|
return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 };
|
||||||
}
|
}
|
||||||
@@ -103,12 +163,13 @@ function linkKey(a: number, b: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function steer(d: Drone, world: World, dt: number): Drone {
|
function steer(d: Drone, world: World, dt: number): Drone {
|
||||||
const wp = WAYPOINTS[(d.waypoint + d.id) % WAYPOINTS.length];
|
const route = routeOf(d);
|
||||||
|
const wp = route[(d.waypoint + d.id) % route.length];
|
||||||
let ax = wp.x - d.pos.x;
|
let ax = wp.x - d.pos.x;
|
||||||
let ay = wp.y - d.pos.y;
|
let ay = wp.y - d.pos.y;
|
||||||
const wpDist = Math.hypot(ax, ay);
|
const wpDist = Math.hypot(ax, ay);
|
||||||
let waypoint = d.waypoint;
|
let waypoint = d.waypoint;
|
||||||
if (wpDist < 90) waypoint = (d.waypoint + 1) % WAYPOINTS.length;
|
if (wpDist < 90) waypoint = (d.waypoint + 1) % route.length;
|
||||||
ax /= wpDist || 1;
|
ax /= wpDist || 1;
|
||||||
ay /= wpDist || 1;
|
ay /= wpDist || 1;
|
||||||
|
|
||||||
@@ -147,7 +208,7 @@ function steer(d: Drone, world: World, dt: number): Drone {
|
|||||||
vel: { x: vx, y: vy },
|
vel: { x: vx, y: vy },
|
||||||
heading,
|
heading,
|
||||||
pos: {
|
pos: {
|
||||||
x: Math.max(20, Math.min(AREA - 20, d.pos.x + vx * dt)),
|
x: Math.max(20, Math.min(areaX - 20, d.pos.x + vx * dt)),
|
||||||
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
|
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
|
||||||
},
|
},
|
||||||
battery: Math.max(0, d.battery - dt * 0.05),
|
battery: Math.max(0, d.battery - dt * 0.05),
|
||||||
@@ -157,18 +218,15 @@ function steer(d: Drone, world: World, dt: number): Drone {
|
|||||||
|
|
||||||
export function tick(world: World, dt: number): World {
|
export function tick(world: World, dt: number): World {
|
||||||
const drones = world.drones.map((d) => steer(d, world, dt));
|
const drones = world.drones.map((d) => steer(d, world, dt));
|
||||||
const obstacles = world.obstacles.map((ob) =>
|
const obstacles = world.obstacles.map((ob) => {
|
||||||
ob.moving
|
if (!ob.moving) return ob;
|
||||||
? {
|
const moved = {
|
||||||
...ob,
|
...ob,
|
||||||
pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt },
|
pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt },
|
||||||
vel: {
|
};
|
||||||
x: ob.pos.x < 60 || ob.pos.x > AREA - 60 ? -ob.vel.x : ob.vel.x,
|
// Once a transit object leaves the area, respawn it at a random edge
|
||||||
y: ob.pos.y < 60 || ob.pos.y > AREA - 60 ? -ob.vel.y : ob.vel.y,
|
return outOfTransit(moved) ? spawnTransit(ob.id) : moved;
|
||||||
},
|
});
|
||||||
}
|
|
||||||
: ob,
|
|
||||||
);
|
|
||||||
|
|
||||||
const links = new Map(world.links);
|
const links = new Map(world.links);
|
||||||
let totalBytes = world.totalBytes;
|
let totalBytes = world.totalBytes;
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
data
|
||||||
|
.venv
|
||||||
|
README.md
|
||||||
@@ -1,9 +1,33 @@
|
|||||||
|
FROM python:3.12-slim AS test
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt pytest
|
||||||
|
COPY virtual_drone ./virtual_drone
|
||||||
|
COPY monitoring ./monitoring
|
||||||
|
COPY explorer ./explorer
|
||||||
|
COPY tests ./tests
|
||||||
|
RUN groupadd --gid 10001 swarm \
|
||||||
|
&& useradd --uid 10001 --gid swarm --home-dir /app --shell /usr/sbin/nologin swarm \
|
||||||
|
&& chown -R swarm:swarm /app
|
||||||
|
USER swarm
|
||||||
|
RUN pytest tests/ -q
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
|
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
|
||||||
COPY virtual_drone ./virtual_drone
|
COPY virtual_drone ./virtual_drone
|
||||||
|
# Monitoring and explorer ship in the same image so Kubernetes can run
|
||||||
|
# them without bind mounts (Compose overrides these with live mounts)
|
||||||
|
COPY monitoring ./monitoring
|
||||||
|
COPY explorer ./explorer
|
||||||
|
RUN groupadd --gid 10001 swarm \
|
||||||
|
&& useradd --uid 10001 --gid swarm --home-dir /app --shell /usr/sbin/nologin swarm \
|
||||||
|
&& mkdir -p /data \
|
||||||
|
&& chown -R swarm:swarm /app /data
|
||||||
|
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
|
USER swarm
|
||||||
CMD ["python", "-m", "virtual_drone.main"]
|
CMD ["python", "-m", "virtual_drone.main"]
|
||||||
|
|||||||
+6
-4
@@ -14,14 +14,16 @@ 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 `./data/` with the standard layout:
|
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:
|
||||||
|
|
||||||
```
|
```
|
||||||
data/dataset=telemetry/flight=…/drone=…/sensor=imu/year=…/…/hour=…/data.parquet
|
var/t1/dataset=telemetry/flight=…/drone=…/sensor=imu/year=…/…/hour=…/data.parquet
|
||||||
data/dataset=detections/flight=…/drone=…/…
|
var/t1/dataset=detections/flight=…/drone=…/…
|
||||||
data/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).
|
||||||
|
|
||||||
## Run a single drone without Docker
|
## Run a single drone without Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
# Compose default bridge network: subnet-directed broadcast works
|
# Compose default bridge network: subnet-directed broadcast works
|
||||||
BROADCAST_ADDR: ${BROADCAST_ADDR:-255.255.255.255}
|
BROADCAST_ADDR: ${BROADCAST_ADDR:-255.255.255.255}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ${SWARM_T1_DIR:-../var/t1}:/data
|
||||||
|
|
||||||
# --- Monitoring profile: docker compose --profile monitoring up ---
|
# --- Monitoring profile: docker compose --profile monitoring up ---
|
||||||
|
|
||||||
@@ -24,9 +24,10 @@ 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:
|
||||||
- ./data:/data:ro
|
- ${SWARM_T1_DIR:-../var/t1}:/data:ro
|
||||||
- ./monitoring:/app/monitoring:ro
|
- ./monitoring:/app/monitoring:ro
|
||||||
ports:
|
ports:
|
||||||
- "${EXPORTER_PORT:-9105}:9105"
|
- "${EXPORTER_PORT:-9105}:9105"
|
||||||
@@ -37,8 +38,11 @@ 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:
|
||||||
- ./data:/data:ro
|
- ${SWARM_T1_DIR:-../var/t1}:/data:ro
|
||||||
- ./explorer:/app/explorer:ro
|
- ./explorer:/app/explorer:ro
|
||||||
ports:
|
ports:
|
||||||
- "${EXPLORER_UI_PORT:-8088}:8088"
|
- "${EXPLORER_UI_PORT:-8088}:8088"
|
||||||
@@ -58,6 +62,10 @@ services:
|
|||||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||||
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||||
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
||||||
|
# Air-gap hygiene: no phone-home, no update checks, no plugin fetches
|
||||||
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
|
||||||
volumes:
|
volumes:
|
||||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
|||||||
+106
-45
@@ -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,21 +153,31 @@ 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)))
|
||||||
|
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
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
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
|
||||||
@@ -146,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"]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pyarrow>=16.0
|
pyarrow>=16.0
|
||||||
duckdb>=1.0
|
duckdb>=1.0
|
||||||
pytz>=2024.1 # duckdb needs it to fetch TIMESTAMPTZ values into Python
|
pytz>=2024.1 # duckdb needs it to fetch TIMESTAMPTZ values into Python
|
||||||
|
pytest>=8.0
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Pose broadcast frame encode/decode roundtrip."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from virtual_drone.broadcast import FRAME, decode_state, encode_state
|
||||||
|
from virtual_drone.flight import Pose
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_size_matches_wire_spec() -> None:
|
||||||
|
# Keep docs/04, the journey, and prototype/src/sim.ts POSE_BYTES in lockstep.
|
||||||
|
assert FRAME.size == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_roundtrip() -> None:
|
||||||
|
pose = Pose(
|
||||||
|
ts_ns=1_700_000_000_000_000_000,
|
||||||
|
x=123.456,
|
||||||
|
y=78.9,
|
||||||
|
z=60.0,
|
||||||
|
roll=1.5,
|
||||||
|
pitch=-2.0,
|
||||||
|
yaw=45.0,
|
||||||
|
vx=3.2,
|
||||||
|
vy=-1.1,
|
||||||
|
vz=0.05,
|
||||||
|
)
|
||||||
|
payload = encode_state("dr-01", pose, flags=0)
|
||||||
|
assert len(payload) == FRAME.size
|
||||||
|
|
||||||
|
decoded = decode_state(payload)
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["peer_id"] == "dr-01"
|
||||||
|
assert decoded["ts_ns"] == pose.ts_ns
|
||||||
|
assert decoded["pos_x"] == pytest.approx(pose.x, abs=0.001)
|
||||||
|
assert decoded["pos_y"] == pytest.approx(pose.y, abs=0.001)
|
||||||
|
assert decoded["pos_z"] == pytest.approx(pose.z, abs=0.001)
|
||||||
|
assert decoded["roll"] == pytest.approx(pose.roll, abs=0.01)
|
||||||
|
assert decoded["pitch"] == pytest.approx(pose.pitch, abs=0.01)
|
||||||
|
assert decoded["yaw"] == pytest.approx(pose.yaw, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_invalid_payload() -> None:
|
||||||
|
assert decode_state(b"short") is None
|
||||||
|
assert decode_state(b"XX" + b"\0" * (FRAME.size - 2)) is None
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Flight kinematics — waypoint patrol and step integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from virtual_drone.config import Config
|
||||||
|
from virtual_drone.flight import initial_pose, make_waypoints, step
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_waypoints_returns_closed_perimeter() -> None:
|
||||||
|
cfg = Config()
|
||||||
|
rng = random.Random(7)
|
||||||
|
wps = make_waypoints(cfg, rng)
|
||||||
|
assert len(wps) == 4
|
||||||
|
assert all(len(p) == 2 for p in wps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_moves_toward_target() -> None:
|
||||||
|
cfg = Config()
|
||||||
|
rng = random.Random(3)
|
||||||
|
pose = initial_pose(cfg, rng, 1_000_000_000)
|
||||||
|
target = (pose.x + 50.0, pose.y + 50.0)
|
||||||
|
next_pose, _ = step(pose, target, cfg, rng, dt=0.1, ts_ns=1_100_000_000)
|
||||||
|
dist_before = ((target[0] - pose.x) ** 2 + (target[1] - pose.y) ** 2) ** 0.5
|
||||||
|
dist_after = ((target[0] - next_pose.x) ** 2 + (target[1] - next_pose.y) ** 2) ** 0.5
|
||||||
|
assert dist_after < dist_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_reached_when_close_to_waypoint() -> None:
|
||||||
|
cfg = Config()
|
||||||
|
rng = random.Random(1)
|
||||||
|
pose = initial_pose(cfg, rng, 2_000_000_000)
|
||||||
|
_, reached = step(pose, (pose.x, pose.y), cfg, rng, dt=1.0, ts_ns=2_100_000_000)
|
||||||
|
assert reached is True
|
||||||
@@ -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])
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Read-only SQL gate — same rules as the peer-query forced command."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "explorer"))
|
||||||
|
|
||||||
|
from server import gate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_allows_read_statements() -> None:
|
||||||
|
assert gate("SELECT 1") is None
|
||||||
|
assert gate("WITH t AS (SELECT 1) SELECT * FROM t") is None
|
||||||
|
assert gate("DESCRIBE telemetry") is None
|
||||||
|
assert gate("SUMMARIZE state") is None
|
||||||
|
assert gate("SHOW TABLES") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_writes_and_config() -> None:
|
||||||
|
assert gate("DROP TABLE telemetry") is not None
|
||||||
|
assert gate("INSERT INTO t VALUES (1)") is not None
|
||||||
|
assert gate("UPDATE t SET x=1") is not None
|
||||||
|
assert gate("DELETE FROM t") is not None
|
||||||
|
assert gate("CREATE TABLE t (x INT)") is not None
|
||||||
|
assert gate("INSTALL httpfs") is not None
|
||||||
|
assert gate("SET memory_limit='1GB'") is not None
|
||||||
|
assert gate("PRAGMA threads=4") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_multi_statement_and_injection() -> None:
|
||||||
|
assert gate("SELECT 1; SELECT 2") is not None
|
||||||
|
assert gate("/* sneaky */ COPY t TO 'x'") is not None
|
||||||
|
assert gate("") is not None
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Hive-partitioned Parquet writer layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
|
from virtual_drone.flight import Pose
|
||||||
|
from virtual_drone.sensors import imu_row
|
||||||
|
from virtual_drone.writer import PartitionWriter, make_writers
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_path_follows_hive_layout(tmp_path: Path) -> None:
|
||||||
|
ts_ns = int(time.time() * 1e9)
|
||||||
|
writer = PartitionWriter(tmp_path, "telemetry", "flt-01", "dr-01", "imu")
|
||||||
|
writer.append(imu_row(
|
||||||
|
Pose(ts_ns=ts_ns, x=1.0, y=2.0, z=3.0, roll=0.0, pitch=0.0, yaw=0.0, vx=0.0, vy=0.0, vz=0.0),
|
||||||
|
__import__("random").Random(0),
|
||||||
|
ts_ns,
|
||||||
|
))
|
||||||
|
writer.seal()
|
||||||
|
|
||||||
|
files = list(tmp_path.rglob("*.parquet"))
|
||||||
|
assert len(files) >= 1
|
||||||
|
path_str = str(files[0])
|
||||||
|
assert "dataset=telemetry" in path_str
|
||||||
|
assert "flight=flt-01" in path_str
|
||||||
|
assert "drone=dr-01" in path_str
|
||||||
|
assert "sensor=imu" in path_str
|
||||||
|
assert "year=" in path_str and "hour=" in path_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_seal_compacts_current_blocks(tmp_path: Path) -> None:
|
||||||
|
writer = PartitionWriter(tmp_path, "state", "flt-02", "dr-02", None)
|
||||||
|
base_ts = 1_700_000_000_000_000_000
|
||||||
|
for i in range(3):
|
||||||
|
writer.append({"ts_ns": base_ts + i * 5_000_000_000, "pos_x": float(i)})
|
||||||
|
writer.seal()
|
||||||
|
|
||||||
|
sealed = list(tmp_path.rglob("data.parquet"))
|
||||||
|
assert len(sealed) == 1
|
||||||
|
assert not list(tmp_path.rglob("current/min_*.parquet"))
|
||||||
|
rows = duckdb.sql(f"SELECT count(*) FROM read_parquet('{sealed[0]}')").fetchone()[0]
|
||||||
|
assert rows == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_writers_registers_expected_datasets(tmp_path: Path) -> None:
|
||||||
|
writers = make_writers(tmp_path, "flt-03", "dr-03", ["imu", "battery"])
|
||||||
|
assert set(writers) == {"imu", "battery", "detections", "state"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_duckdb_reads_written_partition(tmp_path: Path) -> None:
|
||||||
|
writers = make_writers(tmp_path, "flt-04", "dr-04", ["imu"])
|
||||||
|
ts_ns = int(time.time() * 1e9)
|
||||||
|
pose = Pose(ts_ns=ts_ns, x=0.0, y=0.0, z=0.0, roll=0.0, pitch=0.0, yaw=0.0, vx=0.0, vy=0.0, vz=0.0)
|
||||||
|
writers["imu"].append(imu_row(pose, __import__("random").Random(1), ts_ns))
|
||||||
|
for w in writers.values():
|
||||||
|
w.seal()
|
||||||
|
|
||||||
|
count = duckdb.sql(f"""
|
||||||
|
SELECT count(*) FROM read_parquet(
|
||||||
|
'{tmp_path}/dataset=telemetry/**/*.parquet',
|
||||||
|
hive_partitioning=true, union_by_name=true)
|
||||||
|
""").fetchone()[0]
|
||||||
|
assert count >= 1
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"""State broadcast over UDP: the compact pose frame from the sync design.
|
"""State broadcast over UDP: the compact pose frame from the sync design.
|
||||||
|
|
||||||
Frame layout (little-endian, 46 bytes):
|
Frame layout (little-endian, 45 bytes) — source of truth for docs and the
|
||||||
|
prototype bandwidth estimate:
|
||||||
|
|
||||||
magic 2s b"SH"
|
magic 2s b"SH"
|
||||||
version B
|
version B
|
||||||
drone_id 8s zero-padded ascii
|
drone_id 8s zero-padded ascii (PoC; production may switch to uint16 registry)
|
||||||
ts_ns q epoch nanoseconds
|
ts_ns q epoch nanoseconds
|
||||||
pos_mm 3i position, millimeters (quantized on the wire only)
|
pos_mm 3i position in the mission frame, millimeters (wire quantization only)
|
||||||
att_cdeg 3h roll/pitch/yaw, centi-degrees
|
att_cdeg 3h roll/pitch/yaw, centi-degrees
|
||||||
vel_cms 3h velocity, cm/s
|
vel_cms 3h velocity, cm/s
|
||||||
frame_ref B
|
frame_ref B
|
||||||
|
|||||||
@@ -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