spiriconfig 0.1.0__py3-none-any.whl
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/__init__.py +15 -0
- spiriconfig/__main__.py +8 -0
- spiriconfig/advanced.py +146 -0
- spiriconfig/auth.py +246 -0
- spiriconfig/cli.py +197 -0
- spiriconfig/commands.py +495 -0
- spiriconfig/config.py +138 -0
- spiriconfig/logging.py +45 -0
- spiriconfig/plugins.py +133 -0
- spiriconfig/preferences.py +96 -0
- spiriconfig/service.py +345 -0
- spiriconfig/terminal.py +211 -0
- spiriconfig/theme.py +108 -0
- spiriconfig/tls.py +247 -0
- spiriconfig/web.py +311 -0
- spiriconfig-0.1.0.dist-info/METADATA +159 -0
- spiriconfig-0.1.0.dist-info/RECORD +44 -0
- spiriconfig-0.1.0.dist-info/WHEEL +4 -0
- spiriconfig-0.1.0.dist-info/entry_points.txt +8 -0
- spiriconfig_appstore/__init__.py +55 -0
- spiriconfig_appstore/cli.py +611 -0
- spiriconfig_appstore/config.py +73 -0
- spiriconfig_appstore/credentials.py +307 -0
- spiriconfig_appstore/installs.py +175 -0
- spiriconfig_appstore/stores.py +672 -0
- spiriconfig_appstore/web.py +827 -0
- spiriconfig_docker/__init__.py +41 -0
- spiriconfig_docker/cli.py +424 -0
- spiriconfig_docker/config.py +50 -0
- spiriconfig_docker/env.py +276 -0
- spiriconfig_docker/settings.py +630 -0
- spiriconfig_docker/stacks.py +674 -0
- spiriconfig_docker/web.py +796 -0
- spiriconfig_docker/widgets.py +374 -0
- spiriconfig_terminal/__init__.py +48 -0
- spiriconfig_terminal/cli.py +53 -0
- spiriconfig_terminal/config.py +40 -0
- spiriconfig_terminal/shell.py +86 -0
- spiriconfig_terminal/web.py +97 -0
- spiriconfig_users/__init__.py +43 -0
- spiriconfig_users/cli.py +211 -0
- spiriconfig_users/config.py +52 -0
- spiriconfig_users/users.py +331 -0
- spiriconfig_users/web.py +425 -0
spiriconfig/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SpiriConfig: plugin-based configuration and container management.
|
|
2
|
+
|
|
3
|
+
The core is deliberately small. It discovers plugins, gives them a CLI to hang
|
|
4
|
+
subcommands off and a web UI to render pages into, and runs commands on their
|
|
5
|
+
behalf. Everything a user would actually call a feature lives in a plugin.
|
|
6
|
+
|
|
7
|
+
The rule the whole project is built around: **anything the web UI can do, the
|
|
8
|
+
user must be able to do without it.** Concretely, that means we drive the
|
|
9
|
+
system by running the same commands a human would run -- see
|
|
10
|
+
:mod:`spiriconfig.commands`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
spiriconfig/__main__.py
ADDED
spiriconfig/advanced.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Advanced mode: a filter on what the web UI *shows*.
|
|
2
|
+
|
|
3
|
+
Advanced mode hides clutter. It is not a permission system, and it must never be
|
|
4
|
+
used as one -- a hidden button is still a reachable capability, and the CLI does
|
|
5
|
+
everything regardless of what the UI is currently showing. That is deliberate:
|
|
6
|
+
the CLI is the escape hatch that makes progressive enhancement true, so gating it
|
|
7
|
+
would undercut the whole project. If you ever need "this person may not restart
|
|
8
|
+
containers", that is authorisation, and it belongs in front of the *command*, not
|
|
9
|
+
in front of the button.
|
|
10
|
+
|
|
11
|
+
So: advanced mode decides what a page renders. Nothing else.
|
|
12
|
+
|
|
13
|
+
Plugins use it like this::
|
|
14
|
+
|
|
15
|
+
from spiriconfig import advanced
|
|
16
|
+
|
|
17
|
+
ui.button("Up", on_click=...) # everyone sees this
|
|
18
|
+
|
|
19
|
+
with advanced.only():
|
|
20
|
+
ui.button("Edit", on_click=...) # only developers see this
|
|
21
|
+
|
|
22
|
+
Elements inside :func:`only` are *bound* to the setting rather than conditionally
|
|
23
|
+
created, so flipping the toggle shows and hides them instantly, with no page
|
|
24
|
+
rebuild and no lost state in whatever the user was doing.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
|
|
32
|
+
from loguru import logger
|
|
33
|
+
from nicegui import app, binding, context, ui
|
|
34
|
+
from nicegui.element import Element
|
|
35
|
+
|
|
36
|
+
from spiriconfig import theme
|
|
37
|
+
from spiriconfig.config import settings
|
|
38
|
+
from spiriconfig.preferences import preferences
|
|
39
|
+
|
|
40
|
+
#: Key this setting is stored under, in whatever store `preferences()` resolves to.
|
|
41
|
+
PREFERENCE_KEY = "advanced"
|
|
42
|
+
|
|
43
|
+
#: Key for the per-connection state object in NiceGUI's client storage.
|
|
44
|
+
_STATE_KEY = "advanced_state"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@binding.bindable_dataclass
|
|
48
|
+
class AdvancedState:
|
|
49
|
+
"""The live setting for one connected client.
|
|
50
|
+
|
|
51
|
+
A bindable dataclass, so that assigning to :attr:`enabled` immediately
|
|
52
|
+
propagates to every element bound to it.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
enabled: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def state() -> AdvancedState:
|
|
59
|
+
"""Return the advanced-mode state for the client being served right now.
|
|
60
|
+
|
|
61
|
+
Seeded on first access from the person's stored preference, falling back to
|
|
62
|
+
``SPIRICONFIG_ADVANCED`` -- so a developer image can ship with advanced mode
|
|
63
|
+
on by default, and a customer image with it off, from the same code.
|
|
64
|
+
|
|
65
|
+
The live object is held per connection; the *durable* value lives in the
|
|
66
|
+
preference store, which is the thing that will become per-user.
|
|
67
|
+
"""
|
|
68
|
+
client_storage = app.storage.client
|
|
69
|
+
if _STATE_KEY not in client_storage:
|
|
70
|
+
default = settings().advanced
|
|
71
|
+
try:
|
|
72
|
+
stored = preferences().get(PREFERENCE_KEY, default)
|
|
73
|
+
except Exception: # noqa: BLE001 - a broken store must not break the page
|
|
74
|
+
logger.exception("could not read the advanced-mode preference")
|
|
75
|
+
stored = default
|
|
76
|
+
client_storage[_STATE_KEY] = AdvancedState(enabled=bool(stored))
|
|
77
|
+
return client_storage[_STATE_KEY]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def enabled() -> bool:
|
|
81
|
+
"""Whether advanced mode is on for the client being served right now."""
|
|
82
|
+
return state().enabled
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def set_enabled(value: bool) -> None:
|
|
86
|
+
"""Turn advanced mode on or off, and remember the choice.
|
|
87
|
+
|
|
88
|
+
The live state updates first so the UI responds even if persistence fails --
|
|
89
|
+
a preference we could not save is a much smaller problem than a toggle that
|
|
90
|
+
appears not to work.
|
|
91
|
+
"""
|
|
92
|
+
state().enabled = value
|
|
93
|
+
try:
|
|
94
|
+
preferences().set(PREFERENCE_KEY, value)
|
|
95
|
+
except Exception: # noqa: BLE001
|
|
96
|
+
logger.exception("could not save the advanced-mode preference")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def mark(element: Element) -> Element:
|
|
100
|
+
"""Show ``element`` only in advanced mode. Returns it, so it chains.
|
|
101
|
+
|
|
102
|
+
Also makes it *look* advanced, which is the same act: an element cannot be
|
|
103
|
+
advanced-only and yet fail to say so, because this is the only way to make it
|
|
104
|
+
advanced-only in the first place.
|
|
105
|
+
|
|
106
|
+
How it says so depends on what it is. A button takes the purple as its Quasar
|
|
107
|
+
``color`` prop -- purple lettering on a flat button, a purple face on a solid
|
|
108
|
+
one -- because Quasar's own stylesheet sets ``outline: 0`` on ``.q-btn``, and
|
|
109
|
+
a ring drawn on a button is a fight with the framework that we would be
|
|
110
|
+
re-fighting at every upgrade. Everything else gets the dashed ring.
|
|
111
|
+
"""
|
|
112
|
+
element.classes(add=theme.ADVANCED_CLASS)
|
|
113
|
+
if isinstance(element, ui.button):
|
|
114
|
+
element.props(f"color={theme.ADVANCED}")
|
|
115
|
+
return element.bind_visibility_from(state(), "enabled")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@contextmanager
|
|
119
|
+
def only() -> Iterator[None]:
|
|
120
|
+
"""Show everything created inside this block only in advanced mode.
|
|
121
|
+
|
|
122
|
+
Binds the elements that appear in the current slot, rather than wrapping them
|
|
123
|
+
in a container: a wrapper element would sit inside the parent's flex or grid
|
|
124
|
+
layout and quietly change how the visible siblings are arranged.
|
|
125
|
+
"""
|
|
126
|
+
slot = context.slot
|
|
127
|
+
before = len(slot.children)
|
|
128
|
+
try:
|
|
129
|
+
yield
|
|
130
|
+
finally:
|
|
131
|
+
for element in slot.children[before:]:
|
|
132
|
+
mark(element)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def toggle() -> ui.switch:
|
|
136
|
+
"""A switch for advanced mode. Always visible -- it is the way back.
|
|
137
|
+
|
|
138
|
+
Purple when it is on, and grey when it is off, wearing the same colour as the
|
|
139
|
+
ring around everything it reveals: the switch is the legend for the marks.
|
|
140
|
+
"""
|
|
141
|
+
switch = ui.switch(
|
|
142
|
+
"Advanced",
|
|
143
|
+
value=enabled(),
|
|
144
|
+
on_change=lambda event: set_enabled(event.value),
|
|
145
|
+
).props(f"color={theme.ADVANCED}")
|
|
146
|
+
return switch.tooltip("Show developer features: raw commands, file editing")
|
spiriconfig/auth.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""The optional PAM login in front of the web UI.
|
|
2
|
+
|
|
3
|
+
Off unless ``SPIRICONFIG_AUTH=pam`` (see :class:`~spiriconfig.config.Settings`).
|
|
4
|
+
When on, every page redirects to ``/login`` until the browser has authenticated
|
|
5
|
+
against the host's PAM stack, exactly as ``login`` or ``sshd`` would.
|
|
6
|
+
|
|
7
|
+
This is only a login gate -- authentication, not authorization (see
|
|
8
|
+
:doc:`design </design>`). Once past it everyone shares the one process, which runs
|
|
9
|
+
as whoever launched it, so every authenticated user has the same access. It answers
|
|
10
|
+
"is this a person the machine trusts?", not "what may this particular person do?";
|
|
11
|
+
the second question has no answer here, because per-user permissions are not part
|
|
12
|
+
of the model.
|
|
13
|
+
|
|
14
|
+
Why the login rule below is shaped the way it is: only root can read
|
|
15
|
+
``/etc/shadow``, so only a root process can verify *another* user's password. A
|
|
16
|
+
non-root process can verify *its own* user's password and no one else's (PAM's
|
|
17
|
+
setuid ``unix_chkpwd`` helper is what lets it do even that). :func:`authenticate`
|
|
18
|
+
enforces exactly that boundary rather than pretending to a power the kernel will
|
|
19
|
+
not grant it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import grp
|
|
25
|
+
import os
|
|
26
|
+
import pwd
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
from loguru import logger
|
|
30
|
+
from nicegui import app, ui
|
|
31
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
32
|
+
from starlette.requests import Request
|
|
33
|
+
from starlette.responses import RedirectResponse, Response
|
|
34
|
+
|
|
35
|
+
from spiriconfig import theme
|
|
36
|
+
from spiriconfig.config import Settings
|
|
37
|
+
|
|
38
|
+
log = logger.bind(component="auth")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_root() -> bool:
|
|
42
|
+
"""Whether the process can verify any account's password, not just its own.
|
|
43
|
+
|
|
44
|
+
A function, not a module constant, so a test can mock it -- and so the answer
|
|
45
|
+
is read now rather than frozen at import.
|
|
46
|
+
"""
|
|
47
|
+
return os.geteuid() == 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def running_user() -> str:
|
|
51
|
+
"""The account this process runs as: the only login possible when not root."""
|
|
52
|
+
return pwd.getpwuid(os.geteuid()).pw_name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AuthResult:
|
|
57
|
+
"""The outcome of one login attempt.
|
|
58
|
+
|
|
59
|
+
``error`` is written for a person to read on the login page, so it names what
|
|
60
|
+
is wrong without leaking whether it was the username or the password that
|
|
61
|
+
failed the PAM check itself -- that distinction is a gift to someone guessing.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
ok: bool
|
|
65
|
+
username: str | None = None
|
|
66
|
+
error: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _in_group(username: str, group: str) -> bool:
|
|
70
|
+
"""Whether ``username`` belongs to ``group``, by membership or primary gid.
|
|
71
|
+
|
|
72
|
+
A user's primary group does not list them in ``gr_mem``, so checking only the
|
|
73
|
+
member list would miss someone whose *primary* group is the admin one. A group
|
|
74
|
+
that does not exist is a misconfiguration, not a member: logged loudly, because
|
|
75
|
+
the visible symptom is otherwise "nobody can log in" with no reason given.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
entry = grp.getgrnam(group)
|
|
79
|
+
except KeyError:
|
|
80
|
+
log.error(
|
|
81
|
+
"auth group {!r} does not exist; no one can log in until it does or "
|
|
82
|
+
"SPIRICONFIG_AUTH_GROUP names a group that does",
|
|
83
|
+
group,
|
|
84
|
+
)
|
|
85
|
+
return False
|
|
86
|
+
if username in entry.gr_mem:
|
|
87
|
+
return True
|
|
88
|
+
try:
|
|
89
|
+
return pwd.getpwnam(username).pw_gid == entry.gr_gid
|
|
90
|
+
except KeyError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def authenticate(username: str, password: str, config: Settings) -> AuthResult:
|
|
95
|
+
"""Decide a login, applying the who-may-I-even-check rule before touching PAM.
|
|
96
|
+
|
|
97
|
+
The policy, and the only part of this module with logic worth testing on its
|
|
98
|
+
own. PAM is imported lazily and last: a box where ``auth`` is ``none`` never
|
|
99
|
+
loads libpam at all, and a box where libpam will not load fails one login with
|
|
100
|
+
a clear message instead of crashing the process at import.
|
|
101
|
+
"""
|
|
102
|
+
username = username.strip()
|
|
103
|
+
if not username or not password:
|
|
104
|
+
return AuthResult(False, error="Enter a username and a password.")
|
|
105
|
+
|
|
106
|
+
if not is_root():
|
|
107
|
+
# We can only verify our own account's password, so no other name could
|
|
108
|
+
# succeed even if we tried it. Say so plainly rather than failing the PAM
|
|
109
|
+
# check for a reason the user cannot act on.
|
|
110
|
+
me = running_user()
|
|
111
|
+
if username != me:
|
|
112
|
+
return AuthResult(
|
|
113
|
+
False,
|
|
114
|
+
error=(
|
|
115
|
+
f"SpiriConfig is running as {me!r}, and a non-root process can "
|
|
116
|
+
f"only log in that one account. Log in as {me!r}."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
elif not _in_group(username, config.auth_group):
|
|
120
|
+
# Root can verify anyone, so without this gate every system account --
|
|
121
|
+
# nobody, service users -- would be an admin login.
|
|
122
|
+
return AuthResult(
|
|
123
|
+
False,
|
|
124
|
+
error=f"{username!r} is not a member of the {config.auth_group!r} group.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
import pamela
|
|
129
|
+
except Exception as exc: # noqa: BLE001 - libpam may be missing/unloadable
|
|
130
|
+
log.error("PAM is unavailable, cannot authenticate: {}", exc)
|
|
131
|
+
return AuthResult(False, error="PAM is unavailable on this host; cannot log in.")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
pamela.authenticate(username, password, service=config.auth_service)
|
|
135
|
+
except pamela.PAMError as exc:
|
|
136
|
+
# INFO, not WARNING: a failed login is a normal event, and the reason
|
|
137
|
+
# (bad password vs. expired account) belongs in the log, not on the page.
|
|
138
|
+
log.info("PAM rejected {!r} via service {!r}: {}", username, config.auth_service, exc)
|
|
139
|
+
return AuthResult(False, error="Incorrect username or password.")
|
|
140
|
+
|
|
141
|
+
log.info("{!r} logged in", username)
|
|
142
|
+
return AuthResult(True, username=username)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Routes reachable without a session. The login page has to be, or there is no
|
|
146
|
+
# way to get one; NiceGUI's own traffic is handled by prefix in the middleware.
|
|
147
|
+
unrestricted_page_routes = {"/login"}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
151
|
+
"""Send an unauthenticated request to ``/login``, remembering where it meant to go.
|
|
152
|
+
|
|
153
|
+
Everything under ``/_nicegui`` is let through unconditionally: it is the
|
|
154
|
+
framework's own assets and websocket, and the login page itself cannot render
|
|
155
|
+
without them. Only whole-page navigations are gated -- which is all that needs
|
|
156
|
+
to be, since a page is where a person actually arrives.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
async def dispatch(
|
|
160
|
+
self, request: Request, call_next: RequestResponseEndpoint
|
|
161
|
+
) -> Response:
|
|
162
|
+
if not app.storage.user.get("authenticated", False):
|
|
163
|
+
path = request.url.path
|
|
164
|
+
if path not in unrestricted_page_routes and not path.startswith("/_nicegui"):
|
|
165
|
+
app.storage.user["referrer_path"] = path
|
|
166
|
+
return RedirectResponse("/login")
|
|
167
|
+
return await call_next(request)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def logout() -> None:
|
|
171
|
+
"""Drop the session and return to the login page."""
|
|
172
|
+
username = app.storage.user.get("username")
|
|
173
|
+
app.storage.user.clear()
|
|
174
|
+
if username:
|
|
175
|
+
log.info("{!r} logged out", username)
|
|
176
|
+
ui.navigate.to("/login")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def header_account() -> None:
|
|
180
|
+
"""The 'you are X / log out' control for the shared header.
|
|
181
|
+
|
|
182
|
+
Renders nothing when no one is authenticated, so the header can call it
|
|
183
|
+
unconditionally: with ``auth`` off no session ever carries a username, so this
|
|
184
|
+
simply draws nothing and the header is unchanged.
|
|
185
|
+
"""
|
|
186
|
+
username = app.storage.user.get("username")
|
|
187
|
+
if not username:
|
|
188
|
+
return
|
|
189
|
+
ui.space()
|
|
190
|
+
ui.label(username).classes("text-white text-sm").mark("account-user")
|
|
191
|
+
ui.button(icon="logout", on_click=logout).props(
|
|
192
|
+
"flat round dense color=white"
|
|
193
|
+
).mark("logout").tooltip("Log out")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def login_page(config: Settings) -> None:
|
|
197
|
+
"""Register ``/login``. Called only when :attr:`~spiriconfig.config.Settings.auth` is on."""
|
|
198
|
+
|
|
199
|
+
@ui.page("/login")
|
|
200
|
+
def _login() -> None:
|
|
201
|
+
theme.apply()
|
|
202
|
+
|
|
203
|
+
# Already in? The middleware lets /login through, so a logged-in visitor
|
|
204
|
+
# would otherwise sit staring at a login form. Send them on.
|
|
205
|
+
if app.storage.user.get("authenticated", False):
|
|
206
|
+
ui.navigate.to("/")
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Not root -> only running_user() can ever succeed, so name it and lock the
|
|
210
|
+
# field. Making someone guess the one account that works would be a small
|
|
211
|
+
# cruelty the PAM rule lets us avoid.
|
|
212
|
+
fixed_user = None if is_root() else running_user()
|
|
213
|
+
|
|
214
|
+
with ui.card().classes("absolute-center w-80 gap-3"):
|
|
215
|
+
ui.label("SpiriConfig").classes("text-xl font-bold")
|
|
216
|
+
username = ui.input("Username", value=fixed_user or "").classes("w-full")
|
|
217
|
+
username.mark("username")
|
|
218
|
+
if fixed_user is not None:
|
|
219
|
+
username.props("readonly")
|
|
220
|
+
password = ui.input(
|
|
221
|
+
"Password", password=True, password_toggle_button=True
|
|
222
|
+
).classes("w-full")
|
|
223
|
+
password.mark("password")
|
|
224
|
+
|
|
225
|
+
def _submit() -> None:
|
|
226
|
+
result = authenticate(username.value, password.value, config)
|
|
227
|
+
if result.ok:
|
|
228
|
+
app.storage.user.update(authenticated=True, username=result.username)
|
|
229
|
+
ui.navigate.to(app.storage.user.pop("referrer_path", "/"))
|
|
230
|
+
else:
|
|
231
|
+
ui.notify(result.error, type="negative")
|
|
232
|
+
|
|
233
|
+
password.on("keydown.enter", _submit)
|
|
234
|
+
ui.button("Log in", on_click=_submit).classes("w-full").mark("login")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = [
|
|
238
|
+
"AuthMiddleware",
|
|
239
|
+
"AuthResult",
|
|
240
|
+
"authenticate",
|
|
241
|
+
"header_account",
|
|
242
|
+
"is_root",
|
|
243
|
+
"login_page",
|
|
244
|
+
"logout",
|
|
245
|
+
"running_user",
|
|
246
|
+
]
|
spiriconfig/cli.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The ``spiriconfig`` command.
|
|
2
|
+
|
|
3
|
+
The root CLI owns almost nothing. It configures logging, discovers plugins, and
|
|
4
|
+
mounts each plugin's Typer app as a subcommand, so ``spiriconfig docker up foo``
|
|
5
|
+
is the docker plugin's own code. The only thing the core adds is ``serve``, which
|
|
6
|
+
starts the web UI, and ``plugins``, which tells you what got loaded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
import secrets
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from spiriconfig import logging
|
|
19
|
+
from spiriconfig.config import settings
|
|
20
|
+
from spiriconfig.plugins import Plugin, discover
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="spiriconfig",
|
|
24
|
+
help="Plugin-based configuration and container management.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _mount(plugins: list[Plugin]) -> None:
|
|
30
|
+
"""Mount each plugin's Typer app as ``spiriconfig <name>``."""
|
|
31
|
+
for plugin in plugins:
|
|
32
|
+
sub = plugin.cli()
|
|
33
|
+
if sub is None:
|
|
34
|
+
continue
|
|
35
|
+
app.add_typer(sub, name=plugin.name, help=plugin.description or None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def serve() -> None:
|
|
40
|
+
"""Start the web UI."""
|
|
41
|
+
from spiriconfig import web
|
|
42
|
+
|
|
43
|
+
config = settings()
|
|
44
|
+
web.serve(config)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("plugins")
|
|
48
|
+
def list_plugins() -> None:
|
|
49
|
+
"""List the installed plugins."""
|
|
50
|
+
plugins = discover()
|
|
51
|
+
if not plugins:
|
|
52
|
+
typer.echo("No plugins installed.")
|
|
53
|
+
return
|
|
54
|
+
width = max(len(p.name) for p in plugins)
|
|
55
|
+
for plugin in plugins:
|
|
56
|
+
faces = []
|
|
57
|
+
if plugin.cli() is not None:
|
|
58
|
+
faces.append("cli")
|
|
59
|
+
if plugin.has_page:
|
|
60
|
+
faces.append("web")
|
|
61
|
+
typer.echo(f"{plugin.name:<{width}} {','.join(faces):<8} {plugin.description}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_file(path: Path, content: str) -> None:
|
|
65
|
+
"""Create parent directories and write a file, for the install's two files."""
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
path.write_text(content)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def install(
|
|
72
|
+
source: Annotated[
|
|
73
|
+
str,
|
|
74
|
+
typer.Argument(
|
|
75
|
+
help="What to install: a PyPI spec, a git+ URL, or '.' with --editable."
|
|
76
|
+
),
|
|
77
|
+
] = "spiriconfig",
|
|
78
|
+
compose_dir: Annotated[
|
|
79
|
+
Path | None,
|
|
80
|
+
typer.Option(help="Where the docker plugin looks for apps. [system: /srv/compose]"),
|
|
81
|
+
] = None,
|
|
82
|
+
auth: Annotated[
|
|
83
|
+
str, typer.Option(help="Login gate: 'pam' (the default) or 'none'.")
|
|
84
|
+
] = "pam",
|
|
85
|
+
host: Annotated[str, typer.Option(help="Address to bind.")] = "127.0.0.1",
|
|
86
|
+
port: Annotated[int, typer.Option(help="Port to bind.")] = 8080,
|
|
87
|
+
auth_group: Annotated[
|
|
88
|
+
str, typer.Option(help="Group whose members may log in, when run as root.")
|
|
89
|
+
] = "wheel",
|
|
90
|
+
editable: Annotated[
|
|
91
|
+
bool, typer.Option("--editable", "-e", help="Install the source in editable mode.")
|
|
92
|
+
] = False,
|
|
93
|
+
show: Annotated[
|
|
94
|
+
bool, typer.Option("--show", help="Print everything install would do, and stop."),
|
|
95
|
+
] = False,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Install SpiriConfig as a systemd service on this machine.
|
|
98
|
+
|
|
99
|
+
As root it becomes a system service that runs as root -- the only install
|
|
100
|
+
where the PAM login is multi-user and the users plugin can manage accounts. As
|
|
101
|
+
a normal user it becomes a `systemctl --user` service running as you, so the
|
|
102
|
+
login only ever authenticates your own account.
|
|
103
|
+
|
|
104
|
+
`--show` prints the exact `uv tool install`, the unit file, the env file, and
|
|
105
|
+
the `systemctl` commands, so you can do the whole thing by hand instead.
|
|
106
|
+
"""
|
|
107
|
+
from spiriconfig import service
|
|
108
|
+
|
|
109
|
+
scope = service.Scope.detect()
|
|
110
|
+
config = service.ServiceConfig(
|
|
111
|
+
compose_dir=compose_dir
|
|
112
|
+
or (Path("/srv/compose") if scope.system else Path.home() / "compose"),
|
|
113
|
+
storage_secret=secrets.token_urlsafe(32),
|
|
114
|
+
auth=auth,
|
|
115
|
+
auth_group=auth_group,
|
|
116
|
+
host=host,
|
|
117
|
+
port=port,
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
service.check_exposure(config)
|
|
121
|
+
except service.ServiceError as exc:
|
|
122
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
123
|
+
raise typer.Exit(1) from exc
|
|
124
|
+
|
|
125
|
+
exec_path = service.executable_path()
|
|
126
|
+
unit = service.render_unit_file(scope, exec_path)
|
|
127
|
+
env_file = service.render_env_file(config)
|
|
128
|
+
|
|
129
|
+
tool = service.install_tool_command(source, editable=editable)
|
|
130
|
+
steps = [tool, service.daemon_reload_command(scope), service.enable_command(scope)]
|
|
131
|
+
if not scope.system:
|
|
132
|
+
steps.append(service.linger_command(getpass.getuser()))
|
|
133
|
+
|
|
134
|
+
if show:
|
|
135
|
+
typer.echo(f"# {scope.name} install\n")
|
|
136
|
+
typer.echo(str(tool))
|
|
137
|
+
typer.echo(f"\n# write {scope.env_path}\n{env_file}")
|
|
138
|
+
typer.echo(f"# write {scope.unit_path}\n{unit}")
|
|
139
|
+
for step in steps[1:]:
|
|
140
|
+
typer.echo(str(step))
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
from spiriconfig.commands import run
|
|
144
|
+
|
|
145
|
+
run(tool, timeout=None).check()
|
|
146
|
+
_write_file(scope.env_path, env_file)
|
|
147
|
+
_write_file(scope.unit_path, unit)
|
|
148
|
+
for step in steps[1:]:
|
|
149
|
+
run(step).check()
|
|
150
|
+
typer.echo(f"Installed and started {service.SERVICE_NAME} ({scope.name} service).")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command()
|
|
154
|
+
def update(
|
|
155
|
+
reinstall: Annotated[
|
|
156
|
+
bool,
|
|
157
|
+
typer.Option("--reinstall", help="Force a refetch -- needed for a git branch."),
|
|
158
|
+
] = False,
|
|
159
|
+
show: Annotated[
|
|
160
|
+
bool, typer.Option("--show", help="Print the upgrade and restart, and stop."),
|
|
161
|
+
] = False,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Update SpiriConfig in place, then restart the service.
|
|
164
|
+
|
|
165
|
+
The restart is handed to systemd to do a moment later rather than run inline,
|
|
166
|
+
because this very process is what gets restarted -- an inline restart would cut
|
|
167
|
+
the command off before it could report back.
|
|
168
|
+
"""
|
|
169
|
+
from spiriconfig import service
|
|
170
|
+
|
|
171
|
+
scope = service.Scope.detect()
|
|
172
|
+
upgrade = service.upgrade_tool_command(reinstall=reinstall)
|
|
173
|
+
restart = service.restart_command(scope)
|
|
174
|
+
|
|
175
|
+
if show:
|
|
176
|
+
typer.echo(str(upgrade))
|
|
177
|
+
typer.echo(str(restart))
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
from spiriconfig.commands import run
|
|
181
|
+
|
|
182
|
+
run(upgrade, timeout=None).check()
|
|
183
|
+
run(restart).check()
|
|
184
|
+
typer.echo("Update applied; the service is restarting.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Logging is configured, and plugins mounted, at import time: Typer needs every
|
|
188
|
+
# subcommand registered before it can route `spiriconfig docker ...` or list them
|
|
189
|
+
# in `--help`, and discovery logs as it goes, so the sinks have to exist first.
|
|
190
|
+
# Discovery failures are logged and skipped inside discover(), so one broken
|
|
191
|
+
# plugin cannot stop the CLI from starting.
|
|
192
|
+
logging.configure(settings())
|
|
193
|
+
_mount(discover())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
app()
|