magehand 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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
magehand-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Larrimore
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.5
2
+ Name: magehand
3
+ Version: 0.1.0
4
+ Summary: The developer CLI for building apps on davidlarrimore's homelab
5
+ Project-URL: Source, https://github.com/davidlarrimore/magehand
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: pyyaml>=6.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # magehand
17
+
18
+ The developer CLI for building apps on [davidlarrimore](https://github.com/davidlarrimore)'s
19
+ homelab, the same way on the owner's MacBook (Claude Code) and in OpenClaw's
20
+ VM (its coding workers). It reads platform state and runs apps locally. It
21
+ never deploys: changes go through pull requests.
22
+
23
+ It is public because it holds nothing sensitive. Every command needs access
24
+ that only the owner has: the private homelab repos on GitHub, the private
25
+ `*.lab` network (home LAN or UniFi Teleport), and an authentik passkey for
26
+ OpenBao.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ brew install uv # once
32
+ uv tool install magehand
33
+ magehand doctor
34
+ ```
35
+
36
+ Upgrade with `uv tool upgrade magehand`. You also need `gh auth login`, and
37
+ Docker Desktop or OrbStack for database blocks. Away from home, `*.lab` needs
38
+ Teleport and a one-time resolver file (`magehand doctor` prints it).
39
+
40
+ ## Commands
41
+
42
+ | Command | What it does |
43
+ | --- | --- |
44
+ | `magehand guide [topic]` | The app platform guide, live from homelab-apps `docs/platform/`; `magehand guide catalog` for blocks and models |
45
+ | `magehand app [name]` | The app's blocks, every env var its pod gets and where it comes from, its URLs. In an app repo the name is the repo's |
46
+ | `magehand login` | Sign in to OpenBao through authentik (passkey) with role `dev`: read-only access to agent apps' LiteLLM keys, 1h (8h max). The token is kept in the macOS Keychain |
47
+ | `magehand dev up\|down\|status` | Docker containers for the app's `postgres`/`redis` blocks (the blocks' own pinned images) on a per-app network, and local values for `secret` blocks |
48
+ | `magehand run [--no-proxy] -- CMD` | Runs CMD with the env the app's pod gets: values from its Deployment, the LLM key from OpenBao, databases from `dev up`. Precedence: manifest < the repo's `.magehand.env` (committed, non-secret overrides such as `WEB_DIR=./web`) < your shell < secrets. A proxy on `127.0.0.1:8080` adds the `X-authentik-*` headers Traefik would, as you |
49
+ | `magehand run --container [--no-build] [-- CMD]` | Builds the repo's Dockerfile and runs the image as its pod runs: read-only, `/tmp` tmpfs, the pod's user, no capabilities, exactly the pod's env. Secrets go in as `-e NAME` only, never in the image, argv or on disk |
50
+ | `magehand doctor` | Checks GitHub access, that `openbao.lab` is reachable, the sign-in and Docker |
51
+ | `magehand version` | The installed version |
52
+
53
+ ## Development
54
+
55
+ ```sh
56
+ uv venv && uv pip install -e .
57
+ .venv/bin/python -m unittest discover -s tests
58
+ ```
59
+
60
+ Python 3.9+ (the Mac's system Python works). Stdlib plus PyYAML. Releases:
61
+ bump `version` in `pyproject.toml`, merge, then tag `vX.Y.Z`. The release
62
+ workflow builds and publishes to PyPI through trusted publishing (no stored
63
+ token). OpenClaw's VM installs a pinned version from homelab
64
+ `platform/openclaw/vm/versions.env`.
65
+
66
+ Never commit secrets, tokens, IP addresses or email addresses: this repo is
67
+ public.
@@ -0,0 +1,52 @@
1
+ # magehand
2
+
3
+ The developer CLI for building apps on [davidlarrimore](https://github.com/davidlarrimore)'s
4
+ homelab, the same way on the owner's MacBook (Claude Code) and in OpenClaw's
5
+ VM (its coding workers). It reads platform state and runs apps locally. It
6
+ never deploys: changes go through pull requests.
7
+
8
+ It is public because it holds nothing sensitive. Every command needs access
9
+ that only the owner has: the private homelab repos on GitHub, the private
10
+ `*.lab` network (home LAN or UniFi Teleport), and an authentik passkey for
11
+ OpenBao.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ brew install uv # once
17
+ uv tool install magehand
18
+ magehand doctor
19
+ ```
20
+
21
+ Upgrade with `uv tool upgrade magehand`. You also need `gh auth login`, and
22
+ Docker Desktop or OrbStack for database blocks. Away from home, `*.lab` needs
23
+ Teleport and a one-time resolver file (`magehand doctor` prints it).
24
+
25
+ ## Commands
26
+
27
+ | Command | What it does |
28
+ | --- | --- |
29
+ | `magehand guide [topic]` | The app platform guide, live from homelab-apps `docs/platform/`; `magehand guide catalog` for blocks and models |
30
+ | `magehand app [name]` | The app's blocks, every env var its pod gets and where it comes from, its URLs. In an app repo the name is the repo's |
31
+ | `magehand login` | Sign in to OpenBao through authentik (passkey) with role `dev`: read-only access to agent apps' LiteLLM keys, 1h (8h max). The token is kept in the macOS Keychain |
32
+ | `magehand dev up\|down\|status` | Docker containers for the app's `postgres`/`redis` blocks (the blocks' own pinned images) on a per-app network, and local values for `secret` blocks |
33
+ | `magehand run [--no-proxy] -- CMD` | Runs CMD with the env the app's pod gets: values from its Deployment, the LLM key from OpenBao, databases from `dev up`. Precedence: manifest < the repo's `.magehand.env` (committed, non-secret overrides such as `WEB_DIR=./web`) < your shell < secrets. A proxy on `127.0.0.1:8080` adds the `X-authentik-*` headers Traefik would, as you |
34
+ | `magehand run --container [--no-build] [-- CMD]` | Builds the repo's Dockerfile and runs the image as its pod runs: read-only, `/tmp` tmpfs, the pod's user, no capabilities, exactly the pod's env. Secrets go in as `-e NAME` only, never in the image, argv or on disk |
35
+ | `magehand doctor` | Checks GitHub access, that `openbao.lab` is reachable, the sign-in and Docker |
36
+ | `magehand version` | The installed version |
37
+
38
+ ## Development
39
+
40
+ ```sh
41
+ uv venv && uv pip install -e .
42
+ .venv/bin/python -m unittest discover -s tests
43
+ ```
44
+
45
+ Python 3.9+ (the Mac's system Python works). Stdlib plus PyYAML. Releases:
46
+ bump `version` in `pyproject.toml`, merge, then tag `vX.Y.Z`. The release
47
+ workflow builds and publishes to PyPI through trusted publishing (no stored
48
+ token). OpenClaw's VM installs a pinned version from homelab
49
+ `platform/openclaw/vm/versions.env`.
50
+
51
+ Never commit secrets, tokens, IP addresses or email addresses: this repo is
52
+ public.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "magehand"
7
+ version = "0.1.0"
8
+ description = "The developer CLI for building apps on davidlarrimore's homelab"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["PyYAML>=6.0"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Operating System :: MacOS",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Programming Language :: Python :: 3",
19
+ ]
20
+
21
+ [project.urls]
22
+ Source = "https://github.com/davidlarrimore/magehand"
23
+
24
+ [project.scripts]
25
+ magehand = "magehand.cli:main"
26
+
27
+ [tool.hatch.build.targets.sdist]
28
+ include = ["src", "tests", "README.md", "LICENSE"]
@@ -0,0 +1 @@
1
+ """magehand: the developer CLI for building apps on the homelab."""
@@ -0,0 +1,3 @@
1
+ from magehand.cli import main
2
+
3
+ main()
@@ -0,0 +1,640 @@
1
+ """magehand: build homelab apps from anywhere, the same way everywhere.
2
+
3
+ The developer CLI for apps on the homelab, used by the owner (Claude Code on
4
+ the MacBook) and by OpenClaw's coding workers:
5
+
6
+ magehand guide [topic] the platform guide for apps (live from homelab-apps)
7
+ magehand app [name] this app's blocks, env vars and URLs
8
+ magehand login sign in to OpenBao through authentik (passkey)
9
+ magehand dev up|down|status local containers for the app's postgres/redis blocks
10
+ magehand run [--no-proxy] -- CMD...
11
+ run CMD with the env the app's pod gets; secrets
12
+ come from OpenBao and local containers, never disk
13
+ magehand run --container [--no-build] [-- CMD...]
14
+ build the repo's Dockerfile and run the image as
15
+ its pod runs (read-only, non-root, no capabilities)
16
+ magehand doctor check GitHub access, VPN/LAN reach, login, Docker
17
+ magehand version this version (upgrade: uv tool upgrade magehand)
18
+
19
+ Private by design: it talks only to GitHub and *.lab (on the LAN or over the
20
+ VPN), never to a public homelab endpoint. Its OpenBao role `dev` can only read
21
+ the LiteLLM keys of agent apps (homelab platform/openbao/policies/dev-read.hcl).
22
+ """
23
+ import base64
24
+ import http.client
25
+ import http.server
26
+ import json
27
+ import os
28
+ import pathlib
29
+ import secrets
30
+ import shutil
31
+ import signal
32
+ import socket
33
+ import subprocess # nosec B404 # runs docker, git, gh and the user's own command
34
+ import sys
35
+ import threading
36
+ import time
37
+ import urllib.error
38
+ import urllib.parse
39
+ import urllib.request
40
+ import webbrowser
41
+ from importlib.metadata import PackageNotFoundError, version as package_version
42
+
43
+ import yaml
44
+
45
+ HOMELAB_REPO = 'davidlarrimore/homelab'
46
+ APPS_REPO = 'davidlarrimore/homelab-apps'
47
+ OPENBAO = os.environ.get('MAGEHAND_OPENBAO', 'https://openbao.lab.davidlarrimore.com')
48
+ LAB = 'lab.davidlarrimore.com'
49
+ HOME = pathlib.Path.home()
50
+ STATE = pathlib.Path(os.environ.get('XDG_STATE_HOME', HOME / '.local/state')) / 'magehand'
51
+ CACHE = pathlib.Path(os.environ.get('XDG_CACHE_HOME', HOME / '.cache')) / 'magehand'
52
+ KEYCHAIN_SERVICE = 'magehand'
53
+ CALLBACK = 'http://localhost:8250/oidc/callback'
54
+ PROXY_PORT = int(os.environ.get('MAGEHAND_PROXY_PORT', '8080'))
55
+ # Block type -> the Secret its chart hands over (platform/building-blocks/*).
56
+ SECRET_NAME = {'postgres': '{}-connection', 'redis': '{}-connection', 'llm': '{}-connection', 'secret': '{}'}
57
+
58
+
59
+ def die(msg):
60
+ sys.exit(f'magehand: {msg}')
61
+
62
+
63
+ # --- GitHub (read-only: the guide, app manifests, block images) ---------------
64
+
65
+ def github_token():
66
+ token = os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN')
67
+ if token:
68
+ return token
69
+ if shutil.which('gh'):
70
+ out = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True) # nosec B603 B607
71
+ if out.returncode == 0 and out.stdout.strip():
72
+ return out.stdout.strip()
73
+ die('no GitHub access: run `gh auth login` (or set GITHUB_TOKEN)')
74
+
75
+
76
+ def github_file(repo, path, required=True):
77
+ """A file on main, cached for offline use; None if it doesn't exist."""
78
+ cached = CACHE / repo / path
79
+ req = urllib.request.Request(
80
+ f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
81
+ headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github.raw',
82
+ 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'magehand'})
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
85
+ text = resp.read().decode()
86
+ except urllib.error.HTTPError as error:
87
+ if error.code == 404:
88
+ if required:
89
+ die(f'{repo}: {path} not found on main')
90
+ return None
91
+ text = None
92
+ except OSError:
93
+ text = None
94
+ if text is None:
95
+ if cached.is_file():
96
+ print(f'magehand: GitHub unreachable, using the cached {path}', file=sys.stderr)
97
+ return cached.read_text()
98
+ die(f'cannot read {repo}/{path} from GitHub and nothing is cached')
99
+ cached.parent.mkdir(parents=True, exist_ok=True)
100
+ cached.write_text(text)
101
+ return text
102
+
103
+
104
+ def github_dir(repo, path):
105
+ """Names in a directory on main ([] if it doesn't exist)."""
106
+ req = urllib.request.Request(
107
+ f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
108
+ headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github+json',
109
+ 'User-Agent': 'magehand'})
110
+ try:
111
+ with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
112
+ return sorted(item['name'] for item in json.load(resp) if isinstance(item, dict))
113
+ except (urllib.error.HTTPError, OSError, ValueError):
114
+ return []
115
+
116
+
117
+ # --- The app ------------------------------------------------------------------
118
+
119
+ def current_app():
120
+ """The app this checkout is: an app repo is named after its app."""
121
+ out = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True) # nosec B603 B607
122
+ if out.returncode != 0:
123
+ die('not in a git checkout; pass the app name')
124
+ name = out.stdout.strip().rstrip('/').rsplit('/', 1)[-1].rsplit(':', 1)[-1]
125
+ name = name[:-4] if name.endswith('.git') else name
126
+ if name in ('homelab', 'homelab-apps'):
127
+ die('this is a platform repo; pass the app name')
128
+ return name
129
+
130
+
131
+ def load_app(name):
132
+ spec = yaml.safe_load(github_file(APPS_REPO, f'apps/{name}/app.yaml')) or {}
133
+ services = spec.get('services') or {}
134
+ docs = list(yaml.safe_load_all(github_file(APPS_REPO, f'apps/{name}/base/deployment.yaml')))
135
+ deployment = next((d for d in docs if isinstance(d, dict) and d.get('kind') == 'Deployment'), None)
136
+ if not deployment:
137
+ die(f'apps/{name}/base/deployment.yaml has no Deployment')
138
+ pod = deployment['spec']['template']['spec']
139
+ return {'name': name, 'spec': spec, 'services': services, 'container': pod['containers'][0], 'pod': pod}
140
+
141
+
142
+ def block_of(app, secret_name):
143
+ """(block name, type) whose Secret this is, or (None, None)."""
144
+ for name, svc in app['services'].items():
145
+ kind = (svc or {}).get('type')
146
+ if kind in SECRET_NAME and SECRET_NAME[kind].format(name) == secret_name:
147
+ return name, kind
148
+ return None, None
149
+
150
+
151
+ def env_plan(app):
152
+ """[(VAR, 'value', text) | (VAR, block, type, key)] from the pod spec."""
153
+ plan = []
154
+ for item in app['container'].get('env') or []:
155
+ if 'value' in item:
156
+ plan.append((item['name'], 'value', str(item['value'])))
157
+ continue
158
+ ref = ((item.get('valueFrom') or {}).get('secretKeyRef')) or {}
159
+ block, kind = block_of(app, ref.get('name'))
160
+ plan.append((item['name'], block, kind, ref.get('key')) if block
161
+ else (item['name'], 'unknown', ref.get('name') or '?'))
162
+ return plan
163
+
164
+
165
+ def app_port(app):
166
+ for item in app['container'].get('env') or []:
167
+ if item.get('name') == 'PORT' and 'value' in item:
168
+ return int(item['value'])
169
+ ports = app['container'].get('ports') or []
170
+ return int(ports[0]['containerPort']) if ports else 8000
171
+
172
+
173
+ # --- OpenBao (role dev: read-only LiteLLM keys) ---------------------------------
174
+
175
+ def bao(method, path, token=None, body=None):
176
+ headers = {'Content-Type': 'application/json'}
177
+ if token:
178
+ headers['X-Vault-Token'] = token
179
+ req = urllib.request.Request(f'{OPENBAO}/v1/{path}', method=method, headers=headers,
180
+ data=json.dumps(body).encode() if body is not None else None)
181
+ try:
182
+ with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # OpenBao URL (https, *.lab)
183
+ raw = resp.read()
184
+ return resp.status, (json.loads(raw) if raw.strip() else {})
185
+ except urllib.error.HTTPError as error:
186
+ return error.code, None
187
+ except OSError:
188
+ die(f'cannot reach {OPENBAO}: on the home network or the VPN? (`magehand doctor`)')
189
+
190
+
191
+ def store_token(token, identity):
192
+ if sys.platform == 'darwin':
193
+ subprocess.run(['security', 'add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
194
+ '-a', 'openbao-token', '-w', token], check=True, capture_output=True)
195
+ else:
196
+ STATE.mkdir(parents=True, exist_ok=True)
197
+ path = STATE / 'openbao-token'
198
+ path.touch(mode=0o600)
199
+ path.write_text(token)
200
+ STATE.mkdir(parents=True, exist_ok=True)
201
+ (STATE / 'identity.json').write_text(json.dumps(identity))
202
+
203
+
204
+ def saved_token():
205
+ if os.environ.get('MAGEHAND_BAO_TOKEN'):
206
+ return os.environ['MAGEHAND_BAO_TOKEN']
207
+ if sys.platform == 'darwin':
208
+ out = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
209
+ '-a', 'openbao-token', '-w'], capture_output=True, text=True)
210
+ return out.stdout.strip() if out.returncode == 0 else None
211
+ path = STATE / 'openbao-token'
212
+ return path.read_text().strip() if path.is_file() else None
213
+
214
+
215
+ def valid_token():
216
+ token = saved_token()
217
+ if token and bao('GET', 'auth/token/lookup-self', token)[0] == 200:
218
+ return token
219
+ die('not signed in, or the token expired: run `magehand login`')
220
+
221
+
222
+ def identity():
223
+ path = STATE / 'identity.json'
224
+ return json.loads(path.read_text()) if path.is_file() else {}
225
+
226
+
227
+ def cmd_login(_args):
228
+ status, body = bao('POST', 'auth/oidc/oidc/auth_url', body={'role': 'dev', 'redirect_uri': CALLBACK})
229
+ url = ((body or {}).get('data') or {}).get('auth_url')
230
+ if status != 200 or not url:
231
+ die(f'OpenBao gave no login URL (HTTP {status}); is the `dev` OIDC role set up? (scripts/setup-openbao.py)')
232
+ params = {}
233
+
234
+ class Callback(http.server.BaseHTTPRequestHandler):
235
+ def do_GET(self):
236
+ parsed = urllib.parse.urlparse(self.path)
237
+ if parsed.path != '/oidc/callback':
238
+ self.send_response(404)
239
+ self.end_headers()
240
+ return
241
+ params.update(urllib.parse.parse_qsl(parsed.query))
242
+ self.send_response(200)
243
+ self.send_header('Content-Type', 'text/plain')
244
+ self.end_headers()
245
+ self.wfile.write(b'magehand: signed in. Return to the terminal.')
246
+
247
+ def log_message(self, *args):
248
+ pass
249
+
250
+ with http.server.HTTPServer(('localhost', 8250), Callback) as server:
251
+ server.timeout = 5
252
+ print('Sign in through authentik in your browser (passkey)...')
253
+ webbrowser.open(url)
254
+ deadline = time.monotonic() + 300
255
+ while 'code' not in params and time.monotonic() < deadline:
256
+ server.handle_request()
257
+ if 'code' not in params:
258
+ die('sign-in timed out or was refused')
259
+ query = urllib.parse.urlencode({k: params[k] for k in ('state', 'code') if k in params})
260
+ status, body = bao('GET', f'auth/oidc/oidc/callback?{query}')
261
+ if status != 200:
262
+ die(f'OpenBao refused the sign-in (HTTP {status})')
263
+ auth = body['auth']
264
+ meta = auth.get('metadata') or {}
265
+ email = meta.get('email') or auth.get('display_name', '').removeprefix('oidc-')
266
+ who = {'email': email, 'uid': auth.get('entity_id', ''), 'username': email.split('@')[0],
267
+ 'name': meta.get('name') or email.split('@')[0], 'groups': 'homelab-admins'}
268
+ store_token(auth['client_token'], who)
269
+ hours = auth.get('lease_duration', 3600) // 3600
270
+ print(f'Signed in as {email or "?"} (policy {", ".join(auth.get("policies", []))}, {hours}h).')
271
+
272
+
273
+ # --- guide and app --------------------------------------------------------------
274
+
275
+ def cmd_guide(args):
276
+ topics = [n[:-3] for n in github_dir(APPS_REPO, 'docs/platform') if n.endswith('.md')]
277
+ if not topics: # before docs/platform exists
278
+ print(github_file(APPS_REPO, 'AGENTS.md'))
279
+ return
280
+ topic = args[0] if args else 'README'
281
+ if topic == 'catalog':
282
+ print(github_file(APPS_REPO, 'catalog.yaml'))
283
+ return
284
+ if topic not in topics:
285
+ die(f'no topic {topic!r}; topics: {", ".join(topics)}, catalog')
286
+ print(github_file(APPS_REPO, f'docs/platform/{topic}.md'))
287
+ if not args:
288
+ print(f'\nMore: magehand guide <topic> ({", ".join(t for t in topics if t != "README")}, catalog)')
289
+
290
+
291
+ def cmd_app(args):
292
+ app = load_app(args[0] if args else current_app())
293
+ name = app['name']
294
+ source = (app['spec'].get('source') or {}).get('repo')
295
+ print(f'{name} (code: {"davidlarrimore/" + source if source else "homelab-apps apps/" + name + "/image"};'
296
+ f' deployment: homelab-apps apps/{name})')
297
+ print(f' dev: https://{name}-dev.{LAB}')
298
+ for preview in github_dir(APPS_REPO, f'previews/{name}'):
299
+ if preview.endswith('.yaml'):
300
+ print(f' preview: https://{name}-dev-{preview[:-5]}.{LAB}')
301
+ print('Blocks (app.yaml services:):')
302
+ if not app['services']:
303
+ print(' none; request one with a PR to homelab-apps apps/%s/app.yaml (`magehand guide blocks`)' % name)
304
+ for block, svc in app['services'].items():
305
+ opts = ', '.join(f'{k}={v}' for k, v in (svc or {}).items() if k != 'type')
306
+ print(f' {block}: {(svc or {}).get("type")}{" (" + opts + ")" if opts else ""}')
307
+ print(f'Env (the pod spec; locally: magehand run, app port {app_port(app)}):')
308
+ for entry in env_plan(app):
309
+ if entry[1] == 'value':
310
+ print(f' {entry[0]} = {entry[2]}')
311
+ elif entry[1] == 'unknown':
312
+ print(f' {entry[0]} <- Secret {entry[2]} (not a block; magehand run leaves it unset)')
313
+ else:
314
+ print(f' {entry[0]} <- block {entry[1]} ({entry[2]}) key {entry[3]}')
315
+
316
+
317
+ # --- local containers -------------------------------------------------------------
318
+
319
+ def docker(*args, check=True):
320
+ if not shutil.which('docker'):
321
+ die('docker is required for local blocks (Docker Desktop, OrbStack or colima)')
322
+ return subprocess.run(['docker', *args], capture_output=True, text=True, check=check) # nosec B603 B607
323
+
324
+
325
+ def local_state(app_name):
326
+ path = STATE / f'{app_name}.json'
327
+ return json.loads(path.read_text()) if path.is_file() else {}
328
+
329
+
330
+ def save_local_state(app_name, data):
331
+ STATE.mkdir(parents=True, exist_ok=True)
332
+ path = STATE / f'{app_name}.json'
333
+ path.touch(mode=0o600)
334
+ path.write_text(json.dumps(data, indent=1))
335
+
336
+
337
+ def block_image(kind, svc):
338
+ values = yaml.safe_load(github_file(HOMELAB_REPO, f'platform/building-blocks/{kind}/values.yaml'))
339
+ if kind == 'postgres' and (svc or {}).get('vector') in (True, 'true'):
340
+ return values['pgvector']['image']
341
+ return values['image']
342
+
343
+
344
+ def cmd_dev(args):
345
+ action = args[0] if args else 'status'
346
+ app = load_app(args[1] if len(args) > 1 else current_app())
347
+ state = local_state(app['name'])
348
+ network = f'magehand-{app["name"]}'
349
+ if action == 'up': # blocks and `run --container` share it, like a namespace
350
+ docker('network', 'create', network, check=False)
351
+ for block, svc in app['services'].items():
352
+ kind = (svc or {}).get('type')
353
+ container = f'magehand-{app["name"]}-{block}'
354
+ if kind == 'secret' and action == 'up':
355
+ state.setdefault(block, {'value': secrets.token_urlsafe(36)[:48]})
356
+ if kind not in ('postgres', 'redis'):
357
+ continue
358
+ if action == 'down':
359
+ docker('rm', '-f', '-v', container, check=False)
360
+ state.pop(block, None)
361
+ print(f'{block}: removed')
362
+ continue
363
+ running = docker('inspect', '-f', '{{.State.Running}}', container, check=False).stdout.strip() == 'true'
364
+ if action == 'status':
365
+ print(f'{block} ({kind}): {"running" if running else "not running"}')
366
+ continue
367
+ if not running:
368
+ password = state.get(block, {}).get('password') or secrets.token_urlsafe(24)
369
+ docker('rm', '-f', container, check=False)
370
+ if kind == 'postgres':
371
+ docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::5432',
372
+ '-e', 'POSTGRES_USER=app',
373
+ '-e', 'POSTGRES_DB=app', '-e', f'POSTGRES_PASSWORD={password}', block_image(kind, svc))
374
+ else:
375
+ docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::6379',
376
+ block_image(kind, svc),
377
+ 'valkey-server', '--requirepass', password, '--maxmemory', '200mb',
378
+ '--maxmemory-policy', 'allkeys-lru', '--appendonly', 'yes')
379
+ state[block] = {'password': password}
380
+ inner = '5432' if kind == 'postgres' else '6379'
381
+ port = docker('port', container, inner).stdout.strip().splitlines()[0].rsplit(':', 1)[-1]
382
+ state[block]['port'] = port
383
+ print(f'{block} ({kind}): 127.0.0.1:{port}')
384
+ if action == 'up':
385
+ save_local_state(app['name'], state)
386
+ elif action == 'down':
387
+ (STATE / f'{app["name"]}.json').unlink(missing_ok=True)
388
+ docker('network', 'rm', network, check=False)
389
+ elif action != 'status':
390
+ die('usage: magehand dev up|down|status [app]')
391
+
392
+
393
+ def local_connection(kind, block, local, app_name=None):
394
+ """A block's Secret keys for a local run: on the host (published port), or
395
+ with app_name, from a container on the app's Docker network."""
396
+ if block not in local:
397
+ die(f'block {block} ({kind}) has no local container: run `magehand dev up`')
398
+ conn = local[block]
399
+ if kind == 'secret':
400
+ return {'value': conn['value']}
401
+ pw = conn['password']
402
+ inner = '5432' if kind == 'postgres' else '6379'
403
+ host, port = (f'magehand-{app_name}-{block}', inner) if app_name else ('127.0.0.1', conn['port'])
404
+ if kind == 'postgres':
405
+ return {'host': host, 'port': port, 'username': 'app', 'database': 'app', 'password': pw,
406
+ 'uri': f'postgresql://app:{pw}@{host}:{port}/app'}
407
+ return {'host': host, 'port': port, 'password': pw, 'uri': f'redis://:{pw}@{host}:{port}/0'}
408
+
409
+
410
+ # --- run: the secrets adapter ---------------------------------------------------------
411
+
412
+ def dotenv(path):
413
+ """KEY=VALUE lines from the repo's committed .magehand.env (local, non-secret overrides)."""
414
+ values = {}
415
+ if path.is_file():
416
+ for line in path.read_text().splitlines():
417
+ line = line.strip()
418
+ if line and not line.startswith('#') and '=' in line:
419
+ key, value = line.split('=', 1)
420
+ values[key.strip()] = os.path.expandvars(value.strip().strip('"\''))
421
+ return values
422
+
423
+
424
+ def resolve_env(app, container=False):
425
+ """The pod's env, resolved for this machine. On the host, precedence is:
426
+ manifest values < .magehand.env < the caller's environment < secrets
427
+ (OpenBao, local blocks). In a container it is exactly the pod's: manifest
428
+ values and secrets, with blocks reached over the app's Docker network."""
429
+ plan = env_plan(app)
430
+ env = {e[0]: e[2] for e in plan if e[1] == 'value'}
431
+ if not container:
432
+ env.update(dotenv(pathlib.Path('.magehand.env')))
433
+ env.update({k: v for k, v in os.environ.items() if k in env})
434
+ local, fetched, token = local_state(app['name']), {}, None
435
+ for entry in plan:
436
+ if entry[1] in ('value', 'unknown'):
437
+ continue
438
+ var, block, kind, key = entry
439
+ if block not in fetched:
440
+ if kind == 'llm':
441
+ token = token or valid_token()
442
+ path = f'apps/data/{app["name"]}-dev/llm-{block}'
443
+ status, body = bao('GET', path, token)
444
+ if status != 200:
445
+ die(f'cannot read {path} (HTTP {status}); has the broker created the key? (`magehand app`)')
446
+ fetched[block] = body['data']['data']
447
+ else:
448
+ fetched[block] = local_connection(kind, block, local, app['name'] if container else None)
449
+ if key not in fetched[block]:
450
+ die(f'{var}: block {block} has no key {key!r} (keys: {", ".join(sorted(fetched[block]))})')
451
+ env[var] = str(fetched[block][key])
452
+ return env
453
+
454
+
455
+ def start_proxy(app_port_, who):
456
+ """127.0.0.1:PROXY_PORT -> the app, adding the X-authentik-* headers Traefik would."""
457
+ headers = {'X-authentik-uid': who.get('uid', 'local'), 'X-authentik-email': who.get('email', ''),
458
+ 'X-authentik-name': who.get('name', 'Local developer'),
459
+ 'X-authentik-username': who.get('username', 'local'), 'X-authentik-groups': who.get('groups', '')}
460
+
461
+ class Proxy(http.server.BaseHTTPRequestHandler):
462
+ protocol_version = 'HTTP/1.1'
463
+
464
+ def forward(self):
465
+ body = self.rfile.read(int(self.headers.get('Content-Length') or 0))
466
+ out = {k: v for k, v in self.headers.items()
467
+ if not k.lower().startswith('x-authentik-') and k.lower() not in ('connection', 'host')}
468
+ out.update(headers)
469
+ out['Host'] = self.headers.get('Host', f'127.0.0.1:{PROXY_PORT}')
470
+ conn = http.client.HTTPConnection('127.0.0.1', app_port_, timeout=300)
471
+ try:
472
+ conn.request(self.command, self.path, body=body or None, headers=out)
473
+ resp = conn.getresponse()
474
+ data = resp.read()
475
+ except OSError:
476
+ self.send_error(502, 'app not reachable yet')
477
+ return
478
+ finally:
479
+ conn.close()
480
+ self.send_response(resp.status, resp.reason)
481
+ for k, v in resp.getheaders():
482
+ if k.lower() not in ('transfer-encoding', 'connection', 'content-length'):
483
+ self.send_header(k, v)
484
+ self.send_header('Content-Length', str(len(data)))
485
+ self.end_headers()
486
+ self.wfile.write(data)
487
+
488
+ do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = forward
489
+
490
+ def log_message(self, *args):
491
+ pass
492
+
493
+ server = http.server.ThreadingHTTPServer(('127.0.0.1', PROXY_PORT), Proxy)
494
+ threading.Thread(target=server.serve_forever, daemon=True).start()
495
+ return server
496
+
497
+
498
+ LAB_SUFFIX = '.' + LAB
499
+
500
+
501
+ def lab_hosts(env):
502
+ """--add-host entries for the *.lab names in env values. Inside a container,
503
+ public DNS's 127.0.0.1 would be the container: use what the host resolves
504
+ (the Studio's LAN address on the MacBook) or, on the Studio, the host itself."""
505
+ hosts = set()
506
+ for value in env.values():
507
+ host = urllib.parse.urlparse(value).hostname if '://' in value else None
508
+ if host and host.endswith(LAB_SUFFIX):
509
+ hosts.add(host)
510
+ out = []
511
+ for host in sorted(hosts):
512
+ try:
513
+ address = socket.gethostbyname(host)
514
+ except OSError:
515
+ die(f'cannot resolve {host}: on the home network or Teleport? (`magehand doctor`)')
516
+ out.append(f'{host}:{"host-gateway" if address.startswith("127.") else address}')
517
+ return out
518
+
519
+
520
+ def container_args(app, env, image, port, hosts, extra):
521
+ """docker run arguments for the app as its pod runs: read-only, non-root,
522
+ no capabilities. Secrets are passed by name only (-e NAME): docker reads
523
+ the values from its own environment, so they are never in argv or on disk."""
524
+ pod_sc = app['pod'].get('securityContext') or {}
525
+ user = f'{pod_sc.get("runAsUser", 65532)}:{pod_sc.get("runAsGroup", 65532)}'
526
+ args = ['run', '--rm', '--name', f'magehand-{app["name"]}-app', '--network', f'magehand-{app["name"]}',
527
+ '-p', f'127.0.0.1:{port}:{port}', '--read-only', '--tmpfs', '/tmp', '--user', user, # nosec B108 # the container's own tmpfs
528
+ '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges']
529
+ if sys.stdin.isatty():
530
+ args.append('-it')
531
+ for host in hosts:
532
+ args += ['--add-host', host]
533
+ for name in sorted(env):
534
+ args += ['-e', name]
535
+ command = extra or (app['container'].get('command') or []) + (app['container'].get('args') or [])
536
+ if command and not extra:
537
+ args += ['--entrypoint', command[0], image, *command[1:]]
538
+ else:
539
+ args += [image, *command]
540
+ return args
541
+
542
+
543
+ def cmd_run(args):
544
+ proxy = '--no-proxy' not in args
545
+ container = '--container' in args
546
+ build = '--no-build' not in args
547
+ args = [a for a in args if a not in ('--no-proxy', '--container', '--no-build')]
548
+ name = None
549
+ if args and args[0] == '--app':
550
+ name, args = args[1], args[2:]
551
+ if args and args[0] == '--':
552
+ args = args[1:]
553
+ if not args and not container:
554
+ die('usage: magehand run [--no-proxy] [--app NAME] -- CMD... or magehand run --container [--no-build]')
555
+ app = load_app(name or current_app())
556
+ env = resolve_env(app, container=container)
557
+ print(f'magehand: {app["name"]} env: {", ".join(sorted(env))}', file=sys.stderr)
558
+ if proxy:
559
+ port = int(env.get('PORT') or app_port(app))
560
+ start_proxy(port, identity())
561
+ print(f'magehand: open http://127.0.0.1:{PROXY_PORT} (signed in as '
562
+ f'{identity().get("email") or "local"}; the app itself listens on {port})', file=sys.stderr)
563
+ if container:
564
+ image = f'magehand/{app["name"]}:local'
565
+ if build:
566
+ print(f'magehand: docker build -t {image} .', file=sys.stderr)
567
+ if subprocess.run(['docker', 'build', '-t', image, '.']).returncode: # nosec B603 B607
568
+ die('docker build failed')
569
+ docker('network', 'create', f'magehand-{app["name"]}', check=False)
570
+ docker('rm', '-f', f'magehand-{app["name"]}-app', check=False)
571
+ port = int(env.get('PORT') or app_port(app))
572
+ args = ['docker', *container_args(app, env, image, port, lab_hosts(env), args)]
573
+ child = subprocess.Popen(args, env={**os.environ, **env}) # nosec B603 # the user's own command
574
+ for sig in (signal.SIGINT, signal.SIGTERM):
575
+ signal.signal(sig, lambda s, _f: child.send_signal(s))
576
+ sys.exit(child.wait())
577
+
578
+
579
+ # --- doctor and update ---------------------------------------------------------------
580
+
581
+ def cmd_doctor(_args):
582
+ ok = True
583
+
584
+ def check(label, passed, hint=''):
585
+ nonlocal ok
586
+ ok = ok and passed
587
+ print(f'{"ok " if passed else "FAIL"} {label}{"" if passed else " -> " + hint}')
588
+
589
+ try:
590
+ github_token()
591
+ check('GitHub access', github_file(APPS_REPO, 'catalog.yaml', required=False) is not None)
592
+ except SystemExit:
593
+ check('GitHub access', False, '`gh auth login`')
594
+ try:
595
+ reach = urllib.request.urlopen(f'{OPENBAO}/v1/sys/health', timeout=5).status == 200 # nosec B310
596
+ except (OSError, urllib.error.HTTPError):
597
+ reach = False
598
+ host = urllib.parse.urlparse(OPENBAO).hostname
599
+ try:
600
+ address = socket.gethostbyname(host)
601
+ except OSError:
602
+ address = None
603
+ if not reach and address == '127.0.0.1':
604
+ # Public DNS answers 127.0.0.1 for *.lab; only the home gateway (or the
605
+ # Studio itself) knows better. On Teleport the Mac keeps public DNS.
606
+ check(f'resolve {host}', False, 'public DNS answered 127.0.0.1: send *.lab lookups to the gateway once, '
607
+ '`echo "nameserver <gateway LAN IP>" | sudo tee /etc/resolver/lab.davidlarrimore.com` '
608
+ '(magehand guide local-dev)')
609
+ else:
610
+ check(f'reach {OPENBAO}', reach, f'resolved to {address or "nothing"}: on the home network or Teleport?')
611
+ token = saved_token()
612
+ check('signed in to OpenBao', bool(reach and token and bao('GET', 'auth/token/lookup-self', token)[0] == 200),
613
+ '`magehand login`')
614
+ check('docker (for postgres/redis blocks)', bool(shutil.which('docker')), 'install Docker Desktop or OrbStack')
615
+ sys.exit(0 if ok else 1)
616
+
617
+
618
+ def cmd_version(_args):
619
+ try:
620
+ print(package_version('magehand'))
621
+ except PackageNotFoundError:
622
+ print('unknown (not installed as a package)')
623
+ print('upgrade: uv tool upgrade magehand')
624
+
625
+
626
+ COMMANDS = {'guide': cmd_guide, 'app': cmd_app, 'login': cmd_login, 'dev': cmd_dev, 'run': cmd_run,
627
+ 'doctor': cmd_doctor, 'version': cmd_version}
628
+
629
+
630
+ def main():
631
+ if len(sys.argv) > 1 and sys.argv[1] in ('--version', '-V'):
632
+ sys.argv[1] = 'version'
633
+ if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
634
+ print(__doc__.split('Private by design')[0].strip())
635
+ sys.exit(0 if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help', 'help') else 2)
636
+ COMMANDS[sys.argv[1]](sys.argv[2:])
637
+
638
+
639
+ if __name__ == '__main__':
640
+ main()
@@ -0,0 +1,204 @@
1
+ """Unit tests for magehand (no network, no Docker)."""
2
+ import http.server
3
+ import json
4
+ import os
5
+ import pathlib
6
+ import socket
7
+ import tempfile
8
+ import threading
9
+ import unittest
10
+ import urllib.request
11
+
12
+ from magehand import cli as magehand
13
+
14
+ APP = {
15
+ 'name': 'demo',
16
+ 'pod': {'securityContext': {'runAsUser': 1000, 'runAsGroup': 1000}},
17
+ 'spec': {},
18
+ 'services': {'ai': {'type': 'llm'}, 'db': {'type': 'postgres'}, 'cache': {'type': 'redis'},
19
+ 'session': {'type': 'secret'}},
20
+ 'container': {'command': ['python', '-B', '/app/app.py'], 'ports': [{'containerPort': 9000}], 'env': [
21
+ {'name': 'PORT', 'value': '8000'},
22
+ {'name': 'WEB_DIR', 'value': '/app/web'},
23
+ {'name': 'LLM_KEY', 'valueFrom': {'secretKeyRef': {'name': 'ai-connection', 'key': 'api_key'}}},
24
+ {'name': 'DATABASE_URL', 'valueFrom': {'secretKeyRef': {'name': 'db-connection', 'key': 'uri'}}},
25
+ {'name': 'REDIS_URL', 'valueFrom': {'secretKeyRef': {'name': 'cache-connection', 'key': 'uri'}}},
26
+ {'name': 'SESSION_KEY', 'valueFrom': {'secretKeyRef': {'name': 'session', 'key': 'value'}}},
27
+ {'name': 'OTHER', 'valueFrom': {'secretKeyRef': {'name': 'handmade', 'key': 'x'}}},
28
+ ]},
29
+ }
30
+ LOCAL = {'db': {'password': 'pw', 'port': '5555'}, 'cache': {'password': 'rpw', 'port': '6666'},
31
+ 'session': {'value': 'v' * 48}}
32
+
33
+
34
+ def free_port():
35
+ with socket.socket() as s:
36
+ s.bind(('127.0.0.1', 0))
37
+ return s.getsockname()[1]
38
+
39
+
40
+ class Plan(unittest.TestCase):
41
+ def test_env_plan(self):
42
+ plan = {e[0]: e[1:] for e in magehand.env_plan(APP)}
43
+ self.assertEqual(plan['PORT'], ('value', '8000'))
44
+ self.assertEqual(plan['LLM_KEY'], ('ai', 'llm', 'api_key'))
45
+ self.assertEqual(plan['DATABASE_URL'], ('db', 'postgres', 'uri'))
46
+ self.assertEqual(plan['SESSION_KEY'], ('session', 'secret', 'value'))
47
+ self.assertEqual(plan['OTHER'], ('unknown', 'handmade'))
48
+
49
+ def test_app_port(self):
50
+ self.assertEqual(magehand.app_port(APP), 8000)
51
+ no_port_env = {**APP, 'container': {'ports': [{'containerPort': 9000}], 'env': []}}
52
+ self.assertEqual(magehand.app_port(no_port_env), 9000)
53
+
54
+ def test_local_connection(self):
55
+ pg = magehand.local_connection('postgres', 'db', LOCAL)
56
+ self.assertEqual(pg['uri'], 'postgresql://app:pw@127.0.0.1:5555/app')
57
+ self.assertEqual(magehand.local_connection('redis', 'cache', LOCAL)['uri'], 'redis://:rpw@127.0.0.1:6666/0')
58
+ with self.assertRaises(SystemExit):
59
+ magehand.local_connection('postgres', 'db', {})
60
+
61
+
62
+ class Resolve(unittest.TestCase):
63
+ def setUp(self):
64
+ self.saved = (magehand.bao, magehand.valid_token, magehand.local_state)
65
+ self.paths = []
66
+ magehand.valid_token = lambda: 'tok'
67
+ magehand.local_state = lambda _name: LOCAL
68
+
69
+ def fake_bao(method, path, token=None, body=None):
70
+ self.paths.append(path)
71
+ return 200, {'data': {'data': {'api_key': 'sk-test', 'base_url': 'https://llm.lab/v1'}}}
72
+ magehand.bao = fake_bao
73
+ self.cwd = os.getcwd()
74
+ self.tmp = tempfile.TemporaryDirectory()
75
+ os.chdir(self.tmp.name)
76
+
77
+ def tearDown(self):
78
+ magehand.bao, magehand.valid_token, magehand.local_state = self.saved
79
+ os.chdir(self.cwd)
80
+ self.tmp.cleanup()
81
+ os.environ.pop('PORT', None)
82
+
83
+ def test_resolves_every_source(self):
84
+ env = magehand.resolve_env(APP)
85
+ self.assertEqual(env['LLM_KEY'], 'sk-test')
86
+ self.assertEqual(env['DATABASE_URL'], 'postgresql://app:pw@127.0.0.1:5555/app')
87
+ self.assertEqual(env['SESSION_KEY'], 'v' * 48)
88
+ self.assertNotIn('OTHER', env)
89
+ self.assertEqual(self.paths, ['apps/data/demo-dev/llm-ai'])
90
+
91
+ def test_precedence(self):
92
+ pathlib.Path('.magehand.env').write_text('# local\nWEB_DIR=./web\nPORT=7000\n')
93
+ os.environ['PORT'] = '7100'
94
+ env = magehand.resolve_env(APP)
95
+ self.assertEqual(env['WEB_DIR'], './web') # .magehand.env over the manifest
96
+ self.assertEqual(env['PORT'], '7100') # the caller's environment over both
97
+
98
+ def test_container_mode_is_exactly_the_pod(self):
99
+ pathlib.Path('.magehand.env').write_text('WEB_DIR=./web\n')
100
+ os.environ['PORT'] = '7100'
101
+ env = magehand.resolve_env(APP, container=True)
102
+ self.assertEqual(env['WEB_DIR'], '/app/web') # no host overrides
103
+ self.assertEqual(env['PORT'], '8000')
104
+ self.assertEqual(env['DATABASE_URL'], 'postgresql://app:pw@magehand-demo-db:5432/app')
105
+ self.assertEqual(env['REDIS_URL'], 'redis://:rpw@magehand-demo-cache:6379/0')
106
+
107
+ def test_missing_key_fails(self):
108
+ bad = {**APP, 'container': {'env': [
109
+ {'name': 'X', 'valueFrom': {'secretKeyRef': {'name': 'ai-connection', 'key': 'nope'}}}]}}
110
+ with self.assertRaises(SystemExit):
111
+ magehand.resolve_env(bad)
112
+
113
+
114
+ class Container(unittest.TestCase):
115
+ def test_hardened_and_secrets_by_name_only(self):
116
+ env = {'LLM_KEY': 'sk-secret', 'PORT': '8000'}
117
+ args = magehand.container_args(APP, env, 'magehand/demo:local', 8000, ['llm.lab.davidlarrimore.com:192.0.2.5'], [])
118
+ joined = ' '.join(args)
119
+ self.assertNotIn('sk-secret', joined)
120
+ self.assertIn('-e LLM_KEY', joined)
121
+ for flag in ('--read-only', '--cap-drop ALL', '--security-opt no-new-privileges', '--user 1000:1000',
122
+ '-p 127.0.0.1:8000:8000', '--network magehand-demo',
123
+ '--add-host llm.lab.davidlarrimore.com:192.0.2.5'):
124
+ self.assertIn(flag, joined)
125
+ self.assertEqual(args[args.index('--entrypoint') + 1:], ['python', 'magehand/demo:local', '-B', '/app/app.py'])
126
+
127
+ def test_explicit_command_replaces_the_pods(self):
128
+ args = magehand.container_args(APP, {}, 'img', 8000, [], ['sh', '-c', 'true'])
129
+ self.assertNotIn('--entrypoint', args)
130
+ self.assertEqual(args[-4:], ['img', 'sh', '-c', 'true'])
131
+
132
+ def test_lab_hosts(self):
133
+ saved = magehand.socket.gethostbyname
134
+ magehand.socket.gethostbyname = lambda h: '127.0.0.1' if h.startswith('llm.') else '192.0.2.7'
135
+ try:
136
+ hosts = magehand.lab_hosts({'A': 'https://llm.lab.davidlarrimore.com/v1',
137
+ 'B': 'https://x.lab.davidlarrimore.com', 'C': 'https://example.com', 'D': 'plain'})
138
+ finally:
139
+ magehand.socket.gethostbyname = saved
140
+ self.assertEqual(hosts, ['llm.lab.davidlarrimore.com:host-gateway', 'x.lab.davidlarrimore.com:192.0.2.7'])
141
+
142
+
143
+ class Doctor(unittest.TestCase):
144
+ def test_lab_resolving_to_localhost_suggests_the_resolver(self):
145
+ import contextlib
146
+ import io
147
+ saved = (magehand.urllib.request.urlopen, magehand.socket.gethostbyname, magehand.github_token,
148
+ magehand.github_file, magehand.saved_token)
149
+
150
+ def no_route(*_a, **_k):
151
+ raise OSError('connection refused')
152
+ magehand.urllib.request.urlopen = no_route
153
+ magehand.socket.gethostbyname = lambda _h: '127.0.0.1'
154
+ magehand.github_token = lambda: 't'
155
+ magehand.github_file = lambda *_a, **_k: 'x'
156
+ magehand.saved_token = lambda: None
157
+ out = io.StringIO()
158
+ try:
159
+ with contextlib.redirect_stdout(out), self.assertRaises(SystemExit):
160
+ magehand.cmd_doctor([])
161
+ finally:
162
+ (magehand.urllib.request.urlopen, magehand.socket.gethostbyname, magehand.github_token,
163
+ magehand.github_file, magehand.saved_token) = saved
164
+ self.assertIn('/etc/resolver/lab.davidlarrimore.com', out.getvalue())
165
+
166
+
167
+ class ProxyHeaders(unittest.TestCase):
168
+ def test_injects_identity_and_strips_client_headers(self):
169
+ seen = {}
170
+
171
+ class Echo(http.server.BaseHTTPRequestHandler):
172
+ def do_GET(self):
173
+ seen.update({k.lower(): v for k, v in self.headers.items()})
174
+ body = b'ok'
175
+ self.send_response(200)
176
+ self.send_header('Content-Length', str(len(body)))
177
+ self.end_headers()
178
+ self.wfile.write(body)
179
+
180
+ def log_message(self, *args):
181
+ pass
182
+
183
+ backend = http.server.HTTPServer(('127.0.0.1', 0), Echo)
184
+ threading.Thread(target=backend.serve_forever, daemon=True).start()
185
+ magehand.PROXY_PORT = free_port()
186
+ proxy = magehand.start_proxy(backend.server_address[1], {'email': 'me@example.com', 'uid': 'u1',
187
+ 'username': 'me', 'groups': 'homelab-admins'})
188
+ try:
189
+ req = urllib.request.Request(f'http://127.0.0.1:{magehand.PROXY_PORT}/x',
190
+ headers={'X-authentik-email': 'forged@example.com'})
191
+ with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # local test server
192
+ self.assertEqual(resp.read(), b'ok')
193
+ finally:
194
+ proxy.shutdown()
195
+ proxy.server_close()
196
+ backend.shutdown()
197
+ backend.server_close()
198
+ self.assertEqual(seen['x-authentik-email'], 'me@example.com')
199
+ self.assertEqual(seen['x-authentik-uid'], 'u1')
200
+ self.assertEqual(seen['x-authentik-groups'], 'homelab-admins')
201
+
202
+
203
+ if __name__ == '__main__':
204
+ unittest.main()