spiriconfig 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spiriconfig-0.1.0/.github/workflows/publish.yml +42 -0
- spiriconfig-0.1.0/.gitignore +28 -0
- spiriconfig-0.1.0/.python-version +1 -0
- spiriconfig-0.1.0/.readthedocs.yaml +16 -0
- spiriconfig-0.1.0/NOTES-out-of-process-plugins.md +292 -0
- spiriconfig-0.1.0/PKG-INFO +159 -0
- spiriconfig-0.1.0/README.md +146 -0
- spiriconfig-0.1.0/docs/advanced.md +197 -0
- spiriconfig-0.1.0/docs/api.md +72 -0
- spiriconfig-0.1.0/docs/appstore.md +302 -0
- spiriconfig-0.1.0/docs/conf.py +47 -0
- spiriconfig-0.1.0/docs/configuration.md +167 -0
- spiriconfig-0.1.0/docs/design.md +343 -0
- spiriconfig-0.1.0/docs/docker.md +161 -0
- spiriconfig-0.1.0/docs/index.md +76 -0
- spiriconfig-0.1.0/docs/install.md +137 -0
- spiriconfig-0.1.0/docs/plugins.md +158 -0
- spiriconfig-0.1.0/examples/store/README.md +20 -0
- spiriconfig-0.1.0/examples/store/docs/README.md +1 -0
- spiriconfig-0.1.0/examples/store/grafana/compose.yaml +75 -0
- spiriconfig-0.1.0/examples/store/nextcloud/compose.yaml +35 -0
- spiriconfig-0.1.0/examples/store/traefik/compose.yaml +22 -0
- spiriconfig-0.1.0/examples/store/whoami/compose.yaml +11 -0
- spiriconfig-0.1.0/pyproject.toml +59 -0
- spiriconfig-0.1.0/scripts/test-data.sh +54 -0
- spiriconfig-0.1.0/src/spiriconfig/__init__.py +15 -0
- spiriconfig-0.1.0/src/spiriconfig/__main__.py +8 -0
- spiriconfig-0.1.0/src/spiriconfig/advanced.py +146 -0
- spiriconfig-0.1.0/src/spiriconfig/auth.py +246 -0
- spiriconfig-0.1.0/src/spiriconfig/cli.py +197 -0
- spiriconfig-0.1.0/src/spiriconfig/commands.py +495 -0
- spiriconfig-0.1.0/src/spiriconfig/config.py +138 -0
- spiriconfig-0.1.0/src/spiriconfig/logging.py +45 -0
- spiriconfig-0.1.0/src/spiriconfig/plugins.py +133 -0
- spiriconfig-0.1.0/src/spiriconfig/preferences.py +96 -0
- spiriconfig-0.1.0/src/spiriconfig/service.py +345 -0
- spiriconfig-0.1.0/src/spiriconfig/terminal.py +211 -0
- spiriconfig-0.1.0/src/spiriconfig/theme.py +108 -0
- spiriconfig-0.1.0/src/spiriconfig/tls.py +247 -0
- spiriconfig-0.1.0/src/spiriconfig/web.py +311 -0
- spiriconfig-0.1.0/src/spiriconfig_appstore/__init__.py +55 -0
- spiriconfig-0.1.0/src/spiriconfig_appstore/cli.py +611 -0
- spiriconfig-0.1.0/src/spiriconfig_appstore/config.py +73 -0
- spiriconfig-0.1.0/src/spiriconfig_appstore/credentials.py +307 -0
- spiriconfig-0.1.0/src/spiriconfig_appstore/installs.py +175 -0
- spiriconfig-0.1.0/src/spiriconfig_appstore/stores.py +672 -0
- spiriconfig-0.1.0/src/spiriconfig_appstore/web.py +827 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/__init__.py +41 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/cli.py +424 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/config.py +50 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/env.py +276 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/settings.py +630 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/stacks.py +674 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/web.py +796 -0
- spiriconfig-0.1.0/src/spiriconfig_docker/widgets.py +374 -0
- spiriconfig-0.1.0/src/spiriconfig_terminal/__init__.py +48 -0
- spiriconfig-0.1.0/src/spiriconfig_terminal/cli.py +53 -0
- spiriconfig-0.1.0/src/spiriconfig_terminal/config.py +40 -0
- spiriconfig-0.1.0/src/spiriconfig_terminal/shell.py +86 -0
- spiriconfig-0.1.0/src/spiriconfig_terminal/web.py +97 -0
- spiriconfig-0.1.0/src/spiriconfig_users/__init__.py +43 -0
- spiriconfig-0.1.0/src/spiriconfig_users/cli.py +211 -0
- spiriconfig-0.1.0/src/spiriconfig_users/config.py +52 -0
- spiriconfig-0.1.0/src/spiriconfig_users/users.py +331 -0
- spiriconfig-0.1.0/src/spiriconfig_users/web.py +425 -0
- spiriconfig-0.1.0/tests/__init__.py +0 -0
- spiriconfig-0.1.0/tests/conftest.py +186 -0
- spiriconfig-0.1.0/tests/test_advanced.py +279 -0
- spiriconfig-0.1.0/tests/test_appstore.py +891 -0
- spiriconfig-0.1.0/tests/test_auth.py +200 -0
- spiriconfig-0.1.0/tests/test_cli.py +187 -0
- spiriconfig-0.1.0/tests/test_commands.py +191 -0
- spiriconfig-0.1.0/tests/test_credentials.py +209 -0
- spiriconfig-0.1.0/tests/test_docker_web.py +939 -0
- spiriconfig-0.1.0/tests/test_plugins.py +135 -0
- spiriconfig-0.1.0/tests/test_service.py +197 -0
- spiriconfig-0.1.0/tests/test_settings.py +720 -0
- spiriconfig-0.1.0/tests/test_stacks.py +690 -0
- spiriconfig-0.1.0/tests/test_terminal.py +474 -0
- spiriconfig-0.1.0/tests/test_tls.py +123 -0
- spiriconfig-0.1.0/tests/test_users.py +216 -0
- spiriconfig-0.1.0/tests/test_users_web.py +61 -0
- spiriconfig-0.1.0/tests/test_web.py +132 -0
- spiriconfig-0.1.0/todo.md +37 -0
- spiriconfig-0.1.0/uv.lock +1704 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.13"
|
|
17
|
+
|
|
18
|
+
- name: Install build
|
|
19
|
+
run: pip install build
|
|
20
|
+
|
|
21
|
+
- name: Build package
|
|
22
|
+
run: python -m build
|
|
23
|
+
|
|
24
|
+
- uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/
|
|
28
|
+
|
|
29
|
+
publish:
|
|
30
|
+
needs: build
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment: pypi
|
|
33
|
+
permissions:
|
|
34
|
+
id-token: write
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
- name: Publish to PyPI
|
|
42
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Sphinx
|
|
13
|
+
docs/_build/
|
|
14
|
+
|
|
15
|
+
# Test artifacts
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
|
|
18
|
+
# Built by ./scripts/test-data.sh: a throwaway compose directory and a git app
|
|
19
|
+
# store to install from. Disposable by design -- the defaults in
|
|
20
|
+
# spiriconfig_docker.config and spiriconfig_appstore.config point here so that a
|
|
21
|
+
# checkout never touches /srv/compose on a real machine.
|
|
22
|
+
/test_data/
|
|
23
|
+
|
|
24
|
+
# NiceGUI's on-disk storage for the running dev server.
|
|
25
|
+
.nicegui/
|
|
26
|
+
|
|
27
|
+
# aider local tooling state
|
|
28
|
+
.aider*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
|
|
3
|
+
build:
|
|
4
|
+
os: ubuntu-24.04
|
|
5
|
+
tools:
|
|
6
|
+
python: "3.13"
|
|
7
|
+
jobs:
|
|
8
|
+
install:
|
|
9
|
+
- pip install uv
|
|
10
|
+
- uv sync --all-groups
|
|
11
|
+
build:
|
|
12
|
+
html:
|
|
13
|
+
- uv run sphinx-build -b html docs $READTHEDOCS_OUTPUT/html
|
|
14
|
+
|
|
15
|
+
sphinx:
|
|
16
|
+
configuration: docs/conf.py
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# Notes: out-of-process plugins
|
|
2
|
+
|
|
3
|
+
Scratch. Not documentation. Nothing here is decided.
|
|
4
|
+
|
|
5
|
+
Working assumption: **a plugin is a container, and may be written in any language.**
|
|
6
|
+
Third pass; the first two chased the wrong motivation and are worth not repeating.
|
|
7
|
+
|
|
8
|
+
## This is not a sandbox
|
|
9
|
+
|
|
10
|
+
Stating the premise first, because I derived a whole security architecture from its
|
|
11
|
+
absence and had to throw it away, and future-me will be tempted to do it again.
|
|
12
|
+
|
|
13
|
+
**You are assumed to have root on machines you own.** A plugin is trusted code that
|
|
14
|
+
the operator chose to install. We are not defending against a hostile plugin, and we
|
|
15
|
+
should not build anything that implies we are — a security boundary we half-mean is
|
|
16
|
+
worse than none, because people will lean on it.
|
|
17
|
+
|
|
18
|
+
Consequences, all of them freeing:
|
|
19
|
+
|
|
20
|
+
- A plugin bind-mounts whatever it needs. `/var/run/docker.sock`, `/srv/compose`,
|
|
21
|
+
`/etc`. That's not a hole in the design, it's the design.
|
|
22
|
+
- No capability API, no `Command`-over-RPC, no confirmation dialog as a security
|
|
23
|
+
mechanism. (Drafted all three. Deleted all three. They only existed to defend a
|
|
24
|
+
boundary we don't want.)
|
|
25
|
+
- Same-origin frontend "isolation" is not a goal. We couldn't have it anyway — CSRF
|
|
26
|
+
is unpreventable same-origin, and an iframe `sandbox` that made the child a foreign
|
|
27
|
+
origin would cost us URL sync to buy us protection from code we already trust.
|
|
28
|
+
|
|
29
|
+
So the same-origin question I flagged last pass as "the most important open question"
|
|
30
|
+
is **closed**: same origin, take the deep links, the plugin's JS can touch the shell
|
|
31
|
+
and that's fine.
|
|
32
|
+
|
|
33
|
+
## The actual motivation: version isolation
|
|
34
|
+
|
|
35
|
+
The thing that hurts today, and the reason the original phrasing was *"a plugin means
|
|
36
|
+
tightly coupling third-party code to my project"*:
|
|
37
|
+
|
|
38
|
+
An entry-point plugin shares our interpreter, so it shares **our resolved dependency
|
|
39
|
+
set**. There is exactly one environment, and everything in it must agree.
|
|
40
|
+
|
|
41
|
+
- A plugin cannot depend on a nicegui, pydantic, or typer we didn't pick.
|
|
42
|
+
- Two plugins with conflicting pins cannot coexist. Neither can be installed second.
|
|
43
|
+
- **We cannot bump nicegui without potentially breaking every third-party plugin**,
|
|
44
|
+
and no plugin can upgrade independently of us. The whole ecosystem is welded to one
|
|
45
|
+
`uv lock`.
|
|
46
|
+
- A plugin cannot be written in Go. Obviously. But that's the same problem wearing a
|
|
47
|
+
bigger hat: we are dictating the plugin's entire runtime.
|
|
48
|
+
|
|
49
|
+
Containers dissolve all of it. Each plugin ships its own image with its own closure —
|
|
50
|
+
own language, own runtime, own deps — and the only thing shared is a wire contract.
|
|
51
|
+
We bump nicegui whenever we like. A plugin ships when it likes. Nobody's release
|
|
52
|
+
schedule is anybody else's problem.
|
|
53
|
+
|
|
54
|
+
That's the whole pitch. The rest of this doc is mechanism.
|
|
55
|
+
|
|
56
|
+
### Bonus, not the reason
|
|
57
|
+
|
|
58
|
+
Falls out for free, worth having, but don't lead with it:
|
|
59
|
+
|
|
60
|
+
- A plugin cannot **block the event loop** — the thing we genuinely cannot defend
|
|
61
|
+
against in-process. One synchronous `subprocess.run` in a plugin's `page()` today
|
|
62
|
+
freezes every user's UI, websockets included, so it doesn't even fail visibly. No
|
|
63
|
+
`try`/`except` saves us. A container can't do it.
|
|
64
|
+
- Crashes, OOM, fd exhaustion, `sys.exit()`, a segfault in some C extension. cgroups
|
|
65
|
+
and the process boundary cover what our `except` blocks can't.
|
|
66
|
+
|
|
67
|
+
## Shape
|
|
68
|
+
|
|
69
|
+
- plugin container serves HTTP on some port
|
|
70
|
+
- SpiriConfig reverse-proxies `/plugin/<name>/{path:path}` → the container,
|
|
71
|
+
injecting `X-Forwarded-Prefix: /plugin/<name>`
|
|
72
|
+
- shell renders `<iframe src="/plugin/<name>/">` filling the main area
|
|
73
|
+
|
|
74
|
+
Same origin (a path on us, not a port on localhost) keeps cookies working and lets
|
|
75
|
+
parent and child talk. That matters more than it looks — see "rejected: one port per
|
|
76
|
+
plugin".
|
|
77
|
+
|
|
78
|
+
An iframe is the only way to get a container's UI into our page at all. The
|
|
79
|
+
alternative is a data protocol where the plugin describes widgets and we render them,
|
|
80
|
+
which means inventing a cross-language UI toolkit. No.
|
|
81
|
+
|
|
82
|
+
## A plugin is an app with labels on it
|
|
83
|
+
|
|
84
|
+
If a plugin is a container, a plugin is **a compose app with some labels**, and we
|
|
85
|
+
already built an entire subsystem for installing compose apps from a git repo.
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
services:
|
|
89
|
+
ui:
|
|
90
|
+
image: ghcr.io/someone/spiriconfig-netplan
|
|
91
|
+
labels:
|
|
92
|
+
spiriconfig.plugin.name: netplan
|
|
93
|
+
spiriconfig.plugin.title: Network
|
|
94
|
+
spiriconfig.plugin.icon: lan
|
|
95
|
+
spiriconfig.plugin.port: "8080"
|
|
96
|
+
volumes:
|
|
97
|
+
- /etc/netplan:/etc/netplan # trusted code; mounts what it needs
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Follow it through — almost everything falls out:
|
|
101
|
+
|
|
102
|
+
| question | answer | who does the work |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| what plugins exist? | `docker ps --filter label=spiriconfig.plugin.name` | docker |
|
|
105
|
+
| install a plugin | install an app from a store | the app store, unchanged |
|
|
106
|
+
| uninstall | `rm` a symlink | the app store, unchanged |
|
|
107
|
+
| is it running? | is the container up? | docker |
|
|
108
|
+
| restart when it dies | `restart: unless-stopped` | docker |
|
|
109
|
+
| where did it come from? | `readlink` + `git` | the app store, unchanged |
|
|
110
|
+
| update it | `git merge` | the app store, unchanged |
|
|
111
|
+
|
|
112
|
+
**There is no state that is ours.** No plugin registry, no enabled/disabled, no
|
|
113
|
+
install manifest. Same argument design.md already makes three times, pointed at
|
|
114
|
+
plugins. The plugin system doesn't need building so much as *noticing* — it's the app
|
|
115
|
+
store plus a label convention.
|
|
116
|
+
|
|
117
|
+
Install instructions for a plugin author become "add it to your app store", which
|
|
118
|
+
users already know how to do.
|
|
119
|
+
|
|
120
|
+
## The contract
|
|
121
|
+
|
|
122
|
+
Three things, and they're all boring on purpose. Boring is what makes them
|
|
123
|
+
implementable in Go.
|
|
124
|
+
|
|
125
|
+
1. **Declare yourself** with labels (above).
|
|
126
|
+
2. **Work behind a reverse proxy at a subpath**, honouring `X-Forwarded-Prefix`.
|
|
127
|
+
3. **Include one script tag**, if you want deep links and our theme.
|
|
128
|
+
|
|
129
|
+
### On (2): the adoption tax
|
|
130
|
+
|
|
131
|
+
This is the real cost of the design and it's worth being clear-eyed. "Work correctly
|
|
132
|
+
under a path prefix" is a standard ask, and plenty of apps get it wrong — Flask needs
|
|
133
|
+
`ProxyFix`, anything that hardcodes `/static/…` breaks. A plugin author's first bug
|
|
134
|
+
will be this bug.
|
|
135
|
+
|
|
136
|
+
NiceGUI happens to be *excellent* at it. Verified against the 3.14.0 in `.venv`
|
|
137
|
+
rather than from memory, since it decides viability:
|
|
138
|
+
|
|
139
|
+
| what | where |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| reads `X-Forwarded-Prefix`, threads it into the page | `client.py:198` |
|
|
142
|
+
| assets, importmap, components | `dependencies.py:235-274` |
|
|
143
|
+
| favicon | `favicon.py:26` |
|
|
144
|
+
| socket.io connection path | `static/nicegui.js:422` |
|
|
145
|
+
| `ui.navigate.to` / `ui.download` | `static/nicegui.js:542-546` |
|
|
146
|
+
| redirect `Location` headers | `middlewares.py:12` |
|
|
147
|
+
|
|
148
|
+
A NiceGUI plugin with *no idea* it's behind a prefix emits correct URLs anyway,
|
|
149
|
+
purely from the header. Our bundled two would port with no URL work.
|
|
150
|
+
|
|
151
|
+
Its one gap — `link.py:29` writes `href` straight to the DOM, bypassing the JS that
|
|
152
|
+
prepends the prefix:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
ui.navigate.to('/routes') # -> /plugin/netplan/routes ✓
|
|
156
|
+
ui.link('Routes', target='/routes') # -> /routes ✗ escapes the iframe
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### On (3): `shell.js`
|
|
160
|
+
|
|
161
|
+
The polyglot answer to the deep-link problem. We serve one small script at a
|
|
162
|
+
well-known URL; the plugin adds one tag:
|
|
163
|
+
|
|
164
|
+
```html
|
|
165
|
+
<script src="/plugin-sdk/shell.js"></script>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
It does the history sync (below) and picks up the shell's theme, so a Go plugin
|
|
169
|
+
author gets both without writing either. Optional — a plugin that skips it still
|
|
170
|
+
works, it just sits at a URL that doesn't move and looks like itself.
|
|
171
|
+
|
|
172
|
+
Alternative was rewriting HTML responses in the proxy to inject it. That's a tarpit.
|
|
173
|
+
One script tag is a fair ask.
|
|
174
|
+
|
|
175
|
+
## iframe URL mechanics — the real work
|
|
176
|
+
|
|
177
|
+
Unchanged by any of the above, and still the highest-risk piece.
|
|
178
|
+
|
|
179
|
+
An iframe is a separate browsing context: its own document, its own URL. Not a widget
|
|
180
|
+
that renders someone's HTML into our page — a whole browser tab that happens to be
|
|
181
|
+
rectangle-shaped. **Two URLs at all times**, moving independently.
|
|
182
|
+
|
|
183
|
+
Shell at `robot.local/netplan`, iframe src `/plugin/netplan/`, so the iframe document
|
|
184
|
+
is at `robot.local/plugin/netplan/`. Inside it:
|
|
185
|
+
|
|
186
|
+
| href | resolves to | |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| `routes` | `/plugin/netplan/routes` | stays in |
|
|
189
|
+
| `./routes` | `/plugin/netplan/routes` | stays in |
|
|
190
|
+
| `/routes` | `/routes` | **escapes** |
|
|
191
|
+
|
|
192
|
+
Two consequences, complementary — which hints at fixing both at once:
|
|
193
|
+
|
|
194
|
+
**The address bar never moves.** User goes three levels deep, hits F5, lands back at
|
|
195
|
+
the iframe's original `src`. Position gone, and it could never have been bookmarked or
|
|
196
|
+
shared. As far as the browser is concerned they never left `/netplan`.
|
|
197
|
+
|
|
198
|
+
**But the back button does move.** Iframe navigations land on the tab's joint session
|
|
199
|
+
history — and for NiceGUI they're *real* navigations (`nicegui.js:542` does
|
|
200
|
+
`window.open(url, "_self")`, a full document load, not client-side routing). So the
|
|
201
|
+
user hits Back to leave the plugin and the *iframe* steps back a page while the
|
|
202
|
+
address bar sits there unchanged. We'd hit this on day one.
|
|
203
|
+
|
|
204
|
+
So: the URL doesn't track state that history *does* track. Make it track.
|
|
205
|
+
|
|
206
|
+
### The fix (unproven)
|
|
207
|
+
|
|
208
|
+
Same origin, so the child reaches the parent's `history` directly — no `postMessage`
|
|
209
|
+
handshake, no cooperation needed beyond the script tag.
|
|
210
|
+
|
|
211
|
+
- shell route becomes `/netplan/{sub:path}`, rendering `<iframe src="/plugin/netplan/{sub}">`
|
|
212
|
+
- `shell.js` mirrors the child's path up on load, via `parent.history.replaceState`
|
|
213
|
+
|
|
214
|
+
Address bar tracks the plugin; deep links and reload work because the shell rebuilds
|
|
215
|
+
the iframe src from its own path; `replaceState` not `pushState` so we don't
|
|
216
|
+
double-stack history.
|
|
217
|
+
|
|
218
|
+
**Highest-uncertainty thing in the document.** Well-trodden pattern, does work, but
|
|
219
|
+
"sync two browsing contexts' histories" has more edge cases than that bullet list
|
|
220
|
+
admits — chiefly what Back *should* do once the parent URL tracks properly. Build it
|
|
221
|
+
and click around before believing it.
|
|
222
|
+
|
|
223
|
+
## The CLI face
|
|
224
|
+
|
|
225
|
+
design.md: *"a plugin that offers a web page and no CLI is a bug in spirit."*
|
|
226
|
+
|
|
227
|
+
A container plugin can't add a Typer subcommand, and doesn't need to — the container's
|
|
228
|
+
entrypoint *is* its CLI:
|
|
229
|
+
|
|
230
|
+
```console
|
|
231
|
+
$ docker exec spiriconfig-netplan netplan-cli show
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
A command a human could have run, which is the whole test. `spiriconfig netplan ...`
|
|
235
|
+
can forward to it, and `--show` prints the `docker exec` line. The principle survives
|
|
236
|
+
unamended, which is a good sign we're not fighting the design.
|
|
237
|
+
|
|
238
|
+
## Open questions
|
|
239
|
+
|
|
240
|
+
- **Asset cache is keyed per prefix.** Each plugin's framework assets live under its
|
|
241
|
+
own prefix, so a browser caches Vue/Quasar once *per plugin*. Over loopback the
|
|
242
|
+
bandwidth is free — it's memory and parse time, N runtimes for N plugins. Measure
|
|
243
|
+
before worrying; on a robot it might matter.
|
|
244
|
+
- **Do the bundled two become containers?** They'd need the docker socket, which is
|
|
245
|
+
now fine. But it means shipping SpiriConfig means shipping images, and the dev loop
|
|
246
|
+
gets a build step. Keeping them in-process means two plugin systems, which is a
|
|
247
|
+
smell. Suspect the honest answer is that first-party plugins are privileged and we
|
|
248
|
+
*say so* — but it's unresolved and it affects the dev experience most.
|
|
249
|
+
- **The websocket proxy is the only real code.** HTTP proxying is trivial; the upgrade
|
|
250
|
+
needs an ASGI relay pumping frames both ways. ~100 lines. Needed for any live UI,
|
|
251
|
+
not just NiceGUI ones.
|
|
252
|
+
- **Dead plugin card.** `docker ps` says it's down → shell renders a card instead of
|
|
253
|
+
an iframe. Cheap, because docker already knows.
|
|
254
|
+
- **Version-skew the contract.** The thing we just decoupled will re-couple here if
|
|
255
|
+
we're careless: labels + prefix + `shell.js` is now a public API. It should be tiny
|
|
256
|
+
and it should be versioned (`spiriconfig.plugin.api: "1"`), precisely so we never
|
|
257
|
+
end up where we are today.
|
|
258
|
+
|
|
259
|
+
## Rejected
|
|
260
|
+
|
|
261
|
+
**One port per plugin.** Iframe `http://robot.local:9001/` directly; no prefix
|
|
262
|
+
contract, so the single biggest adoption tax disappears. Killed by the origin: a
|
|
263
|
+
different port is a different origin, so no shared cookies, no `window.parent` (history
|
|
264
|
+
sync needs `postMessage` and cooperation), a port to allocate and firewall per plugin,
|
|
265
|
+
and the plugin is reachable from the network directly, bypassing whatever auth the
|
|
266
|
+
shell grows. Isolation used to be the counter-argument *for* this; now that we don't
|
|
267
|
+
want isolation, its last advantage is gone. Stays rejected, more firmly than before.
|
|
268
|
+
|
|
269
|
+
**Entry points alongside containers.** Two contracts, two docs, and every author's
|
|
270
|
+
first question is "which kind do I write?". The container contract is a superset — a
|
|
271
|
+
Python plugin can be a container. If entry points survive it's as an explicitly
|
|
272
|
+
first-party, privileged mechanism, not a peer.
|
|
273
|
+
|
|
274
|
+
**A capability API (plugin POSTs us a `Command`, we run it).** Elegant — it reuses the
|
|
275
|
+
`Command`/`str(Command)` seam design.md already built — but its entire justification
|
|
276
|
+
was a security boundary. Without sandboxing it's a worse way for a plugin to run
|
|
277
|
+
`docker compose` than mounting the socket and running `docker compose`. Noted because
|
|
278
|
+
it's seductive and I want the reason it died to survive.
|
|
279
|
+
|
|
280
|
+
## Phasing
|
|
281
|
+
|
|
282
|
+
1. **Spike the iframe + history sync.** One hardcoded container serving anything,
|
|
283
|
+
proxied under a prefix, wired into the shell. Click around. Riskiest bit, cheapest
|
|
284
|
+
to test.
|
|
285
|
+
2. Proxy for real: HTTP + websocket, prefix injection.
|
|
286
|
+
3. Label discovery via `docker ps`. Should be small — the app store already installs.
|
|
287
|
+
4. `shell.js`.
|
|
288
|
+
5. Port a bundled plugin, or write a throwaway Go one to prove the polyglot claim
|
|
289
|
+
isn't theoretical. **This is the acceptance test for the whole idea** — if writing a
|
|
290
|
+
Go plugin isn't pleasant, none of the above was worth it.
|
|
291
|
+
|
|
292
|
+
Nothing below 2 matters if 1 is unpleasant.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spiriconfig
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Plugin-based configuration and container management
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: loguru>=0.7.3
|
|
7
|
+
Requires-Dist: nicegui>=3.14.0
|
|
8
|
+
Requires-Dist: pamela>=1.2.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.14.2
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: typer>=0.26.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# SpiriConfig
|
|
15
|
+
|
|
16
|
+
Plugin-based configuration and container management, built around one rule:
|
|
17
|
+
|
|
18
|
+
> **Anything SpiriConfig can do, you must be able to do without it.**
|
|
19
|
+
|
|
20
|
+
Press **Up** on a stack in the web UI and SpiriConfig runs this, showing you the
|
|
21
|
+
line as it goes, with a button to copy it:
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
$ cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
No database, no registry, no bespoke on-disk format. If SpiriConfig vanished
|
|
28
|
+
tomorrow, everything it manages would keep working -- and you would already know
|
|
29
|
+
the commands to manage it.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Every service SpiriConfig manages runs in a container; SpiriConfig itself cannot,
|
|
34
|
+
because the process that starts those containers on boot has to live on the host.
|
|
35
|
+
So it installs itself as a systemd service. You need
|
|
36
|
+
[uv](https://docs.astral.sh/uv/) on the machine, then:
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
$ uvx spiriconfig install
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That pulls the latest release from PyPI, writes a systemd unit and an environment
|
|
43
|
+
file, and enables and starts the service -- the web UI comes up on
|
|
44
|
+
http://localhost:8080 and returns on every boot. Add `--show` to print every
|
|
45
|
+
command it will run before it runs anything.
|
|
46
|
+
|
|
47
|
+
Run it as root for a system-wide service, or as a normal user for a
|
|
48
|
+
single-operator `systemctl --user` one; that choice *is* the security model, not
|
|
49
|
+
a preference. Update in place with `spiriconfig update`. See
|
|
50
|
+
[Installing SpiriConfig](docs/install.md) for the full walkthrough.
|
|
51
|
+
|
|
52
|
+
To track the latest development instead of a release, install from git -- the
|
|
53
|
+
positional argument is anything uv accepts:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
$ spiriconfig install git+https://github.com/spiri-robotics/SpiriConfig.git
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A git-branch install pins to a commit, so `spiriconfig update --reinstall`
|
|
60
|
+
refetches the branch when it moves.
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
Point it at your compose directory and start the web UI:
|
|
65
|
+
|
|
66
|
+
```console
|
|
67
|
+
$ export SPIRICONFIG_DOCKER_COMPOSE_DIR=/srv/compose
|
|
68
|
+
$ spiriconfig serve # web UI on http://localhost:8080
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Adding a service is making a directory. No CLI required -- that is the point:
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
$ mkdir -p /srv/compose/whoami
|
|
75
|
+
$ $EDITOR /srv/compose/whoami/compose.yaml
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
From the shell:
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
$ spiriconfig docker list
|
|
82
|
+
whoami running
|
|
83
|
+
grafana stopped
|
|
84
|
+
|
|
85
|
+
$ spiriconfig docker up whoami
|
|
86
|
+
$ spiriconfig docker logs whoami -f
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Not sure what a command will do? Ask, without running it:
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ spiriconfig docker up whoami --show
|
|
93
|
+
cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Because the compose project name is the directory name, these are the same
|
|
97
|
+
containers you get from running compose yourself -- SpiriConfig and a plain shell
|
|
98
|
+
can manage the same stack on the same afternoon without confusing each other.
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
Environment variables only. See [the docs](docs/configuration.md) for the full
|
|
103
|
+
list; the one you need is:
|
|
104
|
+
|
|
105
|
+
| Variable | Default | Meaning |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `SPIRICONFIG_DOCKER_COMPOSE_DIR` | `test_data/compose` | One subdirectory per compose project. Set it to `/srv/compose` on a real machine. |
|
|
108
|
+
|
|
109
|
+
The defaults are relative on purpose: running out of a checkout should not start
|
|
110
|
+
managing the containers on your actual box. See [configuration](docs/configuration.md).
|
|
111
|
+
|
|
112
|
+
## Plugins
|
|
113
|
+
|
|
114
|
+
The docker plugin is the only one that ships, and it is not special: it is
|
|
115
|
+
discovered through the `spiriconfig.plugins` entry point group exactly as yours
|
|
116
|
+
would be.
|
|
117
|
+
|
|
118
|
+
```toml
|
|
119
|
+
[project.entry-points."spiriconfig.plugins"]
|
|
120
|
+
tailscale = "spiriconfig_tailscale:TailscalePlugin"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Install the package and it appears in the CLI and the web UI. See
|
|
124
|
+
[docs/plugins.md](docs/plugins.md) for the interface and the rules, and
|
|
125
|
+
[docs/design.md](docs/design.md) for why the rules exist.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
Work from a checkout with [uv](https://docs.astral.sh/uv/):
|
|
130
|
+
|
|
131
|
+
```console
|
|
132
|
+
$ uv sync
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
You can drive the whole flow without touching your real machine. `test_data/` is
|
|
136
|
+
gitignored and disposable, and the default settings point at it, so trying
|
|
137
|
+
SpiriConfig out cannot start managing the containers on your box:
|
|
138
|
+
|
|
139
|
+
```console
|
|
140
|
+
$ ./scripts/test-data.sh # a compose dir + an example app store
|
|
141
|
+
$ uv run spiriconfig appstore sync
|
|
142
|
+
$ uv run spiriconfig appstore install whoami
|
|
143
|
+
$ uv run spiriconfig docker up whoami
|
|
144
|
+
$ curl localhost:8080
|
|
145
|
+
Hostname: 2d5bcd6f2629
|
|
146
|
+
GET / HTTP/1.1
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Run the tests and build the docs:
|
|
150
|
+
|
|
151
|
+
```console
|
|
152
|
+
$ uv run pytest # 92 tests
|
|
153
|
+
$ uv run sphinx-build -b html docs docs/_build # docs
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Tests that need a docker daemon are skipped when there is not one, so the suite
|
|
157
|
+
passes on a laptop with no docker and still means something on a machine that has
|
|
158
|
+
it. Most of them never need one anyway: the plugin *builds* commands and the tests
|
|
159
|
+
assert on the command line, which is the actual contract.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SpiriConfig
|
|
2
|
+
|
|
3
|
+
Plugin-based configuration and container management, built around one rule:
|
|
4
|
+
|
|
5
|
+
> **Anything SpiriConfig can do, you must be able to do without it.**
|
|
6
|
+
|
|
7
|
+
Press **Up** on a stack in the web UI and SpiriConfig runs this, showing you the
|
|
8
|
+
line as it goes, with a button to copy it:
|
|
9
|
+
|
|
10
|
+
```console
|
|
11
|
+
$ cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
No database, no registry, no bespoke on-disk format. If SpiriConfig vanished
|
|
15
|
+
tomorrow, everything it manages would keep working -- and you would already know
|
|
16
|
+
the commands to manage it.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Every service SpiriConfig manages runs in a container; SpiriConfig itself cannot,
|
|
21
|
+
because the process that starts those containers on boot has to live on the host.
|
|
22
|
+
So it installs itself as a systemd service. You need
|
|
23
|
+
[uv](https://docs.astral.sh/uv/) on the machine, then:
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ uvx spiriconfig install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That pulls the latest release from PyPI, writes a systemd unit and an environment
|
|
30
|
+
file, and enables and starts the service -- the web UI comes up on
|
|
31
|
+
http://localhost:8080 and returns on every boot. Add `--show` to print every
|
|
32
|
+
command it will run before it runs anything.
|
|
33
|
+
|
|
34
|
+
Run it as root for a system-wide service, or as a normal user for a
|
|
35
|
+
single-operator `systemctl --user` one; that choice *is* the security model, not
|
|
36
|
+
a preference. Update in place with `spiriconfig update`. See
|
|
37
|
+
[Installing SpiriConfig](docs/install.md) for the full walkthrough.
|
|
38
|
+
|
|
39
|
+
To track the latest development instead of a release, install from git -- the
|
|
40
|
+
positional argument is anything uv accepts:
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
$ spiriconfig install git+https://github.com/spiri-robotics/SpiriConfig.git
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A git-branch install pins to a commit, so `spiriconfig update --reinstall`
|
|
47
|
+
refetches the branch when it moves.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
Point it at your compose directory and start the web UI:
|
|
52
|
+
|
|
53
|
+
```console
|
|
54
|
+
$ export SPIRICONFIG_DOCKER_COMPOSE_DIR=/srv/compose
|
|
55
|
+
$ spiriconfig serve # web UI on http://localhost:8080
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Adding a service is making a directory. No CLI required -- that is the point:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
$ mkdir -p /srv/compose/whoami
|
|
62
|
+
$ $EDITOR /srv/compose/whoami/compose.yaml
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
From the shell:
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ spiriconfig docker list
|
|
69
|
+
whoami running
|
|
70
|
+
grafana stopped
|
|
71
|
+
|
|
72
|
+
$ spiriconfig docker up whoami
|
|
73
|
+
$ spiriconfig docker logs whoami -f
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Not sure what a command will do? Ask, without running it:
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
$ spiriconfig docker up whoami --show
|
|
80
|
+
cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Because the compose project name is the directory name, these are the same
|
|
84
|
+
containers you get from running compose yourself -- SpiriConfig and a plain shell
|
|
85
|
+
can manage the same stack on the same afternoon without confusing each other.
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
Environment variables only. See [the docs](docs/configuration.md) for the full
|
|
90
|
+
list; the one you need is:
|
|
91
|
+
|
|
92
|
+
| Variable | Default | Meaning |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `SPIRICONFIG_DOCKER_COMPOSE_DIR` | `test_data/compose` | One subdirectory per compose project. Set it to `/srv/compose` on a real machine. |
|
|
95
|
+
|
|
96
|
+
The defaults are relative on purpose: running out of a checkout should not start
|
|
97
|
+
managing the containers on your actual box. See [configuration](docs/configuration.md).
|
|
98
|
+
|
|
99
|
+
## Plugins
|
|
100
|
+
|
|
101
|
+
The docker plugin is the only one that ships, and it is not special: it is
|
|
102
|
+
discovered through the `spiriconfig.plugins` entry point group exactly as yours
|
|
103
|
+
would be.
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
[project.entry-points."spiriconfig.plugins"]
|
|
107
|
+
tailscale = "spiriconfig_tailscale:TailscalePlugin"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Install the package and it appears in the CLI and the web UI. See
|
|
111
|
+
[docs/plugins.md](docs/plugins.md) for the interface and the rules, and
|
|
112
|
+
[docs/design.md](docs/design.md) for why the rules exist.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
Work from a checkout with [uv](https://docs.astral.sh/uv/):
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
$ uv sync
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
You can drive the whole flow without touching your real machine. `test_data/` is
|
|
123
|
+
gitignored and disposable, and the default settings point at it, so trying
|
|
124
|
+
SpiriConfig out cannot start managing the containers on your box:
|
|
125
|
+
|
|
126
|
+
```console
|
|
127
|
+
$ ./scripts/test-data.sh # a compose dir + an example app store
|
|
128
|
+
$ uv run spiriconfig appstore sync
|
|
129
|
+
$ uv run spiriconfig appstore install whoami
|
|
130
|
+
$ uv run spiriconfig docker up whoami
|
|
131
|
+
$ curl localhost:8080
|
|
132
|
+
Hostname: 2d5bcd6f2629
|
|
133
|
+
GET / HTTP/1.1
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Run the tests and build the docs:
|
|
137
|
+
|
|
138
|
+
```console
|
|
139
|
+
$ uv run pytest # 92 tests
|
|
140
|
+
$ uv run sphinx-build -b html docs docs/_build # docs
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Tests that need a docker daemon are skipped when there is not one, so the suite
|
|
144
|
+
passes on a laptop with no docker and still means something on a machine that has
|
|
145
|
+
it. Most of them never need one anyway: the plugin *builds* commands and the tests
|
|
146
|
+
assert on the command line, which is the actual contract.
|