llamactl 0.3.0a19__py3-none-any.whl → 0.3.0a21__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/auth/client.py +362 -0
- llama_deploy/cli/client.py +14 -5
- llama_deploy/cli/commands/auth.py +300 -33
- llama_deploy/cli/commands/deployment.py +32 -38
- llama_deploy/cli/commands/env.py +19 -14
- llama_deploy/cli/commands/init.py +137 -34
- llama_deploy/cli/commands/serve.py +29 -12
- llama_deploy/cli/config/_config.py +178 -202
- llama_deploy/cli/config/_migrations.py +65 -0
- llama_deploy/cli/config/auth_service.py +64 -2
- llama_deploy/cli/config/env_service.py +15 -14
- 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 +30 -0
- llama_deploy/cli/env.py +2 -1
- llama_deploy/cli/styles.py +10 -0
- llama_deploy/cli/textual/deployment_form.py +63 -7
- llama_deploy/cli/textual/deployment_monitor.py +71 -108
- llama_deploy/cli/textual/github_callback_server.py +4 -4
- llama_deploy/cli/textual/secrets_form.py +4 -0
- llama_deploy/cli/textual/styles.tcss +7 -5
- {llamactl-0.3.0a19.dist-info → llamactl-0.3.0a21.dist-info}/METADATA +5 -3
- llamactl-0.3.0a21.dist-info/RECORD +37 -0
- llama_deploy/cli/platform_client.py +0 -52
- llamactl-0.3.0a19.dist-info/RECORD +0 -32
- {llamactl-0.3.0a19.dist-info → llamactl-0.3.0a21.dist-info}/WHEEL +0 -0
- {llamactl-0.3.0a19.dist-info → llamactl-0.3.0a21.dist-info}/entry_points.txt +0 -0
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
"""Configuration and profile management for llamactl"""
|
|
2
2
|
|
|
3
|
+
import functools
|
|
3
4
|
import os
|
|
4
5
|
import sqlite3
|
|
6
|
+
import uuid
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
|
-
from .
|
|
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)
|
|
9
24
|
|
|
10
25
|
|
|
11
26
|
def _to_auth(row: Any) -> Auth:
|
|
12
27
|
return Auth(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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]),
|
|
17
35
|
)
|
|
18
36
|
|
|
19
37
|
|
|
@@ -28,11 +46,12 @@ def _to_environment(row: Any) -> Environment:
|
|
|
28
46
|
class ConfigManager:
|
|
29
47
|
"""Manages profiles and configuration using SQLite"""
|
|
30
48
|
|
|
31
|
-
def __init__(self):
|
|
49
|
+
def __init__(self, init_database: bool = True):
|
|
32
50
|
self.config_dir = self._get_config_dir()
|
|
33
51
|
self.db_path = self.config_dir / "profiles.db"
|
|
34
52
|
self._ensure_config_dir()
|
|
35
|
-
|
|
53
|
+
if init_database:
|
|
54
|
+
self._init_database()
|
|
36
55
|
|
|
37
56
|
def _get_config_dir(self) -> Path:
|
|
38
57
|
"""Get the configuration directory path based on OS.
|
|
@@ -53,125 +72,58 @@ class ConfigManager:
|
|
|
53
72
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
54
73
|
|
|
55
74
|
def _init_database(self):
|
|
56
|
-
"""Initialize SQLite database
|
|
57
|
-
with sqlite3.connect(self.db_path) as conn:
|
|
58
|
-
# Check if we need to migrate from old schema
|
|
59
|
-
cursor = conn.execute("PRAGMA table_info(profiles)")
|
|
60
|
-
columns = [row[1] for row in cursor.fetchall()]
|
|
75
|
+
"""Initialize SQLite database and run migrations; then seed defaults."""
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
conn.execute(
|
|
66
|
-
"DELETE FROM profiles WHERE active_project_id IS NULL OR active_project_id = ''"
|
|
67
|
-
)
|
|
77
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
78
|
+
# Apply ad-hoc SQL migrations based on PRAGMA user_version
|
|
79
|
+
run_migrations(conn)
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
# Note: SQLite doesn't allow changing column constraints easily, but we enforce
|
|
71
|
-
# the NOT NULL constraint in our application code and new table creation
|
|
72
|
-
conn.execute(
|
|
73
|
-
"ALTER TABLE profiles RENAME COLUMN active_project_id TO project_id"
|
|
74
|
-
)
|
|
81
|
+
conn.commit()
|
|
75
82
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
conn.execute("ALTER TABLE profiles ADD COLUMN api_key TEXT")
|
|
81
|
-
|
|
82
|
-
# Migration: change profiles primary key from name to composite (name, api_url)
|
|
83
|
-
# Only perform if the table exists with single-column PK on name
|
|
84
|
-
pk_info_cur = conn.execute("PRAGMA table_info(profiles)")
|
|
85
|
-
pk_info_rows = pk_info_cur.fetchall()
|
|
86
|
-
pk_columns = [row[1] for row in pk_info_rows if len(row) > 5 and row[5] > 0]
|
|
87
|
-
if pk_columns == ["name"] and "api_url" in columns:
|
|
88
|
-
conn.execute("ALTER TABLE profiles RENAME TO profiles_old")
|
|
89
|
-
conn.execute(
|
|
90
|
-
"""
|
|
91
|
-
CREATE TABLE profiles (
|
|
92
|
-
name TEXT NOT NULL,
|
|
93
|
-
api_url TEXT NOT NULL,
|
|
94
|
-
project_id TEXT NOT NULL,
|
|
95
|
-
api_key TEXT,
|
|
96
|
-
PRIMARY KEY (name, api_url)
|
|
97
|
-
)
|
|
98
|
-
"""
|
|
99
|
-
)
|
|
100
|
-
conn.execute(
|
|
101
|
-
"""
|
|
102
|
-
INSERT INTO profiles (name, api_url, project_id, api_key)
|
|
103
|
-
SELECT name, api_url, project_id, api_key FROM profiles_old
|
|
104
|
-
"""
|
|
105
|
-
)
|
|
106
|
-
conn.execute("DROP TABLE profiles_old")
|
|
107
|
-
|
|
108
|
-
# Create tables with new schema (this will only create if they don't exist)
|
|
109
|
-
conn.execute("""
|
|
110
|
-
CREATE TABLE IF NOT EXISTS profiles (
|
|
111
|
-
name TEXT NOT NULL,
|
|
112
|
-
api_url TEXT NOT NULL,
|
|
113
|
-
project_id TEXT NOT NULL,
|
|
114
|
-
api_key TEXT,
|
|
115
|
-
PRIMARY KEY (name, api_url)
|
|
116
|
-
)
|
|
117
|
-
""")
|
|
83
|
+
def destroy_database(self):
|
|
84
|
+
"""Destroy the database"""
|
|
85
|
+
self.db_path.unlink()
|
|
86
|
+
self._init_database()
|
|
118
87
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
value TEXT NOT NULL
|
|
123
|
-
)
|
|
124
|
-
""")
|
|
88
|
+
#############################################
|
|
89
|
+
## Settings
|
|
90
|
+
#############################################
|
|
125
91
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"""
|
|
129
|
-
CREATE TABLE IF NOT EXISTS environments (
|
|
130
|
-
api_url TEXT PRIMARY KEY,
|
|
131
|
-
requires_auth INTEGER NOT NULL,
|
|
132
|
-
min_llamactl_version TEXT
|
|
133
|
-
)
|
|
134
|
-
"""
|
|
135
|
-
)
|
|
92
|
+
def set_settings_current_profile(self, name: str | None):
|
|
93
|
+
"""Set or clear the current active profile.
|
|
136
94
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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:
|
|
143
101
|
conn.execute(
|
|
144
|
-
"INSERT OR REPLACE INTO settings (key, value) VALUES ('
|
|
145
|
-
(
|
|
102
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
|
|
103
|
+
(name,),
|
|
146
104
|
)
|
|
105
|
+
conn.commit()
|
|
147
106
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
SELECT DISTINCT api_url, 0 FROM profiles
|
|
157
|
-
"""
|
|
158
|
-
)
|
|
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
|
|
159
115
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
163
|
-
)
|
|
164
|
-
cur_env_row = cur_env_cursor.fetchone()
|
|
165
|
-
if cur_env_row:
|
|
166
|
-
conn.execute(
|
|
167
|
-
"INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
|
|
168
|
-
(
|
|
169
|
-
cur_env_row[0],
|
|
170
|
-
1 if DEFAULT_ENVIRONMENT.requires_auth else 0,
|
|
171
|
-
DEFAULT_ENVIRONMENT.min_llamactl_version,
|
|
172
|
-
),
|
|
173
|
-
)
|
|
116
|
+
def set_settings_current_environment(self, api_url: str) -> None:
|
|
117
|
+
"""Set the current environment by URL.
|
|
174
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
|
+
)
|
|
175
127
|
conn.commit()
|
|
176
128
|
|
|
177
129
|
def create_profile(
|
|
@@ -180,26 +132,34 @@ class ConfigManager:
|
|
|
180
132
|
api_url: str,
|
|
181
133
|
project_id: str,
|
|
182
134
|
api_key: str | None = None,
|
|
135
|
+
api_key_id: str | None = None,
|
|
136
|
+
device_oidc: DeviceOIDC | None = None,
|
|
183
137
|
) -> Auth:
|
|
184
138
|
"""Create a new auth profile"""
|
|
185
139
|
if not project_id.strip():
|
|
186
140
|
raise ValueError("Project ID is required")
|
|
187
141
|
profile = Auth(
|
|
142
|
+
id=str(uuid.uuid4()),
|
|
188
143
|
name=name,
|
|
189
144
|
api_url=api_url,
|
|
190
145
|
project_id=project_id,
|
|
191
146
|
api_key=api_key,
|
|
147
|
+
api_key_id=api_key_id,
|
|
148
|
+
device_oidc=device_oidc,
|
|
192
149
|
)
|
|
193
150
|
|
|
194
151
|
with sqlite3.connect(self.db_path) as conn:
|
|
195
152
|
try:
|
|
196
153
|
conn.execute(
|
|
197
|
-
"INSERT INTO profiles (name, api_url, project_id, api_key) VALUES (?, ?, ?, ?)",
|
|
154
|
+
"INSERT INTO profiles (id, name, api_url, project_id, api_key, api_key_id, device_oidc) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
198
155
|
(
|
|
156
|
+
profile.id,
|
|
199
157
|
profile.name,
|
|
200
158
|
profile.api_url,
|
|
201
159
|
profile.project_id,
|
|
202
160
|
profile.api_key,
|
|
161
|
+
profile.api_key_id,
|
|
162
|
+
_serialize_device_oidc(profile.device_oidc),
|
|
203
163
|
),
|
|
204
164
|
)
|
|
205
165
|
conn.commit()
|
|
@@ -210,24 +170,101 @@ class ConfigManager:
|
|
|
210
170
|
|
|
211
171
|
return profile
|
|
212
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
|
+
|
|
213
207
|
def get_profile(self, name: str, env_url: str) -> Auth | None:
|
|
214
208
|
"""Get a profile by name"""
|
|
215
209
|
with sqlite3.connect(self.db_path) as conn:
|
|
216
210
|
row = conn.execute(
|
|
217
|
-
"SELECT name, api_url, project_id, api_key FROM profiles WHERE name = ? AND api_url = ?",
|
|
211
|
+
"SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE name = ? AND api_url = ?",
|
|
218
212
|
(name, env_url),
|
|
219
213
|
).fetchone()
|
|
220
214
|
if row:
|
|
221
215
|
return _to_auth(row)
|
|
222
216
|
return None
|
|
223
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
|
+
|
|
224
261
|
def list_profiles(self, env_url: str) -> list[Auth]:
|
|
225
262
|
"""List all profiles"""
|
|
226
263
|
with sqlite3.connect(self.db_path) as conn:
|
|
227
264
|
return [
|
|
228
265
|
_to_auth(row)
|
|
229
266
|
for row in conn.execute(
|
|
230
|
-
"SELECT name, api_url, project_id, api_key FROM profiles WHERE api_url = ? ORDER BY name",
|
|
267
|
+
"SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE api_url = ? ORDER BY name",
|
|
231
268
|
(env_url,),
|
|
232
269
|
).fetchall()
|
|
233
270
|
]
|
|
@@ -246,37 +283,6 @@ class ConfigManager:
|
|
|
246
283
|
|
|
247
284
|
return cursor.rowcount > 0
|
|
248
285
|
|
|
249
|
-
def set_settings_current_profile(self, name: str | None):
|
|
250
|
-
"""Set or clear the current active profile.
|
|
251
|
-
|
|
252
|
-
If name is None, the setting is removed.
|
|
253
|
-
"""
|
|
254
|
-
with sqlite3.connect(self.db_path) as conn:
|
|
255
|
-
if name is None:
|
|
256
|
-
conn.execute("DELETE FROM settings WHERE key = 'current_profile'")
|
|
257
|
-
else:
|
|
258
|
-
conn.execute(
|
|
259
|
-
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
|
|
260
|
-
(name,),
|
|
261
|
-
)
|
|
262
|
-
conn.commit()
|
|
263
|
-
|
|
264
|
-
def get_settings_current_profile_name(self) -> str | None:
|
|
265
|
-
"""Get the name of the current active profile"""
|
|
266
|
-
with sqlite3.connect(self.db_path) as conn:
|
|
267
|
-
cursor = conn.execute(
|
|
268
|
-
"SELECT value FROM settings WHERE key = 'current_profile'"
|
|
269
|
-
)
|
|
270
|
-
row = cursor.fetchone()
|
|
271
|
-
return row[0] if row else None
|
|
272
|
-
|
|
273
|
-
def get_current_profile(self, env_url: str) -> Auth | None:
|
|
274
|
-
"""Get the current active profile"""
|
|
275
|
-
current_name = self.get_settings_current_profile_name()
|
|
276
|
-
if current_name:
|
|
277
|
-
return self.get_profile(current_name, env_url)
|
|
278
|
-
return None
|
|
279
|
-
|
|
280
286
|
def set_project(self, profile_name: str, env_url: str, project_id: str) -> bool:
|
|
281
287
|
"""Set the project for a profile. Returns True if profile exists."""
|
|
282
288
|
with sqlite3.connect(self.db_path) as conn:
|
|
@@ -287,7 +293,26 @@ class ConfigManager:
|
|
|
287
293
|
conn.commit()
|
|
288
294
|
return cursor.rowcount > 0
|
|
289
295
|
|
|
290
|
-
|
|
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
|
+
##################################
|
|
291
316
|
def create_or_update_environment(
|
|
292
317
|
self, api_url: str, requires_auth: bool, min_llamactl_version: str | None = None
|
|
293
318
|
) -> None:
|
|
@@ -323,57 +348,6 @@ class ConfigManager:
|
|
|
323
348
|
envs = [DEFAULT_ENVIRONMENT]
|
|
324
349
|
return envs
|
|
325
350
|
|
|
326
|
-
def set_settings_current_environment(self, api_url: str) -> None:
|
|
327
|
-
"""Set the current environment by URL.
|
|
328
|
-
|
|
329
|
-
Requires the environment row to already exist (validated elsewhere, e.g. via
|
|
330
|
-
a probe before creation). Raises ValueError if not found.
|
|
331
|
-
"""
|
|
332
|
-
with sqlite3.connect(self.db_path) as conn:
|
|
333
|
-
conn.execute(
|
|
334
|
-
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
335
|
-
(api_url,),
|
|
336
|
-
)
|
|
337
|
-
conn.commit()
|
|
338
|
-
|
|
339
|
-
def get_current_environment(self) -> Environment:
|
|
340
|
-
"""Get the current environment.
|
|
341
|
-
|
|
342
|
-
Ensures there is a settings entry and a corresponding row in `environments`.
|
|
343
|
-
"""
|
|
344
|
-
with sqlite3.connect(self.db_path) as conn:
|
|
345
|
-
cursor = conn.execute(
|
|
346
|
-
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
347
|
-
)
|
|
348
|
-
row = cursor.fetchone()
|
|
349
|
-
api_url = row[0] if row else DEFAULT_ENVIRONMENT.api_url
|
|
350
|
-
if not row:
|
|
351
|
-
conn.execute(
|
|
352
|
-
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
353
|
-
(api_url,),
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
# Ensure environment exists
|
|
357
|
-
conn.execute(
|
|
358
|
-
"INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
|
|
359
|
-
(
|
|
360
|
-
api_url,
|
|
361
|
-
1 if DEFAULT_ENVIRONMENT.requires_auth else 0,
|
|
362
|
-
DEFAULT_ENVIRONMENT.min_llamactl_version,
|
|
363
|
-
),
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
# Read the environment
|
|
367
|
-
env_row = conn.execute(
|
|
368
|
-
"SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
|
|
369
|
-
(api_url,),
|
|
370
|
-
).fetchone()
|
|
371
|
-
if env_row:
|
|
372
|
-
return _to_environment(env_row)
|
|
373
|
-
|
|
374
|
-
# If we somehow got here without returning, raise an error
|
|
375
|
-
raise RuntimeError("Failed to load current environment")
|
|
376
|
-
|
|
377
351
|
def delete_environment(self, api_url: str) -> bool:
|
|
378
352
|
"""Delete an environment and all associated profiles.
|
|
379
353
|
|
|
@@ -411,4 +385,6 @@ class ConfigManager:
|
|
|
411
385
|
|
|
412
386
|
|
|
413
387
|
# Global config manager instance
|
|
414
|
-
|
|
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
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
|
+
from llama_deploy.cli.auth.client import PlatformAuthClient, RefreshMiddleware
|
|
3
4
|
from llama_deploy.cli.config._config import Auth, ConfigManager, Environment
|
|
4
|
-
from llama_deploy.
|
|
5
|
+
from llama_deploy.cli.config.schema import DeviceOIDC
|
|
6
|
+
from llama_deploy.core.client.manage_client import ControlPlaneClient, httpx
|
|
5
7
|
from llama_deploy.core.schema import VersionResponse
|
|
6
8
|
from llama_deploy.core.schema.projects import ProjectSummary
|
|
7
9
|
|
|
@@ -17,6 +19,9 @@ class AuthService:
|
|
|
17
19
|
def get_profile(self, name: str) -> Auth | None:
|
|
18
20
|
return self.config_manager.get_profile(name, self.env.api_url)
|
|
19
21
|
|
|
22
|
+
def get_profile_by_id(self, id: str) -> Auth | None:
|
|
23
|
+
return self.config_manager.get_profile_by_id(id)
|
|
24
|
+
|
|
20
25
|
def set_current_profile(self, name: str) -> None:
|
|
21
26
|
self.config_manager.set_settings_current_profile(name)
|
|
22
27
|
|
|
@@ -37,7 +42,35 @@ class AuthService:
|
|
|
37
42
|
self.config_manager.set_settings_current_profile(auth.name)
|
|
38
43
|
return auth
|
|
39
44
|
|
|
40
|
-
def
|
|
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
|
|
41
74
|
return self.config_manager.delete_profile(name, self.env.api_url)
|
|
42
75
|
|
|
43
76
|
def set_project(self, name: str, project_id: str) -> None:
|
|
@@ -58,6 +91,35 @@ class AuthService:
|
|
|
58
91
|
|
|
59
92
|
return asyncio.run(_run())
|
|
60
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
|
+
|
|
61
123
|
|
|
62
124
|
def _auto_profile_name_from_token(api_key: str) -> str:
|
|
63
125
|
token = api_key or "token"
|