Compare commits

...

14 Commits

Author SHA1 Message Date
83b6cbebe3 init rig 2026-08-20 11:24:42 -03:00
a65c92257d Stop build.py sweeping secrets and bytecode into gen/
gen/<room>/ is the docker build context and soleprint/Dockerfile is `COPY . .`,
so anything reaching gen/ reaches an image layer — and registry.mcrn.ar is
public-read. station/tools/tester/.env has been gitignored since the last
incident, but .gitignore does not bind shutil: copy_path() called
shutil.copytree() with no ignore=, so the key was copied into every built room.
Verified extractable from soleprint_localtest-soleprint:latest (built 8 days
ago) at /app/station/tools/tester/.env.

ctrl/deploy.sh's --exclude='.env' is why this looked handled; it only covers the
rsync path, not the build-and-push path.

Two layers now:
  - copy_path()/merge_into() filter .env, __pycache__, *.pyc, .git, node_modules
    and virtualenvs out of bulk directory copies. Single-file copies named by a
    caller are untouched, so cfg/<room>/.env.example still ships.
  - soleprint/.dockerignore repeats the rule at the docker boundary and is
    copied into the context beside the Dockerfile. Follows the convention
    soleprint/atlas/.dockerignore already set (.env, .env.*, !.env.example).

Runtime is unaffected: no Dockerfile COPYs a .env, and the room compose files
supply it with `env_file: - .env`, read from the host at run time.

The key itself still needs rotating — it remains in git history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:59:26 -03:00
78595f1bd9 Declare pass-through words PHONY in the Makefile
`make build ctrl` ran the build and then printed "make: 'ctrl' is up to date."
The empty rule from $(eval $(ARGS):;@:) is not enough when the word names a real
directory — and cfg, ctrl, docs, gen and init all exist at this level. Only
.PHONY stops make consulting the filesystem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:55:06 -03:00
9bcd439266 Normalise line endings to LF
spr had no .gitattributes at all, despite shipping ctrl/*.sh and generating
gen/<room>/ctrl/*.sh. A checkout on Windows/WSL rewrites those to CRLF, and a
shell script with CRLF fails as `bad interpreter: /usr/bin/env bash^M` — which
reads as a broken installer rather than a line-ending problem.

Copied verbatim from rig/.gitattributes and deliberately duplicated rather than
shared: rig/ has to carry its own so it survives being handed over alone.

No tracked file in either repo currently has CRLF, so `git add --renormalize .`
rewrote nothing. Landing it now, while that is true, keeps it off the diff of
whatever lands next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:55:06 -03:00
74f03566f1 Ignore client rigs at the repo root, before rig's tree lands
A rig is a copy of rig/ renamed after the environment it models, so its k8s
files spell out a real architecture — the one thing that must not be committed
here. rig/.gitignore already refuses them, but only within rig/: a copy is a
SIBLING of rig/, where that file has no reach. spr had no rule at all, so the
first `git add -A` after the fold would have committed one.

Anchored at the root, and the negation names the full path because `*-rig/` is
unanchored and would otherwise match rig/sample-rig too.

Verified both ways: a file under client-rig/ is ignored, one under
rig/sample-rig/ is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:54:45 -03:00
2d9bc9289c updates 33.2 112 2026-08-11 07:30:27 -03:00
910927993e updates 33.1 139 2026-08-10 09:19:19 -03:00
9a6337e493 updates 33.1 84 2026-08-10 05:36:32 -03:00
0b04516cbb updates 33.1 56 2026-08-10 03:44:50 -03:00
ef63b02554 update 33.1 50 2026-08-10 03:23:30 -03:00
33f0559268 updates 33.1 23 2026-08-10 01:35:13 -03:00
dfb1991ae3 updates 33.1 8 2026-08-10 00:32:47 -03:00
9fc4c23143 update docs 2026-05-06 12:04:19 -03:00
973f0a01c9 update sample app 2026-05-06 11:59:42 -03:00
206 changed files with 18876 additions and 1245 deletions

27
.gitattributes vendored Normal file
View File

@@ -0,0 +1,27 @@
# Copied verbatim from rig/.gitattributes, and deliberately duplicated rather
# than shared: rig/ must carry its own so it survives being handed over on its
# own, and spr had none at all despite shipping ctrl/*.sh and generating
# gen/<room>/ctrl/*.sh.
#
# Line endings are normalised to LF in the repository and on checkout, on every
# platform. Without this, a checkout on Windows/WSL rewrites files to CRLF and
# every one of them shows up as modified without anyone having touched it.
#
# For the scripts it is not cosmetic: a shell script with CRLF fails on Linux
# with `bad interpreter: /usr/bin/env bash^M`, which reads as a broken installer
# rather than a line-ending problem — the worst possible first impression on a
# machine where nothing has been proven yet.
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.env text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
# Never touch binaries.
*.png binary
*.jpg binary
*.zip binary
*.tar binary
*.gz binary

26
.gitignore vendored
View File

@@ -9,12 +9,38 @@ __pycache__/
.venv/ .venv/
venv/ venv/
# Secrets. There was no rule here, which is how a real API key ended up tracked
# in station/tools/tester/.env. Templates still ship.
.env
!.env.example
# Node
node_modules/
# Built library bundles (regenerate with `pnpm build` in the package).
# The dist is what a container boots; it is an artifact, not source.
dist/
# Generated runnable instance (entirely gitignored - regenerate with build.py) # Generated runnable instance (entirely gitignored - regenerate with build.py)
gen/ gen/
# Specs and sheets uploaded through shuntgen's UI. Inputs someone dropped in a
# browser, not source — and often a client's real data.
soleprint/station/tools/shuntgen/uploads/
# Room configurations (separate repo - contains credentials and room-specific data) # Room configurations (separate repo - contains credentials and room-specific data)
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms # Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
cfg/amar/ cfg/amar/
cfg/dlt/ cfg/dlt/
# Add new rooms here as they are created # Add new rooms here as they are created
# cfg/<room>/ # cfg/<room>/
# Client rigs. A rig is a copy of rig/ renamed after the environment it models,
# so its k8s files spell out a real architecture — exactly the thing that must
# not land here. They are versioned in their own repo.
#
# Anchored at the ROOT on purpose: a copy is a SIBLING of rig/, so a rule inside
# rig/.gitignore cannot see it. The negation must name the full path for the same
# reason — `*-rig/` is unanchored and matches at any depth, including rig/sample-rig.
*-rig/
!rig/sample-rig/

147
CLAUDE.md
View File

@@ -51,20 +51,19 @@ spr/
│ └── amar/ # Amar room config │ └── amar/ # Amar room config
│ ├── config.json │ ├── config.json
│ ├── data/ │ ├── data/
│ ├── artery/ # Amar-specific (merged into output) │ ├── soleprint/ # Room overlay — merged over soleprint/ on build
│ │ ── shunts/amar/ │ │ ── artery/ # room shunts, pulses
├── atlas/ # Amar-specific books └── shunts/amar/
│ │ ── books/ │ │ ── atlas/ # room books
├── station/ # Amar-specific tools config └── books/
│ │ ── tools/datagen/ │ │ ── station/ # room tool configs
│ │ │ └── tools/datagen/
│ │ └── nginx/
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
│ ├── link/ # Bridge to managed app │ ├── link/ # Bridge to managed app
── soleprint/ # Soleprint docker config ── amar/ # The managed app itself
│ ├── databrowse/
│ ├── tester/
│ ├── monitors/
│ └── models/
├── ctrl/ # Build/run scripts ├── ctrl/ # Build/run scripts (see Build & Run)
└── gen/ # Built instances (gitignored) └── gen/ # Built instances (gitignored)
├── standalone/ ├── standalone/
@@ -100,26 +99,38 @@ Each room in `cfg/` has:
- `config.json` - Framework branding/terminology - `config.json` - Framework branding/terminology
- `data/` - Data files (veins.json, shunts.json, etc.) - `data/` - Data files (veins.json, shunts.json, etc.)
Room-specific system configs (merged into output): Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
- `artery/` - Room-specific shunts, pulses the core `soleprint/` tree at build time:
- `atlas/` - Room-specific books - `soleprint/artery/` - Room-specific shunts, pulses
- `station/` - Room-specific tool configs (datagen, tester tests, etc.) - `soleprint/atlas/` - Room-specific books
- `soleprint/station/` - Room-specific tool configs (datagen generators, tester tests)
A room's own lifecycle scripts live in `cfg/<room>/ctrl/` and land in
`gen/<room>/ctrl/` — that is what `make start <room>` dispatches to.
## Build & Run ## Build & Run
`make` is the front door — one target per `ctrl/` script, with the subcommand as an
argument (`make cluster up`, not `make cluster-up`). The logic lives in the scripts,
never in the Makefile. `make help` lists every target.
```bash ```bash
# Build make # = make help
python build.py # -> gen/standalone/ make build [room|all|models] # -> gen/<room>/ (default: standalone)
python build.py --cfg amar # -> gen/amar/ make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
python build.py --all # -> all rooms make stop [room]
make cluster [up|down|status] # the shared `spr` kind cluster
make component [list|sync|watch|publish|diff]
make deploy [--build|--sync-only]
```
# Run bare-metal Every script stays runnable on its own — the standalone rule holds:
cd gen/standalone && python run.py
# Using ctrl scripts ```bash
./ctrl/build.sh [room] python build.py --cfg amar # -> gen/amar/
./ctrl/start.sh [room] [-d] cd gen/standalone && python run.py # bare-metal
./ctrl/stop.sh [room] ./ctrl/kind-up.sh # still works directly
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
``` ```
## Adding a New Room ## Adding a New Room
@@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data
cp cfg/standalone/config.json cfg/newroom/ cp cfg/standalone/config.json cfg/newroom/
cp -r cfg/standalone/data/* cfg/newroom/data/ cp -r cfg/standalone/data/* cfg/newroom/data/
# Add room-specific configs as needed: # Add room-specific configs as needed (note the soleprint/ overlay level):
# cfg/newroom/artery/shunts/... # cfg/newroom/soleprint/artery/shunts/...
# cfg/newroom/atlas/books/... # cfg/newroom/soleprint/atlas/books/...
# cfg/newroom/station/tools/... # cfg/newroom/soleprint/station/tools/datagen/<name>.py
python build.py --cfg newroom make build newroom
``` ```
## Ports ## Ports
@@ -149,10 +160,51 @@ python build.py --cfg newroom
|------|---------| |------|---------|
| modelgen | Generate models from config | | modelgen | Generate models from config |
| datagen | Generate test data (uses faker) | | datagen | Generate test data (uses faker) |
| tester | BDD/playwright test runner | | tester | HTTP contract test runner |
| graphgen | Generate navigable model graphs | | graphgen | Generate navigable model graphs |
| databrowse | SQL data browser | | databrowse | SQL data browser |
## Shared UI (`soleprint/common/ui`)
Upstream copy of the `soleprint-ui` package. `mpr/ui/framework` and
`meetus/ui/framework` are **downstream copies** synced by `ctrl/spr.py` — edit here,
never there, or the next sync overwrites the change.
```bash
cd soleprint/common/ui
pnpm install && pnpm typecheck && pnpm test
pnpm build # -> dist/soleprint-ui.js + dist/style.css
```
**The theme ships with the bundle.** Every component styles itself with
`var(--surface-0)` and friends from `src/tokens.css`, so `src/index.ts` imports
`src/theme.css` (tokens + `base.css`). A bundle without it renders broken, not merely
unbranded. Retheme by overriding the variables in `tokens.css`; don't inline colours
in components.
`dist/` and `node_modules/` are gitignored — the dist is an artifact a container
boots, not source. To hand someone a running artifact without the sources:
```bash
python ctrl/spr.py publish soleprint-ui <dest> --dist # bundle + manifest only
```
## Tests
**No test bodies are committed to core.** `soleprint/station/tools/tester/` ships the
base class, runner and UI; tests belong to a room at
`cfg/<room>/soleprint/station/tools/tester/tests/`.
Two rules, both in `tester/tests/test_template.py`:
1. **One suite, any environment** — a test states what the API promises, never who
implements it or where it runs.
2. **No helper framework tools** — stdlib `unittest` + `httpx`. No pytest fixtures,
no framework test client, no factories, no ORM or database access.
Django, where it appears, is an optional private DB editor via its admin — never the
framework, and never something a test reaches into.
## External Paths ## External Paths
| What | Path | | What | Path |
@@ -161,6 +213,35 @@ python build.py --cfg newroom
## Files Ignored ## Files Ignored
- `gen/` - Regenerate with `python build.py` - `gen/` - Regenerate with `make build [room]`
- `dist/` - Build artifact; regenerate with `pnpm build`
- `node_modules/`
- `fails/`, `def/` - Drafts - `fails/`, `def/` - Drafts
- `__pycache__/`, `.venv/` - `__pycache__/`, `.venv/`
## Known Broken (found, not yet fixed)
- `tester/tests/example/test_health.py` imports `pytest`, which is neither a
dependency nor allowed here — `tests/README.md` says stdlib `unittest` and
`httpx`, no pytest. It fails to import, so `python -m tester discover` reports
it as `_FailedTest`. It also duplicates `test_template.py`, which is the
sanctioned example. Removing it is probably the fix; core ships no tests.
- Old vocabulary survives in prose — `album`, `larder`, `ward`, `nest` and
`pawprint` still appear in READMEs, `docs/`, and this file's history. Live code
is clean; the docs lag.
## Fixed
- **A real API key was tracked** in `station/tools/tester/.env`. Untracked, `.env`
added to `.gitignore`, `.env.example` added. **The key is still in git history
and must be rotated** — untracking does not unpublish it.
- Client vocabulary is out of live code. `atlas/main.py` was written entirely as
`Album`/`larder`/`PAWPRINT_URL`; `databrowse` docs described a `larder/` the
code stopped using; `get-api-key.sh` defaulted to the client's database name.
- Two dead back-links, found while renaming: `atlas/main.py` passed
`pawprint_url` and `artery/index.html` read `pawprint_url`, while `run.py`
passes `soleprint_url`. Neither "← Soleprint" link had ever rendered.
- `atlas/main.py` fetched `/api/data/album`; `main.py` serves `/api/data/atlas`.
`get_data()` had been failing and returning empty lists.
- `tester/tests/_dev/test_health.py` imported a non-existent `..endpoints`. The
import was unused; removing it makes the module discoverable (2 tests).

80
Makefile Normal file
View File

@@ -0,0 +1,80 @@
# Thin control Makefile — one target per ctrl/ script, and the subcommand is an
# argument rather than a second target: `make cluster down`, not `make cluster-down`.
#
# The logic lives in the scripts, never here. Each target maps to exactly one
# file, and that file holds the variants:
#
# make cluster up -> ctrl/cluster.sh up
# make build amar -> ctrl/build.sh amar
#
# Bare words pass straight through. Anything starting with a dash would be
# swallowed by make itself, so pass those via ARGS instead:
#
# make component ARGS="publish soleprint-ui /tmp/out --dist"
# make deploy ARGS="--build"
#
# Every script stays runnable on its own (./ctrl/kind-up.sh still works, and each
# built room keeps its own gen/<room>/ctrl/*.sh) — the standalone rule holds, and
# this only saves typing.
#
# Start with: make build && make start
# The room to act on when none is named. Rooms live in cfg/ and build into gen/.
ROOM ?= standalone
PYTHON ?= python3
export PYTHON
# Words after the target become the script's subcommand. Make would otherwise
# treat them as goals of their own, so each gets a no-op rule.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
# Declares each extra word as a target that does nothing: `:` is an empty rule
# body and `@` silences it. Without this, `make build sample` runs the build and
# then fails with "No rule to make target 'sample'", because make reads every
# word on the line as something it has been asked to build.
$(eval $(ARGS):;@:)
# ...and as PHONY, because some of those words name real directories. `cfg`,
# `ctrl`, `docs`, `gen` and `init` all exist at this level, and make considers a
# target that is an existing directory already built — so `make build ctrl` ran
# the build and then printed "make: 'ctrl' is up to date". The empty rule above
# is not enough on its own; only .PHONY stops make consulting the filesystem.
.PHONY: $(ARGS)
endif
.DEFAULT_GOAL := help
.PHONY: help build start stop dist docs cluster deploy component
help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
# ── rooms ──────────────────────────────────────────────────────────────────
build: ## build a room into gen/ [<room>|all|models]
bash ctrl/build.sh $(or $(ARGS),$(ROOM))
start: ## run a built room [<room>] [-d] [--build]
bash ctrl/start.sh $(or $(ARGS),$(ROOM))
stop: ## stop a running room [<room>]
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [<room>]
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# ── cluster ────────────────────────────────────────────────────────────────
cluster: ## shared kind cluster [up|down|status] (default status)
bash ctrl/cluster.sh $(or $(ARGS),status)
# ── distribution ───────────────────────────────────────────────────────────
component: ## publish components [list|sync|watch|publish|diff]
$(PYTHON) ctrl/spr.py $(or $(ARGS),list)
deploy: ## push standalone to the server [--build|--sync-only]
bash ctrl/deploy.sh $(ARGS)

View File

@@ -2,44 +2,14 @@
Development workflow platform. Wraps existing applications with tools, testing, and documentation — without touching source code. Development workflow platform. Wraps existing applications with tools, testing, and documentation — without touching source code.
Cada paso deja huella. *Cada paso deja huella.*
## Quick Start ---
```bash
# Create a room
python -m init.cli myroom
# Build
python build.py --cfg myroom
# Run
cd gen/myroom/soleprint && docker compose up
# Visit http://localhost:12000
```
Or use the browser wizard: `python -m init.web` → http://localhost:9000
## Docs ## Docs
```bash ```bash
cd docs && python -m http.server 8080 cd docs && python -m http.server 8080
# http://localhost:8080
``` ```
## Structure Visit `http://localhost:8080`. Start with [Concepts](docs/data/en/concepts.md) for the mental model, then [Quickstart](docs/data/en/quickstart.md) to run your first room.
```
spr/
├── soleprint/ # Core framework
│ ├── artery/ # Connectors (veins, shunts, pulses)
│ ├── atlas/ # Documentation (books, templates)
│ └── station/ # Tools (tester, datagen, modelgen)
├── cfg/ # Room configurations
├── init/ # Room setup (CLI + web wizard)
├── docs/ # Documentation site
├── ctrl/ # Build/deploy scripts
├── build.py # Build tool: cfg/ → gen/
└── gen/ # Built instances (gitignored)
```

439
build.py
View File

@@ -23,6 +23,7 @@ Generated structure for managed rooms:
""" """
import argparse import argparse
import importlib.util
import json import json
import logging import logging
import shutil import shutil
@@ -79,6 +80,30 @@ def _rmtree_resilient(path: Path):
) )
# Never swept into a built room, wherever they appear in a source tree.
#
# This is a SECURITY boundary, not tidiness. gen/<room>/ is the docker build
# context, soleprint/Dockerfile is `COPY . .`, and there is no .dockerignore —
# so anything that reaches gen/ reaches an image layer, and registry.mcrn.ar is
# public-read. That is how station/tools/tester/.env, gitignored since the last
# incident, still ended up baked into soleprint_localtest-soleprint:latest with
# its API key intact. .gitignore does not bind shutil.
#
# Applied to bulk directory copies only. A caller naming a single file is making
# an explicit request (cfg/<room>/.env.example is the one that matters) and is
# left alone.
ALWAYS_IGNORE = {".git", "__pycache__", "node_modules", ".venv", "venv", ".env"}
ALWAYS_IGNORE_SUFFIXES = (".pyc", ".pyo")
def is_ignored(name: str) -> bool:
return name in ALWAYS_IGNORE or name.endswith(ALWAYS_IGNORE_SUFFIXES)
def _copytree_ignore(directory, files):
return {f for f in files if is_ignored(f)}
def copy_path(source: Path, target: Path, quiet: bool = False): def copy_path(source: Path, target: Path, quiet: bool = False):
"""Copy file or directory, resolving symlinks.""" """Copy file or directory, resolving symlinks."""
if target.is_symlink(): if target.is_symlink():
@@ -90,7 +115,7 @@ def copy_path(source: Path, target: Path, quiet: bool = False):
target.unlink() target.unlink()
if source.is_dir(): if source.is_dir():
shutil.copytree(source, target, symlinks=False) shutil.copytree(source, target, symlinks=False, ignore=_copytree_ignore)
if not quiet: if not quiet:
log.info(f" {target.name}/") log.info(f" {target.name}/")
else: else:
@@ -110,6 +135,8 @@ def merge_into(source: Path, target: Path):
for item in source.rglob("*"): for item in source.rglob("*"):
if item.is_file(): if item.is_file():
rel = item.relative_to(source) rel = item.relative_to(source)
if any(is_ignored(part) for part in rel.parts):
continue
dest = target / rel dest = target / rel
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest) shutil.copy2(item, dest)
@@ -317,6 +344,363 @@ def copy_cfg(output_dir: Path, room: str):
copy_path(item, output_dir / item.name) copy_path(item, output_dir / item.name)
def load_cabinets(room: str) -> list[dict]:
"""The dependency containers a room asked for, in the order it listed them.
Read from cfg/<room>/data/cabinets.json — the same shape and the same place
as its sibling data/*.json files, so nothing new has to know about it.
Entries are {"name": "postgres"} and may carry an "env" override.
"""
path = SPR_ROOT / "cfg" / room / "data" / "cabinets.json"
if not path.exists():
return []
try:
entries = json.loads(path.read_text())
except ValueError as e:
log.warning(f" cabinets.json is not valid JSON, ignoring: {e}")
return []
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def resolve_cabinets(requested: list[dict]) -> list[dict]:
"""Expand each request into its definition, pulling in what it depends on.
Airflow without postgres is a container that exits on boot, so a cabinet's
depends_on is added for you rather than left as something to remember.
"""
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
resolved: dict[str, dict] = {}
def add(name: str, overrides: dict) -> None:
if name in resolved:
# Already pulled in as somebody's dependency. The room asking for it
# by name is the more specific statement, so its env still applies —
# otherwise declaring airflow before postgres would silently drop
# postgres's settings.
if overrides.get("env"):
resolved[name]["env"] = {
**resolved[name].get("env", {}),
**overrides["env"],
}
return
definition_path = cabinets_dir / name / "cabinet.json"
if not definition_path.exists():
available = sorted(
p.name for p in cabinets_dir.iterdir() if p.is_dir()
) if cabinets_dir.exists() else []
log.warning(f" no such cabinet: {name} (available: {', '.join(available) or 'none'})")
return
try:
definition = json.loads(definition_path.read_text())
except ValueError as e:
log.warning(f" cabinet {name} has invalid cabinet.json: {e}")
return
# Mark it claimed before recursing, so a dependency cycle terminates.
resolved[name] = definition
for dependency in definition.get("depends_on", []) or []:
add(dependency, {})
definition["env"] = {**definition.get("env", {}), **overrides.get("env", {})}
for entry in requested:
add(entry["name"], entry)
# Dependencies first, so compose reads in the order things start.
ordered: list[dict] = []
seen: set[str] = set()
def emit(name: str) -> None:
if name in seen or name not in resolved:
return
seen.add(name)
for dependency in resolved[name].get("depends_on", []) or []:
emit(dependency)
ordered.append(resolved[name])
for name in resolved:
emit(name)
return ordered
def compose_cabinets(output_dir: Path, room: str):
"""Merge the room's cabinets into its docker-compose.yml and .env.example.
This is the compile step for dependencies: a room declares postgres, and the
built instance comes out with postgres in its compose file rather than with
instructions for adding it.
"""
requested = load_cabinets(room)
if not requested:
return
try:
import yaml
except ImportError:
log.warning(
" cabinets need PyYAML to merge into docker-compose.yml "
"(pip install pyyaml) — skipping"
)
return
cabinets = resolve_cabinets(requested)
if not cabinets:
return
compose_path = output_dir / "docker-compose.yml"
if not compose_path.exists():
log.warning(
f" no docker-compose.yml in {output_dir.name}, "
f"so there is nothing to merge {len(cabinets)} cabinet(s) into"
)
return
original = compose_path.read_text()
# A YAML round-trip drops every comment, and the room's compose file leads
# with the one that says how to run it. Keep the header block; the rest is
# generated anyway.
header = []
for line in original.splitlines():
if line.startswith("#") or not line.strip():
header.append(line)
else:
break
while header and not header[-1].strip():
header.pop()
compose = yaml.safe_load(original) or {}
services = compose.setdefault("services", {})
volumes = compose.setdefault("volumes", {}) or {}
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
added, skipped = [], []
for cabinet in cabinets:
name = cabinet["name"]
service_name = cabinet.get("service", name)
# The room's own compose file is the authority. A room that already
# declares `db` has arranged it deliberately, and silently replacing it
# would be the worst possible outcome of switching a cabinet on.
if service_name in services:
skipped.append(service_name)
continue
fragment_path = cabinets_dir / name / "service.yml"
if not fragment_path.exists():
log.warning(f" cabinet {name} has no service.yml")
continue
fragment = yaml.safe_load(fragment_path.read_text()) or {}
for key, value in fragment.items():
if key in services:
skipped.append(key)
continue
services[key] = value
added.append(key)
for volume in cabinet.get("volumes", []) or []:
volumes.setdefault(volume, None)
if volumes:
compose["volumes"] = volumes
rendered = yaml.safe_dump(compose, sort_keys=False, default_flow_style=False)
banner = f"# Cabinets merged in by build.py: {', '.join(c['name'] for c in cabinets)}.\n"
preamble = ("\n".join(header) + "\n" + banner + "\n") if header else banner + "\n"
compose_path.write_text(preamble + rendered)
if added:
log.info(f" cabinets: {', '.join(added)}")
if skipped:
log.info(f" cabinets already declared by the room, left alone: {', '.join(skipped)}")
_append_cabinet_env(output_dir, cabinets)
def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
"""Add each cabinet's settings to .env.example, without touching .env."""
example = output_dir / ".env.example"
existing = example.read_text() if example.exists() else ""
# Match whole settings, not substrings: `POSTGRES_DB=` appears inside
# `MY_POSTGRES_DB=`, and a substring test would decide the setting was
# already there and skip it.
declared = {
line.split("=", 1)[0].strip()
for line in existing.splitlines()
if "=" in line and not line.lstrip().startswith("#")
}
lines = []
for cabinet in cabinets:
env = cabinet.get("env", {})
if not env:
continue
block = [f"\n# ── {cabinet.get('title', cabinet['name'])} (cabinet) ──"]
for note in cabinet.get("notes", []) or []:
block.append(f"# {note}")
wrote = False
for key, value in env.items():
if key in declared:
continue
block.append(f"{key}={value}")
declared.add(key)
wrote = True
if wrote:
lines.extend(block)
if lines:
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
def load_plexuses(room: str) -> list[dict]:
"""The plexuses a room asked for. Same shape as its sibling data/*.json."""
path = SPR_ROOT / "cfg" / room / "data" / "plexuses.json"
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except ValueError as e:
log.warning(f" plexuses.json is not valid JSON, ignoring: {e}")
return []
entries = raw.get("items", raw) if isinstance(raw, dict) else raw
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def _theme_css(theme: str) -> str:
"""The token contract plus one theme, flattened for inlining.
Only the named theme ships alongside the others it can switch to, because
the export has to work with no server: there is no /theme.css to fetch.
"""
theme_dir = SPR_ROOT / "soleprint" / "common" / "theme"
parts = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
# Every theme, so the switcher in the page has something to switch to.
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(sheet.read_text())
return "\n".join(parts)
def _inline_svg(name: str, theme: str) -> str:
"""A rendered graph, stripped of its XML prolog so it can sit in HTML.
Inlined rather than <img>-linked so the page's CSS can recolour it when the
theme switches — graphviz writes class="node accent" into the SVG, and CSS
outranks the presentation attributes it bakes in.
"""
graphs = SPR_ROOT / "docs" / "graphs"
for candidate in (graphs / f"{name}.{theme}.svg", graphs / f"{name}.svg"):
if candidate.exists():
svg = candidate.read_text()
start = svg.find("<svg")
return svg[start:] if start >= 0 else svg
log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh")
return "<p>diagram not rendered</p>"
def build_plexuses(output_dir: Path, room: str):
"""Export each plexus the room declared to a single self-contained file.
A plexus is exported, not served. The output is one index.html carrying its
theme, its data and its diagram, so it survives a locked-down machine, a zip
attachment and a double-click — which is the whole point of the format.
"""
requested = load_plexuses(room)
if not requested:
return
source_root = SPR_ROOT / "soleprint" / "artery" / "plexuses"
built = []
for entry in requested:
name = entry["name"]
source = source_root / name
manifest_path = source / "plexus.json"
if not manifest_path.exists():
available = sorted(
p.name for p in source_root.iterdir() if p.is_dir()
) if source_root.exists() else []
log.warning(
f" no such plexus: {name} (available: {', '.join(available) or 'none'})"
)
continue
try:
manifest = json.loads(manifest_path.read_text())
except ValueError as e:
log.warning(f" plexus {name} has invalid plexus.json: {e}")
continue
# The room may override anything the plexus declares — theme first.
manifest.update({k: v for k, v in entry.items() if k != "name"})
template_path = source / "app" / "index.html"
if not template_path.exists():
log.warning(f" plexus {name} has no app/index.html")
continue
theme = manifest.get("theme", "soleprint")
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
# A plexus may ship a showcase.py exposing collect(): anything it returns
# is merged into the page's data. The bundle uses it to run the real
# tools over the real fixtures at build time, so what the page shows
# cannot drift from what the tools do.
collector = source / "showcase.py"
if collector.exists():
try:
spec = importlib.util.spec_from_file_location(
f"plexus_{name}_showcase", collector
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data["showcase"] = module.collect()
except Exception as e:
log.warning(f" {name}: showcase.py failed ({type(e).__name__}: {e})")
page = template_path.read_text()
for token, value in (
("%%TITLE%%", manifest.get("title", name)),
("%%DESCRIPTION%%", manifest.get("description", "")),
("%%DEFAULT_THEME%%", theme),
("%%BUILT%%", f"{room} · built by soleprint build.py"),
("%%THEME_CSS%%", _theme_css(theme)),
("%%GRAPH%%", _inline_svg(manifest.get("graph", "system_overview"), theme)),
("%%BUNDLE%%", json.dumps(data, indent=2)),
):
page = page.replace(token, value)
target = output_dir / "plexuses" / name
ensure_dir(target)
(target / "index.html").write_text(page)
# Anything else in app/ rides along, for a plexus that outgrows one file.
for extra in (source / "app").iterdir():
if extra.name != "index.html":
copy_path(extra, target / extra.name, quiet=True)
built.append(f"{name} ({theme})")
if built:
log.info(f" plexuses: {', '.join(built)}")
def build_soleprint(output_dir: Path, room: str): def build_soleprint(output_dir: Path, room: str):
"""Build soleprint folder with core + room config merged.""" """Build soleprint folder with core + room config merged."""
soleprint = SPR_ROOT / "soleprint" soleprint = SPR_ROOT / "soleprint"
@@ -329,6 +713,7 @@ def build_soleprint(output_dir: Path, room: str):
"index.html", "index.html",
"requirements.txt", "requirements.txt",
"Dockerfile", "Dockerfile",
".dockerignore",
]: ]:
if (soleprint / name).exists(): if (soleprint / name).exists():
copy_path(soleprint / name, output_dir / name) copy_path(soleprint / name, output_dir / name)
@@ -348,6 +733,16 @@ def build_soleprint(output_dir: Path, room: str):
# Room config (includes merging room-specific artery/atlas/station) # Room config (includes merging room-specific artery/atlas/station)
copy_cfg(output_dir, room) copy_cfg(output_dir, room)
# Dependency containers the room asked for, merged into its compose file.
# After copy_cfg, because the compose file being merged into is the room's.
log.info("Composing cabinets...")
compose_cabinets(output_dir, room)
# Plexuses are exported rather than served, so this is a compile step like
# the cabinet merge above — not something run.py does at request time.
log.info("Exporting plexuses...")
build_plexuses(output_dir, room)
# Generate models # Generate models
log.info("Generating models...") log.info("Generating models...")
if not generate_models(output_dir, room): if not generate_models(output_dir, room):
@@ -404,6 +799,33 @@ def build_models_only():
sys.exit(1) sys.exit(1)
def build_plexuses_only(room: str):
"""Compile just the plexus UIs, without rebuilding the room around them.
The equivalent of `vite build` for this repo: the iteration loop when you
are working on the UI itself is edit, compile, reopen the file — and a full
room build to see a CSS change is a slow way to do that.
"""
output_dir = SPR_ROOT / "gen" / room
if not output_dir.exists():
log.error(f"Room '{room}' is not built — run: python build.py --cfg {room}")
sys.exit(1)
log.info(f"Compiling plexus UIs for {room}...")
build_plexuses(output_dir, room)
built = sorted((output_dir / "plexuses").glob("*/index.html"))
if not built:
log.warning(
f" nothing compiled — does cfg/{room}/data/plexuses.json list one?"
)
return
for page in built:
log.info(f" {page.relative_to(SPR_ROOT)} ({page.stat().st_size // 1024} KB)")
log.info("\n✓ Open directly — no server needed:")
log.info(f" xdg-open {built[0]}")
def main(): def main():
parser = argparse.ArgumentParser(description="Soleprint Build Tool") parser = argparse.ArgumentParser(description="Soleprint Build Tool")
@@ -411,15 +833,26 @@ def main():
parser.add_argument("--cfg", "-c", type=str, help="Room config name") parser.add_argument("--cfg", "-c", type=str, help="Room config name")
parser.add_argument("--all", action="store_true", help="Build all rooms") parser.add_argument("--all", action="store_true", help="Build all rooms")
parser.add_argument("--models", action="store_true", help="Only regenerate models") parser.add_argument("--models", action="store_true", help="Only regenerate models")
parser.add_argument(
"--plexuses",
action="store_true",
help="Only compile the plexus UIs into an already-built room",
)
args = parser.parse_args() args = parser.parse_args()
if args.models: if args.plexuses:
build_plexuses_only(args.cfg or "standalone")
elif args.models:
build_models_only() build_models_only()
elif args.all: elif args.all:
build(SPR_ROOT / "gen" / "standalone", None) build(SPR_ROOT / "gen" / "standalone", None)
for room in (SPR_ROOT / "cfg").iterdir(): for room in (SPR_ROOT / "cfg").iterdir():
if room.is_dir() and room.name not in ("__pycache__", "standalone"): # cfg/ is itself a git repo and rooms may carry dot-dirs — skip them,
# or --all tries to build ".git" as a room.
if room.name.startswith(".") or room.name == "__pycache__":
continue
if room.is_dir() and room.name != "standalone":
build(SPR_ROOT / "gen" / room.name, room.name) build(SPR_ROOT / "gen" / room.name, room.name)
else: else:
if args.output: if args.output:

4
cfg/.gitignore vendored
View File

@@ -9,5 +9,9 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
standalone
sample
# standalone/ and sample/ are kept in the main soleprint repo as templates. # standalone/ and sample/ are kept in the main soleprint repo as templates.
# Other rooms (amar/, dlt/, etc.) are listed in the repo-root .gitignore. # Other rooms (amar/, dlt/, etc.) are listed in the repo-root .gitignore.

View File

@@ -1,5 +1,5 @@
""" """
Pawprint Data Layer Soleprint Data Layer
JSON file storage (future: MongoDB) JSON file storage (future: MongoDB)
""" """

View File

@@ -1,14 +1,3 @@
{ {
"items": [ "items": []
{
"name": "feature-flow",
"slug": "feature-flow",
"title": "Feature Flow Pipeline",
"status": "ready",
"template": null,
"larder": null,
"output_larder": null,
"system": "atlas"
}
]
} }

View File

@@ -1,12 +1,3 @@
{ {
"items": [ "items": []
{
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Forms",
"status": "ready",
"source_template": "feature-form",
"data_path": "album/book/feature-form-samples/feature-form"
}
]
} }

View File

@@ -1,22 +1,3 @@
{ {
"items": [ "items": []
{
"name": "turnos",
"slug": "turnos",
"title": "Turnos Monitor",
"status": "dev",
"system": "ward",
"description": "Pipeline view of requests → turnos. Shows vet-petowner at a glance.",
"path": "ward/monitor/turnos"
},
{
"name": "data_browse",
"slug": "data-browse",
"title": "Data Browse",
"status": "ready",
"system": "ward",
"description": "Quick navigation to test users and data states. Book/larder pattern with SQL mode for manual testing workflows.",
"path": "ward/monitor/data_browse"
}
]
} }

View File

@@ -1,5 +1,3 @@
{ {
"items": [ "items": []
{"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"}
]
} }

View File

@@ -1,18 +1,3 @@
{ {
"items": [ "items": []
{
"name": "mercadopago",
"slug": "mercadopago",
"title": "MercadoPago",
"status": "ready",
"description": "Mock payment API for testing"
},
{
"name": "example",
"slug": "example",
"title": "Example",
"status": "ready",
"description": "Example shunt template"
}
]
} }

View File

@@ -1,38 +0,0 @@
# {{nombre_flujo}}
## Tipo de usuario
{{tipo_usuario}}
## Donde empieza
{{punto_entrada}}
## Que quiere hacer el usuario
{{objetivo}}
## Pasos
1. {{paso_1}}
2. {{paso_2}}
3. {{paso_3}}
## Que deberia pasar
- {{resultado_1}}
- {{resultado_2}}
## Problemas comunes
- {{problema_1}}
- {{problema_2}}
## Casos especiales
- {{caso_especial_1}}
## Flujos relacionados
- {{flujo_relacionado_1}}
## Notas tecnicas
- {{nota_tecnica_1}}

View File

@@ -1,12 +1,3 @@
{ {
"items": [ "items": []
{
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Form Template",
"status": "ready",
"template_path": "data/template/feature-form",
"system": "album"
}
]
} }

View File

@@ -1,47 +1,36 @@
{ {
"items": [ "items": [
{
"name": "tester",
"slug": "tester",
"title": "Contract Tests",
"status": "live",
"system": "ward",
"type": "app",
"description": "HTTP contract test runner with multi-environment support. Filter, run, and track tests against dev/stage/prod.",
"path": "ward/tools/tester",
"url": "/tools/tester/"
},
{ {
"name": "datagen", "name": "datagen",
"slug": "datagen", "slug": "datagen",
"title": "Test Data Generator", "title": "Datagen",
"status": "live", "status": "live",
"system": "ward", "system": "station",
"type": "cli", "type": "app",
"description": "Generate realistic test data for Amar domain (users, pets, services) and MercadoPago API responses. Used by mock veins and test seeders.", "description": "Generate realistic test data via faker-backed generators.",
"path": "ward/tools/datagen", "path": "station/tools/datagen",
"cli": "python -m datagen" "url": "/station/tools/datagen/"
}, },
{ {
"name": "generate_test_data", "name": "graphgen",
"slug": "generate-test-data", "slug": "graphgen",
"title": "DB Test Data Extractor", "title": "Graphgen",
"status": "dev", "status": "live",
"system": "ward", "system": "station",
"type": "cli", "type": "app",
"description": "Extract representative subsets from PostgreSQL dumps for testing/development.", "description": "Render the data model as an interactive graph (entities + FK edges).",
"path": "ward/tools/generate_test_data", "path": "station/tools/graphgen",
"cli": "python -m generate_test_data" "url": "/station/tools/graphgen/"
}, },
{ {
"name": "modelgen", "name": "modelgen",
"slug": "modelgen", "slug": "modelgen",
"title": "Model Generator", "title": "Modelgen",
"status": "dev", "status": "live",
"system": "ward", "system": "station",
"type": "cli", "type": "cli",
"description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.", "description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.",
"path": "ward/tools/modelgen", "path": "station/tools/modelgen",
"cli": "python -m modelgen" "cli": "python -m modelgen"
} }
] ]

View File

@@ -1,60 +1,12 @@
{ {
"items": [ "items": [
{
"name": "jira",
"slug": "jira",
"title": "Jira",
"status": "live",
"system": "artery"
},
{
"name": "slack",
"slug": "slack",
"title": "Slack",
"status": "building",
"system": "artery"
},
{ {
"name": "google", "name": "google",
"slug": "google", "slug": "google",
"title": "Google", "title": "Google",
"status": "building",
"system": "artery"
},
{
"name": "maps",
"slug": "maps",
"title": "Maps",
"status": "planned",
"system": "artery"
},
{
"name": "whatsapp",
"slug": "whatsapp",
"title": "WhatsApp",
"status": "planned",
"system": "artery"
},
{
"name": "gnucash",
"slug": "gnucash",
"title": "GNUCash",
"status": "planned",
"system": "artery"
},
{
"name": "vpn",
"slug": "vpn",
"title": "VPN",
"status": "planned",
"system": "artery"
},
{
"name": "ia",
"slug": "ia",
"title": "IA",
"status": "live", "status": "live",
"system": "artery" "system": "artery",
"description": "Google OAuth login + Drive/Calendar/Gmail APIs."
} }
] ]
} }

View File

@@ -1,44 +0,0 @@
# Fixture Invoicing — Soleprint Demo
> **This book describes a deliberately-fake invoicing app used as a test
> fixture for the Soleprint framework.** It is not a real product.
## Purpose
The fixture exercises every soleprint tool end-to-end so we can iterate on
the framework without needing a real managed app. See
[`examples/fixture-invoicing/`](../../../../../../examples/fixture-invoicing/)
for the app itself.
## Data model
```
Customer ──< Invoice ──< LineItem
└──────< Payment
```
| Table | Key fields |
|-------------|-------------------------------------------------|
| `customer` | id · name · email · created_at |
| `invoice` | id · number · customer_id · issued_at · status |
| `line_item` | id · invoice_id · description · qty · unit_price|
| `payment` | id · invoice_id · amount · method · paid_at |
## Happy-path flow
1. Create a customer (`POST /api/customers`)
2. Create a draft invoice for them (`POST /api/invoices`)
3. Add one or more line items (`POST /api/line-items/invoices/{id}`)
4. Record a payment (`POST /api/payments/invoices/{id}`) — if the total
paid ≥ total billed, the invoice auto-transitions to `paid`.
## How this connects to Soleprint
| Soleprint tool | Fixture hook |
|----------------|--------------|
| datagen | `station/tools/datagen/fixture.py` — FixtureInvoicingGenerator |
| graphgen | `station/tools/graphgen/schema.json` — 4 models + FK edges |
| databrowse | `station/tools/databrowse/depot/{schema,views}.json` |
| tester | `station/tools/tester/tests/fixture/` |
| link | SQLAlchemy reflection against `customer`, `invoice`, etc. |
| sbwrapper | Injected into fixture's Vue frontend by nginx |

View File

@@ -1,5 +1,5 @@
""" """
Pawprint Data Layer Soleprint Data Layer
JSON file storage (future: MongoDB) JSON file storage (future: MongoDB)
""" """

View File

@@ -1,3 +1,7 @@
{ {
"items": [] "items": [
{
"name": "bundle"
}
]
} }

View File

@@ -1,5 +1,5 @@
{ {
"items": [ "items": [
{"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"} {"name": "standalone-local", "slug": "standalone-local", "title": "Standalone Local", "status": "dev", "config_path": "deploy/standalone-local"}
] ]
} }

37
ctrl/build.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Build a room into gen/ — thin wrapper over build.py.
#
# Usage:
# ./ctrl/build.sh # standalone -> gen/standalone/
# ./ctrl/build.sh amar # amar -> gen/amar/
# ./ctrl/build.sh all # every room under cfg/
# ./ctrl/build.sh models # only regenerate models
#
# build.py holds the logic; this exists so `make build` has exactly one
# script to call, and so the room name is a plain argument.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR"
PYTHON="${PYTHON:-python3}"
ROOM="${1:-standalone}"
case "$ROOM" in
all) exec "$PYTHON" build.py --all ;;
models) exec "$PYTHON" build.py --models ;;
esac
if [[ ! -d "cfg/$ROOM" ]]; then
echo "No such room: cfg/$ROOM" >&2
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
exit 1
fi
# standalone is build.py's default and takes no --cfg
if [[ "$ROOM" == "standalone" ]]; then
exec "$PYTHON" build.py
fi
exec "$PYTHON" build.py --cfg "$ROOM"

24
ctrl/cluster.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# The shared `spr` kind cluster [up|down|status] (default status).
#
# Usage:
# ./ctrl/cluster.sh up # create the cluster (no-op if it exists)
# ./ctrl/cluster.sh down # delete it (drops every room's namespace)
# ./ctrl/cluster.sh status # what's running on it
#
# One target, one script — the variants live here. The kind-*.sh files stay
# exactly as they are and remain runnable on their own; this only dispatches.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
case "${1:-status}" in
up) exec "$SCRIPT_DIR/kind-up.sh" ;;
down) exec "$SCRIPT_DIR/kind-down.sh" ;;
status) exec "$SCRIPT_DIR/kind-status.sh" ;;
*)
echo "Unknown subcommand: $1" >&2
echo "Usage: cluster.sh [up|down|status]" >&2
exit 1
;;
esac

View File

@@ -41,6 +41,14 @@ if [ "$SYNC_ONLY" = true ]; then
fi fi
echo "Restarting soleprint on server..." echo "Restarting soleprint on server..."
ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d --build" # The compose file runs the container as ${UID:-1000}:${GID:-1000} and bind-mounts
# the deployed tree at /app. Those have to be the ids that OWN the tree, and they
# are not 1000 on every host — mcrn.ar's user is 1001. Without this the container
# starts fine and then 500s on the first file it reads, which reads as an app bug
# rather than a permissions one.
#
# `env` rather than a prefix assignment: UID is readonly in bash, so
# `UID=$(id -u) docker ...` fails outright.
ssh "$SERVER" "cd $REMOTE_DIR && env UID=\$(id -u) GID=\$(id -g) docker compose up -d --build"
echo "Deploy complete" echo "Deploy complete"

31
ctrl/dist.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Compile the plexus UIs to distributable files — this repo's `vite build`.
#
# Usage:
# ./ctrl/dist.sh # standalone
# ./ctrl/dist.sh sample # a named room
#
# A plexus is a UI that gets EXPORTED, not served. The output is a single
# index.html carrying its theme, its data and its diagrams inline, so it opens
# from a double-click on a machine with no server, no node and no network — the
# state a regulated Windows box is usually in.
#
# `make build` runs this as one of its steps. This exists for the loop where the
# UI is what you are working on: rebuilding a whole room to see a CSS change is
# a slow way to iterate.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR"
PYTHON="${PYTHON:-python3}"
ROOM="${1:-standalone}"
if [[ ! -d "cfg/$ROOM" ]]; then
echo "No such room: cfg/$ROOM" >&2
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
exit 1
fi
exec "$PYTHON" build.py --plexuses --cfg "$ROOM"

47
ctrl/docs.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Documentation: serve the pages, and re-render the diagrams.
#
# Usage: docs.sh serve [port] | graphs [theme]
#
# The docs are a static SPA — index.html plus data/*.md read at runtime — so
# they need a server only because fetch() refuses file:// origins. Any static
# server does; python is already a hard dependency here (build.py is python), so
# there is no reason to reach for docker the way rig does.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
DOCS_DIR="$ROOT_DIR/docs"
PYTHON="${PYTHON:-python3}"
PORT="${DOCS_PORT:-8080}"
serve() {
[ -n "${1:-}" ] && PORT="$1"
if [ ! -f "$DOCS_DIR/index.html" ]; then
echo "no docs/index.html" >&2
exit 1
fi
echo "docs on http://localhost:${PORT}/"
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
cd "$DOCS_DIR"
exec "$PYTHON" -m http.server "$PORT"
}
# Re-render docs/graphs/*.dot through every theme. The sources carry structure;
# the palette lives in docs/graphs/themes/*.gvpr. See docs/graphs/README.md.
graphs() {
if [ ! -x "$DOCS_DIR/graphs/render.sh" ]; then
echo "no docs/graphs/render.sh" >&2
exit 1
fi
exec bash "$DOCS_DIR/graphs/render.sh" "$@"
}
case "${1:-serve}" in
serve) shift || true; serve "$@" ;;
graphs) shift || true; graphs "$@" ;;
# `make docs 8090` is the obvious thing to type, so take it.
''|*[!0-9]*) echo "usage: $0 [serve [port]|graphs [theme]]" >&2; exit 1 ;;
*) serve "$1" ;;
esac

View File

@@ -11,6 +11,7 @@ Usage:
python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework
python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop
python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework
python ctrl/spr.py publish soleprint-ui /tmp/out --dist # built bundle only
python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework
""" """
@@ -194,6 +195,37 @@ def cmd_list(args):
log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"]) log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"])
def publish_dist(source, dest):
"""Copy only the built bundle — dist/ plus the manifest and any docs.
The artifact-only form: a consumer gets something that runs, not the
sources. Deliberately NOT a variant of copy_tree, which walks the whole
tree and strips dist; here dist is the entire point.
"""
dist = source / "dist"
if not dist.is_dir() or not any(dist.iterdir()):
log.error("no build at %s", dist)
log.info("build it first: cd %s && pnpm build", source)
sys.exit(1)
count = 0
dest.mkdir(parents=True, exist_ok=True)
for item in dist.rglob("*"):
if item.is_file():
target = dest / "dist" / item.relative_to(dist)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
count += 1
# The manifest travels too, or the bundle's entry points aren't resolvable.
for name in ("package.json", "README.md", "LICENSE"):
if (source / name).is_file():
shutil.copy2(source / name, dest / name)
count += 1
return count
def cmd_publish(args): def cmd_publish(args):
registry = load_registry() registry = load_registry()
comp_type, source = resolve_component(registry, args.component) comp_type, source = resolve_component(registry, args.component)
@@ -202,12 +234,17 @@ def cmd_publish(args):
if dest.exists(): if dest.exists():
shutil.rmtree(dest) shutil.rmtree(dest)
count = copy_tree(source, dest) if args.dist:
write_stamp(dest, args.component, comp_type, source, "published") count = publish_dist(source, dest)
mode = "published-dist"
else:
count = copy_tree(source, dest)
mode = "published"
write_stamp(dest, args.component, comp_type, source, mode)
version = get_version(comp_type, source) version = get_version(comp_type, source)
sha = get_sha() sha = get_sha()
log.info("%s v%s (%s) -> %s (%d files)", args.component, version, sha, dest, count) log.info("%s v%s (%s) -> %s (%d files, %s)", args.component, version, sha, dest, count, mode)
def cmd_sync(args): def cmd_sync(args):
@@ -306,6 +343,12 @@ def main():
p = sub.add_parser(cmd) p = sub.add_parser(cmd)
p.add_argument("component", help="component name") p.add_argument("component", help="component name")
p.add_argument("dest", help="target folder path") p.add_argument("dest", help="target folder path")
if cmd == "publish":
p.add_argument(
"--dist",
action="store_true",
help="ship only the built bundle (dist/ + manifest), not the sources",
)
p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)") p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)")
p.add_argument("component", help="component name") p.add_argument("component", help="component name")

35
ctrl/start.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Start a built room by dispatching to its own ctrl/start.sh in gen/.
#
# Usage:
# ./ctrl/start.sh # standalone, foreground
# ./ctrl/start.sh amar -d # amar, detached
# ./ctrl/start.sh sample --build # flags pass straight through
#
# Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays
# runnable on its own — this only saves cd'ing there and picks the default room.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ROOM="standalone"
if [[ $# -gt 0 && "$1" != -* ]]; then
ROOM="$1"
shift
fi
ROOM_DIR="$ROOT_DIR/gen/$ROOM"
if [[ ! -d "$ROOM_DIR" ]]; then
echo "Room '$ROOM' is not built — run: ./ctrl/build.sh $ROOM" >&2
exit 1
fi
if [[ ! -x "$ROOM_DIR/ctrl/start.sh" ]]; then
echo "No start script at gen/$ROOM/ctrl/start.sh" >&2
echo "(rebuild the room, or start it by hand from $ROOM_DIR)" >&2
exit 1
fi
exec "$ROOM_DIR/ctrl/start.sh" "$@"

27
ctrl/stop.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Stop a running room by dispatching to its own ctrl/stop.sh in gen/.
#
# Usage:
# ./ctrl/stop.sh # standalone
# ./ctrl/stop.sh amar # a named room
#
# Mirror of ctrl/start.sh — the room's own script does the work.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ROOM="standalone"
if [[ $# -gt 0 && "$1" != -* ]]; then
ROOM="$1"
shift
fi
ROOM_DIR="$ROOT_DIR/gen/$ROOM"
if [[ ! -x "$ROOM_DIR/ctrl/stop.sh" ]]; then
echo "No stop script at gen/$ROOM/ctrl/stop.sh — nothing to stop." >&2
exit 0
fi
exec "$ROOM_DIR/ctrl/stop.sh" "$@"

View File

@@ -0,0 +1,93 @@
# Plexuses
A **plexus** is a full app — the vocabulary has always said so
(*"full app with backend, frontend and DB"*). What was missing is that a plexus
is **exported, not served**. It is compiled to a distributable file the way vite
builds for production, and that compile is the whole point of the format.
```bash
make dist # compile the plexus UIs for standalone
make dist sample # for a named room
```
Output is one `index.html` per plexus under `gen/<room>/plexuses/<name>/`,
carrying its theme, its data and its diagrams inline. No server, no node, no
network. Zip it, mail it, double-click it.
`make build` runs the same step as part of a room build. `make dist` exists for
the loop where the UI is what you are working on — rebuilding a whole room to
see a CSS change is a slow way to iterate.
## The constraint that shapes it
It has to open from a **double-clicked file on a machine with no egress**. That
is the state a regulated Windows box is usually in, and it rules out three
things a normal web app does:
| Ruled out | Because |
| --- | --- |
| `fetch("bundle.json")` | `file://` treats every sibling file as cross-origin |
| `<link href="/theme.css">` | an absolute path assumes a server at the root |
| a webfont `@import` | a blocked stylesheet is a stall, not a fallback |
So the data is a JS object, the theme is inlined at compile time, and the fonts
are stacks. The test that matters is opening the output with the network off and
seeing zero failed requests — everything else is cosmetic.
## Declaring one
A room opts in through `cfg/<room>/data/plexuses.json`, the same shape and the
same place as its sibling `data/*.json` files:
```json
{ "items": [ { "name": "bundle" } ] }
```
A room may override anything the plexus declares — most usefully the theme:
```json
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
```
## Writing one
```
soleprint/artery/plexuses/<name>/
plexus.json identity, theme, the data the page renders
app/index.html the template
```
`build.py` fills these placeholders and writes one file:
| Placeholder | Becomes |
| --- | --- |
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to |
| `%%BUNDLE%%` | `plexus.json` as a JS object |
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
## The bundle plexus
The one that ships. It answers "what does a rig installation have at its
disposal" — tools, cabinets, veins, themes — and embeds the system diagram.
Because the SVG is **inlined** rather than `<img>`-linked, the theme switch
recolours the diagram too: graphviz writes `class="node accent"` into the SVG,
and CSS outranks the presentation attributes it bakes in. Switching to `lucid`
turns both the page and the diagram into something printable, which is the
demonstration the format exists for.
## Not the same as rig's bundle
Two artifacts, both called bundle, generated by different repos:
| | soleprint | rig |
| --- | --- | --- |
| Command | `make dist` | `make manifest` in `sample-rig` |
| Artifact | `plexuses/<name>/index.html` | `generated/<slug>.yaml` |
| Needs | nothing | kind + MetalLB |
| Answers | what shipped, on any machine | whether this cluster install is sound |
Complementary. One proves the environment, the other travels.

View File

@@ -31,10 +31,10 @@ Core books live in `soleprint/atlas/books/`. Room-specific books live in `cfg/<r
At build time, room-specific books are merged into the output: At build time, room-specific books are merged into the output:
``` ```
soleprint/atlas/books/ # Core books (all rooms) soleprint/atlas/books/ # Core books (all rooms)
cfg/amar/atlas/books/ # Amar-specific books cfg/<room>/atlas/books/ # Room-specific books
↓ build.py ↓ build.py
gen/amar/soleprint/atlas/books/ # Merged output gen/<room>/soleprint/atlas/books/ # Merged output
``` ```
Core books ship with every room. Room books add to or override them. The build copies the core first, then overlays the room-specific content. Core books ship with every room. Room books add to or override them. The build copies the core first, then overlays the room-specific content.

View File

@@ -11,7 +11,6 @@ A room is an isolated configuration. Each room lives in `cfg/<room>/` and contai
``` ```
cfg/ cfg/
standalone/ # Soleprint only, no managed app standalone/ # Soleprint only, no managed app
amar/ # Soleprint wrapping the Amar application
myroom/ # Your room myroom/ # Your room
``` ```

144
docs/data/en/export.md Normal file
View File

@@ -0,0 +1,144 @@
# Export / Compile
Soleprint's source tree is not what runs. `build.py` compiles the framework plus
a room's configuration into a self-contained instance under `gen/<room>/`, and
that directory is what a container boots, what `deploy.sh` rsyncs, and what the
cluster manifests point at.
Everything below is a `make` target, and every target is one script in `ctrl/`.
The logic lives in the scripts, never in the Makefile.
```bash
make build # cfg/standalone -> gen/standalone
make build sample # cfg/sample -> gen/sample
make start # run it
```
## The targets
| Command | Runs | Does |
| --- | --- | --- |
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
| `make dist [<room>]` | `ctrl/dist.sh` | compile just the plexus UIs to single files |
| `make docs [serve\|graphs]` | `ctrl/docs.sh` | serve the docs, or re-render the diagrams |
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
| `make cluster [up\|down\|status]` | `ctrl/cluster.sh` | the shared kind cluster |
| `make component [list\|publish\|sync\|watch\|diff]` | `ctrl/spr.py` | publish a distributable component |
| `make deploy` | `ctrl/deploy.sh` | rsync `gen/standalone` to the server and restart |
Bare words pass straight through, so `make build sample` becomes
`ctrl/build.sh sample`. Anything starting with a dash would be eaten by make
itself, so those go through `ARGS`:
```bash
make deploy ARGS="--build"
make component ARGS="publish soleprint-ui /tmp/out --dist"
```
## What a build does
`python build.py --cfg <room>` runs these in order:
1. **Clean** `gen/<room>/`. A build is not incremental — a stale file left
behind is worse than a slow build.
2. **Copy the framework.** `main.py`, `run.py`, `index.html`, `Dockerfile`,
`requirements.txt`, `dataloader/`, `common/`, and the three systems
(`artery/`, `atlas/`, `station/`).
3. **Merge the room** (`copy_cfg`). `cfg/<room>/config.json` lands in `cfg/`,
`data/*.json` in `data/`, and anything under `cfg/<room>/soleprint/artery|atlas|station/`
is merged *over* the framework copy — which is how a room adds its own vein,
shunt, tool or generator without forking the tree.
4. **Compose cabinets.** The dependency containers the room declared in
`data/cabinets.json` are merged into its `docker-compose.yml`. See
[Cabinets](#station-cabinets).
5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
compiled to a single self-contained `index.html` — theme, data and diagrams
inlined, so it opens with no server. See [Plexuses](#artery-plexuses).
6. **Generate models.** modelgen reads the room's `config.json` and writes
`models/pydantic/__init__.py`.
7. **Render k8s** (optional). When the room's config enables it,
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
## What comes out
A standalone room is a flat instance:
```
gen/standalone/
run.py main.py Dockerfile docker-compose.yml
artery/ atlas/ station/ common/
cfg/config.json
data/*.json
models/pydantic/
plexuses/<name>/index.html # one file each, opens with no server
```
A **managed** room — one that wraps an existing application — is three folders
instead, because soleprint sits beside the app rather than containing it:
```
gen/<room>/
<app>/ the application's repos, plus its ctrl scripts
link/ bridge code between the two
soleprint/ the instance, exactly as above
```
`build.py` picks between them on whether the room's `config.json` has a
`managed` block. `gen/` is gitignored in full: it is an artifact, and the way to
change it is to change `cfg/<room>/` and rebuild.
## Distributing components
Rooms are compiled; *components* are published. `registry.json` lists what can
be shipped out of this repo on its own:
```bash
make component # list
make component ARGS="publish soleprint-ui /tmp/out --dist"
make component ARGS="watch soleprint-ui ../unt/ui/framework"
```
`--dist` copies only the built bundle — `dist/**` plus `package.json`,
`README.md` and `LICENSE` — rather than the source. It refuses to publish an
empty `dist/`, because a component whose bundle was never built is the failure
that shows up later as a container that starts and renders nothing:
```
no build at soleprint/common/ui
build it first: cd soleprint/common/ui && pnpm build
```
Each publish leaves a `.spr` stamp in the destination recording name, version,
type, source and mode, so a copy can say where it came from.
## Deploying
```bash
make deploy ARGS="--build" # rebuild, sync, restart
make deploy ARGS="--sync-only" # sync, leave it running
```
`deploy.sh` rsyncs `gen/standalone/` and runs `docker compose up -d --build` on
the far side. `.env` is excluded, so server secrets stay on the server.
## Running without building
`python run.py` from `soleprint/` serves every subsystem on one port (12000 by
default) straight from the source tree. It is for developing the framework
itself; a room's `cfg/config.json` does not exist there, so the landing pages
fall back to their defaults. Rooms use docker.
## Diagrams
The `.dot` sources under `docs/graphs/` carry structure; the palette lives in
`docs/graphs/themes/*.gvpr` and is applied at render time, so one source renders
in every theme.
```bash
make docs graphs # every graph, every theme
make docs graphs lucid # one theme
```
`<name>.svg` is the dark default the docs link to; other themes write
`<name>.<theme>.svg`. See [Themes](#themes).

View File

@@ -0,0 +1,107 @@
# Cabinets
A cabinet is a **dependency container** a room can switch on: postgres, redis,
airflow. The vocabulary already had the word — `execution.container` in every
room's `config.json` is *"Cabinet — tool container"* — and until now nothing
stood behind it.
The problem it solves: a generated artifact knows what it needs and had no way
to say so. A shunt built from a client's spreadsheets holds its rows in memory
happily, but the moment you want them to survive a restart you need postgres,
and wiring postgres in meant hand-editing a room's `docker-compose.yml` and then
hand-editing the cluster too. A cabinet is that declaration, made once and read
by both paths.
## Switching one on
Add `cfg/<room>/data/cabinets.json`, the same shape as its sibling `data/*.json`
files:
```json
[
{ "name": "postgres" },
{ "name": "redis" },
{ "name": "airflow", "env": { "AIRFLOW_ADMIN_PASSWORD": "change-me" } }
]
```
Then build. The compose merge is a step in [Export / Compile](#export):
```bash
python build.py --cfg sample
cd gen/sample && docker compose up -d
```
`build.py` merges each cabinet's compose fragment into the room's
`docker-compose.yml`, declares its named volumes, and appends its settings to
`.env.example` — never to `.env`.
**A service the room already declares wins.** `cfg/amar/docker-compose.yml`
ships its own `db`; switching the postgres cabinet on will not replace it. The
build says so when it skips one:
```
Composing cabinets...
cabinets: redis, airflow
cabinets already declared by the room, left alone: postgres
```
Dependencies come along automatically. Airflow without a metadata database is a
container that exits on boot, so asking for `airflow` brings `postgres` and
`redis` with it, ordered so compose reads them before the thing that needs them.
## On a cluster
Every cabinet names a `rig_addon`. Where a room runs on kind rather than
compose, the same dependency installs as a rig addon of that name:
```bash
cd rig
PROFILE=data make cluster up
PROFILE=data make addons install
kubectl -n data port-forward svc/postgres 5432:5432
kubectl -n data port-forward svc/airflow 8080:8080
```
The two paths are deliberately separate — compose for a laptop, manifests for a
cluster — and `rig_addon` is the thread between them, so the room declares the
dependency once either way. The addons generate their own passwords on first
install and keep them across re-runs, so re-running never rotates a credential
out from under something already connected.
## What ships
| Cabinet | Image | Notes |
| --- | --- | --- |
| `postgres` | `postgres:16-alpine` | healthcheck wired, so `depends_on: service_healthy` works |
| `redis` | `redis:7-alpine` | cache, and the broker for anything queue-shaped |
| `airflow` | `apache/airflow:2.10.4` | one container on `standalone`; needs postgres and redis |
## Writing one
```
soleprint/station/cabinets/<name>/
cabinet.json what it is, what it needs, what it exports
service.yml the compose service, verbatim
```
`cabinet.json`:
| Key | Purpose |
| --- | --- |
| `name` | must match the directory |
| `title`, `description` | shown on the station index |
| `service` | the key to merge under in `services:` (defaults to `name`) |
| `env` | settings and defaults, written to `.env.example` |
| `volumes` | named volumes to declare at the top level |
| `depends_on` | other cabinets that must come with it |
| `rig_addon` | the matching `rig/ctrl/addons/<name>.sh`, if there is one |
| `notes` | lines written into `.env.example` as comments |
`service.yml` is a plain compose fragment with one top-level key — the service
name. It stays YAML rather than being generated from JSON so it reads like the
file it becomes, and so anything compose supports is available without this tool
learning about it first.
Adding a cabinet is adding a directory. Nothing dispatches on the name.

View File

@@ -1,6 +1,8 @@
# Datagen # Datagen
Test data generator using faker. Produces realistic, domain-specific data for testing and development. Test data generator. Produces realistic, domain-specific records for testing and
development, from generators a room writes or that
[modelgen](#station-modelgen) writes for it.
**Status:** live **Status:** live
@@ -8,50 +10,95 @@ Test data generator using faker. Produces realistic, domain-specific data for te
## What It Does ## What It Does
Datagen generates fake but realistic data. Names, emails, addresses, transactions -- whatever your domain needs. It uses Python's faker library under the hood. Datagen hands out instances of a room's models. Core ships the base class, the
discovery, the HTTP API and the browser UI; the generators themselves belong to
a room, because what counts as realistic is a property of the domain.
Core datagen is a placeholder. The real work happens in room-specific generators. Generation is stdlib `random`, `uuid` and `datetime`**not** faker, which is
not a dependency of this repo.
## Structure ## Structure
``` ```
soleprint/station/tools/datagen/ # Core (base classes, placeholder) soleprint/station/tools/datagen/ # base class, api, UI
cfg/<room>/soleprint/station/tools/datagen/ # Room-specific generators cfg/<room>/soleprint/station/tools/datagen/ # the room's generators
``` ```
After build, both merge into `gen/<room>/station/tools/datagen/`. After build, both merge into `gen/<room>/station/tools/datagen/`.
## Pattern ## The contract
Rooms subclass a base generator and provide domain-specific data factories: A generator subclasses `BaseDataGenerator` and defines **one method per model,
named after it**. There is no registration step: the method name *is* the model
name.
```python ```python
from station.tools.datagen.base import BaseGenerator from station.tools.datagen.base import BaseDataGenerator
class AmarDataGenerator(BaseGenerator): class RoomDataGenerator(BaseDataGenerator):
def generate_customers(self, count=10): def customer(self, **kwargs):
return [self.fake_customer() for _ in range(count)] return {"id": str(uuid4()), "name": ..., "email": ..., **kwargs}
def fake_customer(self): def invoice(self, customer_id=None, **kwargs):
return { return {"id": str(uuid4()), "customer_id": customer_id, **kwargs}
"name": self.faker.name(), ```
"email": self.faker.email(),
"phone": self.faker.phone_number(), The base class provides:
}
| Method | Does |
| --- | --- |
| `generate(model, count=1, **kwargs)` | calls the matching method `count` times; `kwargs` pass through to every call |
| `available_models()` | the method names, which are the model names |
| `schema()` | override to return a graphgen-compatible schema |
Discovery is by convention too: any `*.py` in the datagen directory whose first
class ends in `Generator` is loaded and keyed by its filename.
## Generating a generator
Writing one by hand is optional. modelgen's `datagen` target emits the whole
class from a schema — and when the schema came from spreadsheets, the generated
class **samples the real rows** rather than inventing values:
```bash
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t datagen
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t datagen
``` ```
Each room defines what data it needs. Core provides the faker instance and base class. Rooms provide the factories. This is how [shuntgen](#station-shuntgen) fills a generated shunt.
## HTTP API
Mounted under `/station/tools/datagen/`:
| Route | Returns |
| --- | --- |
| `GET /` | the browser UI |
| `GET /api/generators` | loaded generator files and their models |
| `GET /api/models?generator=` | model names |
| `POST /api/generate` | `{model, count, generator?, kwargs?}` → the records |
| `GET /api/schema?generator=` | the generator's schema, if it exposes one |
## Room Configuration ## Feeding graphgen
Room generators live in `cfg/<room>/soleprint/station/tools/datagen/`. They are fully self-contained -- they define their own models, factories, and output formats. A generator that overrides `schema()` is surfaced at `/api/schema` in the format
[graphgen](#station-graphgen) reads, so the same definition draws the diagram:
The core module provides: ```python
- Base generator class with faker instance def schema(self):
- CLI entry point return {
- Output formatting (JSON, CSV) "models": {
"Invoice": {
"doc": "A billed order.",
"fields": {
"id": {"type": "UUID", "pk": True},
"customer_id": {"type": "FK:Customer"},
"total": {"type": "float"},
},
}
}
}
```
Rooms provide: `FK:<Model>` and `M2M:<Model>` are how relations are written. modelgen's
- Domain-specific generator subclasses `datagen` target emits this method for you.
- Field definitions and relationships
- Volume and distribution configuration

View File

@@ -1,54 +1,111 @@
# Modelgen # Modelgen
Generates platform-specific models from JSON Schema. Reads schema once, writes models for multiple targets. Multi-source, multi-target model generator. Reads a schema from wherever it
already lives, and writes it out for every stack that needs it.
**Status:** dev **Status:** live
--- ---
## What It Does ## What It Does
Modelgen takes a JSON Schema definition and produces model code for different platforms: Everything passes through one intermediate representation — `ModelDefinition`,
`FieldDefinition`, `EnumDefinition`. **Loaders** fill it, **generators** emit
from it, and the two sides do not know about each other. Adding an input means
one extractor and every output comes with it; adding an output means one
generator and every input already feeds it.
- **Pydantic** -- Python data validation models ```
- **Django ORM** -- Django model classes dataclasses ─┐ ┌─ pydantic
- **Prisma** -- Prisma schema definitions Django │ ├─ django
SQLAlchemy ├──▶ ModelDefinition ──▶├─ sqlmodel
a live DB │ FieldDefinition ├─ typescript
OpenAPI │ EnumDefinition ├─ protobuf
CSV/ODS ─┘ ├─ prisma
├─ strawberry
├─ schema (graphgen)
└─ datagen
```
One schema, multiple outputs. Core is **pure standard library**. It is published as `soleprint-modelgen` and
installs with no dependencies; live-database extraction is an extra
(`pip install "soleprint-modelgen[db]"`), and YAML specs need PyYAML.
## Extractors ## Sources
Modelgen also works in reverse. Extractors read existing codebases and produce a normalized schema representation: | Command | Reads |
| --- | --- |
| `from-schema` | Python dataclasses in a `schema/` folder |
| `from-config` | a room's `config.json` |
| `extract` | a Django or SQLAlchemy codebase (`--framework auto` detects) |
| `from-db` | a live database, any SQLAlchemy dialect |
| `from-openapi` | an OpenAPI 3.x / Swagger 2.0 document |
| `from-tabular` | a directory of `.csv` / `.tsv` / `.ods` spreadsheets |
- **Django extractor** -- reads Django model files ```bash
- **SQLAlchemy extractor** -- reads SQLAlchemy model files python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript,schema
- **Prisma extractor** -- reads Prisma schema files python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t pydantic,datagen
python -m station.tools.modelgen extract -s /path/to/django -o out/ -t prisma
python -m station.tools.modelgen from-db -u postgresql://… -o out/ -t typescript
python -m station.tools.modelgen list-formats
```
Extractors feed into graphgen for visualization. ### From a spec
## Output `components.schemas` (or Swagger's `definitions`) become models. `$ref` chains
and `allOf` are resolved, enums are materialised as real `Enum` classes so every
target names them properly, and a referenced object becomes a relation rather
than a nested type — the same call the database extractor makes, and what keeps
the generated code valid for every target.
Generated models are written to `gen/<room>/models/`. The parse also yields the *operations*, which is what
[shuntgen](#station-shuntgen) turns into routes.
``` ### From spreadsheets
gen/<room>/models/
├── pydantic/
├── django/
└── prisma/
```
## CLI One model per CSV file, one per sheet in an ODS workbook. Column types are
inferred from the values actually present, and a blank cell makes the column
optional. Keys and relations are inferred by name and then confirmed against the
data: an `id` column that is not unique is not treated as a key, and
`customer_id` is only a foreign key if a `customers` sheet came with it.
```bash The rows are kept, not just the shape — which is what lets the `datagen` target
python -m modelgen sample real values instead of inventing them.
```
Reads from `schema.json` (the project source of truth) and writes to the configured output directory. ODS is read with `zipfile` and `ElementTree`. No odfpy, no pandas: the
dependency-free promise is what makes this package publishable on its own.
## Shared Distribution ## Targets
Modelgen is also distributed as a shared component via `ctrl/spr.py`. This allows other projects to use model generation without running full soleprint. `pydantic`, `django`, `sqlmodel`, `typescript` (`ts`), `protobuf` (`proto`),
`prisma`, `strawberry`, `schema` (`jsonschema`), `datagen`.
## Schema Source Two are worth calling out:
- **`schema`** writes the graphgen-compatible `schema.json` — the portable
artifact [graphgen](#station-graphgen) and databrowse read directly.
Relations come out as `FK:<Model>` and `M2M:<Model>`.
- **`datagen`** writes a `BaseDataGenerator` subclass for
[datagen](#station-datagen), including its `schema()` override. Given
spreadsheet rows it samples them; otherwise it synthesises from the types.
Multiple targets in one run get one file each, named `models_<target><ext>`.
## In a build
`build.py` calls modelgen during every room build, writing
`gen/<room>/models/pydantic/__init__.py` from the room's `config.json`. See
[Export / Compile](#export).
## Tests
```bash
cd soleprint/station/tools
python -m unittest modelgen.tests.test_extractors
```
The source of truth is `schema.json` at the project root. All model generation starts from this file. Room-specific schema extensions live in `cfg/<room>/models/`. stdlib `unittest`, no pytest, and every input is built in a temp directory — the
tests have to pass with nothing installed and nothing else in the tree. Run them
from `station/tools/`, not from inside `modelgen/`: the package ships a
`types.py`, and putting its own directory on `sys.path` shadows the standard
library module of that name.

View File

@@ -0,0 +1,129 @@
# Shuntgen
Generates runnable [shunts](#artery-shunts) from the two things people actually
have: a service contract, or a folder of spreadsheets.
Writing a shunt by hand means copying `artery/shunts/example/` and filling in
`responses.json` entry by entry. That is fine for three endpoints and untenable
for eighty — and it is the wrong work anyway, because the endpoints are already
described in the spec somebody handed you.
```bash
# a spec you were handed
python -m station.tools.shuntgen from-openapi -s api.yaml -o artery/shunts/petstore
# sheets a client sent
python -m station.tools.shuntgen from-tabular -s ./sheets -o artery/shunts/books
python -m station.tools.shuntgen list
```
Run from `soleprint/`. Also in the browser at `/station/tools/shuntgen/`, where
you can upload a spec, preview the routes it would serve, and generate.
## What comes out
```
artery/shunts/<name>/
main.py builds the app from the spec
run.py uvicorn entry point (PORT, or depot/config.json)
shunt_runtime.py vendored runtime — no soleprint import
models.py pydantic, via modelgen
datagen_<name>.py BaseDataGenerator subclass, via modelgen
depot/spec.json routes, collections and schema
depot/responses.json pinned overrides — yours, never overwritten
depot/config.json delays, error rate, prefill — yours, never overwritten
depot/data.json imported rows
templates/index.html config UI
README.md
```
```bash
cd artery/shunts/books && python run.py
curl localhost:8098/customers
```
The routes are built at startup from `spec.json` rather than written out as
source. That keeps the generated code short enough to read, and puts the
behaviour in one reviewable place: fixing `runtime.py` fixes every shunt, and
regenerating is a copy.
## Where a response comes from
First hit wins:
1. `depot/responses.json` — a pinned override, keyed `"METHOD /path"`
2. the store — rows imported from sheets, plus anything POSTed since
3. the spec's `example`, if the source document carried one
4. `datagen_<name>.py`, synthesising from the schema
5. `{}`
The store is what makes it behave like a service rather than a random-value
faucet: POST something and GET it back, ask for `/pets/7` and get the pet whose
id is 7. Collections that arrived with no rows are prefilled with generated
ones, so the first call answers with something.
## Two sources, one pipeline
Both inputs are [modelgen](#station-modelgen) extractors, so the same shapes
also generate pydantic, TypeScript, prisma and a
[graphgen](#station-graphgen) schema:
```bash
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t schema,datagen
```
| Source | Becomes | Routes |
| --- | --- | --- |
| OpenAPI 3.x / Swagger 2.0 | one model per schema; enums become real Enums, `$ref` becomes a relation | the operations the document declares |
| `.csv` / `.tsv` / `.ods` | one model per file or sheet, types inferred per column | five CRUD routes per table |
Keys and relations are inferred by name and then **checked against the data**:
an `id` column that is not unique is not treated as a key, and `customer_id` is
only a foreign key if a `customers` sheet came with it.
ODS is read with `zipfile` and `ElementTree` — no odfpy, no pandas — which is
what lets modelgen stay dependency-free and publishable on its own.
## Control endpoints
Every generated shunt serves these:
| Endpoint | Purpose |
| --- | --- |
| `GET /health` | liveness |
| `GET /mock/spec` | the routes it was built from |
| `GET /mock/stats` | call counts and row counts |
| `POST /mock/reset` | restore the imported rows, clear counters |
| `GET,POST /mock/config` | delays, error rate, `unknown_id`, page size |
| `GET,POST /mock/responses` | pin an override; set a key to `null` to drop it |
```bash
# make it slow and flaky, the way the real thing is
curl -X POST localhost:8098/mock/config \
-H 'content-type: application/json' \
-d '{"enable_random_delays": true, "error_rate": 0.2}'
# make one call answer something specific
curl -X POST localhost:8098/mock/responses \
-H 'content-type: application/json' \
-d '{"GET /customers/1": {"id": 1, "name": "PINNED"}}'
```
`unknown_id` decides what an unknown key does: `generate` (the default) invents
a record wearing the id that was asked for; `404` refuses it. Generate by
default, because a client pointed at a fresh shunt should just work — flip it
when the error path is what you are testing.
## Dependency containers
`--cabinet postgres,redis` writes a `cabinet.json` declaring what the shunt
expects. `build.py` composes those services into the room's compose file, and on
a cluster they install as rig addons of the same name. See
[Cabinets](#station-cabinets).
## Regenerating
Everything is overwritten except `depot/responses.json` and `depot/config.json`.
Those two are yours.

110
docs/data/en/themes.md Normal file
View File

@@ -0,0 +1,110 @@
# Themes
Three themes ship, and the same palettes drive the diagrams as well as the
pages. `soleprint` is the default; the others are switched to on purpose.
| Theme | Reads as | For |
| --- | --- | --- |
| `soleprint` | dark, rounded, amber | the default — tools and dev chrome |
| `mcrn` | dark, square, monospace, orange glow | the terminal look, matched to mariano.mcrn.ar |
| `lucid` | light, hairline, printable | regulated documents, and sitting beside a real lucid.app export |
Switch with `?theme=lucid`, or the toggle in the corner. The choice persists.
## Where it lives
```
soleprint/common/theme/
tokens.css the vocabulary + neutral defaults
themes/*.css one file per theme
theme.js resolve, apply, remember
bake.py inline the defaults into pages
```
Served together at `/theme.css` — tokens first, then every theme, each scoped to
`[data-theme="..."]`. Adding a theme is adding a file: `run.py` lists the
directory rather than carrying a hardcoded list.
## Two naming families, one set of values
Both are answered, because both were already in use and renaming across a dozen
templates would have been the larger change:
- `--bg` / `--surface` / `--border` / `--text` / `--muted` / `--accent` — the
station tools and the docs site
- `--surface-0..3` / `--text-primary` / `--panel-radius``common/ui`'s Vue
components
The second family is derived from the first in `tokens.css`, so a page using
either name gets the same colour and a theme author fills in one set.
## Order matters
```html
<!-- baked defaults --> <style>:root { }</style>
<link rel="stylesheet" href="/theme.css">
<style> the page's own rules </style>
```
The theme has to load **before** the page's styles, so its element defaults
underpin the page rather than override it. Get this backwards and
`tokens.css`'s `body { background: var(--bg) }` flattens whatever the page
wanted — which is exactly how artery, atlas and station briefly lost their
coloured content columns.
## Baked defaults, and why
`/theme.css` is an absolute path, and soleprint is not always at the root — in a
room's nginx it sits under `/spr/` while `location /` goes to the frontend. A
page that says `background: var(--bg)` and never receives `--bg` does not fall
back to something plainer: the declaration is invalid at computed-value time, so
the background goes transparent and the text goes initial-black on a design that
assumed dark. Unstyled, not merely unbranded.
So every page carries a generated `:root` block **before** the link. Both are
`:root`, so document order decides: the served stylesheet wins when it loads,
and the baked block is what is left when it does not.
```bash
python3 common/theme/bake.py # regenerate
python3 common/theme/bake.py --check # fail if a page is stale
```
Only the variables a page actually uses are emitted, so the blocks stay small.
## No webfonts
The stacks name faces that exist on the target rather than fetching any:
```css
--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
--font-mono: "Cascadia Mono", "JetBrains Mono", Consolas, "SF Mono", monospace;
```
Segoe UI and Consolas ship with Windows. A regulated network blocks
`fonts.googleapis.com` and `file://` stalls on it, and neither failure looks
like a missing font — they look like a broken page.
## Diagrams follow
`docs/graphs/themes/*.gvpr` carry the same palettes for graphviz, so a diagram
and the page around it are one visual language. See
[the graphs README](https://git.mcrn.ar/mariano/soleprint/src/branch/main/docs/graphs/README.md)
and [Export / Compile](#export).
```bash
make docs graphs # every graph, every theme
```
Diagrams are baked per theme rather than styled by CSS, because the docs embed
them with `<img src=…>` — which makes the SVG a separate document the page's
stylesheet cannot reach. A plexus that **inlines** the SVG can style it live,
and the bundle plexus does exactly that.
## Contrast
`lucid` is the first light theme, and the palette was measured rather than
guessed — against both `#ffffff` and the `#f5f7fa` panel, at the sizes actually
used. `--dim` drives 11px notes and `--status-warn` drives 10px labels, so both
need 4.5:1 rather than the 3:1 large text gets away with. The obvious lighter
greys came in at 3.43.8 and were dropped for that reason.

View File

@@ -1,29 +1,188 @@
[ [
{"id": "intro", "title": {"en": "Introduction"}}, {
{"id": "quickstart", "title": {"en": "Quick Start"}}, "id": "intro",
{"id": "concepts", "title": {"en": "Concepts"}}, "title": {
{"id": "room-setup", "title": {"en": "↳ Room Setup"}, "sub": true}, "en": "Introduction"
{"id": "standalone", "title": {"en": "↳ Standalone"}, "sub": true}, }
{"id": "managed", "title": {"en": "↳ Managed"}, "sub": true}, },
{
{"id": "artery", "title": {"en": "Artery"}}, "id": "quickstart",
{"id": "artery-jira", "title": {"en": "↳ Jira"}, "sub": true}, "title": {
{"id": "artery-google", "title": {"en": "↳ Google"}, "sub": true}, "en": "Quick Start"
{"id": "artery-slack", "title": {"en": "↳ Slack"}, "sub": true}, }
{"id": "artery-ia", "title": {"en": "↳ IA"}, "sub": true}, },
{"id": "artery-shunts", "title": {"en": "↳ Shunts"}, "sub": true}, {
"id": "concepts",
{"id": "atlas", "title": {"en": "Atlas"}}, "title": {
{"id": "atlas-books", "title": {"en": "↳ Books"}, "sub": true}, "en": "Concepts"
{"id": "atlas-templates", "title": {"en": "↳ Templates"}, "sub": true}, }
},
{"id": "station", "title": {"en": "Station"}}, {
{"id": "station-tester", "title": {"en": "↳ Tester"}, "sub": true}, "id": "room-setup",
{"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true}, "title": {
{"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true}, "en": "↳ Room Setup"
{"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "sub": true}, },
{"id": "station-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true}, "sub": true
},
{"id": "components", "title": {"en": "Shared Components"}}, {
{"id": "deployment", "title": {"en": "Deployment"}} "id": "standalone",
"title": {
"en": "↳ Standalone"
},
"sub": true
},
{
"id": "managed",
"title": {
"en": "↳ Managed"
},
"sub": true
},
{
"id": "artery",
"title": {
"en": "Artery"
}
},
{
"id": "artery-jira",
"title": {
"en": "↳ Jira"
},
"sub": true
},
{
"id": "artery-google",
"title": {
"en": "↳ Google"
},
"sub": true
},
{
"id": "artery-slack",
"title": {
"en": "↳ Slack"
},
"sub": true
},
{
"id": "artery-ia",
"title": {
"en": "↳ IA"
},
"sub": true
},
{
"id": "artery-shunts",
"title": {
"en": "↳ Shunts"
},
"sub": true
},
{
"id": "artery-plexuses",
"title": {
"en": "↳ Plexuses"
},
"sub": true
},
{
"id": "atlas",
"title": {
"en": "Atlas"
}
},
{
"id": "atlas-books",
"title": {
"en": "↳ Books"
},
"sub": true
},
{
"id": "atlas-templates",
"title": {
"en": "↳ Templates"
},
"sub": true
},
{
"id": "station",
"title": {
"en": "Station"
}
},
{
"id": "station-tester",
"title": {
"en": "↳ Tester"
},
"sub": true
},
{
"id": "station-datagen",
"title": {
"en": "↳ Datagen"
},
"sub": true
},
{
"id": "station-modelgen",
"title": {
"en": "↳ Modelgen"
},
"sub": true
},
{
"id": "station-graphgen",
"title": {
"en": "↳ Graphgen"
},
"sub": true
},
{
"id": "station-shuntgen",
"title": {
"en": "↳ Shuntgen"
},
"sub": true
},
{
"id": "station-databrowse",
"title": {
"en": "↳ Databrowse"
},
"sub": true
},
{
"id": "station-cabinets",
"title": {
"en": "↳ Cabinets"
},
"sub": true
},
{
"id": "components",
"title": {
"en": "Shared Components"
}
},
{
"id": "export",
"title": {
"en": "Export / Compile"
}
},
{
"id": "themes",
"title": {
"en": "Themes"
}
},
{
"id": "deployment",
"title": {
"en": "Deployment"
}
}
] ]

67
docs/graphs/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Graphs
The `.dot` files carry **structure and meaning**. The palette lives in
`themes/*.gvpr` and is applied at render time.
```bash
./render.sh # every graph, every theme
./render.sh lucid # one theme
./render.sh dark system_overview
```
Needs graphviz (`sudo apt install graphviz`). The committed SVGs work without
it; this is only needed to re-render.
## Why themes, and why gvpr
The sources used to hardcode the dark palette inline, which meant one look and
no way to get another. `gvpr` rewrites the *parsed* graph, so it overrides
whatever a `.dot` set — one source renders in any theme without being edited.
Command-line `-G`/`-N`/`-E` flags would not do: those are defaults, and an
attribute written in the file beats them.
Colour is **baked into each SVG** rather than driven by CSS, because both docs
sites embed graphs with `<img src=...>`. That makes the SVG a separate document
the page's stylesheet cannot reach.
## Output
| Theme | Writes | For |
| --- | --- | --- |
| `dark` | `<name>.svg` | the docs site — this is the default, and what `docs/data/en/*.md` links to |
| `lucid` | `<name>.lucid.svg` | regulated documents, print, and sitting beside a real lucid.app export |
## Classes
Nodes, edges, clusters and the graph itself may carry a `class`. Anything
untagged gets the theme's neutral treatment.
| Class | Means | dark | lucid |
| --- | --- | --- | --- |
| `accent` | the emphasised thing | amber outline | blue outline, pale blue fill |
| `accent-text` | emphasised *label*, not a box | amber text | blue text |
| `ok` | live, working | green text | green outline, pale green fill |
| `artery` / `atlas` / `station` | belongs to that system | that system's colour | its pale equivalent |
| `muted` | present but not the point | grey text | grey fill |
`class` survives into the SVG (`<g class="node accent">`), so an **inlined** SVG
can also be styled by page CSS. That is not how the docs embed them, but it is
there if a page wants it.
## What themes never touch
- **`shape`** — a cylinder is a datastore, not a decoration.
- **`style=invis`** — layout scaffolding. Filling it would draw it.
- **`style=dashed`** — a weaker relationship; the theme preserves it and adds to it.
- **`label`, `rankdir`, `rank`, `fontsize`** — content and layout.
## Adding a theme
Drop a `themes/<name>.gvpr` in beside the others; `render.sh` picks it up with no
edit. Match the palette to `soleprint/common/theme/themes/<name>.css` so a
diagram and the page around it are the same visual language.
Name fonts that exist on the target. `lucid.gvpr` uses Arial deliberately: it is
on every Windows box and fontconfig aliases it to Liberation Sans on Linux, so
the SVG measures the same on both and text does not reflow out of its box.

View File

@@ -1,43 +1,37 @@
digraph artery_hierarchy { digraph artery_hierarchy {
bgcolor="#0a0a0a"
rankdir=LR rankdir=LR
fontname="Helvetica" fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box] node [fontname="Helvetica" fontsize=11 style=filled shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#b91c1c"] edge [class="artery" fontname="Helvetica" fontsize=9]
label="Artery — Component Hierarchy" label="Artery — Component Hierarchy"
labelloc=t labelloc=t
fontsize=14 fontsize=14
fontcolor="#fca5a5"
vein [label="Vein\nstateless API connector" fillcolor="#1a1a1a"] vein [label="Vein\nstateless API connector"]
pulse [label="Pulse\nVein + Room + Depot" fillcolor="#1a1a1a"] pulse [label="Pulse\nVein + Room + Depot"]
plexus [label="Plexus\nfull app: backend\n+ frontend + DB" fillcolor="#1a1a1a"] plexus [label="Plexus\nfull app: backend\n+ frontend + DB"]
shunt [label="Shunt\nfake connector\nfor testing" fillcolor="#1a1a1a" color="#d4a574"] shunt [class="accent" label="Shunt\nfake connector\nfor testing"]
vein -> pulse [label="compose"] vein -> pulse [label="compose"]
pulse -> plexus [label="extend"] pulse -> plexus [label="extend"]
shunt -> vein [label="replaces" style=dashed color="#d4a574" fontcolor="#d4a574"] shunt -> vein [class="accent" label="replaces" style=dashed]
// Examples // Examples
subgraph cluster_examples { subgraph cluster_examples {
label="Live Veins" label="Live Veins"
style=dashed style=dashed
color="#333"
fontcolor="#666"
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9] jira [class="ok" label="Jira" fontsize=9]
google [label="Google" fillcolor="#1a1a1a" fontcolor="#d4a574" fontsize=9] google [class="accent-text" label="Google" fontsize=9]
ia [label="IA" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9] ia [class="ok" label="IA" fontsize=9]
} }
subgraph cluster_shunts { subgraph cluster_shunts {
label="Shunts" label="Shunts"
style=dashed style=dashed
color="#333"
fontcolor="#666"
mp [label="MercadoPago" fillcolor="#1a1a1a" fontcolor="#d4a574" fontsize=9] mp [class="accent-text" label="MercadoPago" fontsize=9]
} }
jira -> vein [style=invis] jira -> vein [style=invis]

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: artery_hierarchy Pages: 1 -->
<svg width="776pt" height="292pt"
viewBox="0.00 0.00 776.00 292.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 287.75)">
<title>artery_hierarchy</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-287.75 772.25,-287.75 772.25,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="384.12" y="-266.45" font-family="Arial" font-size="14.00" fill="#1f2933">Artery — Component Hierarchy</text>
<g id="clust1" class="cluster">
<title>cluster_examples</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M143.75,-68C143.75,-68 197.25,-68 197.25,-68 203.25,-68 209.25,-74 209.25,-80 209.25,-80 209.25,-240 209.25,-240 209.25,-246 203.25,-252 197.25,-252 197.25,-252 143.75,-252 143.75,-252 137.75,-252 131.75,-246 131.75,-240 131.75,-240 131.75,-80 131.75,-80 131.75,-74 137.75,-68 143.75,-68"/>
<text xml:space="preserve" text-anchor="middle" x="170.5" y="-234.7" font-family="Arial" font-size="14.00" fill="#616e7c">Live Veins</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_shunts</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M20,-8C20,-8 85.75,-8 85.75,-8 91.75,-8 97.75,-14 97.75,-20 97.75,-20 97.75,-72 97.75,-72 97.75,-78 91.75,-84 85.75,-84 85.75,-84 20,-84 20,-84 14,-84 8,-78 8,-72 8,-72 8,-20 8,-20 8,-14 14,-8 20,-8"/>
<text xml:space="preserve" text-anchor="middle" x="52.88" y="-66.7" font-family="Arial" font-size="14.00" fill="#616e7c">Shunts</text>
</g>
<!-- jira -->
<g id="node1" class="node ok">
<title>jira</title>
<path fill="#e6f5ec" stroke="#1a7f45" d="M185,-112C185,-112 155,-112 155,-112 149,-112 143,-106 143,-100 143,-100 143,-88 143,-88 143,-82 149,-76 155,-76 155,-76 185,-76 185,-76 191,-76 197,-82 197,-88 197,-88 197,-100 197,-100 197,-106 191,-112 185,-112"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-90.7" font-family="Arial" font-size="9.00" fill="#1f2933">Jira</text>
</g>
<!-- vein -->
<g id="node5" class="node">
<title>vein</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M401,-82C401,-82 296.5,-82 296.5,-82 290.5,-82 284.5,-76 284.5,-70 284.5,-70 284.5,-58 284.5,-58 284.5,-52 290.5,-46 296.5,-46 296.5,-46 401,-46 401,-46 407,-46 413,-52 413,-58 413,-58 413,-70 413,-70 413,-76 407,-82 401,-82"/>
<text xml:space="preserve" text-anchor="middle" x="348.75" y="-67.05" font-family="Arial" font-size="11.00" fill="#1f2933">Vein</text>
<text xml:space="preserve" text-anchor="middle" x="348.75" y="-53.55" font-family="Arial" font-size="11.00" fill="#1f2933">stateless API connector</text>
</g>
<!-- jira&#45;&gt;vein -->
<!-- google -->
<g id="node2" class="node accent&#45;text">
<title>google</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M185,-166C185,-166 155,-166 155,-166 149,-166 143,-160 143,-154 143,-154 143,-142 143,-142 143,-136 149,-130 155,-130 155,-130 185,-130 185,-130 191,-130 197,-136 197,-142 197,-142 197,-154 197,-154 197,-160 191,-166 185,-166"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-144.7" font-family="Arial" font-size="9.00" fill="#3a7dff">Google</text>
</g>
<!-- ia -->
<g id="node3" class="node ok">
<title>ia</title>
<path fill="#e6f5ec" stroke="#1a7f45" d="M185,-220C185,-220 155,-220 155,-220 149,-220 143,-214 143,-208 143,-208 143,-196 143,-196 143,-190 149,-184 155,-184 155,-184 185,-184 185,-184 191,-184 197,-190 197,-196 197,-196 197,-208 197,-208 197,-214 191,-220 185,-220"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-198.7" font-family="Arial" font-size="9.00" fill="#1f2933">IA</text>
</g>
<!-- mp -->
<g id="node4" class="node accent&#45;text">
<title>mp</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M77.75,-52C77.75,-52 28,-52 28,-52 22,-52 16,-46 16,-40 16,-40 16,-28 16,-28 16,-22 22,-16 28,-16 28,-16 77.75,-16 77.75,-16 83.75,-16 89.75,-22 89.75,-28 89.75,-28 89.75,-40 89.75,-40 89.75,-46 83.75,-52 77.75,-52"/>
<text xml:space="preserve" text-anchor="middle" x="52.88" y="-30.7" font-family="Arial" font-size="9.00" fill="#3a7dff">MercadoPago</text>
</g>
<!-- shunt -->
<g id="node8" class="node accent">
<title>shunt</title>
<path fill="#d6e4ff" stroke="#3a7dff" d="M201.25,-58.25C201.25,-58.25 138.75,-58.25 138.75,-58.25 132.75,-58.25 126.75,-52.25 126.75,-46.25 126.75,-46.25 126.75,-21.75 126.75,-21.75 126.75,-15.75 132.75,-9.75 138.75,-9.75 138.75,-9.75 201.25,-9.75 201.25,-9.75 207.25,-9.75 213.25,-15.75 213.25,-21.75 213.25,-21.75 213.25,-46.25 213.25,-46.25 213.25,-52.25 207.25,-58.25 201.25,-58.25"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-43.8" font-family="Arial" font-size="11.00" fill="#1f2933">Shunt</text>
<text xml:space="preserve" text-anchor="middle" x="170" y="-30.3" font-family="Arial" font-size="11.00" fill="#1f2933">fake connector</text>
<text xml:space="preserve" text-anchor="middle" x="170" y="-16.8" font-family="Arial" font-size="11.00" fill="#1f2933">for testing</text>
</g>
<!-- mp&#45;&gt;shunt -->
<!-- pulse -->
<g id="node6" class="node">
<title>pulse</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M595.5,-82C595.5,-82 498.5,-82 498.5,-82 492.5,-82 486.5,-76 486.5,-70 486.5,-70 486.5,-58 486.5,-58 486.5,-52 492.5,-46 498.5,-46 498.5,-46 595.5,-46 595.5,-46 601.5,-46 607.5,-52 607.5,-58 607.5,-58 607.5,-70 607.5,-70 607.5,-76 601.5,-82 595.5,-82"/>
<text xml:space="preserve" text-anchor="middle" x="547" y="-67.05" font-family="Arial" font-size="11.00" fill="#1f2933">Pulse</text>
<text xml:space="preserve" text-anchor="middle" x="547" y="-53.55" font-family="Arial" font-size="11.00" fill="#1f2933">Vein + Room + Depot</text>
</g>
<!-- vein&#45;&gt;pulse -->
<g id="edge1" class="edge artery">
<title>vein&#45;&gt;pulse</title>
<path fill="none" stroke="#c0392b" d="M413.47,-64C434.03,-64 456.89,-64 477.76,-64"/>
<polygon fill="#c0392b" stroke="#c0392b" points="477.75,-66.45 484.75,-64 477.75,-61.55 477.75,-66.45"/>
<text xml:space="preserve" text-anchor="middle" x="449.75" y="-65.95" font-family="Arial" font-size="9.00" fill="#c0392b">compose</text>
</g>
<!-- plexus -->
<g id="node7" class="node">
<title>plexus</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M756.25,-88.25C756.25,-88.25 683.25,-88.25 683.25,-88.25 677.25,-88.25 671.25,-82.25 671.25,-76.25 671.25,-76.25 671.25,-51.75 671.25,-51.75 671.25,-45.75 677.25,-39.75 683.25,-39.75 683.25,-39.75 756.25,-39.75 756.25,-39.75 762.25,-39.75 768.25,-45.75 768.25,-51.75 768.25,-51.75 768.25,-76.25 768.25,-76.25 768.25,-82.25 762.25,-88.25 756.25,-88.25"/>
<text xml:space="preserve" text-anchor="middle" x="719.75" y="-73.8" font-family="Arial" font-size="11.00" fill="#1f2933">Plexus</text>
<text xml:space="preserve" text-anchor="middle" x="719.75" y="-60.3" font-family="Arial" font-size="11.00" fill="#1f2933">full app: backend</text>
<text xml:space="preserve" text-anchor="middle" x="719.75" y="-46.8" font-family="Arial" font-size="11.00" fill="#1f2933">+ frontend + DB</text>
</g>
<!-- pulse&#45;&gt;plexus -->
<g id="edge2" class="edge artery">
<title>pulse&#45;&gt;plexus</title>
<path fill="none" stroke="#c0392b" d="M607.72,-64C625.49,-64 644.89,-64 662.44,-64"/>
<polygon fill="#c0392b" stroke="#c0392b" points="662.37,-66.45 669.37,-64 662.37,-61.55 662.37,-66.45"/>
<text xml:space="preserve" text-anchor="middle" x="639.38" y="-65.95" font-family="Arial" font-size="9.00" fill="#c0392b">extend</text>
</g>
<!-- shunt&#45;&gt;vein -->
<g id="edge3" class="edge accent">
<title>shunt&#45;&gt;vein</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M213.34,-41.19C232.09,-44.37 254.68,-48.2 275.94,-51.81"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="275.53,-54.23 282.84,-52.98 276.35,-49.4 275.53,-54.23"/>
<text xml:space="preserve" text-anchor="middle" x="248.88" y="-51.85" font-family="Arial" font-size="9.00" fill="#3a7dff">replaces</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -4,98 +4,98 @@
<!-- Generated by graphviz version 14.1.2 (0) <!-- Generated by graphviz version 14.1.2 (0)
--> -->
<!-- Title: artery_hierarchy Pages: 1 --> <!-- Title: artery_hierarchy Pages: 1 -->
<svg width="845pt" height="317pt" <svg width="845pt" height="294pt"
viewBox="0.00 0.00 845.00 317.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> viewBox="0.00 0.00 845.00 294.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 313.25)"> <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 290.25)">
<title>artery_hierarchy</title> <title>artery_hierarchy</title>
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-313.25 840.5,-313.25 840.5,4 -4,4"/> <polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-290.25 840.5,-290.25 840.5,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="418.25" y="-291.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Component Hierarchy</text> <text xml:space="preserve" text-anchor="middle" x="418.25" y="-268.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Artery — Component Hierarchy</text>
<g id="clust1" class="cluster"> <g id="clust1" class="cluster">
<title>cluster_examples</title> <title>cluster_examples</title>
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="135.88,-8 135.88,-193 220.88,-193 220.88,-8 135.88,-8"/> <polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="135.88,-68 135.88,-253 220.88,-253 220.88,-68 135.88,-68"/>
<text xml:space="preserve" text-anchor="middle" x="178.38" y="-175.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Live Veins</text> <text xml:space="preserve" text-anchor="middle" x="178.38" y="-235.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Live Veins</text>
</g> </g>
<g id="clust2" class="cluster"> <g id="clust2" class="cluster">
<title>cluster_shunts</title> <title>cluster_shunts</title>
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="8,-199 8,-276 100,-276 100,-199 8,-199"/> <polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="8,-8 8,-85 100,-85 100,-8 8,-8"/>
<text xml:space="preserve" text-anchor="middle" x="54" y="-258.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Shunts</text> <text xml:space="preserve" text-anchor="middle" x="54" y="-67.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Shunts</text>
</g>
<!-- jira -->
<g id="node1" class="node ok">
<title>jira</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-112 150.88,-112 150.88,-76 204.88,-76 204.88,-112"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-91.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">Jira</text>
</g> </g>
<!-- vein --> <!-- vein -->
<g id="node1" class="node"> <g id="node5" class="node">
<title>vein</title> <title>vein</title>
<polygon fill="#1a1a1a" stroke="#333333" points="446,-167 300.25,-167 300.25,-131 446,-131 446,-167"/> <polygon fill="#1a1a1a" stroke="#333333" points="446,-82 300.25,-82 300.25,-46 446,-46 446,-82"/>
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-152.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein</text> <text xml:space="preserve" text-anchor="middle" x="373.12" y="-67.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein</text>
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless API connector</text> <text xml:space="preserve" text-anchor="middle" x="373.12" y="-53.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless API connector</text>
</g>
<!-- jira&#45;&gt;vein -->
<!-- google -->
<g id="node2" class="node accent&#45;text">
<title>google</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-166 150.88,-166 150.88,-130 204.88,-130 204.88,-166"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-145.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">Google</text>
</g>
<!-- ia -->
<g id="node3" class="node ok">
<title>ia</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-220 150.88,-220 150.88,-184 204.88,-184 204.88,-220"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-199.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">IA</text>
</g>
<!-- mp -->
<g id="node4" class="node accent&#45;text">
<title>mp</title>
<polygon fill="#1a1a1a" stroke="#333333" points="92,-52 16,-52 16,-16 92,-16 92,-52"/>
<text xml:space="preserve" text-anchor="middle" x="54" y="-31.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">MercadoPago</text>
</g>
<!-- shunt -->
<g id="node8" class="node accent">
<title>shunt</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="226.75,-58.25 129,-58.25 129,-9.75 226.75,-9.75 226.75,-58.25"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-43.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Shunt</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">fake connector</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-16.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">for testing</text>
</g> </g>
<!-- mp&#45;&gt;shunt -->
<!-- pulse --> <!-- pulse -->
<g id="node2" class="node"> <g id="node6" class="node">
<title>pulse</title> <title>pulse</title>
<polygon fill="#1a1a1a" stroke="#333333" points="659.25,-167 522.5,-167 522.5,-131 659.25,-131 659.25,-167"/> <polygon fill="#1a1a1a" stroke="#333333" points="659.25,-82 522.5,-82 522.5,-46 659.25,-46 659.25,-82"/>
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-152.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Pulse</text> <text xml:space="preserve" text-anchor="middle" x="590.88" y="-67.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Pulse</text>
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein + Room + Depot</text> <text xml:space="preserve" text-anchor="middle" x="590.88" y="-53.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein + Room + Depot</text>
</g> </g>
<!-- vein&#45;&gt;pulse --> <!-- vein&#45;&gt;pulse -->
<g id="edge1" class="edge"> <g id="edge1" class="edge artery">
<title>vein&#45;&gt;pulse</title> <title>vein&#45;&gt;pulse</title>
<path fill="none" stroke="#b91c1c" d="M446.27,-149C467.03,-149 489.79,-149 510.95,-149"/> <path fill="none" stroke="#b91c1c" d="M446.27,-64C467.03,-64 489.79,-64 510.95,-64"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="510.67,-152.5 520.67,-149 510.67,-145.5 510.67,-152.5"/> <polygon fill="#b91c1c" stroke="#b91c1c" points="510.67,-67.5 520.67,-64 510.67,-60.5 510.67,-67.5"/>
<text xml:space="preserve" text-anchor="middle" x="484.25" y="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">compose</text> <text xml:space="preserve" text-anchor="middle" x="484.25" y="-66.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">compose</text>
</g> </g>
<!-- plexus --> <!-- plexus -->
<g id="node3" class="node"> <g id="node7" class="node">
<title>plexus</title> <title>plexus</title>
<polygon fill="#1a1a1a" stroke="#333333" points="836.5,-173.25 726.75,-173.25 726.75,-124.75 836.5,-124.75 836.5,-173.25"/> <polygon fill="#1a1a1a" stroke="#333333" points="836.5,-88.25 726.75,-88.25 726.75,-39.75 836.5,-39.75 836.5,-88.25"/>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-158.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Plexus</text> <text xml:space="preserve" text-anchor="middle" x="781.62" y="-73.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Plexus</text>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-145.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">full app: backend</text> <text xml:space="preserve" text-anchor="middle" x="781.62" y="-60.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">full app: backend</text>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-131.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">+ frontend + DB</text> <text xml:space="preserve" text-anchor="middle" x="781.62" y="-46.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">+ frontend + DB</text>
</g> </g>
<!-- pulse&#45;&gt;plexus --> <!-- pulse&#45;&gt;plexus -->
<g id="edge2" class="edge"> <g id="edge2" class="edge artery">
<title>pulse&#45;&gt;plexus</title> <title>pulse&#45;&gt;plexus</title>
<path fill="none" stroke="#b91c1c" d="M659.48,-149C677.62,-149 697.19,-149 715.21,-149"/> <path fill="none" stroke="#b91c1c" d="M659.48,-64C677.62,-64 697.19,-64 715.21,-64"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="714.98,-152.5 724.98,-149 714.98,-145.5 714.98,-152.5"/> <polygon fill="#b91c1c" stroke="#b91c1c" points="714.98,-67.5 724.98,-64 714.98,-60.5 714.98,-67.5"/>
<text xml:space="preserve" text-anchor="middle" x="693" y="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">extend</text> <text xml:space="preserve" text-anchor="middle" x="693" y="-66.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">extend</text>
</g>
<!-- shunt -->
<g id="node4" class="node">
<title>shunt</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="226.75,-249.25 129,-249.25 129,-200.75 226.75,-200.75 226.75,-249.25"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-234.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Shunt</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-221.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">fake connector</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-207.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">for testing</text>
</g> </g>
<!-- shunt&#45;&gt;vein --> <!-- shunt&#45;&gt;vein -->
<g id="edge3" class="edge"> <g id="edge3" class="edge accent">
<title>shunt&#45;&gt;vein</title> <title>shunt&#45;&gt;vein</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M227.14,-206.02C253.71,-195.57 286.85,-182.54 314.73,-171.57"/> <path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M227.14,-41.49C245.81,-44.39 267.72,-47.79 288.78,-51.06"/>
<polygon fill="#d4a574" stroke="#d4a574" points="315.71,-174.95 323.73,-168.03 313.14,-168.43 315.71,-174.95"/> <polygon fill="#d4a574" stroke="#d4a574" points="288.11,-54.5 298.53,-52.58 289.18,-47.58 288.11,-54.5"/>
<text xml:space="preserve" text-anchor="middle" x="263.5" y="-200.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">replaces</text> <text xml:space="preserve" text-anchor="middle" x="263.5" y="-52.6" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">replaces</text>
</g>
<!-- jira -->
<g id="node5" class="node">
<title>jira</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-52 150.88,-52 150.88,-16 204.88,-16 204.88,-52"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-31.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#15803d">Jira</text>
</g>
<!-- jira&#45;&gt;vein -->
<!-- google -->
<g id="node6" class="node">
<title>google</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-106 150.88,-106 150.88,-70 204.88,-70 204.88,-106"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-85.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">Google</text>
</g>
<!-- ia -->
<g id="node7" class="node">
<title>ia</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-160 150.88,-160 150.88,-124 204.88,-124 204.88,-160"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-139.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#15803d">IA</text>
</g> </g>
<!-- mp -->
<g id="node8" class="node">
<title>mp</title>
<polygon fill="#1a1a1a" stroke="#333333" points="92,-243 16,-243 16,-207 92,-207 92,-243"/>
<text xml:space="preserve" text-anchor="middle" x="54" y="-222.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">MercadoPago</text>
</g>
<!-- mp&#45;&gt;shunt -->
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,43 +1,37 @@
digraph cfg_gen_flow { digraph cfg_gen_flow {
bgcolor="#0a0a0a"
rankdir=LR rankdir=LR
fontname="Helvetica" fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box] node [fontname="Helvetica" fontsize=11 style=filled shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#d4a574"] edge [class="accent" fontname="Helvetica" fontsize=9]
label="Build Flow — cfg/ to gen/" label="Build Flow — cfg/ to gen/"
labelloc=t labelloc=t
fontsize=14 fontsize=14
fontcolor="#d4a574"
// Source // Source
subgraph cluster_source { subgraph cluster_source {
label="Source (committed)" label="Source (committed)"
style=dashed style=dashed
color="#333"
fontcolor="#666"
core [label="soleprint/\ncore framework" fillcolor="#1a1a1a"] core [label="soleprint/\ncore framework"]
cfg [label="cfg/<room>/\nroom config" fillcolor="#1a1a1a"] cfg [label="cfg/<room>/\nroom config"]
} }
// Build // Build
build [label="build.py\n--cfg <room>" fillcolor="#1a1a1a" color="#d4a574" shape=component] build [class="accent" label="build.py\n--cfg <room>" shape=component]
// Output // Output
subgraph cluster_output { subgraph cluster_output {
label="Output (generated, gitignored)" label="Output (generated, gitignored)"
style=dashed style=dashed
color="#333"
fontcolor="#666"
gen_spr [label="gen/<room>/soleprint/\ncore + room merged" fillcolor="#1a1a1a"] gen_spr [label="gen/<room>/soleprint/\ncore + room merged"]
gen_app [label="gen/<room>/<app>/\ncloned repos" fillcolor="#1a1a1a"] gen_app [label="gen/<room>/<app>/\ncloned repos"]
gen_link [label="gen/<room>/link/\nDB bridge" fillcolor="#1a1a1a"] gen_link [label="gen/<room>/link/\nDB bridge"]
} }
// Run // Run
docker [label="docker compose up" fillcolor="#1a1a1a" shape=component] docker [label="docker compose up" shape=component]
// Flow // Flow
core -> build core -> build

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: cfg_gen_flow Pages: 1 -->
<svg width="661pt" height="232pt"
viewBox="0.00 0.00 661.00 232.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 227.75)">
<title>cfg_gen_flow</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-227.75 657.25,-227.75 657.25,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="326.62" y="-206.45" font-family="Arial" font-size="14.00" fill="#1f2933">Build Flow — cfg/ to gen/</text>
<g id="clust1" class="cluster">
<title>cluster_source</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M12,-62C12,-62 123.25,-62 123.25,-62 129.25,-62 135.25,-68 135.25,-74 135.25,-74 135.25,-180 135.25,-180 135.25,-186 129.25,-192 123.25,-192 123.25,-192 12,-192 12,-192 6,-192 0,-186 0,-180 0,-180 0,-74 0,-74 0,-68 6,-62 12,-62"/>
<text xml:space="preserve" text-anchor="middle" x="67.62" y="-174.7" font-family="Arial" font-size="14.00" fill="#616e7c">Source (committed)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_output</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M329,-8C329,-8 502.5,-8 502.5,-8 508.5,-8 514.5,-14 514.5,-20 514.5,-20 514.5,-180 514.5,-180 514.5,-186 508.5,-192 502.5,-192 502.5,-192 329,-192 329,-192 323,-192 317,-186 317,-180 317,-180 317,-20 317,-20 317,-14 323,-8 329,-8"/>
<text xml:space="preserve" text-anchor="middle" x="415.75" y="-174.7" font-family="Arial" font-size="14.00" fill="#616e7c">Output (generated, gitignored)</text>
</g>
<!-- core -->
<g id="node1" class="node">
<title>core</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M100.62,-160C100.62,-160 33.62,-160 33.62,-160 27.62,-160 21.62,-154 21.62,-148 21.62,-148 21.62,-136 21.62,-136 21.62,-130 27.62,-124 33.62,-124 33.62,-124 100.62,-124 100.62,-124 106.62,-124 112.62,-130 112.62,-136 112.62,-136 112.62,-148 112.62,-148 112.62,-154 106.62,-160 100.62,-160"/>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-145.05" font-family="Arial" font-size="11.00" fill="#1f2933">soleprint/</text>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-131.55" font-family="Arial" font-size="11.00" fill="#1f2933">core framework</text>
</g>
<!-- build -->
<g id="node6" class="node accent">
<title>build</title>
<polygon fill="#d6e4ff" stroke="#3a7dff" points="243.25,-106 164.25,-106 164.25,-102 160.25,-102 160.25,-98 164.25,-98 164.25,-78 160.25,-78 160.25,-74 164.25,-74 164.25,-70 243.25,-70 243.25,-106"/>
<polyline fill="none" stroke="#3a7dff" points="164.25,-102 168.25,-102 168.25,-98 164.25,-98"/>
<polyline fill="none" stroke="#3a7dff" points="164.25,-78 168.25,-78 168.25,-74 164.25,-74"/>
<text xml:space="preserve" text-anchor="middle" x="203.75" y="-91.05" font-family="Arial" font-size="11.00" fill="#1f2933">build.py</text>
<text xml:space="preserve" text-anchor="middle" x="203.75" y="-77.55" font-family="Arial" font-size="11.00" fill="#1f2933">&#45;&#45;cfg &lt;room&gt;</text>
</g>
<!-- core&#45;&gt;build -->
<g id="edge1" class="edge accent">
<title>core&#45;&gt;build</title>
<path fill="none" stroke="#3a7dff" d="M112.95,-124.02C126.89,-118.43 142.29,-112.25 156.33,-106.62"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="156.99,-108.99 162.58,-104.11 155.17,-104.45 156.99,-108.99"/>
</g>
<!-- cfg -->
<g id="node2" class="node">
<title>cfg</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M92.38,-106C92.38,-106 41.88,-106 41.88,-106 35.88,-106 29.88,-100 29.88,-94 29.88,-94 29.88,-82 29.88,-82 29.88,-76 35.88,-70 41.88,-70 41.88,-70 92.38,-70 92.38,-70 98.38,-70 104.38,-76 104.38,-82 104.38,-82 104.38,-94 104.38,-94 104.38,-100 98.38,-106 92.38,-106"/>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-91.05" font-family="Arial" font-size="11.00" fill="#1f2933">cfg/&lt;room&gt;/</text>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-77.55" font-family="Arial" font-size="11.00" fill="#1f2933">room config</text>
</g>
<!-- cfg&#45;&gt;build -->
<g id="edge2" class="edge accent">
<title>cfg&#45;&gt;build</title>
<path fill="none" stroke="#3a7dff" d="M104.56,-88C120.35,-88 139.04,-88 155.85,-88"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="155.42,-90.45 162.42,-88 155.42,-85.55 155.42,-90.45"/>
</g>
<!-- gen_spr -->
<g id="node3" class="node">
<title>gen_spr</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M464.12,-160C464.12,-160 366.38,-160 366.38,-160 360.38,-160 354.38,-154 354.38,-148 354.38,-148 354.38,-136 354.38,-136 354.38,-130 360.38,-124 366.38,-124 366.38,-124 464.12,-124 464.12,-124 470.12,-124 476.12,-130 476.12,-136 476.12,-136 476.12,-148 476.12,-148 476.12,-154 470.12,-160 464.12,-160"/>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-145.05" font-family="Arial" font-size="11.00" fill="#1f2933">gen/&lt;room&gt;/soleprint/</text>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-131.55" font-family="Arial" font-size="11.00" fill="#1f2933">core + room merged</text>
</g>
<!-- docker -->
<g id="node7" class="node">
<title>docker</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="653.25,-160 543.5,-160 543.5,-156 539.5,-156 539.5,-152 543.5,-152 543.5,-132 539.5,-132 539.5,-128 543.5,-128 543.5,-124 653.25,-124 653.25,-160"/>
<polyline fill="none" stroke="#9aa5b1" points="543.5,-156 547.5,-156 547.5,-152 543.5,-152"/>
<polyline fill="none" stroke="#9aa5b1" points="543.5,-132 547.5,-132 547.5,-128 543.5,-128"/>
<text xml:space="preserve" text-anchor="middle" x="598.38" y="-138.3" font-family="Arial" font-size="11.00" fill="#1f2933">docker compose up</text>
</g>
<!-- gen_spr&#45;&gt;docker -->
<g id="edge6" class="edge accent">
<title>gen_spr&#45;&gt;docker</title>
<path fill="none" stroke="#3a7dff" d="M476.56,-142C495.21,-142 515.8,-142 534.61,-142"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="534.6,-144.45 541.6,-142 534.6,-139.55 534.6,-144.45"/>
</g>
<!-- gen_app -->
<g id="node4" class="node">
<title>gen_app</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M459.62,-106C459.62,-106 370.88,-106 370.88,-106 364.88,-106 358.88,-100 358.88,-94 358.88,-94 358.88,-82 358.88,-82 358.88,-76 364.88,-70 370.88,-70 370.88,-70 459.62,-70 459.62,-70 465.62,-70 471.62,-76 471.62,-82 471.62,-82 471.62,-94 471.62,-94 471.62,-100 465.62,-106 459.62,-106"/>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-91.05" font-family="Arial" font-size="11.00" fill="#1f2933">gen/&lt;room&gt;/&lt;app&gt;/</text>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-77.55" font-family="Arial" font-size="11.00" fill="#1f2933">cloned repos</text>
</g>
<!-- gen_link -->
<g id="node5" class="node">
<title>gen_link</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M451.75,-52C451.75,-52 378.75,-52 378.75,-52 372.75,-52 366.75,-46 366.75,-40 366.75,-40 366.75,-28 366.75,-28 366.75,-22 372.75,-16 378.75,-16 378.75,-16 451.75,-16 451.75,-16 457.75,-16 463.75,-22 463.75,-28 463.75,-28 463.75,-40 463.75,-40 463.75,-46 457.75,-52 451.75,-52"/>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">gen/&lt;room&gt;/link/</text>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-23.55" font-family="Arial" font-size="11.00" fill="#1f2933">DB bridge</text>
</g>
<!-- build&#45;&gt;gen_spr -->
<g id="edge3" class="edge accent">
<title>build&#45;&gt;gen_spr</title>
<path fill="none" stroke="#3a7dff" d="M243.64,-98.38C249.54,-99.94 255.55,-101.52 261.25,-103 288.9,-110.19 319.39,-117.99 345.83,-124.71"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="345.05,-127.04 352.43,-126.39 346.25,-122.29 345.05,-127.04"/>
</g>
<!-- build&#45;&gt;gen_app -->
<g id="edge4" class="edge accent">
<title>build&#45;&gt;gen_app</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M243.66,-88C273.57,-88 315.52,-88 350.33,-88"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="350.04,-90.45 357.04,-88 350.04,-85.55 350.04,-90.45"/>
<text xml:space="preserve" text-anchor="middle" x="284.12" y="-89.95" font-family="Arial" font-size="9.00" fill="#3a7dff">if managed</text>
</g>
<!-- build&#45;&gt;gen_link -->
<g id="edge5" class="edge accent">
<title>build&#45;&gt;gen_link</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M243.66,-77.97C275.9,-69.66 322.12,-57.75 358.32,-48.42"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="358.72,-50.84 364.88,-46.72 357.49,-46.1 358.72,-50.84"/>
<text xml:space="preserve" text-anchor="middle" x="284.12" y="-73.99" font-family="Arial" font-size="9.00" fill="#3a7dff">if managed</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -28,7 +28,7 @@
<text xml:space="preserve" text-anchor="middle" x="75.75" y="-131.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">core framework</text> <text xml:space="preserve" text-anchor="middle" x="75.75" y="-131.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">core framework</text>
</g> </g>
<!-- build --> <!-- build -->
<g id="node3" class="node"> <g id="node6" class="node accent">
<title>build</title> <title>build</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="271.75,-106 181.5,-106 181.5,-102 177.5,-102 177.5,-98 181.5,-98 181.5,-78 177.5,-78 177.5,-74 181.5,-74 181.5,-70 271.75,-70 271.75,-106"/> <polygon fill="#1a1a1a" stroke="#d4a574" points="271.75,-106 181.5,-106 181.5,-102 177.5,-102 177.5,-98 181.5,-98 181.5,-78 177.5,-78 177.5,-74 181.5,-74 181.5,-70 271.75,-70 271.75,-106"/>
<polyline fill="none" stroke="#d4a574" points="181.5,-102 185.5,-102 185.5,-98 181.5,-98"/> <polyline fill="none" stroke="#d4a574" points="181.5,-102 185.5,-102 185.5,-98 181.5,-98"/>
@@ -37,7 +37,7 @@
<text xml:space="preserve" text-anchor="middle" x="226.62" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">&#45;&#45;cfg &lt;room&gt;</text> <text xml:space="preserve" text-anchor="middle" x="226.62" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">&#45;&#45;cfg &lt;room&gt;</text>
</g> </g>
<!-- core&#45;&gt;build --> <!-- core&#45;&gt;build -->
<g id="edge1" class="edge"> <g id="edge1" class="edge accent">
<title>core&#45;&gt;build</title> <title>core&#45;&gt;build</title>
<path fill="none" stroke="#d4a574" d="M127.3,-124.07C135.72,-121.06 144.36,-117.95 152.5,-115 158.3,-112.89 164.36,-110.68 170.4,-108.47"/> <path fill="none" stroke="#d4a574" d="M127.3,-124.07C135.72,-121.06 144.36,-117.95 152.5,-115 158.3,-112.89 164.36,-110.68 170.4,-108.47"/>
<polygon fill="#d4a574" stroke="#d4a574" points="171.59,-111.76 179.76,-105.02 169.17,-105.19 171.59,-111.76"/> <polygon fill="#d4a574" stroke="#d4a574" points="171.59,-111.76 179.76,-105.02 169.17,-105.19 171.59,-111.76"/>
@@ -50,65 +50,65 @@
<text xml:space="preserve" text-anchor="middle" x="75.75" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">room config</text> <text xml:space="preserve" text-anchor="middle" x="75.75" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">room config</text>
</g> </g>
<!-- cfg&#45;&gt;build --> <!-- cfg&#45;&gt;build -->
<g id="edge2" class="edge"> <g id="edge2" class="edge accent">
<title>cfg&#45;&gt;build</title> <title>cfg&#45;&gt;build</title>
<path fill="none" stroke="#d4a574" d="M119.41,-88C135.12,-88 153.15,-88 169.84,-88"/> <path fill="none" stroke="#d4a574" d="M119.41,-88C135.12,-88 153.15,-88 169.84,-88"/>
<polygon fill="#d4a574" stroke="#d4a574" points="169.63,-91.5 179.63,-88 169.63,-84.5 169.63,-91.5"/> <polygon fill="#d4a574" stroke="#d4a574" points="169.63,-91.5 179.63,-88 169.63,-84.5 169.63,-91.5"/>
</g> </g>
<!-- gen_spr --> <!-- gen_spr -->
<g id="node4" class="node"> <g id="node3" class="node">
<title>gen_spr</title> <title>gen_spr</title>
<polygon fill="#1a1a1a" stroke="#333333" points="536.5,-160 394.5,-160 394.5,-124 536.5,-124 536.5,-160"/> <polygon fill="#1a1a1a" stroke="#333333" points="536.5,-160 394.5,-160 394.5,-124 536.5,-124 536.5,-160"/>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-145.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/soleprint/</text> <text xml:space="preserve" text-anchor="middle" x="465.5" y="-145.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/soleprint/</text>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-131.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">core + room merged</text> <text xml:space="preserve" text-anchor="middle" x="465.5" y="-131.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">core + room merged</text>
</g> </g>
<!-- build&#45;&gt;gen_spr --> <!-- docker -->
<g id="edge3" class="edge"> <g id="node7" class="node">
<title>build&#45;&gt;gen_spr</title> <title>docker</title>
<path fill="none" stroke="#d4a574" d="M272.06,-98.83C278,-100.25 284.02,-101.67 289.75,-103 320.22,-110.08 353.74,-117.62 383.21,-124.16"/> <polygon fill="#1a1a1a" stroke="#333333" points="733.5,-160 610.25,-160 610.25,-156 606.25,-156 606.25,-152 610.25,-152 610.25,-132 606.25,-132 606.25,-128 610.25,-128 610.25,-124 733.5,-124 733.5,-160"/>
<polygon fill="#d4a574" stroke="#d4a574" points="382.29,-127.55 392.81,-126.29 383.8,-120.71 382.29,-127.55"/> <polyline fill="none" stroke="#333333" points="610.25,-156 614.25,-156 614.25,-152 610.25,-152"/>
<polyline fill="none" stroke="#333333" points="610.25,-132 614.25,-132 614.25,-128 610.25,-128"/>
<text xml:space="preserve" text-anchor="middle" x="671.88" y="-138.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">docker compose up</text>
</g>
<!-- gen_spr&#45;&gt;docker -->
<g id="edge6" class="edge accent">
<title>gen_spr&#45;&gt;docker</title>
<path fill="none" stroke="#d4a574" d="M536.83,-142C556.81,-142 578.59,-142 598.68,-142"/>
<polygon fill="#d4a574" stroke="#d4a574" points="598.44,-145.5 608.44,-142 598.44,-138.5 598.44,-145.5"/>
</g> </g>
<!-- gen_app --> <!-- gen_app -->
<g id="node5" class="node"> <g id="node4" class="node">
<title>gen_app</title> <title>gen_app</title>
<polygon fill="#1a1a1a" stroke="#333333" points="531.62,-106 399.38,-106 399.38,-70 531.62,-70 531.62,-106"/> <polygon fill="#1a1a1a" stroke="#333333" points="531.62,-106 399.38,-106 399.38,-70 531.62,-70 531.62,-106"/>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-91.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/&lt;app&gt;/</text> <text xml:space="preserve" text-anchor="middle" x="465.5" y="-91.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/&lt;app&gt;/</text>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">cloned repos</text> <text xml:space="preserve" text-anchor="middle" x="465.5" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">cloned repos</text>
</g> </g>
<!-- build&#45;&gt;gen_app -->
<g id="edge4" class="edge">
<title>build&#45;&gt;gen_app</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M272.18,-88C304.75,-88 349.72,-88 388,-88"/>
<polygon fill="#d4a574" stroke="#d4a574" points="387.6,-91.5 397.6,-88 387.6,-84.5 387.6,-91.5"/>
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-90.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text>
</g>
<!-- gen_link --> <!-- gen_link -->
<g id="node6" class="node"> <g id="node5" class="node">
<title>gen_link</title> <title>gen_link</title>
<polygon fill="#1a1a1a" stroke="#333333" points="521.88,-52 409.12,-52 409.12,-16 521.88,-16 521.88,-52"/> <polygon fill="#1a1a1a" stroke="#333333" points="521.88,-52 409.12,-52 409.12,-16 521.88,-16 521.88,-52"/>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/link/</text> <text xml:space="preserve" text-anchor="middle" x="465.5" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/link/</text>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-23.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">DB bridge</text> <text xml:space="preserve" text-anchor="middle" x="465.5" y="-23.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">DB bridge</text>
</g> </g>
<!-- build&#45;&gt;gen_spr -->
<g id="edge3" class="edge accent">
<title>build&#45;&gt;gen_spr</title>
<path fill="none" stroke="#d4a574" d="M272.06,-98.83C278,-100.25 284.02,-101.67 289.75,-103 320.22,-110.08 353.74,-117.62 383.21,-124.16"/>
<polygon fill="#d4a574" stroke="#d4a574" points="382.29,-127.55 392.81,-126.29 383.8,-120.71 382.29,-127.55"/>
</g>
<!-- build&#45;&gt;gen_app -->
<g id="edge4" class="edge accent">
<title>build&#45;&gt;gen_app</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M272.18,-88C304.75,-88 349.72,-88 388,-88"/>
<polygon fill="#d4a574" stroke="#d4a574" points="387.6,-91.5 397.6,-88 387.6,-84.5 387.6,-91.5"/>
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-90.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">if managed</text>
</g>
<!-- build&#45;&gt;gen_link --> <!-- build&#45;&gt;gen_link -->
<g id="edge5" class="edge"> <g id="edge5" class="edge accent">
<title>build&#45;&gt;gen_link</title> <title>build&#45;&gt;gen_link</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M272.05,-76.97C278,-75.53 284.02,-74.09 289.75,-72.75 325.35,-64.44 365.13,-55.58 397.73,-48.44"/> <path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M272.05,-76.97C278,-75.53 284.02,-74.09 289.75,-72.75 325.35,-64.44 365.13,-55.58 397.73,-48.44"/>
<polygon fill="#d4a574" stroke="#d4a574" points="398.44,-51.86 407.46,-46.31 396.95,-45.03 398.44,-51.86"/> <polygon fill="#d4a574" stroke="#d4a574" points="398.44,-51.86 407.46,-46.31 396.95,-45.03 398.44,-51.86"/>
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-75.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text> <text xml:space="preserve" text-anchor="middle" x="315.25" y="-75.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">if managed</text>
</g>
<!-- docker -->
<g id="node7" class="node">
<title>docker</title>
<polygon fill="#1a1a1a" stroke="#333333" points="733.5,-160 610.25,-160 610.25,-156 606.25,-156 606.25,-152 610.25,-152 610.25,-132 606.25,-132 606.25,-128 610.25,-128 610.25,-124 733.5,-124 733.5,-160"/>
<polyline fill="none" stroke="#333333" points="610.25,-156 614.25,-156 614.25,-152 610.25,-152"/>
<polyline fill="none" stroke="#333333" points="610.25,-132 614.25,-132 614.25,-128 610.25,-128"/>
<text xml:space="preserve" text-anchor="middle" x="671.88" y="-138.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">docker compose up</text>
</g>
<!-- gen_spr&#45;&gt;docker -->
<g id="edge6" class="edge">
<title>gen_spr&#45;&gt;docker</title>
<path fill="none" stroke="#d4a574" d="M536.83,-142C556.81,-142 578.59,-142 598.68,-142"/>
<polygon fill="#d4a574" stroke="#d4a574" points="598.44,-145.5 608.44,-142 598.44,-138.5 598.44,-145.5"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

69
docs/graphs/render.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Render every .dot through every theme.
#
# ./render.sh # all graphs, all themes
# ./render.sh lucid # one theme
# ./render.sh dark system_overview
#
# The sources carry structure and meaning; themes/*.gvpr carry palette. gvpr
# rewrites the parsed graph, so it wins over anything a .dot sets inline — which
# is what lets one source render in several looks without being edited.
#
# Output naming: the default theme writes <name>.svg, because that is what
# docs/data/en/*.md already links to and those links should keep working. Every
# other theme writes <name>.<theme>.svg.
#
# Why baked rather than CSS: both docs sites embed graphs with <img src=...>,
# which makes the SVG a separate document that the page's stylesheet cannot
# reach. Colour has to be in the file.
set -euo pipefail
cd "$(dirname "$0")"
DEFAULT_THEME=dark
if ! command -v dot >/dev/null 2>&1 || ! command -v gvpr >/dev/null 2>&1; then
echo "graphviz not found — install with: sudo apt install graphviz" >&2
echo "(the committed .svg files already work; this is only needed to re-render)" >&2
exit 1
fi
theme_arg="${1:-}"
graph_arg="${2:-}"
themes=()
if [ -n "$theme_arg" ]; then
if [ ! -f "themes/${theme_arg}.gvpr" ]; then
echo "no such theme: themes/${theme_arg}.gvpr" >&2
echo "available: $(ls themes/*.gvpr 2>/dev/null | xargs -n1 basename | sed 's/\.gvpr$//' | tr '\n' ' ')" >&2
exit 1
fi
themes=("$theme_arg")
else
for t in themes/*.gvpr; do themes+=("$(basename "$t" .gvpr)"); done
fi
shopt -s nullglob
sources=(*.dot)
if [ -n "$graph_arg" ]; then
sources=("${graph_arg%.dot}.dot")
[ -f "${sources[0]}" ] || { echo "no such graph: ${sources[0]}" >&2; exit 1; }
fi
[ ${#sources[@]} -gt 0 ] || { echo "no .dot files here"; exit 0; }
for theme in "${themes[@]}"; do
for src in "${sources[@]}"; do
base="${src%.dot}"
if [ "$theme" = "$DEFAULT_THEME" ]; then
out="${base}.svg"
else
out="${base}.${theme}.svg"
fi
# One pipeline, so a gvpr failure fails the whole render rather than
# silently writing a half-themed file.
gvpr -c -f "themes/${theme}.gvpr" "$src" | dot -Tsvg -o "$out"
printf " %-14s %s\n" "$theme" "$out"
done
done
echo
echo "done — ${#sources[@]} graph(s) x ${#themes[@]} theme(s)"

View File

@@ -1,22 +1,20 @@
digraph room_layers { digraph room_layers {
bgcolor="#0a0a0a"
rankdir=TB rankdir=TB
fontname="Helvetica" fontname="Helvetica"
node [fontname="Helvetica" fontsize=10 style=filled color="#333" fontcolor="#e5e5e5" shape=record] node [fontname="Helvetica" fontsize=10 style=filled shape=record]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"] edge [fontname="Helvetica" fontsize=9]
label="Room Layers — init wizard" label="Room Layers — init wizard"
labelloc=t labelloc=t
fontsize=14 fontsize=14
fontcolor="#d4a574"
l0 [label="{Layer 0 | Config + Data | config.json · data/*.json}" fillcolor="#1a1a1a" color="#d4a574"] l0 [class="accent" label="{Layer 0 | Config + Data | config.json · data/*.json}"]
l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}" fillcolor="#1a1a1a"] l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}"]
l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}" fillcolor="#1a1a1a"] l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}"]
l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}" fillcolor="#1a1a1a"] l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}"]
l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}" fillcolor="#1a1a1a"] l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}"]
l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}" fillcolor="#1a1a1a"] l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}"]
l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}" fillcolor="#1a1a1a"] l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}"]
l0 -> l1 [label="required"] l0 -> l1 [label="required"]
l1 -> l2 [label="if managed"] l1 -> l2 [label="if managed"]
@@ -26,6 +24,6 @@ digraph room_layers {
l5 -> l6 [label="if frontend"] l5 -> l6 [label="if frontend"]
// Annotations // Annotations
note_req [label="every room" fillcolor="#0a0a0a" fontcolor="#d4a574" color="#0a0a0a" shape=plaintext fontsize=9] note_req [class="accent" label="every room" shape=plaintext fontsize=9]
note_req -> l0 [style=invis] note_req -> l0 [style=invis]
} }

View File

@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: room_layers Pages: 1 -->
<svg width="423pt" height="596pt"
viewBox="0.00 0.00 423.00 596.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 591.75)">
<title>room_layers</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-591.75 418.62,-591.75 418.62,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="207.31" y="-570.45" font-family="Arial" font-size="14.00" fill="#1f2933">Room Layers — init wizard</text>
<!-- l0 -->
<g id="node1" class="node accent">
<title>l0</title>
<polygon fill="#d6e4ff" stroke="#3a7dff" points="143.12,-430.5 143.12,-490.5 261.88,-490.5 261.88,-430.5 143.12,-430.5"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-477" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 0</text>
<polyline fill="none" stroke="#3a7dff" points="143.12,-470.5 261.88,-470.5"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-457" font-family="Arial" font-size="10.00" fill="#1f2933">Config + Data</text>
<polyline fill="none" stroke="#3a7dff" points="143.12,-450.5 261.88,-450.5"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-437" font-family="Arial" font-size="10.00" fill="#1f2933">config.json · data/*.json</text>
</g>
<!-- l1 -->
<g id="node2" class="node">
<title>l1</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="115.38,-323 115.38,-383 289.62,-383 289.62,-323 115.38,-323"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-369.5" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 1</text>
<polyline fill="none" stroke="#9aa5b1" points="115.38,-363 289.62,-363"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-349.5" font-family="Arial" font-size="10.00" fill="#1f2933">Docker</text>
<polyline fill="none" stroke="#9aa5b1" points="115.38,-343 289.62,-343"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-329.5" font-family="Arial" font-size="10.00" fill="#1f2933">soleprint/docker&#45;compose.yml · .env</text>
</g>
<!-- l0&#45;&gt;l1 -->
<g id="edge1" class="edge">
<title>l0&#45;&gt;l1</title>
<path fill="none" stroke="#9aa5b1" d="M202.5,-430.11C202.5,-418.22 202.5,-404.35 202.5,-391.73"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="204.95,-391.74 202.5,-384.74 200.05,-391.74 204.95,-391.74"/>
<text xml:space="preserve" text-anchor="middle" x="219.75" y="-403.45" font-family="Arial" font-size="9.00" fill="#616e7c">required</text>
</g>
<!-- l2 -->
<g id="node3" class="node">
<title>l2</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="0,-215.5 0,-275.5 193,-275.5 193,-215.5 0,-215.5"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-262" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 2</text>
<polyline fill="none" stroke="#9aa5b1" points="0,-255.5 193,-255.5"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-242" font-family="Arial" font-size="10.00" fill="#1f2933">Managed App</text>
<polyline fill="none" stroke="#9aa5b1" points="0,-235.5 193,-235.5"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-222" font-family="Arial" font-size="10.00" fill="#1f2933">docker&#45;compose.yml · Dockerfiles · .env</text>
</g>
<!-- l1&#45;&gt;l2 -->
<g id="edge2" class="edge">
<title>l1&#45;&gt;l2</title>
<path fill="none" stroke="#9aa5b1" d="M172.97,-322.61C160.22,-309.92 145.2,-294.97 131.86,-281.7"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="133.63,-280 126.94,-276.8 130.17,-283.47 133.63,-280"/>
<text xml:space="preserve" text-anchor="middle" x="175.55" y="-295.95" font-family="Arial" font-size="9.00" fill="#616e7c">if managed</text>
</g>
<!-- l4 -->
<g id="node4" class="node">
<title>l4</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="211.12,-215.5 211.12,-275.5 407.88,-275.5 407.88,-215.5 211.12,-215.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-262" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 4</text>
<polyline fill="none" stroke="#9aa5b1" points="211.12,-255.5 407.88,-255.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-242" font-family="Arial" font-size="10.00" fill="#1f2933">Scripts</text>
<polyline fill="none" stroke="#9aa5b1" points="211.12,-235.5 407.88,-235.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-222" font-family="Arial" font-size="10.00" fill="#1f2933">ctrl/start.sh · stop.sh · status.sh · logs.sh</text>
</g>
<!-- l1&#45;&gt;l4 -->
<g id="edge3" class="edge">
<title>l1&#45;&gt;l4</title>
<path fill="none" stroke="#9aa5b1" d="M232.31,-322.61C245.18,-309.92 260.34,-294.97 273.8,-281.7"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="275.51,-283.45 278.78,-276.79 272.07,-279.96 275.51,-283.45"/>
<text xml:space="preserve" text-anchor="middle" x="275.71" y="-295.95" font-family="Arial" font-size="9.00" fill="#616e7c">optional</text>
</g>
<!-- l3 -->
<g id="node5" class="node">
<title>l3</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="10.88,-108 10.88,-168 182.12,-168 182.12,-108 10.88,-108"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-154.5" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 3</text>
<polyline fill="none" stroke="#9aa5b1" points="10.88,-148 182.12,-148"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-134.5" font-family="Arial" font-size="10.00" fill="#1f2933">Link</text>
<polyline fill="none" stroke="#9aa5b1" points="10.88,-128 182.12,-128"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-114.5" font-family="Arial" font-size="10.00" fill="#1f2933">link/main.py · adapters/ · Dockerfile</text>
</g>
<!-- l2&#45;&gt;l3 -->
<g id="edge4" class="edge">
<title>l2&#45;&gt;l3</title>
<path fill="none" stroke="#9aa5b1" d="M96.5,-215.11C96.5,-203.22 96.5,-189.35 96.5,-176.73"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="98.95,-176.74 96.5,-169.74 94.05,-176.74 98.95,-176.74"/>
<text xml:space="preserve" text-anchor="middle" x="113" y="-188.45" font-family="Arial" font-size="9.00" fill="#616e7c">optional</text>
</g>
<!-- l5 -->
<g id="node6" class="node">
<title>l5</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="231,-108 231,-168 388,-168 388,-108 231,-108"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-154.5" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 5</text>
<polyline fill="none" stroke="#9aa5b1" points="231,-148 388,-148"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-134.5" font-family="Arial" font-size="10.00" fill="#1f2933">Systems</text>
<polyline fill="none" stroke="#9aa5b1" points="231,-128 388,-128"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-114.5" font-family="Arial" font-size="10.00" fill="#1f2933">tester/environments.json · tests/</text>
</g>
<!-- l4&#45;&gt;l5 -->
<g id="edge5" class="edge">
<title>l4&#45;&gt;l5</title>
<path fill="none" stroke="#9aa5b1" d="M309.5,-215.11C309.5,-203.22 309.5,-189.35 309.5,-176.73"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="311.95,-176.74 309.5,-169.74 307.05,-176.74 311.95,-176.74"/>
<text xml:space="preserve" text-anchor="middle" x="326" y="-188.45" font-family="Arial" font-size="9.00" fill="#616e7c">optional</text>
</g>
<!-- l6 -->
<g id="node7" class="node">
<title>l6</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="204.38,-0.5 204.38,-60.5 414.62,-60.5 414.62,-0.5 204.38,-0.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-47" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 6</text>
<polyline fill="none" stroke="#9aa5b1" points="204.38,-40.5 414.62,-40.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-27" font-family="Arial" font-size="10.00" fill="#1f2933">Nginx</text>
<polyline fill="none" stroke="#9aa5b1" points="204.38,-20.5 414.62,-20.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-7" font-family="Arial" font-size="10.00" fill="#1f2933">nginx/local.conf · docker&#45;compose.nginx.yml</text>
</g>
<!-- l5&#45;&gt;l6 -->
<g id="edge6" class="edge">
<title>l5&#45;&gt;l6</title>
<path fill="none" stroke="#9aa5b1" d="M309.5,-107.61C309.5,-95.72 309.5,-81.85 309.5,-69.23"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="311.95,-69.24 309.5,-62.24 307.05,-69.24 311.95,-69.24"/>
<text xml:space="preserve" text-anchor="middle" x="329.75" y="-80.95" font-family="Arial" font-size="9.00" fill="#616e7c">if frontend</text>
</g>
<!-- note_req -->
<g id="node8" class="node accent">
<title>note_req</title>
<polygon fill="#d6e4ff" stroke="none" points="233.38,-564 171.62,-564 171.62,-528 233.38,-528 233.38,-564"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-542.7" font-family="Arial" font-size="9.00" fill="#1f2933">every room</text>
</g>
<!-- note_req&#45;&gt;l0 -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -11,7 +11,7 @@
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-607.5 456.62,-607.5 456.62,4 -4,4"/> <polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-607.5 456.62,-607.5 456.62,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="226.31" y="-586.2" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Room Layers — init wizard</text> <text xml:space="preserve" text-anchor="middle" x="226.31" y="-586.2" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Room Layers — init wizard</text>
<!-- l0 --> <!-- l0 -->
<g id="node1" class="node"> <g id="node1" class="node accent">
<title>l0</title> <title>l0</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="155.12,-442.5 155.12,-504.75 285.88,-504.75 285.88,-442.5 155.12,-442.5"/> <polygon fill="#1a1a1a" stroke="#d4a574" points="155.12,-442.5 155.12,-504.75 285.88,-504.75 285.88,-442.5 155.12,-442.5"/>
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-491.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 0</text> <text xml:space="preserve" text-anchor="middle" x="220.5" y="-491.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 0</text>
@@ -55,7 +55,7 @@
<text xml:space="preserve" text-anchor="middle" x="191.95" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text> <text xml:space="preserve" text-anchor="middle" x="191.95" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text>
</g> </g>
<!-- l4 --> <!-- l4 -->
<g id="node5" class="node"> <g id="node4" class="node">
<title>l4</title> <title>l4</title>
<polygon fill="#1a1a1a" stroke="#333333" points="229.25,-221.5 229.25,-283.75 441.75,-283.75 441.75,-221.5 229.25,-221.5"/> <polygon fill="#1a1a1a" stroke="#333333" points="229.25,-221.5 229.25,-283.75 441.75,-283.75 441.75,-221.5 229.25,-221.5"/>
<text xml:space="preserve" text-anchor="middle" x="335.5" y="-270.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 4</text> <text xml:space="preserve" text-anchor="middle" x="335.5" y="-270.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 4</text>
@@ -65,14 +65,14 @@
<text xml:space="preserve" text-anchor="middle" x="335.5" y="-228.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">ctrl/start.sh · stop.sh · status.sh · logs.sh</text> <text xml:space="preserve" text-anchor="middle" x="335.5" y="-228.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">ctrl/start.sh · stop.sh · status.sh · logs.sh</text>
</g> </g>
<!-- l1&#45;&gt;l4 --> <!-- l1&#45;&gt;l4 -->
<g id="edge4" class="edge"> <g id="edge3" class="edge">
<title>l1&#45;&gt;l4</title> <title>l1&#45;&gt;l4</title>
<path fill="none" stroke="#666666" d="M252.84,-331.61C266.06,-319.14 281.48,-304.59 295.41,-291.45"/> <path fill="none" stroke="#666666" d="M252.84,-331.61C266.06,-319.14 281.48,-304.59 295.41,-291.45"/>
<polygon fill="#666666" stroke="#666666" points="297.43,-294.36 302.3,-284.95 292.63,-289.26 297.43,-294.36"/> <polygon fill="#666666" stroke="#666666" points="297.43,-294.36 302.3,-284.95 292.63,-289.26 297.43,-294.36"/>
<text xml:space="preserve" text-anchor="middle" x="299.45" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">optional</text> <text xml:space="preserve" text-anchor="middle" x="299.45" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">optional</text>
</g> </g>
<!-- l3 --> <!-- l3 -->
<g id="node4" class="node"> <g id="node5" class="node">
<title>l3</title> <title>l3</title>
<polygon fill="#1a1a1a" stroke="#333333" points="10.88,-111 10.88,-173.25 200.12,-173.25 200.12,-111 10.88,-111"/> <polygon fill="#1a1a1a" stroke="#333333" points="10.88,-111 10.88,-173.25 200.12,-173.25 200.12,-111 10.88,-111"/>
<text xml:space="preserve" text-anchor="middle" x="105.5" y="-159.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 3</text> <text xml:space="preserve" text-anchor="middle" x="105.5" y="-159.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 3</text>
@@ -82,7 +82,7 @@
<text xml:space="preserve" text-anchor="middle" x="105.5" y="-118.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">link/main.py · adapters/ · Dockerfile</text> <text xml:space="preserve" text-anchor="middle" x="105.5" y="-118.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">link/main.py · adapters/ · Dockerfile</text>
</g> </g>
<!-- l2&#45;&gt;l3 --> <!-- l2&#45;&gt;l3 -->
<g id="edge3" class="edge"> <g id="edge4" class="edge">
<title>l2&#45;&gt;l3</title> <title>l2&#45;&gt;l3</title>
<path fill="none" stroke="#666666" d="M105.5,-221.11C105.5,-209.81 105.5,-196.79 105.5,-184.67"/> <path fill="none" stroke="#666666" d="M105.5,-221.11C105.5,-209.81 105.5,-196.79 105.5,-184.67"/>
<polygon fill="#666666" stroke="#666666" points="109,-184.92 105.5,-174.92 102,-184.92 109,-184.92"/> <polygon fill="#666666" stroke="#666666" points="109,-184.92 105.5,-174.92 102,-184.92 109,-184.92"/>
@@ -123,10 +123,10 @@
<text xml:space="preserve" text-anchor="middle" x="359.12" y="-83.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if frontend</text> <text xml:space="preserve" text-anchor="middle" x="359.12" y="-83.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if frontend</text>
</g> </g>
<!-- note_req --> <!-- note_req -->
<g id="node8" class="node"> <g id="node8" class="node accent">
<title>note_req</title> <title>note_req</title>
<polygon fill="#0a0a0a" stroke="none" points="254,-578.25 187,-578.25 187,-542.25 254,-542.25 254,-578.25"/> <polygon fill="#1a1a1a" stroke="none" points="254,-578.25 187,-578.25 187,-542.25 254,-542.25 254,-578.25"/>
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-557.33" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">every room</text> <text xml:space="preserve" text-anchor="middle" x="220.5" y="-557.33" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e5e5e5">every room</text>
</g> </g>
<!-- note_req&#45;&gt;l0 --> <!-- note_req&#45;&gt;l0 -->
</g> </g>

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,88 +1,78 @@
digraph system_overview { digraph system_overview {
bgcolor="#0a0a0a"
rankdir=TB rankdir=TB
compound=true compound=true
fontname="Helvetica" fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5"] node [fontname="Helvetica" fontsize=11 style=filled]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"] edge [fontname="Helvetica" fontsize=9]
label="Soleprint — System Overview" label="Soleprint — System Overview"
labelloc=t labelloc=t
fontsize=14 fontsize=14
fontcolor="#d4a574"
// Core // Core
subgraph cluster_core { subgraph cluster_core {
label="Soleprint Hub" label="Soleprint Hub"
style=dashed style=dashed
color="#d4a574" class="accent"
fontcolor="#d4a574"
hub [label="soleprint\ncore coordinator\nport 12000" fillcolor="#1a1a1a" shape=box] hub [label="soleprint\ncore coordinator\nport 12000" shape=box]
} }
// Artery // Artery
subgraph cluster_artery { subgraph cluster_artery {
label="Artery — Todo lo vital" label="Artery — Todo lo vital"
style=dashed style=dashed
color="#b91c1c" class="artery"
fontcolor="#fca5a5"
veins [label="Veins\nstateless connectors" fillcolor="#1a1a1a"] veins [label="Veins\nstateless connectors"]
shunts [label="Shunts\nmock connectors" fillcolor="#1a1a1a"] shunts [label="Shunts\nmock connectors"]
pulses [label="Pulses\ncomposed flows" fillcolor="#1a1a1a"] pulses [label="Pulses\ncomposed flows"]
} }
// Atlas // Atlas
subgraph cluster_atlas { subgraph cluster_atlas {
label="Atlas — Documentacion accionable" label="Atlas — Documentacion accionable"
style=dashed style=dashed
color="#15803d" class="atlas"
fontcolor="#86efac"
books [label="Books\ndocumentation" fillcolor="#1a1a1a"] books [label="Books\ndocumentation"]
templates [label="Templates\npatterns" fillcolor="#1a1a1a"] templates [label="Templates\npatterns"]
} }
// Station // Station
subgraph cluster_station { subgraph cluster_station {
label="Station — Centro de control" label="Station — Centro de control"
style=dashed style=dashed
color="#1d4ed8" class="station"
fontcolor="#93c5fd"
tools [label="Tools\ntester · datagen · modelgen" fillcolor="#1a1a1a"] tools [label="Tools\ntester · datagen · modelgen"]
monitors [label="Monitors\ndatabrowse" fillcolor="#1a1a1a"] monitors [label="Monitors\ndatabrowse"]
} }
// External // External
subgraph cluster_external { subgraph cluster_external {
label="External APIs" label="External APIs"
style=dashed style=dashed
color="#333"
fontcolor="#666"
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#a3a3a3"] jira [label="Jira"]
google [label="Google" fillcolor="#1a1a1a" fontcolor="#a3a3a3"] google [label="Google"]
slack [label="Slack" fillcolor="#1a1a1a" fontcolor="#a3a3a3"] slack [label="Slack"]
} }
// Managed app // Managed app
subgraph cluster_managed { subgraph cluster_managed {
label="Managed App" label="Managed App"
style=dashed style=dashed
color="#333"
fontcolor="#666"
app_fe [label="Frontend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"] app_fe [label="Frontend"]
app_be [label="Backend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"] app_be [label="Backend"]
app_db [label="Database" fillcolor="#1a1a1a" fontcolor="#a3a3a3" shape=cylinder] app_db [label="Database" shape=cylinder]
} }
// Connections // Connections
hub -> veins [label="routes" color="#b91c1c"] hub -> veins [class="artery" label="routes"]
hub -> books [label="routes" color="#15803d"] hub -> books [class="atlas" label="routes"]
hub -> tools [label="routes" color="#1d4ed8"] hub -> tools [class="station" label="routes"]
veins -> jira [label="API"] veins -> jira [label="API"]
veins -> google [label="OAuth"] veins -> google [label="OAuth"]
@@ -93,5 +83,5 @@ digraph system_overview {
monitors -> app_db [label="browse" style=dashed] monitors -> app_db [label="browse" style=dashed]
// Sidebar injection // Sidebar injection
hub -> app_fe [label="sidebar\ninjection" color="#d4a574" style=dashed] hub -> app_fe [class="accent" label="sidebar\ninjection" style=dashed]
} }

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: system_overview Pages: 1 -->
<svg width="1038pt" height="365pt"
viewBox="0.00 0.00 1038.00 365.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 360.75)">
<title>system_overview</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-360.75 1034,-360.75 1034,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="515" y="-339.45" font-family="Arial" font-size="14.00" fill="#1f2933">Soleprint — System Overview</text>
<g id="clust1" class="cluster accent">
<title>cluster_core</title>
<path fill="#d6e4ff" stroke="#3a7dff" stroke-dasharray="5,2" d="M493,-236.75C493,-236.75 579,-236.75 579,-236.75 585,-236.75 591,-242.75 591,-248.75 591,-248.75 591,-313 591,-313 591,-319 585,-325 579,-325 579,-325 493,-325 493,-325 487,-325 481,-319 481,-313 481,-313 481,-248.75 481,-248.75 481,-242.75 487,-236.75 493,-236.75"/>
<text xml:space="preserve" text-anchor="middle" x="536" y="-307.7" font-family="Arial" font-size="14.00" fill="#616e7c">Soleprint Hub</text>
</g>
<g id="clust2" class="cluster artery">
<title>cluster_artery</title>
<path fill="#fdeaea" stroke="#c0392b" stroke-dasharray="5,2" d="M20,-8C20,-8 328,-8 328,-8 334,-8 340,-14 340,-20 340,-20 340,-196.25 340,-196.25 340,-202.25 334,-208.25 328,-208.25 328,-208.25 20,-208.25 20,-208.25 14,-208.25 8,-202.25 8,-196.25 8,-196.25 8,-20 8,-20 8,-14 14,-8 20,-8"/>
<text xml:space="preserve" text-anchor="middle" x="174" y="-190.95" font-family="Arial" font-size="14.00" fill="#616e7c">Artery — Todo lo vital</text>
</g>
<g id="clust3" class="cluster atlas">
<title>cluster_atlas</title>
<path fill="#e6f5ec" stroke="#1a7f45" stroke-dasharray="5,2" d="M360,-119C360,-119 584,-119 584,-119 590,-119 596,-125 596,-131 596,-131 596,-196.25 596,-196.25 596,-202.25 590,-208.25 584,-208.25 584,-208.25 360,-208.25 360,-208.25 354,-208.25 348,-202.25 348,-196.25 348,-196.25 348,-131 348,-131 348,-125 354,-119 360,-119"/>
<text xml:space="preserve" text-anchor="middle" x="472" y="-190.95" font-family="Arial" font-size="14.00" fill="#616e7c">Atlas — Documentacion accionable</text>
</g>
<g id="clust4" class="cluster station">
<title>cluster_station</title>
<path fill="#e8effd" stroke="#2b5fd9" stroke-dasharray="5,2" d="M616,-119C616,-119 938,-119 938,-119 944,-119 950,-125 950,-131 950,-131 950,-196.25 950,-196.25 950,-202.25 944,-208.25 938,-208.25 938,-208.25 616,-208.25 616,-208.25 610,-208.25 604,-202.25 604,-196.25 604,-196.25 604,-131 604,-131 604,-125 610,-119 616,-119"/>
<text xml:space="preserve" text-anchor="middle" x="777" y="-190.95" font-family="Arial" font-size="14.00" fill="#616e7c">Station — Centro de control</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_external</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M360,-14.75C360,-14.75 558,-14.75 558,-14.75 564,-14.75 570,-20.75 570,-26.75 570,-26.75 570,-78.5 570,-78.5 570,-84.5 564,-90.5 558,-90.5 558,-90.5 360,-90.5 360,-90.5 354,-90.5 348,-84.5 348,-78.5 348,-78.5 348,-26.75 348,-26.75 348,-20.75 354,-14.75 360,-14.75"/>
<text xml:space="preserve" text-anchor="middle" x="459" y="-73.2" font-family="Arial" font-size="14.00" fill="#616e7c">External APIs</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_managed</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M774,-14.75C774,-14.75 1010,-14.75 1010,-14.75 1016,-14.75 1022,-20.75 1022,-26.75 1022,-26.75 1022,-78.5 1022,-78.5 1022,-84.5 1016,-90.5 1010,-90.5 1010,-90.5 774,-90.5 774,-90.5 768,-90.5 762,-84.5 762,-78.5 762,-78.5 762,-26.75 762,-26.75 762,-20.75 768,-14.75 774,-14.75"/>
<text xml:space="preserve" text-anchor="middle" x="892" y="-73.2" font-family="Arial" font-size="14.00" fill="#616e7c">Managed App</text>
</g>
<!-- hub -->
<g id="node1" class="node">
<title>hub</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M571,-293.25C571,-293.25 501,-293.25 501,-293.25 495,-293.25 489,-287.25 489,-281.25 489,-281.25 489,-256.75 489,-256.75 489,-250.75 495,-244.75 501,-244.75 501,-244.75 571,-244.75 571,-244.75 577,-244.75 583,-250.75 583,-256.75 583,-256.75 583,-281.25 583,-281.25 583,-287.25 577,-293.25 571,-293.25"/>
<text xml:space="preserve" text-anchor="middle" x="536" y="-278.8" font-family="Arial" font-size="11.00" fill="#1f2933">soleprint</text>
<text xml:space="preserve" text-anchor="middle" x="536" y="-265.3" font-family="Arial" font-size="11.00" fill="#1f2933">core coordinator</text>
<text xml:space="preserve" text-anchor="middle" x="536" y="-251.8" font-family="Arial" font-size="11.00" fill="#1f2933">port 12000</text>
</g>
<!-- veins -->
<g id="node2" class="node">
<title>veins</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="96" cy="-151.75" rx="80.26" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="96" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Veins</text>
<text xml:space="preserve" text-anchor="middle" x="96" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">stateless connectors</text>
</g>
<!-- hub&#45;&gt;veins -->
<g id="edge1" class="edge artery">
<title>hub&#45;&gt;veins</title>
<path fill="none" stroke="#c0392b" d="M488.78,-265.75C419.92,-261.21 288.87,-247.71 185,-208.25 166.55,-201.24 147.77,-190.21 132.24,-179.83"/>
<polygon fill="#c0392b" stroke="#c0392b" points="133.76,-177.9 126.6,-175.98 131,-181.95 133.76,-177.9"/>
<text xml:space="preserve" text-anchor="middle" x="252.94" y="-218.2" font-family="Arial" font-size="9.00" fill="#c0392b">routes</text>
</g>
<!-- books -->
<g id="node5" class="node">
<title>books</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="417" cy="-151.75" rx="61.16" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="417" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Books</text>
<text xml:space="preserve" text-anchor="middle" x="417" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">documentation</text>
</g>
<!-- hub&#45;&gt;books -->
<g id="edge2" class="edge atlas">
<title>hub&#45;&gt;books</title>
<path fill="none" stroke="#1a7f45" d="M511.63,-244.4C492.65,-226.01 466.24,-200.44 446.14,-180.97"/>
<polygon fill="#1a7f45" stroke="#1a7f45" points="447.94,-179.3 441.21,-176.19 444.53,-182.82 447.94,-179.3"/>
<text xml:space="preserve" text-anchor="middle" x="503.41" y="-218.2" font-family="Arial" font-size="9.00" fill="#1a7f45">routes</text>
</g>
<!-- tools -->
<g id="node7" class="node">
<title>tools</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="717" cy="-151.75" rx="105.18" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="717" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Tools</text>
<text xml:space="preserve" text-anchor="middle" x="717" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">tester · datagen · modelgen</text>
</g>
<!-- hub&#45;&gt;tools -->
<g id="edge3" class="edge station">
<title>hub&#45;&gt;tools</title>
<path fill="none" stroke="#2b5fd9" d="M573.06,-244.4C602.58,-225.6 643.92,-199.28 674.73,-179.67"/>
<polygon fill="#2b5fd9" stroke="#2b5fd9" points="675.66,-181.98 680.25,-176.15 673.03,-177.84 675.66,-181.98"/>
<text xml:space="preserve" text-anchor="middle" x="628.41" y="-218.2" font-family="Arial" font-size="9.00" fill="#2b5fd9">routes</text>
</g>
<!-- app_fe -->
<g id="node12" class="node">
<title>app_fe</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="977" cy="-40.75" rx="37.09" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="977" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Frontend</text>
</g>
<!-- hub&#45;&gt;app_fe -->
<g id="edge4" class="edge accent">
<title>hub&#45;&gt;app_fe</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M583.13,-268.1C681.31,-267.28 902.87,-259.62 954,-208.25 991.07,-171.01 987.37,-103.78 981.91,-67.18"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="984.37,-67.01 980.83,-60.49 979.53,-67.79 984.37,-67.01"/>
<text xml:space="preserve" text-anchor="middle" x="1002.46" y="-153.7" font-family="Arial" font-size="9.00" fill="#3a7dff">sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="1002.46" y="-143.2" font-family="Arial" font-size="9.00" fill="#3a7dff">injection</text>
</g>
<!-- pulses -->
<g id="node4" class="node">
<title>pulses</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="96" cy="-40.75" rx="65.94" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="96" y="-43.8" font-family="Arial" font-size="11.00" fill="#1f2933">Pulses</text>
<text xml:space="preserve" text-anchor="middle" x="96" y="-30.3" font-family="Arial" font-size="11.00" fill="#1f2933">composed flows</text>
</g>
<!-- veins&#45;&gt;pulses -->
<g id="edge5" class="edge">
<title>veins&#45;&gt;pulses</title>
<path fill="none" stroke="#9aa5b1" d="M96,-126.56C96,-111.18 96,-91.07 96,-74.3"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="98.45,-74.38 96,-67.39 93.55,-74.39 98.45,-74.38"/>
<text xml:space="preserve" text-anchor="middle" x="114.75" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">compose</text>
</g>
<!-- jira -->
<g id="node9" class="node">
<title>jira</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="383" cy="-40.75" rx="27" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="383" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Jira</text>
</g>
<!-- veins&#45;&gt;jira -->
<g id="edge6" class="edge">
<title>veins&#45;&gt;jira</title>
<path fill="none" stroke="#9aa5b1" d="M144.51,-131.81C157.52,-127.18 171.68,-122.54 185,-119 254.38,-100.54 282.3,-127.21 344,-90.5 354.39,-84.32 363.13,-74.26 369.69,-64.89"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="371.64,-66.39 373.44,-59.19 367.54,-63.69 371.64,-66.39"/>
<text xml:space="preserve" text-anchor="middle" x="333.55" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">API</text>
</g>
<!-- google -->
<g id="node10" class="node">
<title>google</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="459" cy="-40.75" rx="31.48" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="459" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Google</text>
</g>
<!-- veins&#45;&gt;google -->
<g id="edge7" class="edge">
<title>veins&#45;&gt;google</title>
<path fill="none" stroke="#9aa5b1" d="M142.87,-131.26C156.26,-126.46 171.02,-121.87 185,-119 287.63,-97.92 330.48,-146.53 419,-90.5 429.17,-84.06 437.99,-74.16 444.73,-64.95"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="446.63,-66.51 448.61,-59.37 442.61,-63.72 446.63,-66.51"/>
<text xml:space="preserve" text-anchor="middle" x="416.29" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">OAuth</text>
</g>
<!-- slack -->
<g id="node11" class="node">
<title>slack</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="535" cy="-40.75" rx="27" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="535" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Slack</text>
</g>
<!-- veins&#45;&gt;slack -->
<g id="edge8" class="edge">
<title>veins&#45;&gt;slack</title>
<path fill="none" stroke="#9aa5b1" d="M142.37,-131.22C155.88,-126.36 170.83,-121.75 185,-119 293.3,-98.01 324.04,-126.22 433,-109 463.09,-104.24 473.93,-107.8 499,-90.5 508.43,-83.99 516.35,-74.2 522.34,-65.1"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="524.32,-66.55 525.93,-59.3 520.16,-63.97 524.32,-66.55"/>
<text xml:space="preserve" text-anchor="middle" x="492.52" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">API</text>
</g>
<!-- shunts -->
<g id="node3" class="node">
<title>shunts</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="263" cy="-151.75" rx="68.59" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="263" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Shunts</text>
<text xml:space="preserve" text-anchor="middle" x="263" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">mock connectors</text>
</g>
<!-- templates -->
<g id="node6" class="node">
<title>templates</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="542" cy="-151.75" rx="45.79" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="542" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Templates</text>
<text xml:space="preserve" text-anchor="middle" x="542" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">patterns</text>
</g>
<!-- app_be -->
<g id="node13" class="node">
<title>app_be</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="806" cy="-40.75" rx="36.16" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="806" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Backend</text>
</g>
<!-- tools&#45;&gt;app_be -->
<g id="edge9" class="edge">
<title>tools&#45;&gt;app_be</title>
<path fill="none" stroke="#9aa5b1" stroke-dasharray="5,2" d="M736.31,-127.1C751.52,-108.47 772.58,-82.68 787.56,-64.33"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="789.29,-66.08 791.82,-59.11 785.5,-62.99 789.29,-66.08"/>
<text xml:space="preserve" text-anchor="middle" x="765.96" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">test</text>
</g>
<!-- monitors -->
<g id="node8" class="node">
<title>monitors</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="891" cy="-151.75" rx="51.09" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="891" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Monitors</text>
<text xml:space="preserve" text-anchor="middle" x="891" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">databrowse</text>
</g>
<!-- app_db -->
<g id="node14" class="node">
<title>app_db</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M922.25,-55.48C922.25,-57.28 908.24,-58.75 891,-58.75 873.76,-58.75 859.75,-57.28 859.75,-55.48 859.75,-55.48 859.75,-26.02 859.75,-26.02 859.75,-24.22 873.76,-22.75 891,-22.75 908.24,-22.75 922.25,-24.22 922.25,-26.02 922.25,-26.02 922.25,-55.48 922.25,-55.48"/>
<path fill="none" stroke="#9aa5b1" d="M922.25,-55.48C922.25,-53.67 908.24,-52.2 891,-52.2 873.76,-52.2 859.75,-53.67 859.75,-55.48"/>
<text xml:space="preserve" text-anchor="middle" x="891" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Database</text>
</g>
<!-- monitors&#45;&gt;app_db -->
<g id="edge10" class="edge">
<title>monitors&#45;&gt;app_db</title>
<path fill="none" stroke="#9aa5b1" stroke-dasharray="5,2" d="M891,-126.56C891,-108.99 891,-85.25 891,-67.36"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="893.45,-67.61 891,-60.61 888.55,-67.61 893.45,-67.61"/>
<text xml:space="preserve" text-anchor="middle" x="906" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">browse</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -10,22 +10,22 @@
<title>system_overview</title> <title>system_overview</title>
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-368.25 1149,-368.25 1149,4 -4,4"/> <polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-368.25 1149,-368.25 1149,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="572.5" y="-346.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint — System Overview</text> <text xml:space="preserve" text-anchor="middle" x="572.5" y="-346.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint — System Overview</text>
<g id="clust1" class="cluster"> <g id="clust1" class="cluster accent">
<title>cluster_core</title> <title>cluster_core</title>
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="538,-241.25 538,-331 660,-331 660,-241.25 538,-241.25"/> <polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="538,-241.25 538,-331 660,-331 660,-241.25 538,-241.25"/>
<text xml:space="preserve" text-anchor="middle" x="599" y="-313.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint Hub</text> <text xml:space="preserve" text-anchor="middle" x="599" y="-313.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint Hub</text>
</g> </g>
<g id="clust2" class="cluster"> <g id="clust2" class="cluster artery">
<title>cluster_artery</title> <title>cluster_artery</title>
<polygon fill="#0a0a0a" stroke="#b91c1c" stroke-dasharray="5,2" points="8,-8 8,-212 382,-212 382,-8 8,-8"/> <polygon fill="#0a0a0a" stroke="#b91c1c" stroke-dasharray="5,2" points="8,-8 8,-212 382,-212 382,-8 8,-8"/>
<text xml:space="preserve" text-anchor="middle" x="195" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Todo lo vital</text> <text xml:space="preserve" text-anchor="middle" x="195" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Todo lo vital</text>
</g> </g>
<g id="clust3" class="cluster"> <g id="clust3" class="cluster atlas">
<title>cluster_atlas</title> <title>cluster_atlas</title>
<polygon fill="#0a0a0a" stroke="#15803d" stroke-dasharray="5,2" points="390,-121.25 390,-212 665,-212 665,-121.25 390,-121.25"/> <polygon fill="#0a0a0a" stroke="#15803d" stroke-dasharray="5,2" points="390,-121.25 390,-212 665,-212 665,-121.25 390,-121.25"/>
<text xml:space="preserve" text-anchor="middle" x="527.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#86efac">Atlas — Documentacion accionable</text> <text xml:space="preserve" text-anchor="middle" x="527.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#86efac">Atlas — Documentacion accionable</text>
</g> </g>
<g id="clust4" class="cluster"> <g id="clust4" class="cluster station">
<title>cluster_station</title> <title>cluster_station</title>
<polygon fill="#0a0a0a" stroke="#1d4ed8" stroke-dasharray="5,2" points="673,-121.25 673,-212 1062,-212 1062,-121.25 673,-121.25"/> <polygon fill="#0a0a0a" stroke="#1d4ed8" stroke-dasharray="5,2" points="673,-121.25 673,-212 1062,-212 1062,-121.25 673,-121.25"/>
<text xml:space="preserve" text-anchor="middle" x="867.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#93c5fd">Station — Centro de control</text> <text xml:space="preserve" text-anchor="middle" x="867.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#93c5fd">Station — Centro de control</text>
@@ -56,11 +56,11 @@
<text xml:space="preserve" text-anchor="middle" x="108" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless connectors</text> <text xml:space="preserve" text-anchor="middle" x="108" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless connectors</text>
</g> </g>
<!-- hub&#45;&gt;veins --> <!-- hub&#45;&gt;veins -->
<g id="edge1" class="edge"> <g id="edge1" class="edge artery">
<title>hub&#45;&gt;veins</title> <title>hub&#45;&gt;veins</title>
<path fill="none" stroke="#b91c1c" d="M545.18,-270.24C468.57,-265.68 324.55,-252.07 209,-212 188.62,-204.93 167.6,-193.79 150.05,-183.23"/> <path fill="none" stroke="#b91c1c" d="M545.18,-270.24C468.57,-265.68 324.55,-252.07 209,-212 188.62,-204.93 167.6,-193.79 150.05,-183.23"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="152.32,-180.52 141.98,-178.24 148.65,-186.47 152.32,-180.52"/> <polygon fill="#b91c1c" stroke="#b91c1c" points="152.32,-180.52 141.98,-178.24 148.65,-186.47 152.32,-180.52"/>
<text xml:space="preserve" text-anchor="middle" x="284.95" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text> <text xml:space="preserve" text-anchor="middle" x="284.95" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">routes</text>
</g> </g>
<!-- books --> <!-- books -->
<g id="node5" class="node"> <g id="node5" class="node">
@@ -70,11 +70,11 @@
<text xml:space="preserve" text-anchor="middle" x="468" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">documentation</text> <text xml:space="preserve" text-anchor="middle" x="468" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">documentation</text>
</g> </g>
<!-- hub&#45;&gt;books --> <!-- hub&#45;&gt;books -->
<g id="edge2" class="edge"> <g id="edge2" class="edge atlas">
<title>hub&#45;&gt;books</title> <title>hub&#45;&gt;books</title>
<path fill="none" stroke="#15803d" d="M572.81,-249C552.24,-230.55 523.47,-204.75 501.27,-184.84"/> <path fill="none" stroke="#15803d" d="M572.81,-249C552.24,-230.55 523.47,-204.75 501.27,-184.84"/>
<polygon fill="#15803d" stroke="#15803d" points="503.73,-182.34 493.94,-178.27 499.05,-187.55 503.73,-182.34"/> <polygon fill="#15803d" stroke="#15803d" points="503.73,-182.34 493.94,-178.27 499.05,-187.55 503.73,-182.34"/>
<text xml:space="preserve" text-anchor="middle" x="567.14" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text> <text xml:space="preserve" text-anchor="middle" x="567.14" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">routes</text>
</g> </g>
<!-- tools --> <!-- tools -->
<g id="node7" class="node"> <g id="node7" class="node">
@@ -84,25 +84,25 @@
<text xml:space="preserve" text-anchor="middle" x="802" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">tester · datagen · modelgen</text> <text xml:space="preserve" text-anchor="middle" x="802" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">tester · datagen · modelgen</text>
</g> </g>
<!-- hub&#45;&gt;tools --> <!-- hub&#45;&gt;tools -->
<g id="edge3" class="edge"> <g id="edge3" class="edge station">
<title>hub&#45;&gt;tools</title> <title>hub&#45;&gt;tools</title>
<path fill="none" stroke="#1d4ed8" d="M639.59,-249C672.13,-230.17 717.93,-203.66 752.6,-183.59"/> <path fill="none" stroke="#1d4ed8" d="M639.59,-249C672.13,-230.17 717.93,-203.66 752.6,-183.59"/>
<polygon fill="#1d4ed8" stroke="#1d4ed8" points="754.3,-186.65 761.2,-178.61 750.8,-180.59 754.3,-186.65"/> <polygon fill="#1d4ed8" stroke="#1d4ed8" points="754.3,-186.65 761.2,-178.61 750.8,-180.59 754.3,-186.65"/>
<text xml:space="preserve" text-anchor="middle" x="702.6" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text> <text xml:space="preserve" text-anchor="middle" x="702.6" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#93c5fd">routes</text>
</g> </g>
<!-- app_fe --> <!-- app_fe -->
<g id="node12" class="node"> <g id="node12" class="node">
<title>app_fe</title> <title>app_fe</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="1089" cy="-40.75" rx="39.9" ry="18"/> <ellipse fill="#1a1a1a" stroke="#333333" cx="1089" cy="-40.75" rx="39.9" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="1089" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Frontend</text> <text xml:space="preserve" text-anchor="middle" x="1089" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Frontend</text>
</g> </g>
<!-- hub&#45;&gt;app_fe --> <!-- hub&#45;&gt;app_fe -->
<g id="edge10" class="edge"> <g id="edge4" class="edge accent">
<title>hub&#45;&gt;app_fe</title> <title>hub&#45;&gt;app_fe</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M652.46,-273.38C762.51,-274.01 1008.83,-268.85 1066,-212 1103.21,-174.99 1100.13,-108.42 1094.57,-70.42"/> <path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M652.46,-273.38C762.51,-274.01 1008.83,-268.85 1066,-212 1103.21,-174.99 1100.13,-108.42 1094.57,-70.42"/>
<polygon fill="#d4a574" stroke="#d4a574" points="1098.04,-69.95 1092.97,-60.65 1091.14,-71.08 1098.04,-69.95"/> <polygon fill="#d4a574" stroke="#d4a574" points="1098.04,-69.95 1092.97,-60.65 1091.14,-71.08 1098.04,-69.95"/>
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-156.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">sidebar</text> <text xml:space="preserve" text-anchor="middle" x="1116.94" y="-156.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-145.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">injection</text> <text xml:space="preserve" text-anchor="middle" x="1116.94" y="-145.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">injection</text>
</g> </g>
<!-- pulses --> <!-- pulses -->
<g id="node4" class="node"> <g id="node4" class="node">
@@ -112,7 +112,7 @@
<text xml:space="preserve" text-anchor="middle" x="108" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">composed flows</text> <text xml:space="preserve" text-anchor="middle" x="108" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">composed flows</text>
</g> </g>
<!-- veins&#45;&gt;pulses --> <!-- veins&#45;&gt;pulses -->
<g id="edge7" class="edge"> <g id="edge5" class="edge">
<title>veins&#45;&gt;pulses</title> <title>veins&#45;&gt;pulses</title>
<path fill="none" stroke="#666666" d="M108,-128.86C108,-113.7 108,-93.88 108,-76.98"/> <path fill="none" stroke="#666666" d="M108,-128.86C108,-113.7 108,-93.88 108,-76.98"/>
<polygon fill="#666666" stroke="#666666" points="111.5,-77.35 108,-67.35 104.5,-77.35 111.5,-77.35"/> <polygon fill="#666666" stroke="#666666" points="111.5,-77.35 108,-67.35 104.5,-77.35 111.5,-77.35"/>
@@ -122,10 +122,10 @@
<g id="node9" class="node"> <g id="node9" class="node">
<title>jira</title> <title>jira</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="425" cy="-40.75" rx="27" ry="18"/> <ellipse fill="#1a1a1a" stroke="#333333" cx="425" cy="-40.75" rx="27" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="425" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Jira</text> <text xml:space="preserve" text-anchor="middle" x="425" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Jira</text>
</g> </g>
<!-- veins&#45;&gt;jira --> <!-- veins&#45;&gt;jira -->
<g id="edge4" class="edge"> <g id="edge6" class="edge">
<title>veins&#45;&gt;jira</title> <title>veins&#45;&gt;jira</title>
<path fill="none" stroke="#666666" d="M163.97,-133.86C178.52,-129.32 194.25,-124.77 209,-121.25 286.56,-102.75 317.5,-132.81 386,-92 395.89,-86.1 404.2,-76.67 410.57,-67.57"/> <path fill="none" stroke="#666666" d="M163.97,-133.86C178.52,-129.32 194.25,-124.77 209,-121.25 286.56,-102.75 317.5,-132.81 386,-92 395.89,-86.1 404.2,-76.67 410.57,-67.57"/>
<polygon fill="#666666" stroke="#666666" points="413.44,-69.58 415.88,-59.27 407.54,-65.8 413.44,-69.58"/> <polygon fill="#666666" stroke="#666666" points="413.44,-69.58 415.88,-59.27 407.54,-65.8 413.44,-69.58"/>
@@ -135,10 +135,10 @@
<g id="node10" class="node"> <g id="node10" class="node">
<title>google</title> <title>google</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="504" cy="-40.75" rx="33.82" ry="18"/> <ellipse fill="#1a1a1a" stroke="#333333" cx="504" cy="-40.75" rx="33.82" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="504" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Google</text> <text xml:space="preserve" text-anchor="middle" x="504" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Google</text>
</g> </g>
<!-- veins&#45;&gt;google --> <!-- veins&#45;&gt;google -->
<g id="edge5" class="edge"> <g id="edge7" class="edge">
<title>veins&#45;&gt;google</title> <title>veins&#45;&gt;google</title>
<path fill="none" stroke="#666666" d="M161.63,-133.42C176.76,-128.65 193.36,-124.1 209,-121.25 287.39,-106.97 309.3,-123.69 388,-111.25 421.14,-106.01 432.58,-109.83 461,-92 471.11,-85.66 480.09,-76.19 487.2,-67.17"/> <path fill="none" stroke="#666666" d="M161.63,-133.42C176.76,-128.65 193.36,-124.1 209,-121.25 287.39,-106.97 309.3,-123.69 388,-111.25 421.14,-106.01 432.58,-109.83 461,-92 471.11,-85.66 480.09,-76.19 487.2,-67.17"/>
<polygon fill="#666666" stroke="#666666" points="489.88,-69.43 493,-59.31 484.25,-65.28 489.88,-69.43"/> <polygon fill="#666666" stroke="#666666" points="489.88,-69.43 493,-59.31 484.25,-65.28 489.88,-69.43"/>
@@ -148,10 +148,10 @@
<g id="node11" class="node"> <g id="node11" class="node">
<title>slack</title> <title>slack</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="584" cy="-40.75" rx="27.74" ry="18"/> <ellipse fill="#1a1a1a" stroke="#333333" cx="584" cy="-40.75" rx="27.74" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="584" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Slack</text> <text xml:space="preserve" text-anchor="middle" x="584" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Slack</text>
</g> </g>
<!-- veins&#45;&gt;slack --> <!-- veins&#45;&gt;slack -->
<g id="edge6" class="edge"> <g id="edge8" class="edge">
<title>veins&#45;&gt;slack</title> <title>veins&#45;&gt;slack</title>
<path fill="none" stroke="#666666" d="M161.07,-133.39C176.34,-128.56 193.15,-123.98 209,-121.25 326.9,-100.91 359.79,-129.67 478,-111.25 509.46,-106.35 520.8,-110.08 547,-92 556.07,-85.74 563.77,-76.52 569.76,-67.68"/> <path fill="none" stroke="#666666" d="M161.07,-133.39C176.34,-128.56 193.15,-123.98 209,-121.25 326.9,-100.91 359.79,-129.67 478,-111.25 509.46,-106.35 520.8,-110.08 547,-92 556.07,-85.74 563.77,-76.52 569.76,-67.68"/>
<polygon fill="#666666" stroke="#666666" points="572.65,-69.66 574.98,-59.33 566.72,-65.96 572.65,-69.66"/> <polygon fill="#666666" stroke="#666666" points="572.65,-69.66 574.98,-59.33 566.72,-65.96 572.65,-69.66"/>
@@ -175,10 +175,10 @@
<g id="node13" class="node"> <g id="node13" class="node">
<title>app_be</title> <title>app_be</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="906" cy="-40.75" rx="38.96" ry="18"/> <ellipse fill="#1a1a1a" stroke="#333333" cx="906" cy="-40.75" rx="38.96" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="906" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Backend</text> <text xml:space="preserve" text-anchor="middle" x="906" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Backend</text>
</g> </g>
<!-- tools&#45;&gt;app_be --> <!-- tools&#45;&gt;app_be -->
<g id="edge8" class="edge"> <g id="edge9" class="edge">
<title>tools&#45;&gt;app_be</title> <title>tools&#45;&gt;app_be</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M824.06,-129.4C841.45,-110.79 865.64,-84.93 883.22,-66.12"/> <path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M824.06,-129.4C841.45,-110.79 865.64,-84.93 883.22,-66.12"/>
<polygon fill="#666666" stroke="#666666" points="885.72,-68.57 889.99,-58.88 880.6,-63.79 885.72,-68.57"/> <polygon fill="#666666" stroke="#666666" points="885.72,-68.57 889.99,-58.88 880.6,-63.79 885.72,-68.57"/>
@@ -196,10 +196,10 @@
<title>app_db</title> <title>app_db</title>
<path fill="#1a1a1a" stroke="#333333" d="M1031.25,-55.48C1031.25,-57.28 1015.9,-58.75 997,-58.75 978.1,-58.75 962.75,-57.28 962.75,-55.48 962.75,-55.48 962.75,-26.02 962.75,-26.02 962.75,-24.22 978.1,-22.75 997,-22.75 1015.9,-22.75 1031.25,-24.22 1031.25,-26.02 1031.25,-26.02 1031.25,-55.48 1031.25,-55.48"/> <path fill="#1a1a1a" stroke="#333333" d="M1031.25,-55.48C1031.25,-57.28 1015.9,-58.75 997,-58.75 978.1,-58.75 962.75,-57.28 962.75,-55.48 962.75,-55.48 962.75,-26.02 962.75,-26.02 962.75,-24.22 978.1,-22.75 997,-22.75 1015.9,-22.75 1031.25,-24.22 1031.25,-26.02 1031.25,-26.02 1031.25,-55.48 1031.25,-55.48"/>
<path fill="none" stroke="#333333" d="M1031.25,-55.48C1031.25,-53.67 1015.9,-52.2 997,-52.2 978.1,-52.2 962.75,-53.67 962.75,-55.48"/> <path fill="none" stroke="#333333" d="M1031.25,-55.48C1031.25,-53.67 1015.9,-52.2 997,-52.2 978.1,-52.2 962.75,-53.67 962.75,-55.48"/>
<text xml:space="preserve" text-anchor="middle" x="997" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Database</text> <text xml:space="preserve" text-anchor="middle" x="997" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Database</text>
</g> </g>
<!-- monitors&#45;&gt;app_db --> <!-- monitors&#45;&gt;app_db -->
<g id="edge9" class="edge"> <g id="edge10" class="edge">
<title>monitors&#45;&gt;app_db</title> <title>monitors&#45;&gt;app_db</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M997,-128.86C997,-111.64 997,-88.42 997,-70.29"/> <path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M997,-128.86C997,-111.64 997,-88.42 997,-70.29"/>
<polygon fill="#666666" stroke="#666666" points="1000.5,-70.46 997,-60.46 993.5,-70.46 1000.5,-70.46"/> <polygon fill="#666666" stroke="#666666" points="1000.5,-70.46 997,-60.46 993.5,-70.46 1000.5,-70.46"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,92 @@
// Dark — the palette the docs have always used, restated as a theme.
//
// This exists so the .dot sources can stop carrying colour. It reproduces what
// the committed SVGs look like today, which also makes it the regression
// oracle: strip the inline colours from a source, render it through this, and
// the result should be the diagram you had before. If it is not, the strip was
// wrong.
//
// Palette matches common/theme/themes/soleprint.css, so a diagram and the page
// around it are the same visual language.
BEGIN {
string BG = "#0a0a0a";
string FILL = "#1a1a1a";
string LINE = "#333333";
string INK = "#e5e5e5";
string MUTED = "#a3a3a3";
string DIM = "#666666";
string FONT = "Helvetica";
graph_t sg;
// The soleprint amber, and the three system colours, each with the lighter
// text variant the docs use for labels on dark.
string ACC_S = "#d4a574"; string ACC_T = "#d4a574";
string ART_S = "#b91c1c"; string ART_T = "#fca5a5";
string ATL_S = "#15803d"; string ATL_T = "#86efac";
string STA_S = "#1d4ed8"; string STA_T = "#93c5fd";
}
BEG_G {
$G.bgcolor = BG;
setDflt($G, "N", "fontname", FONT);
setDflt($G, "E", "fontname", FONT);
// Declare `class` so reading it on an untagged object is defined
// rather than a warning — most nodes carry no class by design.
setDflt($G, "G", "class", "");
setDflt($G, "N", "class", "");
setDflt($G, "E", "class", "");
$G.fontname = FONT;
// The graph title takes the accent of whatever system the graph is about;
// graphs declare that with a class on the digraph itself.
if ($G.class == "artery") { $G.fontcolor = ART_T; }
else if ($G.class == "atlas") { $G.fontcolor = ATL_T; }
else if ($G.class == "station") { $G.fontcolor = STA_T; }
else { $G.fontcolor = ACC_T; }
for (sg = fstsubg($G); sg; sg = nxtsubg(sg)) {
if (index(sg.name, "cluster") == 0) {
sg.fontname = FONT;
sg.color = LINE;
sg.fontcolor = DIM;
if (sg.class == "artery") { sg.color = ART_S; sg.fontcolor = ART_T; }
else if (sg.class == "atlas") { sg.color = ATL_S; sg.fontcolor = ATL_T; }
else if (sg.class == "station") { sg.color = STA_S; sg.fontcolor = STA_T; }
else if (sg.class == "accent") { sg.color = ACC_S; sg.fontcolor = ACC_T; }
else if (sg.class == "ok") { sg.color = ATL_S; sg.fontcolor = ATL_T; }
if (index(sg.style, "dashed") < 0) { sg.style = "dashed"; }
}
}
}
N {
if (index($.style, "invis") >= 0) { continue; }
$.fontname = FONT;
$.fontcolor = INK;
$.color = LINE;
$.fillcolor = FILL;
if ($.class == "accent") { $.color = ACC_S; }
else if ($.class == "artery") { $.color = ART_S; }
else if ($.class == "atlas") { $.color = ATL_S; }
else if ($.class == "station") { $.color = STA_S; }
else if ($.class == "accent-text") { $.fontcolor = ACC_T; }
else if ($.class == "ok") { $.fontcolor = ATL_T; }
else if ($.class == "muted") { $.fontcolor = MUTED; }
if (index($.style, "dashed") >= 0) { $.style = "filled,dashed"; }
else { $.style = "filled"; }
}
E {
$.fontname = FONT;
$.fontcolor = MUTED;
$.color = DIM;
if ($.class == "accent") { $.color = ACC_S; $.fontcolor = ACC_T; }
else if ($.class == "artery") { $.color = ART_S; $.fontcolor = ART_T; }
else if ($.class == "atlas") { $.color = ATL_S; $.fontcolor = ATL_T; }
else if ($.class == "station") { $.color = STA_S; $.fontcolor = STA_T; }
}

View File

@@ -0,0 +1,114 @@
// Lucid — graphviz output shaped after a lucid.app export.
//
// Run through gvpr, not as .dot attributes, because gvpr rewrites the parsed
// graph: it wins over whatever the source set inline, so one .dot renders in
// any theme without being edited. See render.sh.
//
// The look, and why each part is what it is:
// white canvas this ends up in a document and gets printed
// rounded box, hairline Lucid's default shape is a rounded rect with a grey
// 1px stroke, not a coloured fill
// pale-blue accent #d6e4ff on #3a7dff is Lucid's own selected-shape pair
// Arial the one face guaranteed on Windows, and aliased to
// Liberation Sans by fontconfig on Linux — so the SVG
// measures the same on both and the text does not
// reflow out of its box
//
// Structure is preserved, only palette is replaced. Two styles carry meaning
// rather than decoration and are explicitly kept: `invis` (a layout spacer —
// overwriting it makes hidden scaffolding visible) and `dashed` (a weaker
// relationship). Shapes are never touched: a cylinder is a datastore.
BEGIN {
string BG = "#ffffff";
string INK = "#1f2933";
string MUTED = "#616e7c";
string LINE = "#9aa5b1";
string SOFT = "#cbd2d9";
string FONT = "Arial";
graph_t sg;
string FILL = "#ffffff";
string FILL_ALT = "#f5f7fa";
// class -> (stroke, fill). Lucid's palette for emphasised shapes.
string ACC_S = "#3a7dff"; string ACC_F = "#d6e4ff";
string ART_S = "#c0392b"; string ART_F = "#fdeaea";
string ATL_S = "#1a7f45"; string ATL_F = "#e6f5ec";
string STA_S = "#2b5fd9"; string STA_F = "#e8effd";
}
BEG_G {
$G.bgcolor = BG;
$G.fontname = FONT;
$G.fontcolor = INK;
// Defaults for anything the per-node block does not reach.
setDflt($G, "N", "fontname", FONT);
setDflt($G, "E", "fontname", FONT);
// Declare `class` so reading it on an untagged object is defined
// rather than a warning — most nodes carry no class by design.
setDflt($G, "G", "class", "");
setDflt($G, "N", "class", "");
setDflt($G, "E", "class", "");
// Clusters: a pale container, the way Lucid draws a grouping box.
for (sg = fstsubg($G); sg; sg = nxtsubg(sg)) {
if (index(sg.name, "cluster") == 0) {
sg.fontname = FONT;
sg.color = SOFT;
sg.fontcolor = MUTED;
sg.bgcolor = FILL_ALT;
// A tagged container keeps its identity, the way a Lucid swimlane
// is tinted by what it holds.
if (sg.class == "artery") { sg.color = ART_S; sg.bgcolor = ART_F; }
else if (sg.class == "atlas") { sg.color = ATL_S; sg.bgcolor = ATL_F; }
else if (sg.class == "station") { sg.color = STA_S; sg.bgcolor = STA_F; }
else if (sg.class == "accent") { sg.color = ACC_S; sg.bgcolor = ACC_F; }
// Keep dashed where the source chose it; it reads as "logical
// grouping" rather than "deployed boundary".
if (index(sg.style, "dashed") < 0) { sg.style = "rounded"; }
else { sg.style = "dashed,rounded"; }
}
}
}
N {
// An invisible node is layout scaffolding. Filling it would draw it.
if (index($.style, "invis") >= 0) { continue; }
$.fontname = FONT;
$.fontcolor = INK;
$.color = LINE;
$.fillcolor = FILL;
$.penwidth = 1;
if ($.class == "accent") { $.color = ACC_S; $.fillcolor = ACC_F; }
else if ($.class == "artery") { $.color = ART_S; $.fillcolor = ART_F; }
else if ($.class == "atlas") { $.color = ATL_S; $.fillcolor = ATL_F; }
else if ($.class == "station") { $.color = STA_S; $.fillcolor = STA_F; }
else if ($.class == "accent-text") { $.fontcolor = ACC_S; }
else if ($.class == "ok") { $.color = ATL_S; $.fillcolor = ATL_F; }
else if ($.class == "muted") { $.fillcolor = FILL_ALT; $.fontcolor = MUTED; }
// `record` ignores rounding, and plaintext has no box to round.
if ($.shape == "record" || $.shape == "Mrecord" || $.shape == "plaintext") {
$.style = "filled";
} else if (index($.style, "dashed") >= 0) {
$.style = "filled,rounded,dashed";
} else {
$.style = "filled,rounded";
}
}
E {
$.fontname = FONT;
$.fontcolor = MUTED;
$.color = LINE;
$.arrowsize = 0.7;
if ($.class == "accent") { $.color = ACC_S; $.fontcolor = ACC_S; }
else if ($.class == "artery") { $.color = ART_S; $.fontcolor = ART_S; }
else if ($.class == "atlas") { $.color = ATL_S; $.fontcolor = ATL_S; }
else if ($.class == "station") { $.color = STA_S; $.fontcolor = STA_S; }
}

View File

@@ -1,45 +1,39 @@
digraph wrapping { digraph wrapping {
bgcolor="#0a0a0a"
rankdir=LR rankdir=LR
fontname="Helvetica" fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box] node [fontname="Helvetica" fontsize=11 style=filled shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"] edge [fontname="Helvetica" fontsize=9]
label="Sidebar Injection — How Wrapping Works" label="Sidebar Injection — How Wrapping Works"
labelloc=t labelloc=t
fontsize=14 fontsize=14
fontcolor="#d4a574"
browser [label="Browser" fillcolor="#1a1a1a" shape=oval] browser [label="Browser" shape=oval]
subgraph cluster_nginx { subgraph cluster_nginx {
label="Nginx (reverse proxy)" label="Nginx (reverse proxy)"
style=dashed style=dashed
color="#d4a574" class="accent"
fontcolor="#d4a574"
proxy [label="proxy_pass\n+\nsub_filter\ninjects sidebar" fillcolor="#1a1a1a" shape=component] proxy [label="proxy_pass\n+\nsub_filter\ninjects sidebar" shape=component]
} }
subgraph cluster_app { subgraph cluster_app {
label="Managed App" label="Managed App"
style=dashed style=dashed
color="#333"
fontcolor="#666"
frontend [label="Frontend\n(React/Next/Vue)" fillcolor="#1a1a1a"] frontend [label="Frontend\n(React/Next/Vue)"]
backend [label="Backend API" fillcolor="#1a1a1a"] backend [label="Backend API"]
} }
subgraph cluster_spr { subgraph cluster_spr {
label="Soleprint" label="Soleprint"
style=dashed style=dashed
color="#d4a574" class="accent"
fontcolor="#d4a574"
sidebar_css [label="sidebar.css" fillcolor="#1a1a1a"] sidebar_css [label="sidebar.css"]
sidebar_js [label="sidebar.js" fillcolor="#1a1a1a"] sidebar_js [label="sidebar.js"]
hub [label="Hub API\n/api/sidebar/config" fillcolor="#1a1a1a"] hub [label="Hub API\n/api/sidebar/config"]
} }
browser -> proxy [label="myroom.spr.local.ar"] browser -> proxy [label="myroom.spr.local.ar"]
@@ -47,8 +41,8 @@ digraph wrapping {
proxy -> hub [label="/spr/ → soleprint"] proxy -> hub [label="/spr/ → soleprint"]
// The injection // The injection
proxy -> sidebar_css [label="injects into </head>" color="#d4a574" style=dashed] proxy -> sidebar_css [class="accent" label="injects into </head>" style=dashed]
proxy -> sidebar_js [color="#d4a574" style=dashed] proxy -> sidebar_js [class="accent" style=dashed]
sidebar_js -> hub [label="loads config" color="#d4a574"] sidebar_js -> hub [class="accent" label="loads config"]
} }

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: wrapping Pages: 1 -->
<svg width="742pt" height="325pt"
viewBox="0.00 0.00 742.00 325.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 321.19)">
<title>wrapping</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-321.19 738.19,-321.19 738.19,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="367.1" y="-299.89" font-family="Arial" font-size="14.00" fill="#1f2933">Sidebar Injection — How Wrapping Works</text>
<g id="clust1" class="cluster accent">
<title>cluster_nginx</title>
<path fill="#d6e4ff" stroke="#3a7dff" stroke-dasharray="5,2" d="M189.94,-24.44C189.94,-24.44 310.94,-24.44 310.94,-24.44 316.94,-24.44 322.94,-30.44 322.94,-36.44 322.94,-36.44 322.94,-114.44 322.94,-114.44 322.94,-120.44 316.94,-126.44 310.94,-126.44 310.94,-126.44 189.94,-126.44 189.94,-126.44 183.94,-126.44 177.94,-120.44 177.94,-114.44 177.94,-114.44 177.94,-36.44 177.94,-36.44 177.94,-30.44 183.94,-24.44 189.94,-24.44"/>
<text xml:space="preserve" text-anchor="middle" x="250.44" y="-109.14" font-family="Arial" font-size="14.00" fill="#616e7c">Nginx (reverse proxy)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_app</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M434.44,-155.44C434.44,-155.44 525.69,-155.44 525.69,-155.44 531.69,-155.44 537.69,-161.44 537.69,-167.44 537.69,-167.44 537.69,-273.44 537.69,-273.44 537.69,-279.44 531.69,-285.44 525.69,-285.44 525.69,-285.44 434.44,-285.44 434.44,-285.44 428.44,-285.44 422.44,-279.44 422.44,-273.44 422.44,-273.44 422.44,-167.44 422.44,-167.44 422.44,-161.44 428.44,-155.44 434.44,-155.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-268.14" font-family="Arial" font-size="14.00" fill="#616e7c">Managed App</text>
</g>
<g id="clust3" class="cluster accent">
<title>cluster_spr</title>
<path fill="#d6e4ff" stroke="#3a7dff" stroke-dasharray="5,2" d="M449.44,-17.44C449.44,-17.44 714.19,-17.44 714.19,-17.44 720.19,-17.44 726.19,-23.44 726.19,-29.44 726.19,-29.44 726.19,-135.44 726.19,-135.44 726.19,-141.44 720.19,-147.44 714.19,-147.44 714.19,-147.44 449.44,-147.44 449.44,-147.44 443.44,-147.44 437.44,-141.44 437.44,-135.44 437.44,-135.44 437.44,-29.44 437.44,-29.44 437.44,-23.44 443.44,-17.44 449.44,-17.44"/>
<text xml:space="preserve" text-anchor="middle" x="581.82" y="-130.14" font-family="Arial" font-size="14.00" fill="#616e7c">Soleprint</text>
</g>
<!-- proxy -->
<g id="node1" class="node">
<title>proxy</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="292.07,-94.44 207.82,-94.44 207.82,-90.44 203.82,-90.44 203.82,-86.44 207.82,-86.44 207.82,-40.44 203.82,-40.44 203.82,-36.44 207.82,-36.44 207.82,-32.44 292.07,-32.44 292.07,-94.44"/>
<polyline fill="none" stroke="#9aa5b1" points="207.82,-90.44 211.82,-90.44 211.82,-86.44 207.82,-86.44"/>
<polyline fill="none" stroke="#9aa5b1" points="207.82,-40.44 211.82,-40.44 211.82,-36.44 207.82,-36.44"/>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-79.99" font-family="Arial" font-size="11.00" fill="#1f2933">proxy_pass</text>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-66.49" font-family="Arial" font-size="11.00" fill="#1f2933">+</text>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-52.99" font-family="Arial" font-size="11.00" fill="#1f2933">sub_filter</text>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-39.49" font-family="Arial" font-size="11.00" fill="#1f2933">injects sidebar</text>
</g>
<!-- frontend -->
<g id="node2" class="node">
<title>frontend</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M517.69,-199.44C517.69,-199.44 442.44,-199.44 442.44,-199.44 436.44,-199.44 430.44,-193.44 430.44,-187.44 430.44,-187.44 430.44,-175.44 430.44,-175.44 430.44,-169.44 436.44,-163.44 442.44,-163.44 442.44,-163.44 517.69,-163.44 517.69,-163.44 523.69,-163.44 529.69,-169.44 529.69,-175.44 529.69,-175.44 529.69,-187.44 529.69,-187.44 529.69,-193.44 523.69,-199.44 517.69,-199.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-184.49" font-family="Arial" font-size="11.00" fill="#1f2933">Frontend</text>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-170.99" font-family="Arial" font-size="11.00" fill="#1f2933">(React/Next/Vue)</text>
</g>
<!-- proxy&#45;&gt;frontend -->
<g id="edge2" class="edge">
<title>proxy&#45;&gt;frontend</title>
<path fill="none" stroke="#9aa5b1" d="M292.52,-84.95C333.17,-105.98 394.79,-137.85 435.96,-159.15"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="434.71,-161.26 442.05,-162.3 436.96,-156.91 434.71,-161.26"/>
<text xml:space="preserve" text-anchor="middle" x="372.69" y="-148.53" font-family="Arial" font-size="9.00" fill="#616e7c">/ → app</text>
</g>
<!-- sidebar_css -->
<g id="node4" class="node">
<title>sidebar_css</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M502.69,-115.44C502.69,-115.44 457.44,-115.44 457.44,-115.44 451.44,-115.44 445.44,-109.44 445.44,-103.44 445.44,-103.44 445.44,-91.44 445.44,-91.44 445.44,-85.44 451.44,-79.44 457.44,-79.44 457.44,-79.44 502.69,-79.44 502.69,-79.44 508.69,-79.44 514.69,-85.44 514.69,-91.44 514.69,-91.44 514.69,-103.44 514.69,-103.44 514.69,-109.44 508.69,-115.44 502.69,-115.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-93.74" font-family="Arial" font-size="11.00" fill="#1f2933">sidebar.css</text>
</g>
<!-- proxy&#45;&gt;sidebar_css -->
<g id="edge3" class="edge accent">
<title>proxy&#45;&gt;sidebar_css</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M292.52,-69.64C333.52,-75.75 395.85,-85.04 437.01,-91.18"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="436.42,-93.56 443.71,-92.17 437.15,-88.72 436.42,-93.56"/>
<text xml:space="preserve" text-anchor="middle" x="372.69" y="-89.35" font-family="Arial" font-size="9.00" fill="#3a7dff">injects into &lt;/head&gt;</text>
</g>
<!-- sidebar_js -->
<g id="node5" class="node">
<title>sidebar_js</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M498.57,-61.44C498.57,-61.44 461.57,-61.44 461.57,-61.44 455.57,-61.44 449.57,-55.44 449.57,-49.44 449.57,-49.44 449.57,-37.44 449.57,-37.44 449.57,-31.44 455.57,-25.44 461.57,-25.44 461.57,-25.44 498.57,-25.44 498.57,-25.44 504.57,-25.44 510.57,-31.44 510.57,-37.44 510.57,-37.44 510.57,-49.44 510.57,-49.44 510.57,-55.44 504.57,-61.44 498.57,-61.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-39.74" font-family="Arial" font-size="11.00" fill="#1f2933">sidebar.js</text>
</g>
<!-- proxy&#45;&gt;sidebar_js -->
<g id="edge4" class="edge accent">
<title>proxy&#45;&gt;sidebar_js</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M292.52,-59.8C334.87,-56.08 399.99,-50.38 441.02,-46.78"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="441.13,-49.23 447.89,-46.18 440.7,-44.35 441.13,-49.23"/>
</g>
<!-- hub -->
<g id="node6" class="node">
<title>hub</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M706.19,-61.44C706.19,-61.44 627.19,-61.44 627.19,-61.44 621.19,-61.44 615.19,-55.44 615.19,-49.44 615.19,-49.44 615.19,-37.44 615.19,-37.44 615.19,-31.44 621.19,-25.44 627.19,-25.44 627.19,-25.44 706.19,-25.44 706.19,-25.44 712.19,-25.44 718.19,-31.44 718.19,-37.44 718.19,-37.44 718.19,-49.44 718.19,-49.44 718.19,-55.44 712.19,-61.44 706.19,-61.44"/>
<text xml:space="preserve" text-anchor="middle" x="666.69" y="-46.49" font-family="Arial" font-size="11.00" fill="#1f2933">Hub API</text>
<text xml:space="preserve" text-anchor="middle" x="666.69" y="-32.99" font-family="Arial" font-size="11.00" fill="#1f2933">/api/sidebar/config</text>
</g>
<!-- proxy&#45;&gt;hub -->
<g id="edge5" class="edge">
<title>proxy&#45;&gt;hub</title>
<path fill="none" stroke="#9aa5b1" d="M292.27,-45.37C326.42,-31.48 376.57,-13.43 422.44,-5.94 473,2.31 486.99,1.33 537.69,-5.94 560.76,-9.25 585.47,-15.76 606.97,-22.43"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="606.19,-24.75 613.6,-24.53 607.67,-20.08 606.19,-24.75"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-7.89" font-family="Arial" font-size="9.00" fill="#616e7c">/spr/ → soleprint</text>
</g>
<!-- backend -->
<g id="node3" class="node">
<title>backend</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M507.19,-253.44C507.19,-253.44 452.94,-253.44 452.94,-253.44 446.94,-253.44 440.94,-247.44 440.94,-241.44 440.94,-241.44 440.94,-229.44 440.94,-229.44 440.94,-223.44 446.94,-217.44 452.94,-217.44 452.94,-217.44 507.19,-217.44 507.19,-217.44 513.19,-217.44 519.19,-223.44 519.19,-229.44 519.19,-229.44 519.19,-241.44 519.19,-241.44 519.19,-247.44 513.19,-253.44 507.19,-253.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-231.74" font-family="Arial" font-size="11.00" fill="#1f2933">Backend API</text>
</g>
<!-- sidebar_js&#45;&gt;hub -->
<g id="edge6" class="edge accent">
<title>sidebar_js&#45;&gt;hub</title>
<path fill="none" stroke="#3a7dff" d="M510.73,-43.44C536.46,-43.44 574.49,-43.44 606.44,-43.44"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="606.33,-45.89 613.33,-43.44 606.33,-40.99 606.33,-45.89"/>
<text xml:space="preserve" text-anchor="middle" x="572.44" y="-45.39" font-family="Arial" font-size="9.00" fill="#3a7dff">loads config</text>
</g>
<!-- browser -->
<g id="node7" class="node">
<title>browser</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="35.22" cy="-63.44" rx="35.22" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="35.22" y="-59.74" font-family="Arial" font-size="11.00" fill="#1f2933">Browser</text>
</g>
<!-- browser&#45;&gt;proxy -->
<g id="edge1" class="edge">
<title>browser&#45;&gt;proxy</title>
<path fill="none" stroke="#9aa5b1" d="M70.87,-63.44C105.71,-63.44 159.77,-63.44 199.19,-63.44"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="198.85,-65.89 205.85,-63.44 198.85,-60.99 198.85,-65.89"/>
<text xml:space="preserve" text-anchor="middle" x="128.19" y="-65.39" font-family="Arial" font-size="9.00" fill="#616e7c">myroom.spr.local.ar</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -10,7 +10,7 @@
<title>wrapping</title> <title>wrapping</title>
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-325.41 819.55,-325.41 819.55,4 -4,4"/> <polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-325.41 819.55,-325.41 819.55,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="407.78" y="-304.11" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Sidebar Injection — How Wrapping Works</text> <text xml:space="preserve" text-anchor="middle" x="407.78" y="-304.11" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Sidebar Injection — How Wrapping Works</text>
<g id="clust1" class="cluster"> <g id="clust1" class="cluster accent">
<title>cluster_nginx</title> <title>cluster_nginx</title>
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="193.3,-25.16 193.3,-128.16 360.05,-128.16 360.05,-25.16 193.3,-25.16"/> <polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="193.3,-25.16 193.3,-128.16 360.05,-128.16 360.05,-25.16 193.3,-25.16"/>
<text xml:space="preserve" text-anchor="middle" x="276.68" y="-110.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Nginx (reverse proxy)</text> <text xml:space="preserve" text-anchor="middle" x="276.68" y="-110.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Nginx (reverse proxy)</text>
@@ -20,19 +20,13 @@
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="473.05,-157.16 473.05,-288.16 599.55,-288.16 599.55,-157.16 473.05,-157.16"/> <polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="473.05,-157.16 473.05,-288.16 599.55,-288.16 599.55,-157.16 473.05,-157.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-270.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Managed App</text> <text xml:space="preserve" text-anchor="middle" x="536.3" y="-270.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Managed App</text>
</g> </g>
<g id="clust3" class="cluster"> <g id="clust3" class="cluster accent">
<title>cluster_spr</title> <title>cluster_spr</title>
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="489.55,-18.16 489.55,-149.16 807.55,-149.16 807.55,-18.16 489.55,-18.16"/> <polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="489.55,-18.16 489.55,-149.16 807.55,-149.16 807.55,-18.16 489.55,-18.16"/>
<text xml:space="preserve" text-anchor="middle" x="648.55" y="-131.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint</text> <text xml:space="preserve" text-anchor="middle" x="648.55" y="-131.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint</text>
</g> </g>
<!-- browser -->
<g id="node1" class="node">
<title>browser</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="38.03" cy="-64.16" rx="38.03" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="38.03" y="-60.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Browser</text>
</g>
<!-- proxy --> <!-- proxy -->
<g id="node2" class="node"> <g id="node1" class="node">
<title>proxy</title> <title>proxy</title>
<polygon fill="#1a1a1a" stroke="#333333" points="324.3,-95.16 228.05,-95.16 228.05,-91.16 224.05,-91.16 224.05,-87.16 228.05,-87.16 228.05,-41.16 224.05,-41.16 224.05,-37.16 228.05,-37.16 228.05,-33.16 324.3,-33.16 324.3,-95.16"/> <polygon fill="#1a1a1a" stroke="#333333" points="324.3,-95.16 228.05,-95.16 228.05,-91.16 224.05,-91.16 224.05,-87.16 228.05,-87.16 228.05,-41.16 224.05,-41.16 224.05,-37.16 228.05,-37.16 228.05,-33.16 324.3,-33.16 324.3,-95.16"/>
<polyline fill="none" stroke="#333333" points="228.05,-91.16 232.05,-91.16 232.05,-87.16 228.05,-87.16"/> <polyline fill="none" stroke="#333333" points="228.05,-91.16 232.05,-91.16 232.05,-87.16 228.05,-87.16"/>
@@ -42,15 +36,8 @@
<text xml:space="preserve" text-anchor="middle" x="276.18" y="-53.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sub_filter</text> <text xml:space="preserve" text-anchor="middle" x="276.18" y="-53.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sub_filter</text>
<text xml:space="preserve" text-anchor="middle" x="276.18" y="-40.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">injects sidebar</text> <text xml:space="preserve" text-anchor="middle" x="276.18" y="-40.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">injects sidebar</text>
</g> </g>
<!-- browser&#45;&gt;proxy -->
<g id="edge1" class="edge">
<title>browser&#45;&gt;proxy</title>
<path fill="none" stroke="#666666" d="M76.45,-64.16C114.05,-64.16 172.57,-64.16 216.38,-64.16"/>
<polygon fill="#666666" stroke="#666666" points="216.24,-67.66 226.24,-64.16 216.24,-60.66 216.24,-67.66"/>
<text xml:space="preserve" text-anchor="middle" x="138.68" y="-66.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">myroom.spr.local.ar</text>
</g>
<!-- frontend --> <!-- frontend -->
<g id="node3" class="node"> <g id="node2" class="node">
<title>frontend</title> <title>frontend</title>
<polygon fill="#1a1a1a" stroke="#333333" points="591.55,-201.16 481.05,-201.16 481.05,-165.16 591.55,-165.16 591.55,-201.16"/> <polygon fill="#1a1a1a" stroke="#333333" points="591.55,-201.16 481.05,-201.16 481.05,-165.16 591.55,-165.16 591.55,-201.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-186.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Frontend</text> <text xml:space="preserve" text-anchor="middle" x="536.3" y="-186.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Frontend</text>
@@ -64,56 +51,69 @@
<text xml:space="preserve" text-anchor="middle" x="416.55" y="-150.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/ → app</text> <text xml:space="preserve" text-anchor="middle" x="416.55" y="-150.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/ → app</text>
</g> </g>
<!-- sidebar_css --> <!-- sidebar_css -->
<g id="node5" class="node"> <g id="node4" class="node">
<title>sidebar_css</title> <title>sidebar_css</title>
<polygon fill="#1a1a1a" stroke="#333333" points="575.05,-116.16 497.55,-116.16 497.55,-80.16 575.05,-80.16 575.05,-116.16"/> <polygon fill="#1a1a1a" stroke="#333333" points="575.05,-116.16 497.55,-116.16 497.55,-80.16 575.05,-80.16 575.05,-116.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-94.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.css</text> <text xml:space="preserve" text-anchor="middle" x="536.3" y="-94.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.css</text>
</g> </g>
<!-- proxy&#45;&gt;sidebar_css --> <!-- proxy&#45;&gt;sidebar_css -->
<g id="edge4" class="edge"> <g id="edge3" class="edge accent">
<title>proxy&#45;&gt;sidebar_css</title> <title>proxy&#45;&gt;sidebar_css</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M324.52,-70.4C370.27,-76.43 439.3,-85.52 485.78,-91.64"/> <path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M324.52,-70.4C370.27,-76.43 439.3,-85.52 485.78,-91.64"/>
<polygon fill="#d4a574" stroke="#d4a574" points="485.21,-95.09 495.58,-92.93 486.12,-88.15 485.21,-95.09"/> <polygon fill="#d4a574" stroke="#d4a574" points="485.21,-95.09 495.58,-92.93 486.12,-88.15 485.21,-95.09"/>
<text xml:space="preserve" text-anchor="middle" x="416.55" y="-90.82" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">injects into &lt;/head&gt;</text> <text xml:space="preserve" text-anchor="middle" x="416.55" y="-90.82" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">injects into &lt;/head&gt;</text>
</g> </g>
<!-- sidebar_js --> <!-- sidebar_js -->
<g id="node6" class="node"> <g id="node5" class="node">
<title>sidebar_js</title> <title>sidebar_js</title>
<polygon fill="#1a1a1a" stroke="#333333" points="570.55,-62.16 502.05,-62.16 502.05,-26.16 570.55,-26.16 570.55,-62.16"/> <polygon fill="#1a1a1a" stroke="#333333" points="570.55,-62.16 502.05,-62.16 502.05,-26.16 570.55,-26.16 570.55,-62.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-40.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.js</text> <text xml:space="preserve" text-anchor="middle" x="536.3" y="-40.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.js</text>
</g> </g>
<!-- proxy&#45;&gt;sidebar_js --> <!-- proxy&#45;&gt;sidebar_js -->
<g id="edge5" class="edge"> <g id="edge4" class="edge accent">
<title>proxy&#45;&gt;sidebar_js</title> <title>proxy&#45;&gt;sidebar_js</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M324.52,-60.49C371.88,-56.82 444.19,-51.22 490.6,-47.63"/> <path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M324.52,-60.49C371.88,-56.82 444.19,-51.22 490.6,-47.63"/>
<polygon fill="#d4a574" stroke="#d4a574" points="490.6,-51.14 500.3,-46.87 490.06,-44.16 490.6,-51.14"/> <polygon fill="#d4a574" stroke="#d4a574" points="490.6,-51.14 500.3,-46.87 490.06,-44.16 490.6,-51.14"/>
</g> </g>
<!-- hub --> <!-- hub -->
<g id="node7" class="node"> <g id="node6" class="node">
<title>hub</title> <title>hub</title>
<polygon fill="#1a1a1a" stroke="#333333" points="799.55,-62.16 682.3,-62.16 682.3,-26.16 799.55,-26.16 799.55,-62.16"/> <polygon fill="#1a1a1a" stroke="#333333" points="799.55,-62.16 682.3,-62.16 682.3,-26.16 799.55,-26.16 799.55,-62.16"/>
<text xml:space="preserve" text-anchor="middle" x="740.93" y="-47.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Hub API</text> <text xml:space="preserve" text-anchor="middle" x="740.93" y="-47.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Hub API</text>
<text xml:space="preserve" text-anchor="middle" x="740.93" y="-33.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">/api/sidebar/config</text> <text xml:space="preserve" text-anchor="middle" x="740.93" y="-33.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">/api/sidebar/config</text>
</g> </g>
<!-- proxy&#45;&gt;hub --> <!-- proxy&#45;&gt;hub -->
<g id="edge3" class="edge"> <g id="edge5" class="edge">
<title>proxy&#45;&gt;hub</title> <title>proxy&#45;&gt;hub</title>
<path fill="none" stroke="#666666" d="M324.6,-45.85C363.74,-31.76 421.09,-13.48 473.05,-5.91 528.69,2.18 543.83,1.54 599.55,-5.91 624.52,-9.25 651.33,-15.79 674.76,-22.54"/> <path fill="none" stroke="#666666" d="M324.6,-45.85C363.74,-31.76 421.09,-13.48 473.05,-5.91 528.69,2.18 543.83,1.54 599.55,-5.91 624.52,-9.25 651.33,-15.79 674.76,-22.54"/>
<polygon fill="#666666" stroke="#666666" points="673.59,-25.84 684.17,-25.31 675.57,-19.12 673.59,-25.84"/> <polygon fill="#666666" stroke="#666666" points="673.59,-25.84 684.17,-25.31 675.57,-19.12 673.59,-25.84"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-8.61" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/spr/ → soleprint</text> <text xml:space="preserve" text-anchor="middle" x="536.3" y="-8.61" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/spr/ → soleprint</text>
</g> </g>
<!-- backend --> <!-- backend -->
<g id="node4" class="node"> <g id="node3" class="node">
<title>backend</title> <title>backend</title>
<polygon fill="#1a1a1a" stroke="#333333" points="578.05,-255.16 494.55,-255.16 494.55,-219.16 578.05,-219.16 578.05,-255.16"/> <polygon fill="#1a1a1a" stroke="#333333" points="578.05,-255.16 494.55,-255.16 494.55,-219.16 578.05,-219.16 578.05,-255.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-233.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Backend API</text> <text xml:space="preserve" text-anchor="middle" x="536.3" y="-233.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Backend API</text>
</g> </g>
<!-- sidebar_js&#45;&gt;hub --> <!-- sidebar_js&#45;&gt;hub -->
<g id="edge6" class="edge"> <g id="edge6" class="edge accent">
<title>sidebar_js&#45;&gt;hub</title> <title>sidebar_js&#45;&gt;hub</title>
<path fill="none" stroke="#d4a574" d="M570.76,-44.16C597.83,-44.16 636.85,-44.16 670.6,-44.16"/> <path fill="none" stroke="#d4a574" d="M570.76,-44.16C597.83,-44.16 636.85,-44.16 670.6,-44.16"/>
<polygon fill="#d4a574" stroke="#d4a574" points="670.3,-47.66 680.3,-44.16 670.3,-40.66 670.3,-47.66"/> <polygon fill="#d4a574" stroke="#d4a574" points="670.3,-47.66 680.3,-44.16 670.3,-40.66 670.3,-47.66"/>
<text xml:space="preserve" text-anchor="middle" x="636.93" y="-46.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">loads config</text> <text xml:space="preserve" text-anchor="middle" x="636.93" y="-46.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">loads config</text>
</g>
<!-- browser -->
<g id="node7" class="node">
<title>browser</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="38.03" cy="-64.16" rx="38.03" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="38.03" y="-60.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Browser</text>
</g>
<!-- browser&#45;&gt;proxy -->
<g id="edge1" class="edge">
<title>browser&#45;&gt;proxy</title>
<path fill="none" stroke="#666666" d="M76.45,-64.16C114.05,-64.16 172.57,-64.16 216.38,-64.16"/>
<polygon fill="#666666" stroke="#666666" points="216.24,-67.66 226.24,-64.16 216.24,-60.66 216.24,-67.66"/>
<text xml:space="preserve" text-anchor="middle" x="138.68" y="-66.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">myroom.spr.local.ar</text>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

22
rig/.gitattributes vendored Normal file
View File

@@ -0,0 +1,22 @@
# Line endings are normalised to LF in the repository and on checkout, on every
# platform. Without this, a checkout on Windows/WSL rewrites files to CRLF and
# every one of them shows up as modified without anyone having touched it.
#
# For the scripts it is not cosmetic: a shell script with CRLF fails on Linux
# with `bad interpreter: /usr/bin/env bash^M`, which reads as a broken installer
# rather than a line-ending problem — the worst possible first impression on a
# machine where nothing has been proven yet.
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.env text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
# Never touch binaries.
*.png binary
*.jpg binary
*.zip binary
*.tar binary
*.gz binary

20
rig/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# def/ — the "default" scratch bucket: always gitignored, never versioned
def
# local env (commit the .env.example, never the .env)
.env
.env.local
ctrl/.env
# generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited.
# The .svg IS committed — onboarding material should render in a repo browser.
arch/*.dot
ctrl/Tiltfile.gen
# binaries pulled by `make deps-bundle` for the air-gapped wizard image
vendor
# Client rigs are NOT ignored here. A copy is a SIBLING of this directory
# (spr/acme-rig), so a rule in this file cannot see it — the rules live in
# spr/.gitignore, anchored at spr's root, where `*-rig/` matches the siblings and
# `!rig/sample-rig/` keeps the committed stand-in.

278
rig/BOOTSTRAP.md Normal file
View File

@@ -0,0 +1,278 @@
# From a machine with nothing on it to a project you can work in
The README says the prerequisite is Docker and nothing else. This is what that
actually looks like end to end: a bare Linux box, and a new project running under
Tilt at the end of it.
rig lives inside soleprint, at `spr/rig` — it is soleprint's cluster half, and
a client copy is a sibling (`spr/acme-rig`). Paths below are relative to
soleprint's checkout.
It spans three repos because the work does. **rig** prepares the machine — the
pinned toolchain, the cluster, the port arithmetic. **all** owns the shape a
project takes, in `all/projects/templates/conventions.md` and the `broad`
scaffold beside it. **ppl** owns everything after local, and is where this
document stops.
Read it once before running anything. Three of the steps below need root and one
needs a logout, so knowing about them in advance is cheaper than meeting them
halfway through.
## Docker, and the two sysctls Tilt depends on
rig installs a toolchain; it does not install Docker. That line is not modesty —
Docker is a daemon, a group membership and usually a logout, and a script that
did it would have to be trusted with root on a machine it knows nothing about.
```bash
sudo apt-get install -y docker.io && sudo usermod -aG docker "$USER"
```
Then log out and back in, and check `docker info` answers. Until it does, nothing
below works and everything below reports the same failure.
While you have root, raise the inotify limits:
```bash
echo -e 'fs.inotify.max_user_watches=524288\nfs.inotify.max_user_instances=512' \
| sudo tee /etc/sysctl.d/99-rig.conf
sudo sysctl --system
```
kind and Tilt both watch large trees, and WSL ships 8192 watches and 128
instances — far too low. The failure mode is the reason this is here at step
zero rather than mentioned later: Tilt does not error, it simply stops noticing
that files changed, and you lose an afternoon to a hot reload that silently
isn't.
## Read the docs before installing anything
```bash
cd spr/rig
make docs
```
`ctrl/docs.sh` runs a throwaway `nginx:alpine` over a read-only bind mount of
`docs/` and prints the URL. That is deliberate: the docs are the instructions for
building everything else, so they cannot live in the cluster and cannot need
`python3 -m http.server` either — a minimal Debian has no python. What it has,
by definition, is Docker.
The port is this environment's `HTTP_PORT + 4`. Nothing is installed and nothing
persists; ctrl-c ends it.
## Ask what is wrong with this machine
```bash
make station
cp ctrl/.env.example ctrl/.env
```
`station.sh` reports and instructs, and fixes nothing. It runs bare rather than
in a container because host detection only ever reads `/proc` and `/etc` — no
dependency beyond coreutils.
Read the whole output, but the `ports` block is the one to read carefully. Every
port rig binds derives from this directory's name, so the answer is specific to
this copy, and a clash here surfaces as an opaque `failed to bind host port` in
the middle of cluster creation if you skip it.
Copy the `.env` even though station only warns about it. It is gitignored, it is
where a machine-local override goes, and `ports.sh persist` expects it to exist.
## Install the toolchain — through the container
This is the step where "nothing installed" stops being rhetorical.
`make deps` runs `ctrl/wizard.sh install` directly on the host, and the wizard
fetches with `curl`. A stock `debian:trixie-slim` has no curl — detection runs
fine, then the first download dies with `curl: command not found` and an exit
code of 127. That is the bootstrap paradox `ctrl/Dockerfile.wizard` exists
to kill — the wizard carries its own toolchain so the host needs only Docker —
but building the image and running it are two different things, and only the
build has a Makefile target today. **On a genuinely bare machine, run it by
hand:**
```bash
make wizard # builds rig-wizard:wizard
mkdir -p ~/.local/bin
docker run --rm \
-v /:/host:ro \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$HOME/.local/bin:/out/bin" \
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
rig-wizard:wizard install dev
```
The image name follows the directory, like everything else here: in `spr/rig`
it is `rig-wizard`, in a copy called `spr/acme-rig` it is `acme-rig-wizard`. The
tag is `wizard` (or `full`, below), not `latest`.
None of the four arguments are guessable, so:
- **`/:/host:ro`** — the wizard reads the *host's* `/etc/os-release` and
`/etc/wsl.conf`, not the container's. `HOST_ROOT=/host` is already baked into
the image; this is what it points at. Read-only, and it is the only reason
detection inside a container tells you anything about the machine.
- **the docker socket** — how detection reaches the daemon it is reporting on,
and how it counts kind clusters already running.
- **`/out/bin`** — the image's `OUT_BIN`. Whatever you mount here is where the
four binaries land.
- **`HOST_UID` / `HOST_GID`** — the wizard runs as root so it can reach that
socket, which means everything it writes into a mounted volume is root-owned
and useless to you. These drive the `chown` back. Omit them and the install
looks like it worked.
`dev` is kubectl, jq, kind and tilt. `core` is kubectl and jq alone — no cluster
tooling — which is the right answer on a managed or corporate-issued machine and
is why the split exists.
Then put them on PATH, which the wizard will remind you about because it cannot
edit your shell for you:
```bash
export PATH="$HOME/.local/bin:$PATH" # and add the same line to ~/.bashrc
```
If something else on this machine already provides `kubectl`, the wizard says so
by name rather than shadowing it quietly. `OUT_BIN=$PWD/def/bin` installs
somewhere private instead.
**Two variants worth knowing before you need them.** `make wizard full` bakes
every pinned binary into the image at build time (`DEPS_SOURCE=baked`), so
`docker save` gives you the entire installer as one file to carry into an
air-gapped network. And `DEPS_SOURCE=artifactory` with `DEPS_ARTIFACTORY_URL`
pulls from a generic internal repo, which is usually the only thing a locked-down
client allows.
From here on this machine has curl, so **`make deps` is the short form** for
every later run and every later copy of this directory. The container path is
the first-time path.
## Prove the machine before blaming the project
```bash
make setup
make cluster up
kubectl get nodes
```
`make setup` re-runs every check as a group. It is idempotent and it deliberately
does not abort on the first failure — a setup script that dies at step two hides
the fact that steps four and five were also going to fail. Run now, it should be
`ok` and `done` all the way down, and that is the point: it is the scoreboard,
not the installer.
`make cluster up` builds the default `minimal` profile — one node, no addons,
boots fast. You do not need it to develop anything, but you do want to know that
kind, the kubeconfig context and the derived port block work *before* a new
project has any problems of its own to confuse them with. `make cluster down`
when you are done looking.
Before starting a second cluster, and it will not be long:
```bash
make cluster list
```
Available memory, per-cluster usage and each cluster's port block. On a 16 GiB
box four single-node clusters are comfortable and six push into swap, so this is
worth reading before rather than after. `make cluster free <names>` stops
clusters without deleting them; `docker start` brings them back untouched.
## Scaffold the project
The canonical layout is [`all/projects/templates/conventions.md`](../all/projects/templates/conventions.md).
Read it — it is short, opinionated, and exists precisely so nobody
reverse-engineers a layout from whichever repo they happened to open. What
follows is only the mechanical part.
```bash
SLUG=<slug> # short, lowercase, no separators
cp -r ~/wdir/all/projects/templates/broad ~/wdir/"$SLUG"
cd ~/wdir/"$SLUG"
grep -rl '<slug>' ctrl | xargs sed -i "s/<slug>/$SLUG/g"
cp ctrl/k8s/.env.example ctrl/k8s/.env
git init && git add -A && git commit -m "scaffold $SLUG from broad"
```
`<slug>` is the only placeholder and it lives only under `ctrl/` — cluster name,
namespace, ConfigMap name, and the `NAME=` in `kind-up.sh` / `kind-down.sh`. One
sed does all of it.
The slug is the folder name, lowercase and short — `mpr`, `unt`, `nvi`. The
cluster takes that name and the context becomes `kind-<slug>`, derived by the
scaffold's Makefile from the directory, so there is nothing to edit for either.
**Pick the Tilt port deliberately.** `ctrl/k8s/.env.example` ships a value that
is already in use, so copying it unchanged puts two projects on one port:
```bash
grep -h '^TILT_PORT=' ~/wdir/*/ctrl/k8s/.env 2>/dev/null | sort
```
Choose a free one in `1030010399` — the range ALL reserves in
`projects/index.json` under `policy` — avoiding `10350`, which is Tilt's own
default. Currently taken: `nvi` 10330, `unt` 10340, `mpr` 10360, `mlv` 10370,
`eth` 10380, `lng` 10390. This is the Tilt *web UI* port, not a service port;
each project owns its own service ports separately. The scaffold ships it blank
on purpose, so there is nothing to collide with until you choose.
The scaffold's `ctrl/k8s/` is the same shape as every other project here, and it
builds as shipped:
```
kind-config.yaml one node; gateway NodePort 30080 -> hostPort 8080
base/ namespace, configmap, app (Deployment + Service)
overlays/dev/ promotes the app Service to NodePort 30080
```
Check it before `kind` spends minutes on anything — this renders the whole tree
without a cluster and catches a broken patch immediately:
```bash
kubectl kustomize ctrl/k8s/overlays/dev
```
The workload is an nginx placeholder so a fresh copy reaches something that
answers; replace it. Keep `30080` in step between the overlay patch and
`kind-config.yaml`'s `containerPort` — the hostPort is this project's to pick.
Reachability is a plain kind port mapping: no ingress controller and no MetalLB.
Caddy maps `<slug>.local.ar` onto the host port (`~/wdir/ppl/local/Caddyfile`),
with `*.local.ar` resolving to 127.0.0.1 through dnsmasq. That is the whole chain.
**The one file the scaffold still does not ship is `ctrl/Tiltfile`**`make
tilt-up` runs `cd ctrl && tilt up`, and there is nothing to run until you write
one. Copy it from a live project; `unt` and `nvi` are closest to the plain shape.
## Run it
```bash
make kind-up # idempotent create, then selects the context
make tilt-up # context + your assigned port
```
`tilt-up` passes `--context kind-<slug>` every time, which is the point of going
through `make` at all: tilt cannot deploy into whichever cluster you last looked
at.
`make tilt-down` and `make kind-down` close the loop, and `make kind-reset` is
delete-and-recreate for when a cluster wedges.
## Register it
The project exists; now it is findable. Add an entry to
`~/wdir/all/projects/index.json` and write its `projects/<slug>.md` beside the
others. Structured fields in the index, prose in the markdown.
Putting it on the CI server and deploying it is `ppl`'s half, and it starts at
`~/wdir/ppl/ctrl/init-repo.sh` — gitea remote, then Woodpecker. That is a
different document.

123
rig/Makefile Normal file
View File

@@ -0,0 +1,123 @@
# Thin control Makefile — one target per ctrl/ script, and the subcommand is an
# argument rather than a second target: `make cluster down`, not `make cluster-down`.
#
# The logic lives in the scripts, never here. Each target maps to exactly one
# bash file, and that file holds the variants:
#
# make cluster up -> ctrl/cluster.sh up
# make newbox destroy -> ctrl/newbox.sh destroy
#
# Config layers, weakest first: ctrl/versions.env (pinned toolchain) <
# ctrl/env.d/<profile>.env (cluster shape) < ctrl/.env (local, gitignored) <
# the environment. So `make cluster up PROFILE=client` beats everything.
#
# Start with: make setup (then: make cluster up && make docs)
# Identity follows the FOLDER NAME, so this directory can be copied elsewhere,
# renamed, and run as a separate environment with no edits. ctrl/.env overrides
# it when you want a name that differs from the directory.
SLUG := $(shell echo '$(notdir $(CURDIR))' | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/^-*//; s/-*$$//')
CLUSTER := $(or $(shell sed -n 's/^CLUSTER=//p' ctrl/.env 2>/dev/null),$(SLUG))
KCTX := --context kind-$(CLUSTER)
TILT_PORT := $(shell sed -n 's/^TILT_PORT=//p' ctrl/.env 2>/dev/null)
WIZARD := $(SLUG)-wizard
# Words after the target become the script's subcommand. Make would otherwise
# treat them as goals of their own, so each gets a no-op rule.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
$(eval $(ARGS):;@:)
# ...and as PHONY, because some of those words name real directories. `cfg`,
# `ctrl`, `docs`, `gen` and `init` all exist at this level, and make considers a
# target that is an existing directory already built — so `make build ctrl` ran
# the build and then printed "make: 'ctrl' is up to date". The empty rule above
# is not enough on its own; only .PHONY stops make consulting the filesystem.
.PHONY: $(ARGS)
endif
.PHONY: help setup station deps wizard cluster registry addons ports \
newbox dockerhost docs tilt \
kind-up kind-down kind-reset tilt-up tilt-down
help: ## list targets
@grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
# ── setup ──────────────────────────────────────────────────────────────────
setup: ## prepare this machine [core] [--share-docker] [--cluster]
bash ctrl/setup.sh $(ARGS)
station: ## is this workstation ready? reports, never fixes
bash ctrl/station.sh
deps: ## install the toolchain [core|dev] (default dev)
bash ctrl/wizard.sh install $(or $(ARGS),dev)
wizard: ## build the installer image [full]
docker build -f ctrl/Dockerfile.wizard \
--target $(if $(filter full,$(ARGS)),wizard-full,wizard) \
-t $(WIZARD):$(if $(filter full,$(ARGS)),full,wizard) .
# ── cluster ────────────────────────────────────────────────────────────────
cluster: ## this env + the machine [up|down|reset|list|free]
bash ctrl/cluster.sh $(or $(ARGS),up)
registry: ## registry wiring [up|down|status] (default status)
bash ctrl/registry.sh $(or $(ARGS),status)
addons: ## profile addons [install|list] (default list)
bash ctrl/addons.sh $(or $(ARGS),list)
ports: ## this environment's port block [show|persist]
bash ctrl/ports.sh $(or $(ARGS),show)
# ── host ───────────────────────────────────────────────────────────────────
newbox: ## throwaway environment [create|status|shell|destroy]
bash ctrl/newbox.sh $(or $(ARGS),status)
dockerhost: ## share Docker between distros [status|share|unshare]
$(if $(filter share unshare,$(ARGS)),sudo ,)bash ctrl/dockerhost.sh $(or $(ARGS),status)
# ── docs + dev loop ────────────────────────────────────────────────────────
docs: ## documentation [serve|graphs] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# --port is only passed when TILT_PORT is actually set. It comes from ctrl/.env,
# which does NOT carry it by default — ports are derived at runtime in
# lib/config.sh unless `make ports persist` has written them. Without the guard
# tilt receives a bare `--port` with no value and fails on the flag rather than
# on anything real. `make ports show` prints the derived block.
tilt: ## dev loop [up|down] (default up)
cd ctrl && tilt $(or $(ARGS),up) $(KCTX) $(if $(filter down,$(ARGS)),,$(if $(TILT_PORT),--port $(TILT_PORT)))
# ── the shape every other project uses ─────────────────────────────────────
# Aliases, not a second implementation: each one calls the same script the
# canonical target does.
#
# The header above argues for `make cluster down` over `make cluster-down`, and
# that still holds *within* this file. But rig is one repo among several on the
# same machine, and every other one answers to kind-up / tilt-up. Muscle memory
# spanning six projects beats internal tidiness in one, so both spellings work.
#
# `cluster list` and `cluster free` have no hyphenated twin on purpose — they
# are rig's own, with nothing to be consistent with.
kind-up: ## alias for `cluster up`
bash ctrl/cluster.sh up
kind-down: ## alias for `cluster down`
bash ctrl/cluster.sh down
kind-reset: ## alias for `cluster reset`
bash ctrl/cluster.sh reset
# These two match the other projects' spelling, but rig has no Tiltfile — there
# is nothing to run yet, and they fail the same way `make tilt` does.
tilt-up: ## alias for `tilt up` (rig has no Tiltfile yet)
cd ctrl && tilt up $(KCTX) $(if $(TILT_PORT),--port $(TILT_PORT))
tilt-down: ## alias for `tilt down` (rig has no Tiltfile yet)
cd ctrl && tilt down $(KCTX)

117
rig/README.md Normal file
View File

@@ -0,0 +1,117 @@
# rig
A runnable local model of a large, regulated estate — legacy and new side by
side. Its job is onboarding and exploration, not a production replica: most
services are deliberately mocked, because what has to be faithful is the
topology, not the workloads.
## Prerequisite
**Docker.** Nothing else — no curl, no jq, no python, no apt repositories.
## Read the docs first
```bash
make docs # serves on localhost, prints the URL
```
They run before anything is installed, which matters because they are the
instructions for everything else. No cluster and no toolchain required.
## Then
```bash
make station # report host and config problems; changes nothing
make deps # install the toolchain (add `core` on a managed machine)
make cluster up # build the cluster for the active profile
```
`make help` lists every target.
On a machine where Docker really is the only thing installed, `make deps` has
nothing to download with — see [BOOTSTRAP.md](BOOTSTRAP.md), which runs the
toolchain through the wizard container and carries on to scaffolding and running
a new project.
## One directory is one environment
Copy this directory, rename it, run it. Cluster name, kubectl context, image
tags and the host port block all derive from the directory name, so copies never
collide and neither one's teardown can touch the other.
rig lives inside soleprint, at `spr/rig` — it is soleprint's cluster half, and a
copy is a **sibling**: `spr/acme-rig`. That is why the ignore rules for client
rigs sit in `spr/.gitignore` rather than here; a rule in this directory cannot
see a directory beside it.
## Profiles
A profile is the shape of the cluster: how many nodes, which addons, whether the
apiserver audits. They live in `ctrl/env.d/`, and the active one is `PROFILE`.
| Profile | For |
| --- | --- |
| `minimal` | the default. One node, no addons, boots fast. |
| `client` | the regulated-estate shape — multi-node, audit on, registry mirror. |
| `offline` | air-gapped: everything from a preloaded local registry. |
| `data` | the dependency containers a soleprint room asks for. |
```bash
PROFILE=data make cluster up
PROFILE=data make addons install
make addons # what the active profile wants, and what exists
```
A profile names a **cluster shape** — a file in `ctrl/k8s/` — rather than
restating node count and audit as variables:
| shape | nodes | audit | used by |
| --- | --- | --- | --- |
| `kind-config.yaml.tpl` | 1 | off | `minimal`, `data` |
| `kind-config.audit.yaml.tpl` | 1 | on | `offline` |
| `kind-config.client.yaml.tpl` | 3 | on | `client` |
Both numbers are read back out of the chosen file, so the YAML is the only place
that decides and there is nothing to drift. The layout under `ctrl/k8s/` is the
same as every other project here — a kind config, a kustomize `base/`, an
`overlays/dev/` — see [`ctrl/k8s/README.md`](ctrl/k8s/README.md).
## Addons
Each addon is its own idempotent script in `ctrl/addons/`, and a profile names
the ones it wants in `ADDONS`. Adding one is adding a file — there is no
dispatcher to edit.
**There is no ingress controller, deliberately.** They pin a narrow window of
Kubernetes versions, so depending on one would constrain which k8s a rig can be
built with — and running a trailing-edge control plane to model a legacy estate
is the whole point. Services are reached through MetalLB and
`type: LoadBalancer`, which carries no such constraint and is also what a real
cluster does.
| Addon | Does |
| --- | --- |
| `metallb` | gives `type: LoadBalancer` an address it can actually reach |
| `cert-manager` | a local CA, so TLS works offline |
| `metrics-server` | makes `kubectl top` work on kind |
| `postgres` | database, in the `data` namespace |
| `redis` | cache and broker |
| `airflow` | scheduled pipelines; needs postgres and redis |
The last three are the cluster half of **soleprint's cabinets**. A room declares
what it needs once, in `cfg/<room>/data/cabinets.json`; soleprint's `build.py`
composes those services into `docker-compose.yml` for a laptop, and these
install the same ones here. The names match on purpose — each cabinet carries a
`rig_addon` field pointing at `ctrl/addons/<name>.sh`.
Plain manifests rather than helm charts, like every other addon: a chart repo is
a network dependency, and the `offline` profile exists precisely so there is a
path with none. Images are pinned in `ctrl/versions.env` and can be preloaded.
Passwords are generated on first install and kept across re-runs, so re-running
an addon never rotates a credential out from under something already connected:
```bash
kubectl -n data get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
kubectl -n data port-forward svc/airflow 8080:8080
```

50
rig/ctrl/.env.example Normal file
View File

@@ -0,0 +1,50 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
# Cluster SHAPE lives in ctrl/env.d/<profile>.env — not here.
# The architecture MODEL lives in arch/<name>.json — not here either.
# Which profile in ctrl/env.d/ to build. minimal | client | offline
PROFILE=minimal
# Cluster name; the kubectl context becomes kind-<CLUSTER>.
# LEAVE THIS UNSET unless you need a name that differs from the directory —
# it defaults to this folder's name, which is what makes the folder copyable:
# copy it, rename it, and you get a separate environment with no edits.
# CLUSTER=
# Host ports. LEAVE UNSET — they derive from the directory name so several
# environments coexist without negotiating (see ctrl/ports.sh). `make ports`
# shows this environment's block; `make ports persist` writes it here so it stops
# being derived and becomes fixed. Set a value only to override.
# HTTP_PORT=
# HTTPS_PORT=
# TILT_PORT=
# REGISTRY_PORT=
# Where the application manifests live. The real ones are expected to be
# versioned separately from this installer — they change on a different cadence,
# by different people. Repoint this at their repo and rig stops owning them:
# MANIFESTS_DIR=../platform-manifests/overlays/dev
MANIFESTS_DIR=ctrl/k8s/overlays/dev
# Where the wizard fetches the pinned binaries from.
# upstream GitHub releases / dl.k8s.io (needs internet)
# artifactory a generic repo — what a locked-down client usually allows
# baked already inside the wizard image; no network at all
DEPS_SOURCE=upstream
DEPS_ARTIFACTORY_URL=
# --- Registry -------------------------------------------------------------
# Mode comes from the profile (REGISTRY_MODE). These are the secrets it needs.
# Required for mirror/remote:
REGISTRY_REMOTE_URL=
REGISTRY_USER=
REGISTRY_PASSWORD=
# Corporate root CA, if Artifactory is fronted by an internal CA (it usually is).
# Trust has to reach THREE places and nothing does it for you: the host docker
# daemon, every kind node's containerd, and any in-cluster client. registry.sh
# handles the first two; station.sh reports when it's configured but not trusted.
# Symptom when missing: x509: certificate signed by unknown authority
REGISTRY_CA_FILE=
# (The local registry's host port is part of the derived block above.)

View File

@@ -0,0 +1,46 @@
# The installation wizard. It does NOT run the cluster — it installs a toolchain
# onto the host and gets out of the way.
#
# This exists to kill a bootstrap paradox: a plain bash installer needs curl, jq
# and sha256sum to already be present, and a minimal Debian has none of them.
# The wizard carries its own toolchain, so the only host prerequisite is Docker.
#
# Two variants from one file:
# docker build -f ctrl/Dockerfile.wizard --target wizard -t <slug>-wizard .
# docker build -f ctrl/Dockerfile.wizard --target wizard-full -t <slug>-wizard:full .
#
# wizard-full bakes every pinned binary in at build time. `docker save` it and
# you have the whole installer as one file to carry into an air-gapped network.
FROM debian:trixie-slim AS wizard
# ca-certificates + curl: fetch and verify. graphviz + python3: render diagrams
# and validate the arch model, so the host never needs an apt package.
#
# docker-cli, NOT docker.io: we only ever talk to the host's daemon through the
# mounted socket, and under --no-install-recommends the docker.io package ships
# docker-init without the actual `docker` binary.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl jq graphviz python3 docker-cli \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
COPY ctrl/versions.env /work/ctrl/versions.env
COPY ctrl/wizard.sh /work/ctrl/wizard.sh
RUN chmod +x /work/ctrl/wizard.sh
# Defaults; every one is overridable with -e at run time.
ENV DEPS_SOURCE=upstream \
OUT_BIN=/out/bin \
HOST_ROOT=/host
ENTRYPOINT ["/work/ctrl/wizard.sh"]
CMD ["install"]
# ---------------------------------------------------------------------------
# wizard-full — same wizard, binaries baked in, works with no network at all.
FROM wizard AS wizard-full
RUN /work/ctrl/wizard.sh fetch --to /opt/rig/bin
ENV DEPS_SOURCE=baked \
BAKED_BIN=/opt/rig/bin

39
rig/ctrl/addons.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Install the addons the active profile asked for, in the order listed.
# Each addon is its own idempotent script in ctrl/addons/ — adding one is adding
# a file, not editing a dispatcher.
#
# Usage: addons.sh install | list
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
install() {
if [ -z "${ADDONS// /}" ]; then
echo "no addons in profile '$PROFILE_NAME'"
return
fi
local a
for a in $ADDONS; do
if [ ! -f "addons/${a}.sh" ]; then
echo "no such addon: addons/${a}.sh" >&2
exit 1
fi
echo "addon: $a"
bash "addons/${a}.sh"
done
}
list() {
echo "profile '$PROFILE_NAME' wants: ${ADDONS:-none}"
echo "available:"
ls addons/*.sh 2>/dev/null | xargs -n1 basename | sed 's/\.sh$//' | sed 's/^/ /'
}
case "${1:-list}" in
install) install ;;
list) list ;;
*) echo "usage: $0 [install|list]" >&2; exit 1 ;;
esac

115
rig/ctrl/addons/airflow.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Apache Airflow — the cluster half of soleprint's airflow cabinet.
#
# Airflow needs a metadata database before it will start at all, so this refuses
# rather than rolls a pod that will CrashLoopBackOff while the real problem
# (postgres missing from ADDONS) stays invisible in the logs.
#
# One pod on `standalone`, matching the compose cabinet: migration, admin user,
# scheduler and webserver in a single container. The official chart's five
# deployments model an installation; a room switching this on wants pipelines.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
NS="${DATA_NAMESPACE:-data}"
if ! $K get deployment -n "$NS" postgres >/dev/null 2>&1; then
echo " ! airflow needs the postgres addon, and it is not installed" >&2
echo " add it before airflow in the profile's ADDONS:" >&2
echo " ADDONS=\"... postgres airflow\"" >&2
exit 1
fi
# Reuse the credential postgres generated rather than storing a second copy.
db_user=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_USER}' | base64 -d)
db_pass=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)
db_name=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_DB}' | base64 -d)
if $K get secret -n "$NS" airflow >/dev/null 2>&1; then
echo " secret exists, keeping the current admin password and fernet key"
else
admin_password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
# Airflow requires a 32-byte urlsafe-base64 key; without a fixed one every
# restart invalidates every stored connection.
fernet_key=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_')
$K create secret generic airflow -n "$NS" \
--from-literal=ADMIN_USER="${AIRFLOW_ADMIN_USER:-admin}" \
--from-literal=ADMIN_PASSWORD="$admin_password" \
--from-literal=FERNET_KEY="$fernet_key" \
--from-literal=SQL_ALCHEMY_CONN="postgresql+psycopg2://${db_user}:${db_pass}@postgres:5432/${db_name}" \
>/dev/null
echo " generated an admin password (read it back with the command below)"
fi
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: Service
metadata:
name: airflow
spec:
selector:
app: airflow
ports:
- port: 8080
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: airflow
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: airflow
template:
metadata:
labels:
app: airflow
spec:
containers:
- name: airflow
image: ${AIRFLOW_IMAGE}
args: ["standalone"]
env:
- name: AIRFLOW__CORE__EXECUTOR
value: LocalExecutor
- name: AIRFLOW__CORE__LOAD_EXAMPLES
value: "false"
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
valueFrom:
secretKeyRef: {name: airflow, key: SQL_ALCHEMY_CONN}
- name: AIRFLOW__CORE__FERNET_KEY
valueFrom:
secretKeyRef: {name: airflow, key: FERNET_KEY}
- name: _AIRFLOW_WWW_USER_USERNAME
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_USER}
- name: _AIRFLOW_WWW_USER_PASSWORD
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
# First boot runs the whole migration before it serves anything.
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 20
YAML
echo " waiting for airflow (the first boot migrates the database, so this is slow)..."
$K rollout status deployment/airflow -n "$NS" --timeout=600s
echo " in-cluster: http://airflow.${NS}.svc.cluster.local:8080"
echo " reach it: kubectl --context ${KUBECONTEXT} -n ${NS} port-forward svc/airflow 8080:8080"
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret airflow -o jsonpath='{.data.ADMIN_PASSWORD}' | base64 -d"

65
rig/ctrl/addons/cert-manager.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# cert-manager plus a self-signed cluster issuer.
#
# In a regulated estate almost everything is TLS, so the interesting question
# during onboarding is "does this service present a cert my client trusts" — not
# "can I reach a public ACME server". A local CA answers that offline, which is
# also what makes the air-gapped profile usable.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
if $K get deployment -n cert-manager cert-manager >/dev/null 2>&1; then
echo " already installed"
else
$K apply -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
fi
echo " waiting for cert-manager..."
$K wait --namespace cert-manager \
--for=condition=ready pod --selector=app.kubernetes.io/instance=cert-manager \
--timeout=240s
# A self-signed root, then a CA issuer chained off it. Workloads reference
# ClusterIssuer/local-ca and get a cert from a CA you can actually distribute.
echo " creating local CA issuer"
$K apply -f - <<'YAML' >/dev/null
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-root
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: local-ca
namespace: cert-manager
spec:
isCA: true
commonName: rig-local-ca
secretName: local-ca-key-pair
duration: 87600h
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-root
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: local-ca
spec:
ca:
secretName: local-ca-key-pair
YAML
echo " export the CA for your browser/client with:"
echo " kubectl --context ${KUBECONTEXT} -n cert-manager get secret local-ca-key-pair -o jsonpath='{.data.tls\\.crt}' | base64 -d"

103
rig/ctrl/addons/metallb.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# MetalLB — makes `Service type: LoadBalancer` actually get an address.
#
# Why it matters here: real manifests use LoadBalancer, because a real cluster
# has one. On a bare kind cluster those Services sit at EXTERNAL-IP <pending>
# forever with no error anywhere — the deployment looks fine and simply is not
# reachable. Without this, every such Service has to be edited to NodePort,
# which means the local manifests stop matching the ones being modelled.
#
# The address pool is derived from the kind Docker network at install time, not
# hardcoded: Docker picks that subnet, it differs between machines, and a pool
# outside it is silently unroutable.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
# ── work out an address range ──────────────────────────────────────────────
# kind hands node addresses out from the bottom of the subnet, so the top is
# free. Taking a slice there avoids collisions with current and future nodes.
subnet=$(docker network inspect kind \
-f '{{range .IPAM.Config}}{{.Subnet}} {{end}}' 2>/dev/null \
| tr ' ' '\n' | grep -E '^[0-9]+\.' | head -1)
if [ -z "$subnet" ]; then
echo " ! could not read the kind Docker network subnet" >&2
echo " (is the cluster up? MetalLB needs the network to exist first)" >&2
exit 1
fi
base="${subnet%/*}"; prefix="${subnet#*/}"
o1=$(echo "$base" | cut -d. -f1); o2=$(echo "$base" | cut -d. -f2)
o3=$(echo "$base" | cut -d. -f3)
case "$prefix" in
16) pool_start="${o1}.${o2}.255.200"; pool_end="${o1}.${o2}.255.250" ;;
24) pool_start="${o1}.${o2}.${o3}.200"; pool_end="${o1}.${o2}.${o3}.250" ;;
*)
# Guessing a range inside an unexpected prefix risks handing out
# addresses that belong to something else. Say so instead.
echo " ! kind network is $subnet — only /16 and /24 are handled" >&2
echo " set the pool by hand in ctrl/addons/metallb.sh" >&2
exit 1
;;
esac
echo " kind network $subnet → pool ${pool_start}-${pool_end}"
# ── install ────────────────────────────────────────────────────────────────
if $K get deployment -n metallb-system controller >/dev/null 2>&1; then
echo " already installed"
else
$K apply -f "https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml"
fi
# `kubectl wait` on a selector errors out immediately when nothing matches yet,
# and right after apply the ReplicaSet has not created the pod — so it loses a
# race it looks like it should win. `rollout status` waits for the Deployment
# itself and handles the not-yet-created case.
echo " waiting for the controller..."
$K rollout status deployment/controller -n metallb-system --timeout=240s
$K rollout status daemonset/speaker -n metallb-system --timeout=240s
# The webhook rejects IPAddressPools until it is actually serving, and it comes
# up a moment after the pod is Ready — so retry rather than fail the whole run
# on a race that resolves itself in seconds.
echo " configuring the address pool"
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if $K apply -f - >/dev/null 2>&1 <<YAML
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- ${pool_start}-${pool_end}
---
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
# is what makes this work on a laptop.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default
YAML
then
echo " pool ready: ${pool_start}-${pool_end}"
exit 0
fi
sleep 3
done
echo " ! the pool was rejected after 10 attempts — is the webhook up?" >&2
$K get pods -n metallb-system >&2
exit 1

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# metrics-server — makes `kubectl top` work.
#
# kind nodes serve kubelet metrics over a self-signed cert, so the standard
# manifest never becomes ready without --kubelet-insecure-tls. That is fine here
# (it is a local cluster) and is the single most common reason metrics-server
# sits at 0/1 on kind.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
if ! $K get deployment -n kube-system metrics-server >/dev/null 2>&1; then
$K apply -f "https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml"
fi
$K patch deployment metrics-server -n kube-system --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' \
>/dev/null 2>&1 || true
echo " waiting for metrics-server..."
$K rollout status deployment/metrics-server -n kube-system --timeout=180s

118
rig/ctrl/addons/postgres.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# PostgreSQL — the cluster half of soleprint's postgres cabinet.
#
# A room declares the dependency once, in cfg/<room>/data/cabinets.json. On a
# laptop `build.py` composes it into docker-compose.yml; here it becomes a pod,
# so the same declaration works either way and nothing has to be remembered
# twice.
#
# Plain manifests rather than a helm chart, matching the other addons: a chart
# repo is a network dependency, and the offline profile exists precisely so
# there is a path with none. The image is pinned in ctrl/versions.env and can be
# preloaded into a local registry like every other image here.
#
# One replica on a PVC. This models a dependency for local work, not a
# highly-available database, and pretending otherwise on a kind node would be a
# more elaborate lie rather than a more useful one.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
NS="${DATA_NAMESPACE:-data}"
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
# The password is generated once and then left alone, so re-running this does
# not rotate the credential out from under whatever is already connected.
if $K get secret -n "$NS" postgres >/dev/null 2>&1; then
echo " secret exists, keeping the current password"
else
password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
$K create secret generic postgres -n "$NS" \
--from-literal=POSTGRES_DB="${POSTGRES_DB:-soleprint}" \
--from-literal=POSTGRES_USER="${POSTGRES_USER:-soleprint}" \
--from-literal=POSTGRES_PASSWORD="$password" >/dev/null
echo " generated a password (read it back with the command printed below)"
fi
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: ${POSTGRES_STORAGE:-2Gi}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
# One volume, one writer. Rolling would start a second pod against the same
# PVC before the first exits, and Postgres refuses to share a data directory.
strategy:
type: Recreate
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: ${POSTGRES_IMAGE}
envFrom:
- secretRef:
name: postgres
env:
# The image initialises into the volume root otherwise, and a
# lost+found from the PVC makes it refuse to initdb.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
initialDelaySeconds: 30
periodSeconds: 15
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
YAML
echo " waiting for postgres..."
$K rollout status deployment/postgres -n "$NS" --timeout=240s
echo " in-cluster: postgres.${NS}.svc.cluster.local:5432"
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d"

60
rig/ctrl/addons/redis.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Redis — the cluster half of soleprint's redis cabinet.
#
# Cache, and the broker anything queue-shaped runs on. No persistence: a broker
# that loses its queue on restart is the honest local model, and a PVC here buys
# nothing but a volume to clean up.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
NS="${DATA_NAMESPACE:-data}"
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: ${REDIS_IMAGE}
ports:
- containerPort: 6379
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 3
periodSeconds: 5
YAML
echo " waiting for redis..."
$K rollout status deployment/redis -n "$NS" --timeout=180s
echo " in-cluster: redis://redis.${NS}.svc.cluster.local:6379/0"

149
rig/ctrl/cluster.sh Executable file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Cluster lifecycle, plus what else is running on this machine.
#
# `list` and `free` live here rather than in a separate script because a
# near-identical second name (cluster / clusters) is a trap — you reach for one
# and get the other. One target, one file, unambiguous subcommands.
#
# "Idempotent" here means CONVERGENT, not "exits early if the cluster exists".
# That distinction matters: an interrupted first run can leave a cluster created
# but not finished, and returning early on the re-run would strand it there.
# The create step is conditional; every step after it always runs, and each one
# is individually idempotent.
#
# Usage: cluster.sh up | down | reset | list | free
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
up() {
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
echo "cluster '$CLUSTER' exists — converging"
else
# Say what this profile locks in BEFORE spending minutes building it:
# the audit policy is an apiserver flag and cannot be changed later.
echo "creating cluster '$CLUSTER' from profile '$PROFILE_NAME'"
echo " shape ctrl/k8s/$KIND_CONFIG"
echo " nodes $NODES"
echo " image $NODE_IMAGE"
echo " audit $AUDIT"
echo " ingress $INGRESS_MODE"
echo " registry $REGISTRY_MODE"
echo " (audit is fixed at creation — 'make cluster reset' to change it)"
echo
render_kind_config | kind create cluster --config -
fi
# The cluster can exist while its context does not — a reset or a switched
# KUBECONFIG loses it, and then nothing works despite a healthy cluster.
if ! kubectl config get-contexts -o name 2>/dev/null | grep -qx "$KUBECONTEXT"; then
echo "context '$KUBECONTEXT' missing from kubeconfig — re-exporting"
kind export kubeconfig --name "$CLUSTER"
fi
kubectl config use-context "$KUBECONTEXT" >/dev/null
bash registry.sh up
if [ -n "${ADDONS// /}" ]; then
bash addons.sh install
fi
echo
echo "cluster '$CLUSTER' ready (context $KUBECONTEXT)"
}
down() {
# The registry is a standalone container outside the cluster; take it down
# first so a reset doesn't leave it orphaned and holding a port.
bash registry.sh down || true
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
echo "deleting cluster '$CLUSTER'..."
kind delete cluster --name "$CLUSTER"
else
echo "no cluster '$CLUSTER' to delete"
fi
}
# The escape hatch for a wedged cluster, and the only way to change a
# creation-time setting such as the audit policy.
reset() {
down
echo
up
}
# ── the whole machine ──────────────────────────────────────────────────────
# Every cluster is a running container tree whether or not you are using it, and
# an idle one is the usual reason a new one will not fit.
list() {
local total avail
total=$(awk '/^MemTotal:/{printf "%.1f", $2/1024/1024}' /proc/meminfo)
avail=$(awk '/^MemAvailable:/{printf "%.1f", $2/1024/1024}' /proc/meminfo)
echo "memory: ${avail} GB available of ${total} GB"
echo
local names; names=$(kind get clusters 2>/dev/null || true)
if [ -z "$names" ]; then
echo "no clusters"
return
fi
printf "%-16s %-10s %8s %6s %-13s %s\n" CLUSTER STATE MEM NODES PORTS ""
local c nodes state mem base
for c in $names; do
nodes=$(docker ps -a --filter "label=io.x-k8s.kind.cluster=$c" --format '{{.Names}}' | wc -l)
state=$(docker inspect -f '{{.State.Status}}' "${c}-control-plane" 2>/dev/null || echo unknown)
if [ "$state" = "running" ]; then
mem=$(docker stats --no-stream --format '{{.MemUsage}}' \
$(docker ps --filter "label=io.x-k8s.kind.cluster=$c" -q) 2>/dev/null \
| awk '{gsub(/GiB/,"");gsub(/MiB/,"e-3");s+=$1} END {printf "%.1fG", s}')
else
mem="-"
fi
# A cluster's name is its directory slug, so its port block is derivable
# here without reading that directory's config.
base=$(derive_port_base "$c")
printf "%-16s %-10s %8s %6s %-13s %s\n" "$c" "$state" "$mem" "$nodes" \
"${base}-$((base + 3))" \
"$([ "$c" = "$CLUSTER" ] && echo "<- this one")"
done
}
# Stop the OTHER clusters to free memory. Stops, never deletes — a stopped
# cluster restarts with `docker start`, so nothing is lost.
free() {
local targets=("$@")
if [ ${#targets[@]} -eq 0 ]; then
mapfile -t targets < <(kind get clusters 2>/dev/null | grep -vx "$CLUSTER" || true)
fi
if [ ${#targets[@]} -eq 0 ]; then
echo "nothing to stop"
return
fi
local c ids
for c in "${targets[@]}"; do
ids=$(docker ps --filter "label=io.x-k8s.kind.cluster=$c" -q)
if [ -z "$ids" ]; then
echo "cluster '$c' is not running"
continue
fi
echo "stopping '$c' (restart with: docker start \$(docker ps -aq -f label=io.x-k8s.kind.cluster=$c))"
# shellcheck disable=SC2086
docker stop $ids >/dev/null
done
}
case "${1:-up}" in
up) up ;;
down) down ;;
reset) reset ;;
list) list ;;
free) shift; free "$@" ;;
*) echo "usage: $0 [up|down|reset|list|free]" >&2; exit 1 ;;
esac

272
rig/ctrl/dockerhost.sh Executable file
View File

@@ -0,0 +1,272 @@
#!/usr/bin/env bash
# Share ONE Docker daemon across WSL distros, instead of running one per distro.
#
# Why this exists
# ---------------
# WSL2 distros share a kernel and a network stack. Two dockerd instances then
# contend over docker0 and iptables, which can disturb the daemon you actually
# depend on. Docker Desktop avoids this by running a single daemon in a
# dedicated distro and sharing its socket — this is the same idea, without
# Docker Desktop.
#
# So a throwaway rig box does NOT install Docker. It borrows the daemon from
# whichever distro is the designated host. That also makes the test more honest:
# rig never installs Docker anyway — Docker is its documented prerequisite.
#
# How
# ---
# /mnt/wsl is a tmpfs with `shared` mount propagation, visible to every distro
# in the WSL VM. The owning distro exposes its socket there; guests point
# DOCKER_HOST at it. Two ways, with different costs:
#
# share bind-mount the existing socket onto the shared tmpfs.
# Instant, and dockerd is NEVER restarted. Lasts until the
# next WSL shutdown.
# share --persist additionally install a systemd drop-in so dockerd listens
# there itself. Survives restarts, but requires one Docker
# restart now — which stops every container that has no
# restart policy, since live-restore is off by default.
#
# The bind mount is the default precisely because the persistent version's cost
# is paid on a machine that is already working.
#
# Reversibility is the whole design
# ---------------------------------
# `unshare` removes the bind mount (no restart) and, if present, the drop-in.
# The original systemd unit is never edited — only an additive drop-in file is
# ever created — so undoing is deletion, not repair. `status` always states
# which of the three roles a distro is in, in those words.
#
# Nothing here runs automatically. It does nothing until invoked.
#
# Usage:
# dockerhost.sh status # which distro owns Docker; what this one uses
# dockerhost.sh share # share it (bind mount, no daemon restart)
# dockerhost.sh share --persist # ...and survive WSL restarts (restarts Docker)
# dockerhost.sh unshare # undo it; this distro owns its Docker again
# dockerhost.sh use [--persist] # point THIS distro at the shared socket
set -euo pipefail
SHARED_DIR=/mnt/wsl/shared-docker
SHARED_SOCK="$SHARED_DIR/docker.sock"
OWNER_FILE="$SHARED_DIR/OWNER"
DROPIN=/etc/systemd/system/docker.service.d/10-rig-shared-socket.conf
PROFILE_D=/etc/profile.d/rig-docker-host.sh
distro_name() { echo "${WSL_DISTRO_NAME:-$(hostname)}"; }
require_wsl() {
grep -qi microsoft /proc/version 2>/dev/null && return 0
echo "dockerhost is WSL-only: it relies on /mnt/wsl being shared between distros." >&2
exit 1
}
# ── status ─────────────────────────────────────────────────────────────────
status() {
require_wsl
echo "distro $(distro_name)"
if [ -f "$DROPIN" ] || mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
echo "role SHARING — this distro's Docker is offered to other distros"
elif [ -n "${DOCKER_HOST:-}" ] && [ "${DOCKER_HOST}" = "unix://$SHARED_SOCK" ]; then
echo "role BORROWING — using another distro's Docker"
else
echo "role standalone — this WSL installation has the main host Docker"
fi
echo
if [ -S "$SHARED_SOCK" ]; then
echo "shared sock $SHARED_SOCK (present)"
[ -f "$OWNER_FILE" ] && sed 's/^/ /' "$OWNER_FILE"
else
echo "shared sock none — no distro is sharing right now"
fi
echo
echo "DOCKER_HOST ${DOCKER_HOST:-(unset — using /var/run/docker.sock)}"
if command -v docker >/dev/null 2>&1; then
echo "docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo unreachable)"
else
echo "docker cli not installed"
fi
}
# ── share / unshare (run on the host distro) ───────────────────────────────
# Default: expose the EXISTING socket by bind-mounting it onto the shared tmpfs.
# /mnt/wsl has `shared` propagation, so the mount is visible in other distros.
#
# The point of doing it this way is that dockerd is never restarted. Restarting
# it stops every container that has no restart policy (live-restore is off by
# default), which on a working machine means quietly killing whatever you had
# running. Not a trade worth making just to expose a socket.
#
# Cost: a bind mount does not survive a WSL VM shutdown. `--persist` adds the
# systemd drop-in as well, which does survive but needs that one restart.
share_bind() {
mkdir -p "$SHARED_DIR"
chmod 0755 "$SHARED_DIR"
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
echo "already bind-mounted at $SHARED_SOCK"
else
[ -S /var/run/docker.sock ] || { echo "no /var/run/docker.sock here" >&2; exit 1; }
# The target must exist as a file for a bind mount onto it.
[ -e "$SHARED_SOCK" ] || : > "$SHARED_SOCK"
mount --bind /var/run/docker.sock "$SHARED_SOCK"
echo "bind-mounted /var/run/docker.sock -> $SHARED_SOCK (no daemon restart)"
fi
cat > "$OWNER_FILE" <<EOF
owner distro: $(distro_name)
docker gid: $(getent group docker | cut -d: -f3)
socket: $SHARED_SOCK
method: bind-mount (until the next WSL shutdown)
EOF
}
share() {
require_wsl
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh share" >&2; exit 1; }
share_bind
if [ "${1:-}" != "--persist" ]; then
echo
echo "This lasts until the next WSL shutdown. To make it survive, re-run with"
echo "--persist — but note that adds a systemd drop-in and RESTARTS Docker,"
echo "which stops any container that has no restart policy."
return 0
fi
if [ -f "$DROPIN" ]; then
echo "drop-in already present — sharing persists across restarts."
return 0
fi
echo
echo "--persist: installing a systemd drop-in and restarting Docker."
echo "Containers without a restart policy will stop and will NOT come back."
docker ps --format ' {{.Names}} restart={{.HostConfig.RestartPolicy.Name}}' 2>/dev/null \
|| docker ps --format ' {{.Names}}' 2>/dev/null || true
echo
local exec_line
exec_line=$(systemctl cat docker.service | grep -m1 '^ExecStart=')
if [ -z "$exec_line" ]; then
echo "could not read docker.service ExecStart — refusing to guess" >&2
exit 1
fi
mkdir -p "$(dirname "$DROPIN")" "$SHARED_DIR"
# Additive only: blank the inherited ExecStart, then restate it verbatim
# with one extra -H. Nothing about the original unit is edited.
cat > "$DROPIN" <<EOF
# Added by rig (ctrl/dockerhost.sh share).
#
# Adds a SECOND listening socket on the WSL-shared tmpfs so other distros can
# use this daemon instead of running their own. The original socket is
# untouched, so this distro behaves exactly as before.
#
# To undo: sudo bash ctrl/dockerhost.sh unshare
[Service]
ExecStartPre=-/bin/mkdir -p $SHARED_DIR
ExecStartPre=-/bin/chmod 0755 $SHARED_DIR
ExecStart=
${exec_line} -H unix://$SHARED_SOCK
EOF
systemctl daemon-reload
systemctl restart docker
# Guests need a group with a MATCHING GID to use the socket; GIDs are not
# consistent across distros, so record ours rather than assume.
cat > "$OWNER_FILE" <<EOF
owner distro: $(distro_name)
docker gid: $(getent group docker | cut -d: -f3)
socket: $SHARED_SOCK
EOF
echo "sharing from '$(distro_name)'"
echo " guests: export DOCKER_HOST=unix://$SHARED_SOCK"
echo " undo: sudo bash ctrl/dockerhost.sh unshare"
echo
echo "NOTE: /mnt/wsl is tmpfs and is cleared when the WSL VM shuts down."
echo " The drop-in recreates the directory on the next Docker start."
}
unshare_() {
require_wsl
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh unshare" >&2; exit 1; }
local did=0
# The bind mount first: undoing it needs no restart, so a plain `share`
# is fully reversible without disturbing anything.
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
umount "$SHARED_SOCK"
rm -f "$SHARED_SOCK"
echo " removed the bind mount (no restart needed)"
did=1
fi
rm -f "$OWNER_FILE"
rmdir "$SHARED_DIR" 2>/dev/null || true
if [ -f "$DROPIN" ]; then
rm -f "$DROPIN"
rmdir "$(dirname "$DROPIN")" 2>/dev/null || true
systemctl daemon-reload
systemctl restart docker
echo " removed the systemd drop-in and restarted Docker"
did=1
fi
if [ "$did" -eq 0 ]; then
echo "not sharing — this WSL installation already has the main host Docker."
return 0
fi
echo "restored: this WSL installation has the main host Docker again."
echo " (nothing else was changed; the original unit was never edited)"
}
# ── use (run on a guest distro) ────────────────────────────────────────────
use() {
require_wsl
if [ ! -S "$SHARED_SOCK" ]; then
echo "no shared socket at $SHARED_SOCK" >&2
echo "Run 'sudo bash ctrl/dockerhost.sh share' in the distro that owns Docker." >&2
exit 1
fi
# Align the local docker group GID with the owner's, or the socket is
# unreadable here even though it is visible.
if [ -f "$OWNER_FILE" ] && [ "$(id -u)" -eq 0 ]; then
local gid; gid=$(awk '/docker gid:/ {print $3}' "$OWNER_FILE")
if [ -n "$gid" ]; then
if getent group docker >/dev/null; then
[ "$(getent group docker | cut -d: -f3)" = "$gid" ] || groupmod -g "$gid" docker
else
groupadd -g "$gid" docker
fi
fi
fi
if [ "${1:-}" = "--persist" ]; then
[ "$(id -u)" -eq 0 ] || { echo "--persist needs root" >&2; exit 1; }
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > "$PROFILE_D"
echo "persisted in $PROFILE_D"
fi
echo "export DOCKER_HOST=unix://$SHARED_SOCK"
}
case "${1:-status}" in
status) status ;;
share) shift; share "${1:-}" ;;
unshare) unshare_ ;;
use) shift; use "${1:-}" ;;
*) echo "usage: $0 [status|share|unshare|use [--persist]]" >&2; exit 1 ;;
esac

58
rig/ctrl/docs.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Documentation: render the diagrams, and serve the pages.
#
# The docs are the instructions for building the cluster, so they must work
# BEFORE anything else exists. That rules out serving them from the cluster, and
# it rules out python -m http.server too — a minimal Debian has no python3. What
# it does have, by definition, is Docker: the single prerequisite rig already
# demands. So a throwaway nginx container serves a read-only bind mount.
#
# Rendered SVGs are committed alongside their .dot sources for the same reason:
# the pages have to read on a machine with no Graphviz installed.
#
# Usage: docs.sh serve | graphs
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
REPO="$(cd .. && pwd)"
DOCS_PORT="${DOCS_PORT:-$((HTTP_PORT + 4))}" # +4 sits inside this env's block
serve() {
if [ ! -f "$REPO/docs/index.html" ]; then
echo "no docs/index.html" >&2
exit 1
fi
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
docker run --rm \
--name "${CLUSTER}-docs" \
-p "${DOCS_PORT}:80" \
-v "$REPO/docs:/usr/share/nginx/html:ro" \
nginx:alpine
}
graphs() {
if ! command -v dot >/dev/null 2>&1; then
echo "graphviz not found — install with: sudo apt install graphviz" >&2
echo "(only needed to re-render; the committed .svg files already work)" >&2
exit 1
fi
shopt -s nullglob
local found=0 f out
for f in "$REPO"/docs/graphs/*.dot; do
out="${f%.dot}.svg"
echo " graphviz $(basename "$f")$(basename "$out")"
dot -Tsvg "$f" -o "$out"
found=1
done
[ "$found" -eq 1 ] || echo " no .dot files in docs/graphs/"
}
case "${1:-serve}" in
serve) serve ;;
graphs) graphs ;;
*) echo "usage: $0 [serve|graphs]" >&2; exit 1 ;;
esac

30
rig/ctrl/env.d/client.env Normal file
View File

@@ -0,0 +1,30 @@
# client — the regulated-estate shape. Multi-node so taints, affinity and
# topology are real; apiserver audit on; images through a pull-through cache of
# the corporate registry.
#
# Costs roughly 4-6 GB. Check `make cluster list` before starting this alongside
# other work — see the memory note in the README.
PROFILE_NAME=client
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.client.yaml.tpl
ADDONS="metallb cert-manager metrics-server"
REGISTRY_MODE=mirror
INGRESS_MODE=hostport
DNS_MODE=hosts
# Ports derive from the directory name by default (see ctrl/ports.sh), so
# several environments run side by side.
#
# Opt in to the real ports below only when this is the ONLY environment and
# nothing else owns :80. They fail to bind otherwise, and docker reports it as an
# opaque "failed to bind host port 0.0.0.0:80/tcp: address already in use"
# halfway through cluster creation. `make station` checks before you spend the
# time. Uncommenting also means only one environment can exist at a time.
# HTTP_PORT=80
# HTTPS_PORT=443
# Set these in ctrl/.env (gitignored), not here:
# REGISTRY_REMOTE_URL=https://artifactory.corp.example/artifactory/api/docker/docker-virtual
# REGISTRY_USER / REGISTRY_PASSWORD
# REGISTRY_CA_FILE=/path/to/corp-root-ca.crt

42
rig/ctrl/env.d/data.env Normal file
View File

@@ -0,0 +1,42 @@
# data — a cluster with the dependency containers a soleprint room asks for.
#
# The point of this profile is that a room declares what it needs once, in
# cfg/<room>/data/cabinets.json, and gets it on either target: `build.py`
# composes those services into docker-compose.yml for a laptop, and the addons
# below install the same ones here. The names match deliberately —
# soleprint/station/cabinets/<name>/cabinet.json carries a `rig_addon` field
# pointing at ctrl/addons/<name>.sh.
#
# Everything lands in the `data` namespace (DATA_NAMESPACE to move it), so
# `make cluster reset` on the app namespace leaves the databases alone.
#
# Costs roughly 2-3 GB with airflow, under 1 without. Airflow's first boot runs
# the whole metadata migration, so expect a few minutes before it is ready.
PROFILE_NAME=data
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.yaml.tpl
# Order matters: addons.sh installs in the order listed, and airflow refuses to
# start without a metadata database, so postgres comes first.
ADDONS="metallb postgres redis airflow"
# local, not none — see minimal.env: `none` has no outward-push guard.
REGISTRY_MODE=local
INGRESS_MODE=hostport
DNS_MODE=hosts
# Namespace for the dependency containers.
DATA_NAMESPACE=data
# Postgres identity. The password is not here: postgres.sh generates one on
# first install and keeps it across re-runs, so re-running the addon never
# rotates the credential out from under whatever is already connected.
POSTGRES_DB=soleprint
POSTGRES_USER=soleprint
POSTGRES_STORAGE=2Gi
AIRFLOW_ADMIN_USER=admin
# Ports derive from the directory name by default — see ctrl/ports.sh. Reach
# the databases with port-forward rather than binding more host ports:
# kubectl -n data port-forward svc/postgres 5432:5432
# kubectl -n data port-forward svc/airflow 8080:8080

View File

@@ -0,0 +1,21 @@
# minimal — the default. One node, no addons, no registry.
# Assumes nothing and boots fast. Start here; move to client.env when you need
# the regulated behaviours.
#
PROFILE_NAME=minimal
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.yaml.tpl
ADDONS=""
# local, not none: `none` leaves the cluster with no registry to push to, and an
# unqualified image name then means docker.io/library/<name>. In a regulated
# estate that is a disclosure risk, not a convenience trade — so the default
# carries the guard even though it costs one container.
REGISTRY_MODE=local
INGRESS_MODE=hostport
DNS_MODE=hosts
# Ports are deliberately NOT set here. They derive from the directory name so
# several environments coexist — see ctrl/ports.sh, and `make ports` to see the
# block this one gets. A fixed default here would collide with whatever else the
# machine happens to be running; 8080 in particular is rarely free.

View File

@@ -0,0 +1,18 @@
# offline — air-gapped. Everything comes from a local registry that was loaded
# ahead of time; nothing reaches the internet. Pair with the wizard-full image
# (DEPS_SOURCE=baked) so the toolchain install is offline too.
#
# The heavier addons are left out to keep first boot viable.
PROFILE_NAME=offline
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.audit.yaml.tpl
ADDONS="metallb"
REGISTRY_MODE=local
INGRESS_MODE=hostport
DNS_MODE=hosts
# Derived from the directory name by default — see ctrl/ports.sh.
# Uncomment for the real ports, but only if this is the only environment.
# HTTP_PORT=80
# HTTPS_PORT=443

15
rig/ctrl/hosts.tmpl Normal file
View File

@@ -0,0 +1,15 @@
# /etc/hosts block for this environment. Rendered by newbox.sh; ${CLUSTER} and
# ${HTTP_PORT} are substituted.
#
# Hostnames are a convenience, not a requirement — every service is reachable at
# localhost:<port> without any of this, which is why DNS is not touched by
# default. Add entries here as the model grows.
#
# On Windows the same block has to go in
# C:\Windows\System32\drivers\etc\hosts for a browser to resolve these. That
# file does NOT support wildcards, so every name must be listed explicitly.
# newbox.sh prints the block for you to paste rather than editing it.
127.0.0.1 ${CLUSTER}.local
127.0.0.1 api.${CLUSTER}.local
127.0.0.1 docs.${CLUSTER}.local

71
rig/ctrl/k8s/README.md Normal file
View File

@@ -0,0 +1,71 @@
# `ctrl/k8s` — cluster shape, and what runs on it
Same layout as every other project here (`unt`, `nvi`, `eth`, `mpr`, and
soleprint's generated rooms): a kind config, a kustomize `base/`, and an
`overlays/dev/` that patches it. See ALL `projects/templates/conventions.md`.
```
kind-config*.yaml.tpl the cluster itself — nodes, ports, audit
base/ the components, as plain manifests
overlays/dev/ how this rig differs from the base
audit-policy.yaml mounted into the apiserver by the audit shapes
```
## Why the cluster config is a template
Every other project checks in a literal `kind-config.yaml`, because there is
exactly one `unt` and one `nvi`. A rig is copied and renamed to make a second
environment, and both the cluster name and the host port block follow the
directory name — so a literal would make every copy collide on both.
`ctrl/cluster.sh` renders it with `sed`, substituting `${CLUSTER}`,
`${NODE_IMAGE}`, `${HTTP_PORT}` and `${HOST_WORKDIR}`. Not `envsubst`: that is
`gettext-base`, which a minimal Debian does not have, and Docker being the only
prerequisite is the one promise rig makes.
**The chosen file is the source of truth for node count and audit.**
`lib/config.sh` reads both back out of it, so a profile names a shape and does
not restate what the YAML already says.
| file | nodes | audit | profiles |
| --- | --- | --- | --- |
| `kind-config.yaml.tpl` | 1 | off | `minimal`, `data` |
| `kind-config.audit.yaml.tpl` | 1 | on | `offline` |
| `kind-config.client.yaml.tpl` | 3 | on | `client` |
A profile picks one with `KIND_CONFIG` in `ctrl/env.d/<profile>.env`. Adding a
shape is adding a file — there is no dispatcher to edit.
Audit is an apiserver flag and therefore fixed at creation: changing it is
`make cluster reset`, not a re-apply.
## `base/` — replace these
**The two components in `base/` are examples, not the system.** They exist so
the real manifests have a shape to be written against.
The real ones are expected to be versioned **separately from the installer**
they change on a different cadence, by different people, under different review.
Point `MANIFESTS_DIR` in `ctrl/.env` at their overlay and rig stops owning them:
```
MANIFESTS_DIR=../platform-manifests/overlays/dev
```
Until then it defaults to `ctrl/k8s/overlays/dev`.
### The three states a component can be in
Switching between them should be a one-line change, never a rewrite. The DNS
name stays the same in every case, so callers never know the difference:
| state | what exists | when |
| --- | --- | --- |
| **real** | an image built from source, hot-reloaded | the one thing you are working on |
| **mock** | a stub returning canned responses (`example-mock.yaml`) | everything else — most of the estate |
| **remote** | no pod at all, just a Service (`example-remote.yaml`) | when the real system is reachable and you want it |
Most components should be **mock**. What has to be faithful is the topology —
names, ports, dependency order, who can reach whom, how it fails. The workloads
are noise, and mocking them is what makes several copies of a large estate fit
on one laptop.

View File

@@ -0,0 +1,44 @@
# Apiserver audit policy. Mounted into the control plane at creation when a
# profile sets AUDIT=on — an apiserver flag, so it cannot be added to a running
# cluster without recreating it.
#
# Deliberately modest: enough to make "who changed what, and when" answerable
# during onboarding without filling the disk. Read the log with:
# docker exec <cluster>-control-plane cat /var/log/kubernetes/audit.log
apiVersion: audit.k8s.io/v1
kind: Policy
# Never log the request body for these — they contain credentials.
omitStages:
- RequestReceived
rules:
# Secrets/configmaps: record that access happened, never the contents.
- level: Metadata
resources:
- group: ""
resources: ["secrets", "configmaps"]
# Authn/authz decisions — the part an auditor actually asks about.
- level: Metadata
nonResourceURLs:
- /apis*
- /api*
# Mutations to workloads and policy: full request, so a diff is reconstructable.
- level: Request
verbs: ["create", "update", "patch", "delete"]
resources:
- group: ""
resources: ["pods", "services", "serviceaccounts", "namespaces"]
- group: "apps"
- group: "networking.k8s.io"
- group: "rbac.authorization.k8s.io"
# Everything else that changes state: metadata only.
- level: Metadata
verbs: ["create", "update", "patch", "delete"]
# Reads are dropped entirely — otherwise controller polling drowns the log.
- level: None
verbs: ["get", "list", "watch"]

View File

@@ -0,0 +1,104 @@
# EXAMPLE — a mocked component. Copy, rename, replace.
#
# A stub that answers on the right name and port with canned responses. No image
# to build: the script is mounted from the ConfigMap, so changing the behaviour
# is a kubectl apply, not a rebuild.
#
# Deliberately boring and readable. This is onboarding material — someone should
# be able to read the generated object and recognise what it is.
apiVersion: v1
kind: ConfigMap
metadata:
name: example-service-stub
data:
# Canned responses by path. Add entries as the contract becomes clear;
# anything unmatched returns 404 so a missing route is visible, not silent.
routes.json: |
{
"/health": {"status": 200, "body": {"status": "ok"}},
"/v1/example": {"status": 200, "body": {"items": [], "mocked": true}}
}
serve.py: |
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
ROUTES = json.load(open("/etc/stub/routes.json"))
NAME = os.environ.get("STUB_NAME", "stub")
class H(BaseHTTPRequestHandler):
def do_GET(self):
r = ROUTES.get(self.path)
if r is None:
self.send_response(404)
self.end_headers()
# Say which stub rejected it — with everything mocked, "404"
# alone tells you nothing about where the call actually landed.
self.wfile.write(json.dumps(
{"error": "no canned route", "stub": NAME, "path": self.path}
).encode())
return
body = json.dumps(r["body"]).encode()
self.send_response(r["status"])
self.send_header("Content-Type", "application/json")
self.send_header("X-Mocked-By", NAME)
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print("%s %s" % (NAME, fmt % args), flush=True)
HTTPServer(("0.0.0.0", 8080), H).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: example-service
labels:
app: example-service
rig.component/impl: mock # so `kubectl get deploy -L rig.component/impl`
# shows at a glance what is real and what is not
spec:
replicas: 1
selector:
matchLabels:
app: example-service
template:
metadata:
labels:
app: example-service
spec:
containers:
- name: stub
image: python:3.12-slim
command: ["python3", "/etc/stub/serve.py"]
env:
- name: STUB_NAME
value: example-service
ports:
- containerPort: 8080
volumeMounts:
- name: stub
mountPath: /etc/stub
readinessProbe:
httpGet: { path: /health, port: 8080 }
initialDelaySeconds: 2
# Small enough that a whole estate of these fits alongside the real
# thing you are working on.
resources:
requests: { memory: 32Mi, cpu: 10m }
limits: { memory: 64Mi }
volumes:
- name: stub
configMap:
name: example-service-stub
---
apiVersion: v1
kind: Service
metadata:
name: example-service
spec:
selector:
app: example-service
ports:
- port: 80
targetPort: 8080

View File

@@ -0,0 +1,46 @@
# EXAMPLE — a component that is NOT simulated, pointed at the real system.
#
# This is the payoff of keeping the topology honest: there is no pod here at
# all, yet `example-remote.<namespace>.svc.cluster.local` resolves exactly as it
# does when the same component is mocked. Callers are identical in both cases,
# so moving a dependency from mocked to real is a one-line change and nothing
# downstream is touched.
#
# Use this when the real system is reachable and you want it in the loop.
# Note that reachability depends on where you are running: systems restricted to
# a managed workspace will not resolve from a laptop at all, which is the whole
# reason most components should stay mocked.
apiVersion: v1
kind: Service
metadata:
name: example-remote
labels:
rig.component/impl: remote
spec:
type: ExternalName
externalName: real-system.internal.example.com
---
# If the real system has no DNS name — only an IP, which is common for legacy
# hosts — ExternalName cannot express it. Use a bare Service plus manual
# Endpoints instead, and delete the block above.
#
# apiVersion: v1
# kind: Service
# metadata:
# name: example-remote
# labels:
# rig.component/impl: remote
# spec:
# ports:
# - port: 80
# targetPort: 8080
# ---
# apiVersion: v1
# kind: Endpoints
# metadata:
# name: example-remote # must match the Service name exactly
# subsets:
# - addresses:
# - ip: 10.0.0.42
# ports:
# - port: 8080

View File

@@ -0,0 +1,11 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# The namespace every component lands in. The overlay overrides it, so a rig
# modelling two estates can apply the same base twice under different names.
namespace: rig
resources:
- namespace.yaml
- example-mock.yaml
- example-remote.yaml

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: rig

View File

@@ -0,0 +1,57 @@
# Cluster shape: one node, apiserver audit ON. Used by the `offline` profile.
#
# Audit is an apiserver flag, so it is fixed when the cluster is created —
# changing it means `make cluster reset`, not a re-apply. That is why it is a
# property of the cluster file rather than something switched at runtime.
#
# k8s >= 1.31 uses kubeadm v1beta4, where extraArgs is a LIST of name/value
# pairs. The older map form is silently ignored — it does not error, audit
# simply never turns on.
#
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
# (named without the ${...} braces so this line survives the substitution)
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ${CLUSTER}
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
- name: audit-policy-file
value: /etc/kubernetes/audit/policy.yaml
- name: audit-log-path
value: /var/log/kubernetes/audit.log
- name: audit-log-maxage
value: "7"
extraVolumes:
- name: audit-policy
hostPath: /etc/kubernetes/audit
mountPath: /etc/kubernetes/audit
readOnly: true
- name: audit-log
hostPath: /var/log/kubernetes
mountPath: /var/log/kubernetes
readOnly: false
nodes:
- role: control-plane
image: ${NODE_IMAGE}
# hostPath is resolved by the HOST dockerd, so this must be a host path even
# when cluster.sh runs inside the wizard container. HOST_WORKDIR says where
# this rig lives on the host; bare on a host it is just the repo root.
extraMounts:
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
containerPath: /etc/kubernetes/audit/policy.yaml
readOnly: true
extraPortMappings:
- containerPort: 30080
hostPort: ${HTTP_PORT}
listenAddress: "0.0.0.0"
protocol: TCP

View File

@@ -0,0 +1,55 @@
# Cluster shape: three nodes, apiserver audit ON. Used by the `client` profile —
# the regulated-estate shape.
#
# Multi-node so taints, affinity and topology spread are real rather than
# vacuously satisfied by a single node. It costs roughly 4-6 GB; run
# `make cluster list` before starting this alongside other work.
#
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
# (named without the ${...} braces so this line survives the substitution)
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ${CLUSTER}
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
- name: audit-policy-file
value: /etc/kubernetes/audit/policy.yaml
- name: audit-log-path
value: /var/log/kubernetes/audit.log
- name: audit-log-maxage
value: "7"
extraVolumes:
- name: audit-policy
hostPath: /etc/kubernetes/audit
mountPath: /etc/kubernetes/audit
readOnly: true
- name: audit-log
hostPath: /var/log/kubernetes
mountPath: /var/log/kubernetes
readOnly: false
nodes:
- role: control-plane
image: ${NODE_IMAGE}
extraMounts:
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
containerPath: /etc/kubernetes/audit/policy.yaml
readOnly: true
extraPortMappings:
- containerPort: 30080
hostPort: ${HTTP_PORT}
listenAddress: "0.0.0.0"
protocol: TCP
- role: worker
image: ${NODE_IMAGE}
- role: worker
image: ${NODE_IMAGE}

View File

@@ -0,0 +1,36 @@
# Cluster shape: one node, no audit. Used by the `minimal` and `data` profiles.
#
# A TEMPLATE rather than a plain kind-config.yaml because a rig is copied and
# renamed to make a second environment, and both the cluster name and the host
# port follow the directory. A checked-in literal would make every copy collide
# on both. ctrl/cluster.sh renders it with sed — not envsubst, which is
# gettext-base and absent from a minimal Debian, and rig's whole premise is that
# Docker is the only prerequisite.
#
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
# (named without the ${...} braces so this line survives the substitution)
# Node count and audit are READ BACK from this file by lib/config.sh, so this
# YAML is the source of truth for both — there is no second place to update.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ${CLUSTER}
# Point containerd at a certs.d directory. registry.sh drops per-host hosts.toml
# files in there afterwards, so switching registry mode never requires
# recreating the cluster.
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: ${NODE_IMAGE}
# One NodePort bridged to the host; an in-cluster gateway owns it. There is
# deliberately no ingress controller — they pin a narrow window of k8s
# versions, and running a trailing-edge control plane is the point.
extraPortMappings:
- containerPort: 30080
hostPort: ${HTTP_PORT}
listenAddress: "0.0.0.0"
protocol: TCP

View File

@@ -0,0 +1,22 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
# The dev overlay is where a rig says how its estate differs from the base —
# which components are real, which are mocked, which point at a live system.
# Kept empty on purpose: the base already boots, and an overlay full of examples
# is harder to read than one that starts blank.
#
# The shape a patch takes, for when the first one is needed:
#
# patches:
# - target: {kind: Service, name: example-service}
# patch: |
# - op: replace
# path: /spec/type
# value: NodePort
# - op: add
# path: /spec/ports/0/nodePort
# value: 30080

148
rig/ctrl/lib/config.sh Normal file
View File

@@ -0,0 +1,148 @@
# Shared config loading. Sourced, never executed.
#
# The ecosystem convention is that scripts are standalone with no shared log
# library — that still holds. This file is not a logging lib; it is the single
# definition of how the config layers compose, which every script has to agree
# on exactly. Precedence, weakest first:
#
# ctrl/versions.env pinned toolchain + image digests (committed)
# ctrl/env.d/<profile> cluster shape (committed)
# ctrl/.env machine-local values and secrets (gitignored)
# the caller's env `make cluster up PROFILE=client` (always wins)
#
# That last rule is why this is more than a few `source` lines: .env sets
# PROFILE, so without snapshotting it would silently override the PROFILE the
# user just typed on the command line.
#
# Run from ctrl/.
# Values a user can reasonably override per-invocation. Anything set in the
# environment when load_config runs is restored after the files are read.
# NODES and AUDIT are deliberately NOT here: they are properties of the chosen
# ctrl/k8s/kind-config*.yaml.tpl and are read back out of it below, so there is
# one place that decides the shape of the cluster rather than two that can drift.
CONFIG_OVERRIDABLE="PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS
REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT
SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT"
# The containing folder's name, reduced to something kind accepts as a cluster
# name (a DNS label: lowercase alphanumerics and dashes). Run from ctrl/, so the
# repo root is the parent.
default_cluster_name() {
local n
n=$(basename "$(cd .. && pwd)")
n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-')
n=$(echo "$n" | sed 's/^-*//; s/-*$//')
echo "${n:-rig}"
}
# Base of this environment's 10-port block. cksum is used rather than $RANDOM or
# bash hashing because it is POSIX and returns the same value on every machine,
# which is what makes the block reproducible instead of merely unique.
derive_port_base() {
local h; h=$(printf '%s' "$1" | cksum | awk '{print $1}')
echo $((20000 + (h % 200) * 10))
}
load_config() {
local k saved=""
for k in $CONFIG_OVERRIDABLE; do
# ${!k+x} distinguishes "set but empty" from "unset" — an explicit
# FOO= on the command line is a real choice and must survive.
if [ -n "${!k+x}" ]; then
saved+="$k=$(printf '%q' "${!k}")"$'\n'
fi
done
set -a
source ./versions.env
[ -f ./.env ] && source ./.env
set +a
# Re-apply overrides now so PROFILE is the caller's before we pick the file.
_config_restore "$saved"
local profile="${PROFILE:-minimal}"
if [ ! -f "./env.d/${profile}.env" ]; then
echo "no such profile: env.d/${profile}.env" >&2
echo "available: $(ls env.d/*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | tr '\n' ' ')" >&2
exit 1
fi
set -a
source "./env.d/${profile}.env"
[ -f ./.env ] && source ./.env
set +a
_config_restore "$saved"
# Identity follows the FOLDER, so copying this directory somewhere else and
# renaming it yields a distinct environment with no further edits. Without
# this, two copies would share one cluster and `make cluster down` in either
# would destroy the other's.
CLUSTER="${CLUSTER:-$(default_cluster_name)}"
KUBECONTEXT="kind-${CLUSTER}"
# Host ports are a single shared namespace, so unlike the cluster name they
# cannot just follow the directory — they have to be spread out. Anything
# already set (ctrl/.env, a profile, the command line) wins; only the gaps
# are filled. See ports.sh for the reasoning.
local base; base=$(derive_port_base "$CLUSTER")
HTTP_PORT="${HTTP_PORT:-$base}"
HTTPS_PORT="${HTTPS_PORT:-$((base + 1))}"
TILT_PORT="${TILT_PORT:-$((base + 2))}"
REGISTRY_PORT="${REGISTRY_PORT:-$((base + 3))}"
# Profiles name a k8s minor (v1_36); versions.env holds the pinned digest.
local var="NODE_IMAGE_${K8S_VERSION}"
NODE_IMAGE="${!var:-}"
if [ -z "$NODE_IMAGE" ]; then
echo "K8S_VERSION='${K8S_VERSION}' has no NODE_IMAGE_${K8S_VERSION} in versions.env" >&2
exit 1
fi
# The cluster's shape is a file in ctrl/k8s/, named by the profile. Adding a
# shape is adding a file; there is no dispatcher to edit.
KIND_CONFIG="${KIND_CONFIG:-kind-config.yaml.tpl}"
KIND_CONFIG_PATH="./k8s/${KIND_CONFIG}"
if [ ! -f "$KIND_CONFIG_PATH" ]; then
echo "no such cluster shape: ctrl/k8s/${KIND_CONFIG}" >&2
echo "available: $(ls k8s/kind-config*.yaml.tpl 2>/dev/null | xargs -n1 basename | tr '\n' ' ')" >&2
exit 1
fi
# Read the shape back out of the YAML rather than trusting a profile to
# restate it. station.sh sizes the memory warning on NODES, and cluster.sh
# prints AUDIT before spending minutes building something that cannot be
# changed afterwards — both would mislead if the numbers drifted.
NODES=$(grep -c '^ - role:' "$KIND_CONFIG_PATH")
if grep -q 'audit-policy-file' "$KIND_CONFIG_PATH"; then AUDIT=on; else AUDIT=off; fi
}
# Render a cluster shape to stdout. sed rather than envsubst: envsubst is
# gettext-base, absent from a minimal Debian, and Docker is meant to be the only
# prerequisite. The variable list is explicit so a template cannot quietly start
# depending on something the caller does not set.
#
# hostPath entries are resolved by the HOST dockerd, so HOST_WORKDIR must stay a
# host path even when this runs inside the wizard container.
render_kind_config() {
local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"
sed -e "s|\${CLUSTER}|${CLUSTER}|g" \
-e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" \
-e "s|\${HTTP_PORT}|${HTTP_PORT}|g" \
-e "s|\${HOST_WORKDIR}|${host_workdir}|g" \
"$KIND_CONFIG_PATH"
}
_config_restore() {
local line
while IFS= read -r line; do
if [ -n "$line" ]; then
eval "export $line"
fi
done <<< "$1"
# A while loop returns its last body command's status; the trailing empty
# line would otherwise make this return 1 and trip `set -e` in the caller.
return 0
}

313
rig/ctrl/newbox.sh Executable file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env bash
# Create a disposable Linux environment to validate the installer from a
# genuinely clean slate — one that can be thrown away without touching the
# environment you actually work in.
#
# This is the ONLY host-aware file in the tree. Everything else needs just a
# Linux with Docker, which is what keeps other host types a later addition
# rather than a rewrite.
#
# On WSL it creates a second distro. There is no .bat and no PowerShell script:
# wsl.exe is callable from inside WSL, and wslpath converts the paths it wants.
# A machine with no WSL at all needs `wsl --install` run once by hand first —
# scripting a reboot-requiring Windows feature install is not worth it.
#
# Docker: borrowed by default, never installed twice
# --------------------------------------------------
# WSL2 distros share one kernel and one network stack, so two dockerd instances
# contend over docker0 and iptables and can disturb the daemon you depend on.
# (That is why Docker Desktop runs one daemon in a dedicated distro and shares
# its socket rather than installing one per distro.)
#
# REUSE_DOCKER=1 (default) borrow the host distro's daemon over /mnt/wsl.
# Nothing is installed; nothing can conflict.
# Requires `ctrl/dockerhost.sh share` once on the
# distro that owns Docker.
# REUSE_DOCKER=0 install a second daemon in the new distro. Only
# if you specifically want to test a from-scratch
# Docker install, and not on a machine you need.
#
# Borrowing is also the more honest test: rig never installs Docker anyway — it
# is the documented prerequisite — so a clean box does not need its own to
# exercise everything rig actually does.
#
# Usage: newbox.sh create | destroy [--purge] | status | shell
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
REPO="$(cd .. && pwd)"
# The distro is named after this environment, and that derived name is the ONLY
# thing this script will ever destroy. See guard_name().
BOX="${BOX:-${CLUSTER}box}"
BOX_USER="${BOX_USER:-dev}"
# Borrow the host distro's Docker rather than installing a second daemon.
REUSE_DOCKER="${REUSE_DOCKER:-1}"
SHARED_SOCK=/mnt/wsl/shared-docker/docker.sock
WSL_EXE=/mnt/c/Windows/System32/wsl.exe
# ── host detection ─────────────────────────────────────────────────────────
require_wsl() {
if ! grep -qi microsoft /proc/version 2>/dev/null; then
cat >&2 <<'EOF'
newbox is WSL-only for now.
On native Linux you do not need it: rig already isolates environments by
directory (own cluster, context, images and port block), so a second copy in a
second directory is the clean slate. To validate the installer itself against a
bare system, run the wizard against a stock Debian container instead.
EOF
exit 1
fi
if [ ! -x "$WSL_EXE" ]; then
echo "wsl.exe not found at $WSL_EXE" >&2
exit 1
fi
}
wsl_list() { "$WSL_EXE" -l -q 2>/dev/null | tr -d '\0\r'; }
box_exists() { wsl_list | grep -qx "$BOX"; }
# `wsl --unregister` permanently deletes a distro's filesystem. The whole safety
# story is this function: only the name derived from this directory can ever be
# a target, so a typo or a stray argument cannot destroy the distro you work in.
guard_name() {
local derived="${CLUSTER}box"
if [ "$BOX" != "$derived" ]; then
echo "refusing: BOX='$BOX' is not the name derived from this directory ('$derived')." >&2
echo "That guard exists because --unregister is irreversible." >&2
exit 1
fi
if [ -z "$CLUSTER" ] || [ "$BOX" = "box" ]; then
echo "refusing: empty environment name" >&2
exit 1
fi
}
# ── create ─────────────────────────────────────────────────────────────────
rootfs_path() {
local win_home; win_home=$(wslpath "$("$WSL_EXE" -d "$(wsl_list | head -1)" -e printf '%s' "$USERPROFILE" 2>/dev/null || true)" 2>/dev/null || true)
# Simpler and reliable: use the current user's Windows home via /mnt/c.
ls -d /mnt/c/Users/*/ 2>/dev/null | grep -viE '/(All Users|Default|Default User|Public)/$' | head -1
}
build_rootfs() {
local tar="$1"
if [ -f "$tar" ]; then
echo " rootfs cached: $(basename "$tar")"
return
fi
echo " exporting a stock Debian rootfs (cached for next time)"
local cid; cid=$(docker create debian:trixie-slim)
docker export "$cid" > "$tar"
docker rm -f "$cid" >/dev/null
}
provision() {
echo " provisioning (root)"
local hosts_block
hosts_block=$(CLUSTER="$CLUSTER" HTTP_PORT="$HTTP_PORT" \
envsubst < ./hosts.tmpl 2>/dev/null || sed "s/\${CLUSTER}/$CLUSTER/g" ./hosts.tmpl)
# Piped as stdin rather than a second script file, the same shape as any
# remote provisioning heredoc. Everything here is idempotent so a failed run
# can simply be repeated.
"$WSL_EXE" -d "$BOX" -u root -- bash -s <<PROVISION
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg sudo >/dev/null
if [ "$REUSE_DOCKER" = "1" ]; then
# Borrow the host distro's daemon: CLI only, no dockerd, nothing to
# conflict with. The GID must match the owner's or the shared socket is
# unreadable here even though it is visible.
install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce-cli >/dev/null
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > /etc/profile.d/rig-docker-host.sh
if [ -f /mnt/wsl/shared-docker/OWNER ]; then
gid=\$(awk '/docker gid:/ {print \$3}' /mnt/wsl/shared-docker/OWNER)
if [ -n "\$gid" ]; then
getent group docker >/dev/null && groupmod -g "\$gid" docker || groupadd -g "\$gid" docker
fi
fi
else
# A second daemon. Only when deliberately testing a from-scratch install.
install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io >/dev/null
fi
id -u "$BOX_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$BOX_USER"
usermod -aG sudo,docker "$BOX_USER"
echo "$BOX_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-$BOX_USER
chmod 0440 /etc/sudoers.d/90-$BOX_USER
# systemd is off by default in WSL, and Docker needs it. Takes effect on the
# next start of this distro, which is why create() terminates it below.
cat > /etc/wsl.conf <<WSLCONF
[boot]
systemd=true
[user]
default=$BOX_USER
WSLCONF
# The default inotify limits are low enough that file watching silently stops
# working — no error, changes just stop being noticed. Fix it before it bites.
cat > /etc/sysctl.d/99-rig.conf <<SYSCTL
fs.inotify.max_user_watches=524288
fs.inotify.max_user_instances=512
SYSCTL
if ! grep -q 'rig environment' /etc/hosts 2>/dev/null; then
{ echo ""; echo "# rig environment"; cat <<'HOSTS'
$hosts_block
HOSTS
} >> /etc/hosts
fi
touch /etc/rig-provisioned
PROVISION
}
create() {
require_wsl
guard_name
local winhome; winhome=$(rootfs_path)
[ -n "$winhome" ] || { echo "could not locate the Windows user directory" >&2; exit 1; }
local tar="${winhome}rig-rootfs.tar"
local installdir="${winhome}WSL/${BOX}"
echo "creating '$BOX'"
if [ "$REUSE_DOCKER" = "1" ]; then
echo " docker: borrowing the host distro's daemon (nothing installed)"
if [ ! -S "$SHARED_SOCK" ]; then
echo
echo " No shared socket yet. In the distro that owns Docker, run once:"
echo " sudo bash ctrl/dockerhost.sh share"
echo " That adds one systemd drop-in and nothing else; undo with 'unshare'."
echo " Continuing — the box will be created, but Docker won't work in it"
echo " until you do that."
fi
else
echo
echo " REUSE_DOCKER=0: installing a SECOND Docker daemon."
echo " WSL distros share a network stack, so this can disturb Docker in"
echo " the distro you work in. Ctrl-C now if that is a bad trade today."
echo
sleep 4
fi
echo
if box_exists; then
echo " distro already registered"
else
build_rootfs "$tar"
mkdir -p "$installdir"
"$WSL_EXE" --import "$BOX" "$(wslpath -w "$installdir")" "$(wslpath -w "$tar")" --version 2
fi
# Resumable: a partially-created box is finished rather than restarted.
if "$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null; then
echo " already provisioned"
else
provision
echo " restarting the distro so systemd and group membership apply"
"$WSL_EXE" --terminate "$BOX" # ONLY this distro; never --shutdown
fi
echo " copying rig in"
tar c -C "$REPO" --exclude=def --exclude=.git --exclude=ctrl/.env . \
| "$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc "mkdir -p ~/rig && tar x -C ~/rig"
echo
echo " docker: $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'systemctl is-active docker 2>/dev/null || echo inactive')"
echo
echo "next:"
echo " make newbox shell # a shell inside it"
echo " then: cd ~/rig && make station && make deps && make cluster up"
echo
echo "For a browser on Windows to resolve the hostnames, paste this into"
echo "C:\\Windows\\System32\\drivers\\etc\\hosts (it has no wildcard support):"
CLUSTER="$CLUSTER" envsubst < ./hosts.tmpl 2>/dev/null | grep -v '^#' | grep -v '^$' | sed 's/^/ /'
}
# ── the rest ───────────────────────────────────────────────────────────────
destroy() {
require_wsl
guard_name
if ! box_exists; then
echo "no distro '$BOX' to remove"
else
echo "about to PERMANENTLY delete the distro '$BOX' and its filesystem."
"$WSL_EXE" --terminate "$BOX" 2>/dev/null || true
"$WSL_EXE" --unregister "$BOX"
echo " unregistered"
fi
local winhome; winhome=$(rootfs_path)
rm -rf "${winhome}WSL/${BOX}" 2>/dev/null || true
if [ "${1:-}" = "--purge" ]; then
rm -f "${winhome}rig-rootfs.tar"
echo " cached rootfs removed"
fi
}
status() {
require_wsl
echo "environment $CLUSTER"
echo "distro $BOX"
if box_exists; then
echo "registered yes"
echo "provisioned $("$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null && echo yes || echo no)"
echo "docker $("$WSL_EXE" -d "$BOX" -u root -- bash -lc 'systemctl is-active docker 2>/dev/null' || echo unknown)"
echo "rig copied $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'test -f ~/rig/Makefile && echo yes || echo no' 2>/dev/null)"
else
echo "registered no"
fi
echo
echo "all distros (this one is never touched unless it is '$BOX'):"
wsl_list | sed 's/^/ /'
}
shell() {
require_wsl
box_exists || { echo "no distro '$BOX' — run 'make newbox' first" >&2; exit 1; }
"$WSL_EXE" -d "$BOX" -u "$BOX_USER" --cd '~'
}
case "${1:-status}" in
create) create ;;
destroy) shift; destroy "${1:-}" ;;
status) status ;;
shell) shell ;;
*) echo "usage: $0 [create|destroy [--purge]|status|shell]" >&2; exit 1 ;;
esac

103
rig/ctrl/ports.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Give each environment its own block of host ports.
#
# New versions of a system mean new clusters on ONE machine, not new machines.
# Cluster name, kubectl context, registry container and image tag already derive
# from the directory name, so two copies never collide there — but host ports are
# a single shared namespace and would.
#
# The block is derived from the directory name: stateless, stable, and requiring
# no coordination between copies that know nothing about each other.
#
# base = 20000 + (hash(slug) % 200) * 10
# +0 HTTP +1 HTTPS +2 TILT +3 REGISTRY (+4..9 reserved)
#
# 20000+ deliberately avoids the ports something is already likely to hold: 80,
# 443, 3000, 5432, 8000, 8080.
#
# Derivation is a default, not a decision. On first use the resolved block is
# written into ctrl/.env, so it becomes pinned, visible and editable rather than
# a number that appears from nowhere. Anything already in ctrl/.env wins.
#
# Usage: ports.sh show | derive | persist
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
# derive_port_base lives in lib/config.sh so every script resolves the same block
# without going through this one.
derive_base() { derive_port_base "$1"; }
derive() {
load_config
local base; base=$(derive_base "$CLUSTER")
DERIVED_HTTP=$base
DERIVED_HTTPS=$((base + 1))
DERIVED_TILT=$((base + 2))
DERIVED_REGISTRY=$((base + 3))
}
show() {
derive
echo "environment $CLUSTER"
echo "derived base $(derive_base "$CLUSTER")"
echo
printf " %-14s %-8s %-8s %s\n" KEY DERIVED ACTIVE SOURCE
_row HTTP_PORT "$DERIVED_HTTP"
_row HTTPS_PORT "$DERIVED_HTTPS"
_row TILT_PORT "$DERIVED_TILT"
_row REGISTRY_PORT "$DERIVED_REGISTRY"
}
_row() {
local key="$1" derived="$2" active="${!1:-}" src="derived"
if [ -n "$active" ] && [ "$active" != "$derived" ]; then
src="override"
elif [ -z "$active" ]; then
active="$derived"
fi
printf " %-14s %-8s %-8s %s\n" "$key" "$derived" "$active" "$src"
}
# Write the derived block into ctrl/.env, once. Existing keys are never
# rewritten — an override stays an override.
persist() {
derive
[ -f ./.env ] || cp ./.env.example ./.env
local wrote=0 key val
for key in HTTP_PORT:$DERIVED_HTTP \
HTTPS_PORT:$DERIVED_HTTPS \
TILT_PORT:$DERIVED_TILT \
REGISTRY_PORT:$DERIVED_REGISTRY; do
val="${key#*:}"; key="${key%%:*}"
if grep -qE "^${key}=[0-9]" ./.env 2>/dev/null; then
continue
fi
if [ "$wrote" -eq 0 ]; then
{
echo ""
echo "# Port block for this environment, derived from the directory name"
echo "# so copies never collide. Pinned here on first use — edit freely."
} >> ./.env
wrote=1
fi
# Replace a commented/empty placeholder if present, else append.
if grep -qE "^#?\s*${key}=" ./.env 2>/dev/null; then
sed -i "s|^#\?\s*${key}=.*|${key}=${val}|" ./.env
else
echo "${key}=${val}" >> ./.env
fi
done
[ "$wrote" -eq 1 ] && echo "pinned port block into ctrl/.env" || echo "ports already set in ctrl/.env"
return 0
}
case "${1:-show}" in
show) show ;;
derive) derive; echo "$DERIVED_HTTP $DERIVED_HTTPS $DERIVED_TILT $DERIVED_REGISTRY" ;;
persist) persist ;;
*) echo "usage: $0 [show|derive|persist]" >&2; exit 1 ;;
esac

216
rig/ctrl/registry.sh Executable file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Registry plumbing. THIS is the seam — not a tool.
#
# Four modes, selected by REGISTRY_MODE in the active profile:
#
# none Tilt builds straight into the node. No registry at all — and so no
# guard against an outward push: an unqualified image name means
# docker.io/library/<name>, and only Tilt's kind detection stands
# between that and a real push. Throwaway use only; every profile
# here now defaults to `local` instead.
# local a registry:2 container wired into the cluster.
# mirror the same container, but configured as a pull-through CACHE of the
# corporate registry. What a locked-down client actually looks like:
# images originate from corp, you don't hammer it, and you keep
# working when the VPN drops.
# remote no local container; pull straight from the corporate registry using
# an imagePullSecret.
#
# Deliberately a script rather than a tool. ctlptl collapses the `local` wiring
# into one line, but its Registry spec only accepts name/port/image/listenAddress
# — there is no way to set REGISTRY_PROXY_REMOTEURL, so it cannot express
# `mirror` at all. Keeping the seam here is what keeps the corporate registry
# swappable.
#
# Usage: registry.sh up | down | status
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
REG_NAME="${CLUSTER}-registry"
REG_PORT="${REGISTRY_PORT:-5005}"
K="kubectl --context ${KUBECONTEXT}"
# ── CA trust ───────────────────────────────────────────────────────────────
# A corporate registry is almost always fronted by an internal CA, and trust has
# to reach three separate places. Nothing does this for you, and the symptom when
# it's missing is an opaque:
# x509: certificate signed by unknown authority
#
# 1. the host docker daemon — /etc/docker/certs.d/<host>/ca.crt (needs root)
# 2. every kind node's containerd — nodes do NOT inherit host trust
# 3. anything doing HTTPS from inside the cluster, in its own trust store
#
# We handle (2) here because it's ours to handle. (1) is reported by station.sh
# since it needs root. (3) belongs to the workload.
install_ca_into_nodes() {
[ -n "${REGISTRY_CA_FILE:-}" ] || return 0
if [ ! -r "$REGISTRY_CA_FILE" ]; then
echo "REGISTRY_CA_FILE is set but not readable: $REGISTRY_CA_FILE" >&2
exit 1
fi
echo " distributing CA to kind nodes"
local node
for node in $(kind get nodes --name "$CLUSTER"); do
docker cp "$REGISTRY_CA_FILE" "$node:/usr/local/share/ca-certificates/corp-registry.crt"
docker exec "$node" update-ca-certificates >/dev/null 2>&1
docker exec "$node" systemctl restart containerd
done
}
# Point containerd at a registry host. The cluster config already set
# config_path=/etc/containerd/certs.d, so this is a per-node drop-in and needs no
# cluster recreate — which is what lets registry mode change on a live cluster.
write_hosts_toml() {
local host="$1" upstream="$2" skip_verify="${3:-false}"
local node
for node in $(kind get nodes --name "$CLUSTER"); do
docker exec "$node" mkdir -p "/etc/containerd/certs.d/${host}"
docker exec -i "$node" cp /dev/stdin "/etc/containerd/certs.d/${host}/hosts.toml" <<TOML
server = "${upstream}"
[host."${upstream}"]
capabilities = ["pull", "resolve"]
skip_verify = ${skip_verify}
TOML
done
}
# ── the local container (local + mirror) ───────────────────────────────────
start_registry_container() {
if [ "$(docker inspect -f '{{.State.Running}}' "$REG_NAME" 2>/dev/null || true)" = "true" ]; then
echo " registry container '$REG_NAME' already running"
return
fi
docker rm -f "$REG_NAME" >/dev/null 2>&1 || true
local args=(-d --restart=always --name "$REG_NAME"
-p "127.0.0.1:${REG_PORT}:5000")
if [ "$REGISTRY_MODE" = "mirror" ]; then
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
echo "REGISTRY_MODE=mirror needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
exit 1
fi
echo " starting pull-through cache of ${REGISTRY_REMOTE_URL}"
args+=(-e "REGISTRY_PROXY_REMOTEURL=${REGISTRY_REMOTE_URL}")
[ -n "${REGISTRY_USER:-}" ] && args+=(-e "REGISTRY_PROXY_USERNAME=${REGISTRY_USER}")
[ -n "${REGISTRY_PASSWORD:-}" ] && args+=(-e "REGISTRY_PROXY_PASSWORD=${REGISTRY_PASSWORD}")
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
args+=(-v "$(readlink -f "$REGISTRY_CA_FILE"):/etc/ssl/certs/corp-ca.crt:ro")
fi
else
echo " starting local registry"
fi
docker run "${args[@]}" "$REGISTRY_IMAGE" >/dev/null
}
# The registry must share a network with the nodes so they can resolve it by
# container name; localhost inside a node is the node, not the host.
join_kind_network() {
if docker inspect -f '{{json .NetworkSettings.Networks}}' "$REG_NAME" | grep -q '"kind"'; then
return
fi
docker network connect kind "$REG_NAME" >/dev/null 2>&1 || true
}
# The documented contract that tells tooling (Tilt, skaffold) where the local
# registry is, so they don't have to be configured separately.
apply_hosting_configmap() {
$K apply -f - <<YAML >/dev/null
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${REG_PORT}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
YAML
}
# ── modes ──────────────────────────────────────────────────────────────────
up() {
echo "registry: ${REGISTRY_MODE}"
case "$REGISTRY_MODE" in
none)
echo " no registry — images are built straight into the node"
;;
local|mirror)
start_registry_container
join_kind_network
install_ca_into_nodes
# Nodes reach the registry by container name on the shared network;
# the host reaches it on localhost:PORT. Both names must resolve.
write_hosts_toml "localhost:${REG_PORT}" "http://${REG_NAME}:5000"
if [ "$REGISTRY_MODE" = "mirror" ]; then
# Anything asking for docker.io transparently goes to the cache.
write_hosts_toml "docker.io" "http://${REG_NAME}:5000"
fi
apply_hosting_configmap
echo " ready at localhost:${REG_PORT}"
;;
remote)
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
echo "REGISTRY_MODE=remote needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
exit 1
fi
install_ca_into_nodes
local host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
if [ -n "${REGISTRY_USER:-}" ]; then
echo " creating imagePullSecret for ${host}"
$K create secret docker-registry regcred \
--docker-server="$host" \
--docker-username="$REGISTRY_USER" \
--docker-password="$REGISTRY_PASSWORD" \
--dry-run=client -o yaml | $K apply -f - >/dev/null
# Attach to the default ServiceAccount so plain pods inherit it.
$K patch serviceaccount default \
-p '{"imagePullSecrets":[{"name":"regcred"}]}' >/dev/null
fi
echo " pulling directly from ${host}"
;;
*)
echo "unknown REGISTRY_MODE '$REGISTRY_MODE' (expected none|local|mirror|remote)" >&2
exit 1
;;
esac
}
down() {
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
echo "removing registry container '$REG_NAME'"
docker rm -f "$REG_NAME" >/dev/null
fi
}
status() {
echo "mode ${REGISTRY_MODE}"
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
echo "container ${REG_NAME} $(docker inspect -f '{{.State.Status}}' "$REG_NAME")"
echo "endpoint localhost:${REG_PORT}"
else
echo "container none"
fi
[ -n "${REGISTRY_REMOTE_URL:-}" ] && echo "upstream ${REGISTRY_REMOTE_URL}"
[ -n "${REGISTRY_CA_FILE:-}" ] && echo "ca ${REGISTRY_CA_FILE}"
return 0
}
case "${1:-status}" in
up) up ;;
down) down ;;
status) status ;;
*) echo "usage: $0 [up|down|status]" >&2; exit 1 ;;
esac

249
rig/ctrl/setup.sh Executable file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Prepare a machine to run rig, and say plainly what worked, what was already
# done, and what is left for a human.
#
# This is the grouped entry point: `make setup`. Every step is idempotent and
# independently checked, so running it twice is safe and running it on a
# half-configured machine finishes the job rather than starting over.
#
# It deliberately does NOT abort on the first failure. A setup script that dies
# at step 2 hides the fact that steps 4 and 5 were also going to fail — and on
# an unfamiliar machine, the full picture is the whole point. Failures are
# collected and reported together, and the exit code reflects the worst outcome.
#
# The same script runs inside a fresh throwaway distro (newbox), so the
# provisioning path and the everyday path cannot drift apart.
#
# Usage:
# setup.sh # host checks + the dev toolchain
# setup.sh core # kubectl and jq only — no cluster tooling
# setup.sh --share-docker # ...and offer this distro's Docker to others
# setup.sh --cluster # ...and bring the cluster up
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
WITH_SHARE=0
WITH_CLUSTER=0
# Cluster tooling is not wanted everywhere: a managed or corporate-issued
# machine may legitimately want kubectl and nothing that builds clusters.
TIER=dev
for a in "$@"; do
case "$a" in
core|dev) TIER="$a" ;;
--share-docker) WITH_SHARE=1 ;;
--cluster) WITH_CLUSTER=1 ;;
*) echo "unknown option: $a" >&2; exit 1 ;;
esac
done
if [ "$TIER" = "core" ] && [ "$WITH_CLUSTER" -eq 1 ]; then
echo "core tier installs no cluster tooling, so --cluster cannot work" >&2
exit 1
fi
# ── step framework ─────────────────────────────────────────────────────────
# Statuses are deliberately distinct: "already" and "done" both mean success but
# tell you very different things about the machine you are on.
STEP_NAMES=()
STEP_STATUS=()
STEP_NOTE=()
WORST=0
record() {
STEP_NAMES+=("$1"); STEP_STATUS+=("$2"); STEP_NOTE+=("${3:-}")
# Only a genuine failure is a non-zero exit. "manual" means the machine is
# fine and you have something to do — reporting that as an error makes the
# whole run look broken and trains people to ignore the output.
[ "$2" = "fail" ] && WORST=1 || true
local mark
case "$2" in
already) mark=" ok " ;;
done) mark=" done " ;;
skip) mark=" skip " ;;
manual) mark="MANUAL" ;;
fail) mark=" FAIL " ;;
esac
printf "[%s] %-22s %s\n" "$mark" "$1" "${3:-}"
}
# ── steps ──────────────────────────────────────────────────────────────────
step_host() {
local out
if ! out=$(bash ./wizard.sh detect 2>&1); then
record host fail "detection failed"
return
fi
# Anything the wizard flagged with '!' needs a human; surface the count here
# and the detail below rather than burying it.
local warns; warns=$(echo "$out" | grep -c '^\s*!' || true)
HOST_DETAIL="$out"
if [ "$warns" -gt 0 ]; then
record host manual "$warns item(s) need attention — see below"
else
record host already "no problems detected"
fi
}
step_toolchain() {
local want="kubectl jq"
[ "$TIER" = "dev" ] && want="$want kind tilt"
local missing=""
for b in $want; do
command -v "$b" >/dev/null 2>&1 || missing="$missing $b"
done
if [ -z "$missing" ]; then
record toolchain already "$TIER: $want"
return
fi
if bash ./wizard.sh install "$TIER" >/tmp/rig-deps.$$ 2>&1; then
local still=""
for b in $want; do
[ -x "${OUT_BIN:-$HOME/.local/bin}/$b" ] || still="$still $b"
done
if [ -n "$still" ]; then
record toolchain fail "still missing:$still (see /tmp/rig-deps.$$)"
else
record toolchain done "$TIER, installed:$missing"
rm -f "/tmp/rig-deps.$$"
fi
else
record toolchain fail "install failed — see /tmp/rig-deps.$$"
fi
}
step_path() {
local bin="${OUT_BIN:-$HOME/.local/bin}"
case ":$PATH:" in
*":$bin:"*) ;;
*) record path manual "add to ~/.bashrc: export PATH=\"$bin:\$PATH\""; return ;;
esac
if grep -qs "$bin" "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null; then
record path already "$bin on PATH and persisted"
else
record path manual "on PATH now, but not persisted in ~/.bashrc"
fi
}
step_docker() {
if ! command -v docker >/dev/null 2>&1; then
record docker fail "no docker cli — this is the one prerequisite rig cannot install"
return
fi
if docker info >/dev/null 2>&1; then
record docker already "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
else
record docker fail "daemon unreachable (in the docker group? logged out and back in?)"
fi
}
step_share_docker() {
if [ "$WITH_SHARE" -ne 1 ]; then
record docker-share skip "not requested (--share-docker)"
return
fi
if ! grep -qi microsoft /proc/version 2>/dev/null; then
record docker-share skip "not WSL — sharing only applies between WSL distros"
return
fi
if [ -f /etc/systemd/system/docker.service.d/10-rig-shared-socket.conf ]; then
record docker-share already "this distro is offering its Docker to others"
return
fi
# Needs root, and asking mid-script is worse than telling the user the
# single command to run.
if [ "$(id -u)" -ne 0 ] && ! sudo -n true 2>/dev/null; then
record docker-share manual "run: sudo bash ctrl/dockerhost.sh share"
return
fi
if sudo bash ./dockerhost.sh share >/tmp/rig-share.$$ 2>&1; then
record docker-share done "this distro now owns the shared Docker"
rm -f "/tmp/rig-share.$$"
else
record docker-share fail "see /tmp/rig-share.$$"
fi
}
step_ports() {
local busy=""
for entry in "HTTP:$HTTP_PORT" "HTTPS:$HTTPS_PORT" "TILT:$TILT_PORT" "REGISTRY:$REGISTRY_PORT"; do
local p="${entry#*:}"
if command -v ss >/dev/null 2>&1 && ss -ltn "sport = :$p" 2>/dev/null | grep -q LISTEN; then
busy="$busy ${entry%%:*}($p)"
fi
done
if [ -n "$busy" ]; then
record ports fail "in use:$busy — override in ctrl/.env or rename the directory"
else
record ports already "$HTTP_PORT-$REGISTRY_PORT free"
fi
}
step_cluster() {
if [ "$TIER" = "core" ]; then
record cluster skip "core tier — no cluster tooling on this machine"
return
fi
if [ "$WITH_CLUSTER" -ne 1 ]; then
record cluster skip "not requested (--cluster)"
return
fi
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
record cluster already "'$CLUSTER' exists"
return
fi
if bash ./cluster.sh up >/tmp/rig-cluster.$$ 2>&1; then
record cluster done "'$CLUSTER' created"
rm -f "/tmp/rig-cluster.$$"
else
record cluster fail "see /tmp/rig-cluster.$$"
fi
}
# ── run ────────────────────────────────────────────────────────────────────
echo "setting up '$CLUSTER'"
echo
HOST_DETAIL=""
step_host
step_toolchain
step_path
step_docker
step_share_docker
step_ports
step_cluster
echo
if [ -n "$HOST_DETAIL" ]; then
echo "host detail"
echo "$HOST_DETAIL" | sed 's/^/ /'
echo
fi
# Repeat only what still needs action, so the tail of the output is a to-do list
# rather than a transcript.
outstanding=0
for i in "${!STEP_NAMES[@]}"; do
case "${STEP_STATUS[$i]}" in
fail|manual)
[ "$outstanding" -eq 0 ] && echo "outstanding:"
outstanding=1
printf " %-8s %-16s %s\n" "${STEP_STATUS[$i]}" "${STEP_NAMES[$i]}" "${STEP_NOTE[$i]}"
;;
esac
done
if [ "$outstanding" -eq 0 ]; then
echo "ready. next: make cluster up && make docs"
else
echo
echo "(nothing was aborted — every step ran so the list above is complete)"
fi
exit "$WORST"

106
rig/ctrl/station.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Station check: is this workstation ready to run rig?
#
# Reports and instructs; never silently fixes anything. Everything it finds is
# either already fine, or something a human has to decide on.
#
# Runs the wizard's host detection in a container when Docker is the only thing
# installed, or directly when the toolchain is already present. Then adds the
# checks that need this repo's config: profile sanity, CA trust, port clashes.
set -euo pipefail
cd "$(dirname "$0")"
WIZARD_IMAGE="${WIZARD_IMAGE:-$(basename "$(cd .. && pwd)")-wizard}"
# Host detection. Prefer running it bare — it needs no dependencies beyond
# coreutils — and fall back to the container only if this shell can't.
bash ./wizard.sh detect
# ── repo-level checks ──────────────────────────────────────────────────────
source ./lib/config.sh
load_config
echo
echo "config"
echo " profile ${PROFILE_NAME} (nodes=${NODES} audit=${AUDIT})"
echo " cluster ${CLUSTER} (context ${KUBECONTEXT})"
echo " registry ${REGISTRY_MODE}"
echo " ingress ${INGRESS_MODE}"
if [ ! -f ./.env ]; then
echo " ! ctrl/.env missing — copy it: cp ctrl/.env.example ctrl/.env"
fi
# A 3-node profile on a box that's already full is the most common first
# failure, and it presents as pods stuck Pending rather than anything obvious.
avail=$(awk '/^MemAvailable:/{printf "%d", $2/1024/1024}' /proc/meminfo)
need=$((NODES * 2))
if [ "$avail" -lt "$need" ]; then
echo " ! profile '${PROFILE_NAME}' wants ~${need} GB, ${avail} GB available"
echo " 'make cluster list' shows what else is running; 'make cluster free' stops it"
fi
# The CA reaches three places and only one of them is ours. Report the other two.
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
echo
echo "registry CA"
if [ ! -r "$REGISTRY_CA_FILE" ]; then
echo " ! REGISTRY_CA_FILE not readable: $REGISTRY_CA_FILE"
else
echo " file $REGISTRY_CA_FILE"
host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
if [ -n "$host" ] && [ ! -f "/etc/docker/certs.d/${host}/ca.crt" ]; then
echo " ! the HOST docker daemon does not trust it yet:"
echo " sudo mkdir -p /etc/docker/certs.d/${host}"
echo " sudo cp ${REGISTRY_CA_FILE} /etc/docker/certs.d/${host}/ca.crt"
echo " (kind nodes are handled by registry.sh; in-cluster clients are the workload's job)"
fi
fi
fi
# Host ports this environment will try to bind. Checked before cluster creation
# because docker reports a clash halfway through, as an opaque
# "failed to bind host port ...: address already in use".
echo
echo "ports (block derived from the directory name — see 'make ports')"
port_busy() {
if command -v ss >/dev/null 2>&1; then
ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN && return 0 || return 1
fi
# iproute2 is absent from a minimal Debian, so fall back to procfs rather
# than silently reporting everything as free.
local hex; hex=$(printf ':%04X' "$1")
grep -qi "^ *[0-9]*: [0-9A-F]*$hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null
}
# A port held by THIS environment's own cluster is not a clash — it is the thing
# working. Reporting it as a problem every time the cluster is up would train
# people to ignore this section, which is the opposite of the point.
# Extract with a second grep rather than `tr -d ':->'`: in tr, ':->' is the
# character RANGE ':' to '>', which does not contain '-', so the trailing dash
# survives and nothing ever matches.
ours=$(docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" \
--format '{{.Ports}}' 2>/dev/null | tr ',' '\n' \
| grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
clash=0
for entry in "HTTP:${HTTP_PORT}" "HTTPS:${HTTPS_PORT}" \
"TILT:${TILT_PORT}" "REGISTRY:${REGISTRY_PORT}"; do
name="${entry%%:*}"; p="${entry#*:}"
[ -n "$p" ] || continue
if ! port_busy "$p"; then
printf " %-9s %-6s free\n" "$name" "$p"
elif echo "$ours" | grep -qx "$p"; then
printf " %-9s %-6s in use by this environment's cluster\n" "$name" "$p"
else
printf " ! %-9s %-6s IN USE by something else\n" "$name" "$p"
clash=1
fi
done
if [ "$clash" -eq 1 ]; then
echo " override the clashing one in ctrl/.env, e.g. HTTP_PORT=21080"
echo " (or rename this directory — the whole block follows the name)"
fi

54
rig/ctrl/versions.env Normal file
View File

@@ -0,0 +1,54 @@
# Pinned toolchain — the single manifest the wizard installs from.
# Every entry is a single binary; none of them needs an apt repo.
# kubectl fully static
# kind libc only
# tilt libc + libstdc++ + libgcc (present in base Debian)
# jq upstream static build (Debian's is linked against libjq/libonig)
#
# Checksums are the upstream-published SHA256 of the linux/amd64 artifact.
# To bump: change the version, then re-run `bash ctrl/versions-refresh.sh` and
# commit the result — never hand-edit a checksum.
KIND_VERSION=v0.32.0
KIND_SHA256=50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54
KIND_URL=https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64
KUBECTL_VERSION=v1.36.3
KUBECTL_SHA256=ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336
KUBECTL_URL=https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl
TILT_VERSION=0.37.6
TILT_SHA256=e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6
TILT_URL=https://github.com/tilt-dev/tilt/releases/download/v${TILT_VERSION}/tilt.${TILT_VERSION}.linux.x86_64.tar.gz
JQ_VERSION=1.8.2
JQ_SHA256=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f
JQ_URL=https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64
# Node images shipped with KIND_VERSION above, pinned by digest so a kind upgrade
# can never silently move the k8s version. Profiles select one via K8S_VERSION.
# Older entries are kept deliberately: running a trailing-edge control plane is
# part of simulating a legacy estate.
NODE_IMAGE_v1_36=kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
NODE_IMAGE_v1_35=kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95
NODE_IMAGE_v1_34=kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256
NODE_IMAGE_v1_33=kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4
# Images pulled at runtime (registry, mocks). Pinned by tag; the registry mode
# decides where they are pulled FROM.
REGISTRY_IMAGE=registry:2
STUB_IMAGE=python:3.12-slim
# Addons, installed by ctrl/addons/<name>.sh when listed in a profile's ADDONS.
CERT_MANAGER_VERSION=v1.21.1
METRICS_SERVER_VERSION=v0.9.0
METALLB_VERSION=v0.16.0
# Dependency containers. These mirror soleprint's cabinets
# (soleprint/station/cabinets/), so a room that declares postgres gets the same
# thing whether it runs on compose or in the cluster. Pinned by tag rather than
# digest because they are ordinary upstream images with no supply chain claim
# attached — bump freely, and preload them for the offline profile.
POSTGRES_IMAGE=postgres:16-alpine
REDIS_IMAGE=redis:7-alpine
AIRFLOW_IMAGE=apache/airflow:2.10.4

381
rig/ctrl/wizard.sh Executable file
View File

@@ -0,0 +1,381 @@
#!/usr/bin/env bash
# The installation wizard: detect the host, install a pinned toolchain onto it,
# then report what it could not do. It never runs the cluster and never mutates
# the host outside the directories mounted into it.
#
# Usage (normally via `make station` / `make deps`, or directly):
# wizard.sh detect # report host facts only, change nothing
# wizard.sh fetch [core|dev] [--to DIR] # download + verify into DIR
# wizard.sh install [core|dev] # detect, fetch, install, report
#
# Tiers: 'core' is kubectl + jq (talk to a cluster); 'dev' adds kind and tilt
# Default is dev.
#
# Runs both inside the wizard container and bare on a host. Inside the
# container, host files are read through $HOST_ROOT (mount / as :ro); bare, it
# falls back to /.
set -euo pipefail
# Keep the caller's cwd so a relative --to resolves where the user expects,
# not against ctrl/ once we've moved.
INVOKED_FROM="$PWD"
cd "$(dirname "$0")"
source ./versions.env
# Resolve a possibly-relative path against the caller's original directory.
abspath() {
case "$1" in
/*) echo "$1" ;;
*) echo "$INVOKED_FROM/$1" ;;
esac
}
OUT_BIN="${OUT_BIN:-$HOME/.local/bin}"
HOST_ROOT="${HOST_ROOT:-/}"
DEPS_SOURCE="${DEPS_SOURCE:-upstream}"
DEPS_ARTIFACTORY_URL="${DEPS_ARTIFACTORY_URL:-}"
BAKED_BIN="${BAKED_BIN:-/opt/rig/bin}"
# Collected by detect(), printed by report_manual() at the very end.
MANUAL=()
# Host FILES (/etc/..., /mnt/c/...) must be read through the mount. Kernel-level
# facts (kernel version, meminfo, inotify) are shared with the container, so the
# container's own view is already the host's.
host_file() {
local p="${1#/}"
if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then
echo "$HOST_ROOT/$p"
else
echo "/$p"
fi
}
# ── detect ─────────────────────────────────────────────────────────────────
is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; }
detect() {
echo "host"
echo " kernel $(uname -r)"
local osr; osr=$(host_file /etc/os-release)
[ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")"
local total_kb avail_kb
total_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
avail_kb=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
printf " memory %d GB total, %d GB available\n" \
$((total_kb / 1024 / 1024)) $((avail_kb / 1024 / 1024))
if [ $((avail_kb / 1024 / 1024)) -lt 4 ]; then
echo " ! under 4 GB available — a multi-node profile will struggle."
echo " 'make cluster list' shows the others; 'make cluster free' stops them."
fi
detect_wsl
detect_docker
detect_inotify
}
detect_wsl() {
if ! is_wsl; then
echo " platform native linux"
return
fi
echo " platform WSL"
# systemd is off by default in WSL, and the ingress/DNS paths that use a
# host service need it. Enabling it requires a Windows-side restart, which
# cannot be issued from inside the distro.
local wc; wc=$(host_file /etc/wsl.conf)
if [ -r "$wc" ] && grep -qE '^\s*systemd\s*=\s*true' "$wc"; then
echo " systemd enabled in wsl.conf"
else
echo " ! systemd not enabled in /etc/wsl.conf"
MANUAL+=("Enable systemd — add to /etc/wsl.conf:
[boot]
systemd=true
then from a WINDOWS terminal (not this shell): wsl --shutdown")
fi
# WSL regenerates /etc/resolv.conf on every boot, which silently reverts any
# local DNS setup.
if [ -r "$wc" ] && grep -qE '^\s*generateResolvConf\s*=\s*false' "$wc"; then
echo " resolv.conf pinned (generateResolvConf=false)"
else
echo " - resolv.conf is WSL-generated; DNS_MODE=dnsmasq would be reverted on reboot"
fi
local wcfg
wcfg=$(ls "$HOST_ROOT"/mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true)
if [ -n "$wcfg" ] && grep -qE '^\s*memory\s*=' "$wcfg"; then
echo " wslconfig memory set: $(grep -E '^\s*memory\s*=' "$wcfg" | tr -d ' ')"
else
MANUAL+=("Cap/raise the WSL VM memory — in %USERPROFILE%\\.wslconfig on Windows:
[wsl2]
memory=8GB
then from a WINDOWS terminal: wsl --shutdown")
fi
}
detect_docker() {
# Reachability of the daemon is the real question, and the CLI is only how
# we ask it. Note that when this runs inside the wizard container, Docker
# necessarily exists on the host — otherwise nothing would be executing —
# so a missing CLI in here is a wizard packaging bug, not a host problem.
if ! command -v docker >/dev/null 2>&1; then
if [ -S /var/run/docker.sock ]; then
echo " docker socket present (no cli in this context)"
else
echo " ! docker not found and no socket at /var/run/docker.sock"
MANUAL+=("Install Docker — the one true prerequisite:
sudo apt-get install -y docker.io && sudo usermod -aG docker \"\$USER\"
then log out and back in.")
fi
return
fi
if docker info >/dev/null 2>&1; then
echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null)"
local n
n=$(docker ps --filter "label=io.x-k8s.kind.cluster" --format '{{.Names}}' 2>/dev/null | wc -l)
# Must be an `if`, not `[ ] && echo`: as the last statement in this
# function the latter returns 1 when the count is zero, and `set -e`
# then kills the caller. That is the fresh-machine case — no clusters
# yet — so the bug only ever shows up where it does most harm.
if [ "$n" -gt 0 ]; then
echo " - $n kind node container(s) already running; see 'make cluster list'"
fi
else
echo " ! docker cli present but the daemon is unreachable"
MANUAL+=("Start Docker, or add yourself to the docker group:
sudo usermod -aG docker \"\$USER\" # then log out and back in")
fi
}
# kind and Tilt both watch large trees. WSL ships defaults (8192/128) far too low,
# and the failure mode is silent: Tilt simply stops noticing file changes.
detect_inotify() {
local w i
w=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null || echo 0)
i=$(cat /proc/sys/fs/inotify/max_user_instances 2>/dev/null || echo 0)
echo " inotify watches=$w instances=$i"
if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then
echo " ! inotify limits are low — Tilt will silently stop noticing file changes"
MANUAL+=("Raise inotify limits (needs root on the host):
echo -e 'fs.inotify.max_user_watches=524288\\nfs.inotify.max_user_instances=512' \\
| sudo tee /etc/sysctl.d/99-rig.conf
sudo sysctl --system")
fi
}
# ── fetch ──────────────────────────────────────────────────────────────────
# Resolve where a given artifact comes from, honouring DEPS_SOURCE.
resolve_url() {
local upstream="$1"
case "$DEPS_SOURCE" in
upstream) echo "$upstream" ;;
artifactory)
if [ -z "$DEPS_ARTIFACTORY_URL" ]; then
echo "DEPS_SOURCE=artifactory but DEPS_ARTIFACTORY_URL is empty" >&2
exit 1
fi
echo "${DEPS_ARTIFACTORY_URL%/}/$(basename "$upstream")"
;;
*) echo "unsupported DEPS_SOURCE '$DEPS_SOURCE' for a download" >&2; exit 1 ;;
esac
}
verify() {
local file="$1" want="$2" name="$3" got
got=$(sha256sum "$file" | awk '{print $1}')
if [ "$got" != "$want" ]; then
echo "checksum mismatch for $name" >&2
echo " expected $want" >&2
echo " got $got" >&2
exit 1
fi
}
# fetch_bin <name> <url> <sha256> <dest-dir> — a bare binary
fetch_bin() {
local name="$1" url="$2" sha="$3" dest="$4"
local tmp="$dest/.$name.tmp"
echo " fetching $name"
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
verify "$tmp" "$sha" "$name"
mv "$tmp" "$dest/$name"
chmod +x "$dest/$name"
}
# fetch_tgz <name> <url> <sha256> <dest-dir> <path-inside-archive> <strip>
# Archive layouts differ — tilt's is flat (the binary at the root, strip=0),
# others nest it a directory down — so the caller says which.
fetch_tgz() {
local name="$1" url="$2" sha="$3" dest="$4" inner="$5" strip="$6"
local tmp="$dest/.$name.tgz"
echo " fetching $name"
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
verify "$tmp" "$sha" "$name"
# --no-same-owner: extracting as root would otherwise restore the uid/gid
# baked into the archive (some ship as uid 1001), leaving a binary the host
# user does not own.
tar -xzf "$tmp" -C "$dest" --strip-components="$strip" --no-same-owner "$inner"
rm -f "$tmp"
chmod +x "$dest/$name"
}
# The wizard runs as root so it can reach the docker socket, which means
# everything it writes into a mounted volume lands root-owned and unusable from
# the host. Hand it back to whoever owns the mount point (the host user created
# that directory before mounting it).
fix_ownership() {
local dir="$1"
[ -d "$dir" ] || return 0
local owner="${HOST_UID:-}:${HOST_GID:-}"
if [ "$owner" = ":" ]; then
owner=$(stat -c '%u:%g' "$dir")
fi
[ "$owner" = "0:0" ] && return 0
chown -R "$owner" "$dir" 2>/dev/null || true
}
# Two tiers, because not every machine should get cluster tooling.
#
# core kubectl, jq — talk to a cluster someone else runs. Nothing that
# creates one. Appropriate on a managed or corporate-issued machine
# where development tools are not wanted by default.
# dev core plus kind and tilt — build clusters and hot-reload into them.
#
# The split exists because "install the toolchain" is not one decision: on a
# managed workspace the right answer is kubectl and nothing else.
CORE_TOOLS="kubectl jq"
# No helm: every addon installs with `kubectl apply -f <url>`, so nothing here
# has ever invoked it. Add it back the day something actually needs a chart.
DEV_TOOLS="kind tilt"
fetch() {
local dest="$OUT_BIN" tier="${TIER:-dev}"
while [ $# -gt 0 ]; do
case "$1" in
--to) dest="$2"; shift 2 ;;
core|dev) tier="$1"; shift ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
dest="$(abspath "$dest")"
mkdir -p "$dest"
TIER="$tier"
if [ "$DEPS_SOURCE" = "baked" ]; then
echo "installing baked binaries from $BAKED_BIN"
cp -a "$BAKED_BIN"/. "$dest"/
fix_ownership "$dest"
return
fi
echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)"
fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"
fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"
if [ "$tier" = "dev" ]; then
fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"
fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0
fi
fix_ownership "$dest"
# kind writes the kubeconfig as root too; hand that back as well when it's
# a mounted host directory rather than container-local state.
fix_ownership "${KUBE_DIR:-/out/kube}"
}
# ── install ────────────────────────────────────────────────────────────────
report_manual() {
echo
if [ ${#MANUAL[@]} -eq 0 ]; then
echo "nothing left to do by hand."
return
fi
echo "host actions the wizard cannot perform (${#MANUAL[@]}):"
echo
local n=1
for m in "${MANUAL[@]}"; do
echo " $n. $m"
echo
n=$((n + 1))
done
}
# Installing into a directory that sits early in PATH silently replaces whatever
# the machine was already using — which on a shared or client machine can break
# unrelated work (kubectl more than one minor away from a cluster is the common
# one). Say so; never decide it for them.
tier_tools() { [ "$1" = "core" ] && echo "$CORE_TOOLS" || echo "$CORE_TOOLS $DEV_TOOLS"; }
warn_shadowing() {
local b existing shadowed="" tier="${1:-dev}"
for b in $(tier_tools "$tier"); do
[ -x "$OUT_BIN/$b" ] || continue
# Where would this resolve if OUT_BIN weren't in the way?
existing=$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -vx "$OUT_BIN" | paste -sd:) \
command -v "$b" 2>/dev/null || true)
[ -n "$existing" ] || continue
[ "$existing" = "$OUT_BIN/$b" ] && continue
shadowed+=" $b $existing"$'\n'
done
[ -n "$shadowed" ] || return 0
case ":${PATH}:" in
*":$OUT_BIN:"*) ;;
*) return 0 ;; # not on PATH yet, so nothing is being shadowed
esac
echo
echo " ! these were already installed elsewhere and are now shadowed by $OUT_BIN:"
printf '%s' "$shadowed"
echo " Other projects on this machine will pick up the new versions."
MANUAL+=("Decide which toolchain wins. To keep the previous one, remove what
was just installed:
rm -f $(for b in $(tier_tools "$tier"); do printf '%s ' "$OUT_BIN/$b"; done)
Or install somewhere private instead:
OUT_BIN=\$PWD/def/bin make deps # then put that dir first in PATH")
}
install() {
local tier="${1:-dev}"
detect
echo
fetch "$tier"
echo
echo "installed to $OUT_BIN ($tier):"
for b in $(tier_tools "$tier"); do
[ -x "$OUT_BIN/$b" ] && echo " $b"
done
if [ "$tier" = "core" ]; then
echo " (no kind/tilt — 'make deps dev' adds them)"
fi
warn_shadowing "$tier"
case ":${PATH}:" in
*":$OUT_BIN:"*) ;;
*) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc:
export PATH=\"${OUT_BIN}:\$PATH\"") ;;
esac
report_manual
}
# ── main ───────────────────────────────────────────────────────────────────
case "${1:-install}" in
detect) detect; report_manual ;;
fetch) shift; fetch "$@" ;;
install) shift; install "${1:-dev}" ;;
*) echo "usage: $0 [detect|fetch|install]" >&2; exit 1 ;;
esac

View File

@@ -0,0 +1,47 @@
digraph rig_install {
rankdir=LR
bgcolor="#0a0e17"
fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#1e2a4a" fontcolor="#e8eaf0" shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
label="Installation — the only host prerequisite is Docker"
labelloc=t
fontsize=16
fontcolor="#0066ff"
subgraph cluster_host {
label="Your machine"
style=dashed
color="#1e2a4a"
fontcolor="#8892a8"
docker [label="Docker\n(the one prerequisite)" fillcolor="#1a1a3a" fontcolor="#0066ff" shape=octagon]
bin [label="~/.local/bin\nkind · kubectl · tilt\njq" fillcolor="#121829" shape=cylinder]
}
subgraph cluster_wizard {
label="Installer container (transient)"
style=dashed
color="#1e2a4a"
fontcolor="#8892a8"
wizard [label="wizard\ncurl · jq · python · graphviz" fillcolor="#121829"]
detect [label="detect host\nWSL · memory · inotify · docker" fillcolor="#121829"]
fetch [label="fetch + verify\nSHA256, pinned versions" fillcolor="#121829"]
}
upstream [label="upstream\nreleases / corporate mirror" fillcolor="#1a3a1a" fontcolor="#00c853" shape=octagon]
report [label="report what it\nCANNOT do" fillcolor="#3a1a1a" fontcolor="#ffc107"]
docker -> wizard [label="docker run"]
wizard -> detect
detect -> fetch
fetch -> upstream [label="pinned + checksummed" color="#00c853"]
fetch -> bin [label="install"]
detect -> report [style=dashed label="sudo / Windows-side steps" color="#ffc107"]
// The container is gone after this; nothing depends on it at run time.
wizard -> gone [style=dotted label="exits"]
gone [label="(container discarded)" fillcolor="#0a0e17" fontcolor="#4a5568" color="#1e2a4a" style="filled,dashed"]
}

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: rig_install Pages: 1 -->
<svg width="1145pt" height="287pt"
viewBox="0.00 0.00 1145.00 287.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 283.29)">
<title>rig_install</title>
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-283.29 1141.06,-283.29 1141.06,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="568.53" y="-260.09" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#0066ff">Installation — the only host prerequisite is Docker</text>
<g id="clust1" class="cluster">
<title>cluster_host</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="898.49,-8 898.49,-190 1123.82,-190 1123.82,-8 898.49,-8"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-170.8" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Your machine</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_wizard</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="8,-95 8,-175 745.5,-175 745.5,-95 8,-95"/>
<text xml:space="preserve" text-anchor="middle" x="376.75" y="-155.8" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Installer container (transient)</text>
</g>
<!-- docker -->
<g id="node1" class="node">
<title>docker</title>
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="1115.82,-31.9 1115.82,-54.1 1054.51,-69.79 967.8,-69.79 906.49,-54.1 906.49,-31.9 967.8,-16.21 1054.51,-16.21 1115.82,-31.9"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-46.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#0066ff">Docker</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-32.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#0066ff">(the one prerequisite)</text>
</g>
<!-- wizard -->
<g id="node3" class="node">
<title>wizard</title>
<polygon fill="#121829" stroke="#1e2a4a" points="181.25,-139 16,-139 16,-103 181.25,-103 181.25,-139"/>
<text xml:space="preserve" text-anchor="middle" x="98.62" y="-124.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">wizard</text>
<text xml:space="preserve" text-anchor="middle" x="98.62" y="-110.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">curl · jq · python · graphviz</text>
</g>
<!-- docker&#45;&gt;wizard -->
<g id="edge1" class="edge">
<title>docker&#45;&gt;wizard</title>
<path fill="none" stroke="#4a5568" d="M906.12,-45.7C757.47,-50.51 475.98,-63.12 238.25,-94 223.38,-95.93 207.7,-98.48 192.45,-101.24"/>
<polygon fill="#4a5568" stroke="#4a5568" points="192.13,-97.74 182.93,-103.01 193.4,-104.63 192.13,-97.74"/>
<text xml:space="preserve" text-anchor="middle" x="506.62" y="-74.39" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">docker run</text>
</g>
<!-- bin -->
<g id="node2" class="node">
<title>bin</title>
<path fill="#121829" stroke="#1e2a4a" d="M1069.03,-148.28C1069.03,-151.63 1043.09,-154.34 1011.15,-154.34 979.22,-154.34 953.28,-151.63 953.28,-148.28 953.28,-148.28 953.28,-93.72 953.28,-93.72 953.28,-90.37 979.22,-87.66 1011.15,-87.66 1043.09,-87.66 1069.03,-90.37 1069.03,-93.72 1069.03,-93.72 1069.03,-148.28 1069.03,-148.28"/>
<path fill="none" stroke="#1e2a4a" d="M1069.03,-148.28C1069.03,-144.94 1043.09,-142.22 1011.15,-142.22 979.22,-142.22 953.28,-144.94 953.28,-148.28"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-130.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">~/.local/bin</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-117.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">kind · kubectl · tilt</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-103.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">jq</text>
</g>
<!-- detect -->
<g id="node4" class="node">
<title>detect</title>
<polygon fill="#121829" stroke="#1e2a4a" points="429,-139 238.25,-139 238.25,-103 429,-103 429,-139"/>
<text xml:space="preserve" text-anchor="middle" x="333.62" y="-124.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">detect host</text>
<text xml:space="preserve" text-anchor="middle" x="333.62" y="-110.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">WSL · memory · inotify · docker</text>
</g>
<!-- wizard&#45;&gt;detect -->
<g id="edge2" class="edge">
<title>wizard&#45;&gt;detect</title>
<path fill="none" stroke="#4a5568" d="M181.44,-121C195.97,-121 211.28,-121 226.37,-121"/>
<polygon fill="#4a5568" stroke="#4a5568" points="226.33,-124.5 236.33,-121 226.33,-117.5 226.33,-124.5"/>
</g>
<!-- gone -->
<g id="node8" class="node">
<title>gone</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="400.5,-47 266.75,-47 266.75,-11 400.5,-11 400.5,-47"/>
<text xml:space="preserve" text-anchor="middle" x="333.62" y="-25.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#4a5568">(container discarded)</text>
</g>
<!-- wizard&#45;&gt;gone -->
<g id="edge7" class="edge">
<title>wizard&#45;&gt;gone</title>
<path fill="none" stroke="#4a5568" stroke-dasharray="1,5" d="M119.29,-102.62C138.26,-86 168.54,-62.29 199.25,-49.75 216.76,-42.6 236.49,-37.9 255.29,-34.81"/>
<polygon fill="#4a5568" stroke="#4a5568" points="255.67,-38.29 265.04,-33.35 254.64,-31.37 255.67,-38.29"/>
<text xml:space="preserve" text-anchor="middle" x="209.75" y="-52.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">exits</text>
</g>
<!-- fetch -->
<g id="node5" class="node">
<title>fetch</title>
<polygon fill="#121829" stroke="#1e2a4a" points="737.5,-139 584.25,-139 584.25,-103 737.5,-103 737.5,-139"/>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-124.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">fetch + verify</text>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-110.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">SHA256, pinned versions</text>
</g>
<!-- detect&#45;&gt;fetch -->
<g id="edge3" class="edge">
<title>detect&#45;&gt;fetch</title>
<path fill="none" stroke="#4a5568" d="M429.14,-121C474.43,-121 528.32,-121 572.63,-121"/>
<polygon fill="#4a5568" stroke="#4a5568" points="572.46,-124.5 582.46,-121 572.46,-117.5 572.46,-124.5"/>
</g>
<!-- report -->
<g id="node7" class="node">
<title>report</title>
<polygon fill="#3a1a1a" stroke="#1e2a4a" points="706.75,-219 615,-219 615,-183 706.75,-183 706.75,-219"/>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-204.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">report what it</text>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-190.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">CANNOT do</text>
</g>
<!-- detect&#45;&gt;report -->
<g id="edge6" class="edge">
<title>detect&#45;&gt;report</title>
<path fill="none" stroke="#ffc107" stroke-dasharray="5,2" d="M409.65,-139.45C468.85,-154.02 550.13,-174.01 603.78,-187.2"/>
<polygon fill="#ffc107" stroke="#ffc107" points="602.77,-190.56 613.32,-189.55 604.45,-183.76 602.77,-190.56"/>
<text xml:space="preserve" text-anchor="middle" x="506.62" y="-180.06" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">sudo / Windows&#45;side steps</text>
</g>
<!-- fetch&#45;&gt;bin -->
<g id="edge5" class="edge">
<title>fetch&#45;&gt;bin</title>
<path fill="none" stroke="#4a5568" d="M737.88,-121C798.58,-121 882.9,-121 941.56,-121"/>
<polygon fill="#4a5568" stroke="#4a5568" points="941.43,-124.5 951.43,-121 941.43,-117.5 941.43,-124.5"/>
<text xml:space="preserve" text-anchor="middle" x="811.38" y="-123.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">install</text>
</g>
<!-- upstream -->
<g id="node6" class="node">
<title>upstream</title>
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="1137.06,-213.9 1137.06,-236.1 1063.3,-251.79 959,-251.79 885.25,-236.1 885.25,-213.9 959,-198.21 1063.3,-198.21 1137.06,-213.9"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-228.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">upstream</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-214.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">releases / corporate mirror</text>
</g>
<!-- fetch&#45;&gt;upstream -->
<g id="edge4" class="edge">
<title>fetch&#45;&gt;upstream</title>
<path fill="none" stroke="#00c853" d="M714.8,-139.5C759.85,-154.95 826.43,-177.11 885.25,-194 894.79,-196.74 904.78,-199.46 914.78,-202.09"/>
<polygon fill="#00c853" stroke="#00c853" points="913.59,-205.4 924.15,-204.52 915.35,-198.62 913.59,-205.4"/>
<text xml:space="preserve" text-anchor="middle" x="811.38" y="-191.36" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">pinned + checksummed</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

Some files were not shown because too many files have changed in this diff Show More