llamactl 0.2.7a1__py3-none-any.whl → 0.3.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.
- llama_deploy/cli/__init__.py +9 -22
- llama_deploy/cli/app.py +69 -0
- llama_deploy/cli/auth/client.py +362 -0
- llama_deploy/cli/client.py +47 -170
- llama_deploy/cli/commands/aliased_group.py +33 -0
- llama_deploy/cli/commands/auth.py +696 -0
- llama_deploy/cli/commands/deployment.py +300 -0
- llama_deploy/cli/commands/env.py +211 -0
- llama_deploy/cli/commands/init.py +313 -0
- llama_deploy/cli/commands/serve.py +239 -0
- llama_deploy/cli/config/_config.py +390 -0
- llama_deploy/cli/config/_migrations.py +65 -0
- llama_deploy/cli/config/auth_service.py +130 -0
- llama_deploy/cli/config/env_service.py +67 -0
- llama_deploy/cli/config/migrations/0001_init.sql +35 -0
- llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
- llama_deploy/cli/config/migrations/__init__.py +7 -0
- llama_deploy/cli/config/schema.py +61 -0
- llama_deploy/cli/env.py +5 -3
- llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
- llama_deploy/cli/interactive_prompts/utils.py +6 -72
- llama_deploy/cli/options.py +27 -5
- llama_deploy/cli/py.typed +0 -0
- llama_deploy/cli/styles.py +10 -0
- llama_deploy/cli/textual/deployment_form.py +263 -36
- llama_deploy/cli/textual/deployment_help.py +53 -0
- llama_deploy/cli/textual/deployment_monitor.py +466 -0
- llama_deploy/cli/textual/git_validation.py +20 -21
- llama_deploy/cli/textual/github_callback_server.py +17 -14
- llama_deploy/cli/textual/llama_loader.py +13 -1
- llama_deploy/cli/textual/secrets_form.py +28 -8
- llama_deploy/cli/textual/styles.tcss +49 -8
- llama_deploy/cli/utils/env_inject.py +23 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
- llamactl-0.3.0.dist-info/RECORD +38 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
- llama_deploy/cli/commands.py +0 -549
- llama_deploy/cli/config.py +0 -173
- llama_deploy/cli/textual/profile_form.py +0 -171
- llamactl-0.2.7a1.dist-info/RECORD +0 -19
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Configuration and profile management for llamactl"""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._migrations import run_migrations
|
|
11
|
+
from .schema import DEFAULT_ENVIRONMENT, Auth, DeviceOIDC, Environment
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _serialize_device_oidc(value: DeviceOIDC | None) -> str | None:
|
|
15
|
+
if value is None:
|
|
16
|
+
return None
|
|
17
|
+
return value.model_dump_json()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _deserialize_device_oidc(value: str | None) -> DeviceOIDC | None:
|
|
21
|
+
if not value:
|
|
22
|
+
return None
|
|
23
|
+
return DeviceOIDC.model_validate_json(value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _to_auth(row: Any) -> Auth:
|
|
27
|
+
return Auth(
|
|
28
|
+
id=row[0],
|
|
29
|
+
name=row[1],
|
|
30
|
+
api_url=row[2],
|
|
31
|
+
project_id=row[3],
|
|
32
|
+
api_key=row[4],
|
|
33
|
+
api_key_id=row[5],
|
|
34
|
+
device_oidc=_deserialize_device_oidc(row[6]),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _to_environment(row: Any) -> Environment:
|
|
39
|
+
return Environment(
|
|
40
|
+
api_url=row[0],
|
|
41
|
+
requires_auth=bool(row[1]),
|
|
42
|
+
min_llamactl_version=row[2],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConfigManager:
|
|
47
|
+
"""Manages profiles and configuration using SQLite"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, init_database: bool = True):
|
|
50
|
+
self.config_dir = self._get_config_dir()
|
|
51
|
+
self.db_path = self.config_dir / "profiles.db"
|
|
52
|
+
self._ensure_config_dir()
|
|
53
|
+
if init_database:
|
|
54
|
+
self._init_database()
|
|
55
|
+
|
|
56
|
+
def _get_config_dir(self) -> Path:
|
|
57
|
+
"""Get the configuration directory path based on OS.
|
|
58
|
+
|
|
59
|
+
Honors LLAMACTL_CONFIG_DIR when set. This helps tests isolate state.
|
|
60
|
+
"""
|
|
61
|
+
override = os.environ.get("LLAMACTL_CONFIG_DIR")
|
|
62
|
+
if override:
|
|
63
|
+
return Path(override).expanduser()
|
|
64
|
+
if os.name == "nt": # Windows
|
|
65
|
+
config_dir = Path(os.environ.get("APPDATA", "~")) / "llamactl"
|
|
66
|
+
else: # Unix-like (Linux, macOS)
|
|
67
|
+
config_dir = Path.home() / ".config" / "llamactl"
|
|
68
|
+
return config_dir.expanduser()
|
|
69
|
+
|
|
70
|
+
def _ensure_config_dir(self):
|
|
71
|
+
"""Create configuration directory if it doesn't exist"""
|
|
72
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
def _init_database(self):
|
|
75
|
+
"""Initialize SQLite database and run migrations; then seed defaults."""
|
|
76
|
+
|
|
77
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
78
|
+
# Apply ad-hoc SQL migrations based on PRAGMA user_version
|
|
79
|
+
run_migrations(conn)
|
|
80
|
+
|
|
81
|
+
conn.commit()
|
|
82
|
+
|
|
83
|
+
def destroy_database(self):
|
|
84
|
+
"""Destroy the database"""
|
|
85
|
+
self.db_path.unlink()
|
|
86
|
+
self._init_database()
|
|
87
|
+
|
|
88
|
+
#############################################
|
|
89
|
+
## Settings
|
|
90
|
+
#############################################
|
|
91
|
+
|
|
92
|
+
def set_settings_current_profile(self, name: str | None):
|
|
93
|
+
"""Set or clear the current active profile.
|
|
94
|
+
|
|
95
|
+
If name is None, the setting is removed.
|
|
96
|
+
"""
|
|
97
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
98
|
+
if name is None:
|
|
99
|
+
conn.execute("DELETE FROM settings WHERE key = 'current_profile'")
|
|
100
|
+
else:
|
|
101
|
+
conn.execute(
|
|
102
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
|
|
103
|
+
(name,),
|
|
104
|
+
)
|
|
105
|
+
conn.commit()
|
|
106
|
+
|
|
107
|
+
def get_settings_current_profile_name(self) -> str | None:
|
|
108
|
+
"""Get the name of the current active profile"""
|
|
109
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
110
|
+
cursor = conn.execute(
|
|
111
|
+
"SELECT value FROM settings WHERE key = 'current_profile'"
|
|
112
|
+
)
|
|
113
|
+
row = cursor.fetchone()
|
|
114
|
+
return row[0] if row else None
|
|
115
|
+
|
|
116
|
+
def set_settings_current_environment(self, api_url: str) -> None:
|
|
117
|
+
"""Set the current environment by URL.
|
|
118
|
+
|
|
119
|
+
Requires the environment row to already exist (validated elsewhere, e.g. via
|
|
120
|
+
a probe before creation). Raises ValueError if not found.
|
|
121
|
+
"""
|
|
122
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
123
|
+
conn.execute(
|
|
124
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
125
|
+
(api_url,),
|
|
126
|
+
)
|
|
127
|
+
conn.commit()
|
|
128
|
+
|
|
129
|
+
def create_profile(
|
|
130
|
+
self,
|
|
131
|
+
name: str,
|
|
132
|
+
api_url: str,
|
|
133
|
+
project_id: str,
|
|
134
|
+
api_key: str | None = None,
|
|
135
|
+
api_key_id: str | None = None,
|
|
136
|
+
device_oidc: DeviceOIDC | None = None,
|
|
137
|
+
) -> Auth:
|
|
138
|
+
"""Create a new auth profile"""
|
|
139
|
+
if not project_id.strip():
|
|
140
|
+
raise ValueError("Project ID is required")
|
|
141
|
+
profile = Auth(
|
|
142
|
+
id=str(uuid.uuid4()),
|
|
143
|
+
name=name,
|
|
144
|
+
api_url=api_url,
|
|
145
|
+
project_id=project_id,
|
|
146
|
+
api_key=api_key,
|
|
147
|
+
api_key_id=api_key_id,
|
|
148
|
+
device_oidc=device_oidc,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
152
|
+
try:
|
|
153
|
+
conn.execute(
|
|
154
|
+
"INSERT INTO profiles (id, name, api_url, project_id, api_key, api_key_id, device_oidc) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
155
|
+
(
|
|
156
|
+
profile.id,
|
|
157
|
+
profile.name,
|
|
158
|
+
profile.api_url,
|
|
159
|
+
profile.project_id,
|
|
160
|
+
profile.api_key,
|
|
161
|
+
profile.api_key_id,
|
|
162
|
+
_serialize_device_oidc(profile.device_oidc),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
conn.commit()
|
|
166
|
+
except sqlite3.IntegrityError:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"Profile '{name}' already exists for environment '{api_url}'"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return profile
|
|
172
|
+
|
|
173
|
+
def get_current_profile(self, env_url: str) -> Auth | None:
|
|
174
|
+
"""Get the current active profile"""
|
|
175
|
+
current_name = self.get_settings_current_profile_name()
|
|
176
|
+
if current_name:
|
|
177
|
+
return self.get_profile(current_name, env_url)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
def get_current_environment(self) -> Environment:
|
|
181
|
+
"""Get the current active environment"""
|
|
182
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
183
|
+
cursor = conn.execute(
|
|
184
|
+
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
185
|
+
)
|
|
186
|
+
row = cursor.fetchone()
|
|
187
|
+
api_url = row[0] if row else DEFAULT_ENVIRONMENT.api_url
|
|
188
|
+
|
|
189
|
+
env_row = conn.execute(
|
|
190
|
+
"SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
|
|
191
|
+
(api_url,),
|
|
192
|
+
).fetchone()
|
|
193
|
+
if env_row:
|
|
194
|
+
return _to_environment(env_row)
|
|
195
|
+
|
|
196
|
+
# Fallback: return an in-memory default without writing to DB
|
|
197
|
+
if api_url == DEFAULT_ENVIRONMENT.api_url:
|
|
198
|
+
return DEFAULT_ENVIRONMENT
|
|
199
|
+
return Environment(
|
|
200
|
+
api_url=api_url, requires_auth=False, min_llamactl_version=None
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
##################################
|
|
204
|
+
## Profiles
|
|
205
|
+
##################################
|
|
206
|
+
|
|
207
|
+
def get_profile(self, name: str, env_url: str) -> Auth | None:
|
|
208
|
+
"""Get a profile by name"""
|
|
209
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
210
|
+
row = conn.execute(
|
|
211
|
+
"SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE name = ? AND api_url = ?",
|
|
212
|
+
(name, env_url),
|
|
213
|
+
).fetchone()
|
|
214
|
+
if row:
|
|
215
|
+
return _to_auth(row)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def get_profile_by_id(self, id: str) -> Auth | None:
|
|
219
|
+
"""Get a profile by ID"""
|
|
220
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
221
|
+
row = conn.execute(
|
|
222
|
+
"SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE id = ?",
|
|
223
|
+
(id,),
|
|
224
|
+
).fetchone()
|
|
225
|
+
if row:
|
|
226
|
+
return _to_auth(row)
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def get_profile_by_api_key(self, env_url: str, api_key: str) -> Auth | None:
|
|
230
|
+
"""Get a profile by api_key within an environment URL."""
|
|
231
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
232
|
+
row = conn.execute(
|
|
233
|
+
"""
|
|
234
|
+
SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc
|
|
235
|
+
FROM profiles
|
|
236
|
+
WHERE api_url = ? AND api_key = ?
|
|
237
|
+
LIMIT 1
|
|
238
|
+
""",
|
|
239
|
+
(env_url, api_key),
|
|
240
|
+
).fetchone()
|
|
241
|
+
if row:
|
|
242
|
+
return _to_auth(row)
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
def get_profile_by_device_user_id(self, env_url: str, user_id: str) -> Auth | None:
|
|
246
|
+
"""Get a profile by device OIDC user_id within an environment URL."""
|
|
247
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
248
|
+
row = conn.execute(
|
|
249
|
+
"""
|
|
250
|
+
SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc
|
|
251
|
+
FROM profiles
|
|
252
|
+
WHERE api_url = ? AND JSON_EXTRACT(device_oidc, '$.user_id') = ?
|
|
253
|
+
LIMIT 1
|
|
254
|
+
""",
|
|
255
|
+
(env_url, user_id),
|
|
256
|
+
).fetchone()
|
|
257
|
+
if row:
|
|
258
|
+
return _to_auth(row)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def list_profiles(self, env_url: str) -> list[Auth]:
|
|
262
|
+
"""List all profiles"""
|
|
263
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
264
|
+
return [
|
|
265
|
+
_to_auth(row)
|
|
266
|
+
for row in conn.execute(
|
|
267
|
+
"SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE api_url = ? ORDER BY name",
|
|
268
|
+
(env_url,),
|
|
269
|
+
).fetchall()
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
def delete_profile(self, name: str, env_url: str) -> bool:
|
|
273
|
+
"""Delete a profile by name. Returns True if deleted, False if not found."""
|
|
274
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
275
|
+
cursor = conn.execute(
|
|
276
|
+
"DELETE FROM profiles WHERE name = ? AND api_url = ?", (name, env_url)
|
|
277
|
+
)
|
|
278
|
+
conn.commit()
|
|
279
|
+
|
|
280
|
+
# If this was the active profile, clear it
|
|
281
|
+
if self.get_settings_current_profile_name() == name:
|
|
282
|
+
self.set_settings_current_profile(None)
|
|
283
|
+
|
|
284
|
+
return cursor.rowcount > 0
|
|
285
|
+
|
|
286
|
+
def set_project(self, profile_name: str, env_url: str, project_id: str) -> bool:
|
|
287
|
+
"""Set the project for a profile. Returns True if profile exists."""
|
|
288
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
289
|
+
cursor = conn.execute(
|
|
290
|
+
"UPDATE profiles SET project_id = ? WHERE name = ? AND api_url = ?",
|
|
291
|
+
(project_id, profile_name, env_url),
|
|
292
|
+
)
|
|
293
|
+
conn.commit()
|
|
294
|
+
return cursor.rowcount > 0
|
|
295
|
+
|
|
296
|
+
def update_profile(self, profile: Auth) -> None:
|
|
297
|
+
"""Update a profile"""
|
|
298
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
299
|
+
conn.execute(
|
|
300
|
+
"UPDATE profiles SET name = ?, api_url = ?, project_id = ?, api_key = ?, api_key_id = ?, device_oidc = ? WHERE id = ?",
|
|
301
|
+
(
|
|
302
|
+
profile.name,
|
|
303
|
+
profile.api_url,
|
|
304
|
+
profile.project_id,
|
|
305
|
+
profile.api_key,
|
|
306
|
+
profile.api_key_id,
|
|
307
|
+
_serialize_device_oidc(profile.device_oidc),
|
|
308
|
+
profile.id,
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
conn.commit()
|
|
312
|
+
|
|
313
|
+
##################################
|
|
314
|
+
## Environments
|
|
315
|
+
##################################
|
|
316
|
+
def create_or_update_environment(
|
|
317
|
+
self, api_url: str, requires_auth: bool, min_llamactl_version: str | None = None
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Create or update an environment row."""
|
|
320
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
321
|
+
conn.execute(
|
|
322
|
+
"INSERT OR REPLACE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
|
|
323
|
+
(api_url, 1 if requires_auth else 0, min_llamactl_version),
|
|
324
|
+
)
|
|
325
|
+
conn.commit()
|
|
326
|
+
|
|
327
|
+
def get_environment(self, api_url: str) -> Environment | None:
|
|
328
|
+
"""Retrieve an environment by URL."""
|
|
329
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
330
|
+
row = conn.execute(
|
|
331
|
+
"SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
|
|
332
|
+
(api_url,),
|
|
333
|
+
).fetchone()
|
|
334
|
+
if row:
|
|
335
|
+
return _to_environment(row)
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def list_environments(self) -> list[Environment]:
|
|
339
|
+
"""List all environments."""
|
|
340
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
341
|
+
envs = [
|
|
342
|
+
_to_environment(row)
|
|
343
|
+
for row in conn.execute(
|
|
344
|
+
"SELECT api_url, requires_auth, min_llamactl_version FROM environments ORDER BY api_url"
|
|
345
|
+
).fetchall()
|
|
346
|
+
]
|
|
347
|
+
if not envs:
|
|
348
|
+
envs = [DEFAULT_ENVIRONMENT]
|
|
349
|
+
return envs
|
|
350
|
+
|
|
351
|
+
def delete_environment(self, api_url: str) -> bool:
|
|
352
|
+
"""Delete an environment and all associated profiles.
|
|
353
|
+
|
|
354
|
+
Returns True if the environment existed and was deleted, False otherwise.
|
|
355
|
+
If the deleted environment was current, switch current to the default URL.
|
|
356
|
+
"""
|
|
357
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
358
|
+
# Check existence
|
|
359
|
+
exists_cursor = conn.execute(
|
|
360
|
+
"SELECT 1 FROM environments WHERE api_url = ?",
|
|
361
|
+
(api_url,),
|
|
362
|
+
)
|
|
363
|
+
if exists_cursor.fetchone() is None:
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
# Delete profiles tied to this environment
|
|
367
|
+
conn.execute("DELETE FROM profiles WHERE api_url = ?", (api_url,))
|
|
368
|
+
|
|
369
|
+
# Delete environment row
|
|
370
|
+
conn.execute("DELETE FROM environments WHERE api_url = ?", (api_url,))
|
|
371
|
+
|
|
372
|
+
# If current environment is this one, reset to default
|
|
373
|
+
setting_cursor = conn.execute(
|
|
374
|
+
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
375
|
+
)
|
|
376
|
+
row = setting_cursor.fetchone()
|
|
377
|
+
if row and row[0] == api_url:
|
|
378
|
+
conn.execute(
|
|
379
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
380
|
+
(DEFAULT_ENVIRONMENT.api_url,),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
conn.commit()
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Global config manager instance
|
|
388
|
+
@functools.cache
|
|
389
|
+
def config_manager() -> ConfigManager:
|
|
390
|
+
return ConfigManager()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Ad-hoc SQLite schema migrations using PRAGMA user_version.
|
|
2
|
+
|
|
3
|
+
Inspired by https://eskerda.com/sqlite-schema-migrations-python/
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import sqlite3
|
|
11
|
+
from importlib import import_module, resources
|
|
12
|
+
from importlib.resources.abc import Traversable
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_MIGRATIONS_PKG = "llama_deploy.cli.config.migrations"
|
|
18
|
+
_USER_VERSION_PATTERN = re.compile(r"pragma\s+user_version\s*=\s*(\d+)", re.IGNORECASE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_migration_files() -> list[Traversable]:
|
|
22
|
+
"""Yield packaged SQL migration files in lexicographic order."""
|
|
23
|
+
pkg = import_module(_MIGRATIONS_PKG)
|
|
24
|
+
root = resources.files(pkg)
|
|
25
|
+
files = (p for p in root.iterdir() if p.name.endswith(".sql"))
|
|
26
|
+
if not files:
|
|
27
|
+
raise ValueError("No migration files found")
|
|
28
|
+
return sorted(files, key=lambda p: p.name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_target_version(sql_text: str) -> int | None:
|
|
32
|
+
"""Return target schema version declared in the first PRAGMA line, if any."""
|
|
33
|
+
first_line = sql_text.splitlines()[0] if sql_text else ""
|
|
34
|
+
match = _USER_VERSION_PATTERN.search(first_line)
|
|
35
|
+
return int(match.group(1)) if match else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_migrations(conn: sqlite3.Connection) -> None:
|
|
39
|
+
"""Apply pending migrations found under the migrations package.
|
|
40
|
+
|
|
41
|
+
Each migration file should start with a `PRAGMA user_version=N;` line.
|
|
42
|
+
Files are applied in lexicographic order and only when N > current_version.
|
|
43
|
+
"""
|
|
44
|
+
cur = conn.cursor()
|
|
45
|
+
current_version_row = cur.execute("PRAGMA user_version").fetchone()
|
|
46
|
+
current_version = int(current_version_row[0]) if current_version_row else 0
|
|
47
|
+
|
|
48
|
+
for path in _iter_migration_files():
|
|
49
|
+
sql_text = path.read_text()
|
|
50
|
+
target_version = _parse_target_version(sql_text) or 0
|
|
51
|
+
if target_version <= current_version:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
logger.debug(
|
|
56
|
+
"Applying migration %s → target version %s", path.name, target_version
|
|
57
|
+
)
|
|
58
|
+
cur.executescript("BEGIN;\n" + sql_text)
|
|
59
|
+
except Exception as exc: # noqa: BLE001 – we surface the exact error
|
|
60
|
+
logger.error("Failed migration %s: %s", path.name, exc)
|
|
61
|
+
cur.execute("ROLLBACK")
|
|
62
|
+
raise
|
|
63
|
+
else:
|
|
64
|
+
cur.execute("COMMIT")
|
|
65
|
+
current_version = target_version
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from llama_deploy.cli.auth.client import PlatformAuthClient, RefreshMiddleware
|
|
4
|
+
from llama_deploy.cli.config._config import Auth, ConfigManager, Environment
|
|
5
|
+
from llama_deploy.cli.config.schema import DeviceOIDC
|
|
6
|
+
from llama_deploy.core.client.manage_client import ControlPlaneClient, httpx
|
|
7
|
+
from llama_deploy.core.schema import VersionResponse
|
|
8
|
+
from llama_deploy.core.schema.projects import ProjectSummary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthService:
|
|
12
|
+
def __init__(self, config_manager: ConfigManager, env: Environment):
|
|
13
|
+
self.config_manager = config_manager
|
|
14
|
+
self.env = env
|
|
15
|
+
|
|
16
|
+
def list_profiles(self) -> list[Auth]:
|
|
17
|
+
return self.config_manager.list_profiles(self.env.api_url)
|
|
18
|
+
|
|
19
|
+
def get_profile(self, name: str) -> Auth | None:
|
|
20
|
+
return self.config_manager.get_profile(name, self.env.api_url)
|
|
21
|
+
|
|
22
|
+
def get_profile_by_id(self, id: str) -> Auth | None:
|
|
23
|
+
return self.config_manager.get_profile_by_id(id)
|
|
24
|
+
|
|
25
|
+
def set_current_profile(self, name: str) -> None:
|
|
26
|
+
self.config_manager.set_settings_current_profile(name)
|
|
27
|
+
|
|
28
|
+
def select_any_profile(self) -> None:
|
|
29
|
+
# best effort to select a profile within the environment
|
|
30
|
+
profiles = self.list_profiles()
|
|
31
|
+
if profiles:
|
|
32
|
+
self.set_current_profile(profiles[0].name)
|
|
33
|
+
|
|
34
|
+
def get_current_profile(self) -> Auth | None:
|
|
35
|
+
return self.config_manager.get_current_profile(self.env.api_url)
|
|
36
|
+
|
|
37
|
+
def create_profile_from_token(self, project_id: str, api_key: str | None) -> Auth:
|
|
38
|
+
base = _auto_profile_name_from_token(api_key or "") if api_key else "default"
|
|
39
|
+
auth = self.config_manager.create_profile(
|
|
40
|
+
base, self.env.api_url, project_id, api_key
|
|
41
|
+
)
|
|
42
|
+
self.config_manager.set_settings_current_profile(auth.name)
|
|
43
|
+
return auth
|
|
44
|
+
|
|
45
|
+
def create_or_update_profile_from_oidc(
|
|
46
|
+
self, project_id: str, device_oidc: DeviceOIDC
|
|
47
|
+
) -> Auth:
|
|
48
|
+
base = device_oidc.email
|
|
49
|
+
existing = self.config_manager.get_profile_by_device_user_id(
|
|
50
|
+
self.env.api_url, device_oidc.user_id
|
|
51
|
+
)
|
|
52
|
+
if existing:
|
|
53
|
+
existing.device_oidc = device_oidc
|
|
54
|
+
self.config_manager.update_profile(existing)
|
|
55
|
+
auth = existing
|
|
56
|
+
else:
|
|
57
|
+
auth = self.config_manager.create_profile(
|
|
58
|
+
base, self.env.api_url, project_id, device_oidc=device_oidc
|
|
59
|
+
)
|
|
60
|
+
self.config_manager.set_settings_current_profile(auth.name)
|
|
61
|
+
return auth
|
|
62
|
+
|
|
63
|
+
def update_profile(self, profile: Auth) -> None:
|
|
64
|
+
self.config_manager.update_profile(profile)
|
|
65
|
+
|
|
66
|
+
async def delete_profile(self, name: str) -> bool:
|
|
67
|
+
profile = self.get_profile(name)
|
|
68
|
+
if profile and profile.api_key_id:
|
|
69
|
+
async with self.profile_client(profile) as client:
|
|
70
|
+
try:
|
|
71
|
+
await client.delete_api_key(profile.api_key_id)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return self.config_manager.delete_profile(name, self.env.api_url)
|
|
75
|
+
|
|
76
|
+
def set_project(self, name: str, project_id: str) -> None:
|
|
77
|
+
self.config_manager.set_project(name, self.env.api_url, project_id)
|
|
78
|
+
|
|
79
|
+
def fetch_server_version(self) -> VersionResponse:
|
|
80
|
+
async def _fetch_server_version() -> VersionResponse:
|
|
81
|
+
async with ControlPlaneClient.ctx(self.env.api_url) as client:
|
|
82
|
+
version = await client.server_version()
|
|
83
|
+
return version
|
|
84
|
+
|
|
85
|
+
return asyncio.run(_fetch_server_version())
|
|
86
|
+
|
|
87
|
+
def _validate_token_and_list_projects(self, api_key: str) -> list[ProjectSummary]:
|
|
88
|
+
async def _run():
|
|
89
|
+
async with ControlPlaneClient.ctx(self.env.api_url, api_key) as client:
|
|
90
|
+
return await client.list_projects()
|
|
91
|
+
|
|
92
|
+
return asyncio.run(_run())
|
|
93
|
+
|
|
94
|
+
def auth_middleware(self, profile: Auth | None = None) -> httpx.Auth | None:
|
|
95
|
+
profile = profile or self.get_current_profile()
|
|
96
|
+
if profile and profile.device_oidc:
|
|
97
|
+
_profile = profile # copy to assist type checker being inflexible
|
|
98
|
+
|
|
99
|
+
async def _on_refresh(updated: DeviceOIDC) -> None:
|
|
100
|
+
# Persist refreshed tokens to the database synchronously within async wrapper
|
|
101
|
+
self.refresh_to_db(_profile.id, updated)
|
|
102
|
+
|
|
103
|
+
return RefreshMiddleware(
|
|
104
|
+
profile.device_oidc,
|
|
105
|
+
_on_refresh,
|
|
106
|
+
)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def refresh_to_db(self, profile_id: str, device_oidc: DeviceOIDC) -> None:
|
|
110
|
+
profile = self.get_profile_by_id(profile_id)
|
|
111
|
+
if profile:
|
|
112
|
+
profile.device_oidc = device_oidc
|
|
113
|
+
self.config_manager.update_profile(profile)
|
|
114
|
+
|
|
115
|
+
def profile_client(self, profile: Auth | None = None) -> PlatformAuthClient:
|
|
116
|
+
profile = profile or self.get_current_profile()
|
|
117
|
+
if not profile:
|
|
118
|
+
raise ValueError("No active profile")
|
|
119
|
+
return PlatformAuthClient(
|
|
120
|
+
profile.api_url, profile.api_key, self.auth_middleware(profile)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _auto_profile_name_from_token(api_key: str) -> str:
|
|
125
|
+
token = api_key or "token"
|
|
126
|
+
cleaned = token.replace(" ", "")
|
|
127
|
+
first = cleaned[:6]
|
|
128
|
+
last = cleaned[-4:] if len(cleaned) > 10 else cleaned[-2:]
|
|
129
|
+
base = f"{first}****{last}"
|
|
130
|
+
return base
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from llama_deploy.cli.config.schema import Environment
|
|
5
|
+
|
|
6
|
+
from ._config import ConfigManager, config_manager
|
|
7
|
+
from .auth_service import AuthService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EnvService:
|
|
11
|
+
def __init__(self, config_manager: Callable[[], ConfigManager]):
|
|
12
|
+
self.config_manager = config_manager
|
|
13
|
+
|
|
14
|
+
def list_environments(self) -> list[Environment]:
|
|
15
|
+
return self.config_manager().list_environments()
|
|
16
|
+
|
|
17
|
+
def get_current_environment(self) -> Environment:
|
|
18
|
+
return self.config_manager().get_current_environment()
|
|
19
|
+
|
|
20
|
+
def switch_environment(self, api_url: str) -> Environment:
|
|
21
|
+
env = self.config_manager().get_environment(api_url)
|
|
22
|
+
if not env:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"Environment '{api_url}' not found. Add it with 'llamactl auth env add <API_URL>'"
|
|
25
|
+
)
|
|
26
|
+
self.config_manager().set_settings_current_environment(api_url)
|
|
27
|
+
self.config_manager().set_settings_current_profile(None)
|
|
28
|
+
return env
|
|
29
|
+
|
|
30
|
+
def create_or_update_environment(self, env: Environment) -> None:
|
|
31
|
+
self.config_manager().create_or_update_environment(
|
|
32
|
+
env.api_url, env.requires_auth, env.min_llamactl_version
|
|
33
|
+
)
|
|
34
|
+
self.config_manager().set_settings_current_environment(env.api_url)
|
|
35
|
+
self.config_manager().set_settings_current_profile(None)
|
|
36
|
+
|
|
37
|
+
def delete_environment(self, api_url: str) -> bool:
|
|
38
|
+
return self.config_manager().delete_environment(api_url)
|
|
39
|
+
|
|
40
|
+
def current_auth_service(self) -> AuthService:
|
|
41
|
+
return AuthService(self.config_manager(), self.get_current_environment())
|
|
42
|
+
|
|
43
|
+
def auto_update_env(self, env: Environment) -> Environment:
|
|
44
|
+
svc = AuthService(self.config_manager(), env)
|
|
45
|
+
version = svc.fetch_server_version()
|
|
46
|
+
update = replace(env)
|
|
47
|
+
update.requires_auth = version.requires_auth
|
|
48
|
+
update.min_llamactl_version = version.min_llamactl_version
|
|
49
|
+
if update != env:
|
|
50
|
+
self.config_manager().create_or_update_environment(
|
|
51
|
+
update.api_url, update.requires_auth, update.min_llamactl_version
|
|
52
|
+
)
|
|
53
|
+
return update
|
|
54
|
+
|
|
55
|
+
def probe_environment(self, api_url: str) -> Environment:
|
|
56
|
+
clean = api_url.rstrip("/")
|
|
57
|
+
base_env = Environment(
|
|
58
|
+
api_url=clean, requires_auth=False, min_llamactl_version=None
|
|
59
|
+
)
|
|
60
|
+
svc = AuthService(self.config_manager(), base_env)
|
|
61
|
+
version = svc.fetch_server_version()
|
|
62
|
+
base_env.requires_auth = version.requires_auth
|
|
63
|
+
base_env.min_llamactl_version = version.min_llamactl_version
|
|
64
|
+
return base_env
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
service = EnvService(config_manager)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
PRAGMA user_version=1;
|
|
2
|
+
|
|
3
|
+
-- Initial schema for llamactl config database
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
6
|
+
name TEXT NOT NULL,
|
|
7
|
+
api_url TEXT NOT NULL,
|
|
8
|
+
project_id TEXT NOT NULL,
|
|
9
|
+
api_key TEXT,
|
|
10
|
+
PRIMARY KEY (name, api_url)
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
14
|
+
key TEXT PRIMARY KEY,
|
|
15
|
+
value TEXT NOT NULL
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS environments (
|
|
19
|
+
api_url TEXT PRIMARY KEY,
|
|
20
|
+
requires_auth INTEGER NOT NULL,
|
|
21
|
+
min_llamactl_version TEXT
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- Seed defaults (idempotent)
|
|
25
|
+
-- 1) Ensure current environment setting exists (do not overwrite if already set)
|
|
26
|
+
INSERT OR IGNORE INTO settings (key, value)
|
|
27
|
+
VALUES ('current_environment_api_url', 'https://api.cloud.llamaindex.ai');
|
|
28
|
+
|
|
29
|
+
-- 2) Backfill environments from any existing profiles (avoid duplicates)
|
|
30
|
+
INSERT OR IGNORE INTO environments (api_url, requires_auth)
|
|
31
|
+
SELECT DISTINCT api_url, 0 FROM profiles;
|
|
32
|
+
|
|
33
|
+
-- 3) Ensure the default cloud environment exists with auth required
|
|
34
|
+
INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version)
|
|
35
|
+
VALUES ('https://api.cloud.llamaindex.ai', 1, NULL);
|