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,696 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import httpx
|
|
12
|
+
import questionary
|
|
13
|
+
from dotenv import set_key
|
|
14
|
+
from llama_deploy.cli.auth.client import (
|
|
15
|
+
DeviceAuthorizationRequest,
|
|
16
|
+
OIDCClient,
|
|
17
|
+
PlatformAuthClient,
|
|
18
|
+
PlatformAuthDiscoveryClient,
|
|
19
|
+
TokenRequestDeviceCode,
|
|
20
|
+
decode_jwt_claims,
|
|
21
|
+
decode_jwt_claims_from_device_oidc,
|
|
22
|
+
)
|
|
23
|
+
from llama_deploy.cli.config._config import ConfigManager
|
|
24
|
+
from llama_deploy.cli.config.auth_service import AuthService
|
|
25
|
+
from llama_deploy.cli.config.env_service import service
|
|
26
|
+
from llama_deploy.cli.styles import (
|
|
27
|
+
ACTIVE_INDICATOR,
|
|
28
|
+
HEADER_COLOR,
|
|
29
|
+
MUTED_COL,
|
|
30
|
+
PRIMARY_COL,
|
|
31
|
+
WARNING,
|
|
32
|
+
)
|
|
33
|
+
from llama_deploy.cli.utils.env_inject import env_vars_from_profile
|
|
34
|
+
from llama_deploy.core.client.manage_client import (
|
|
35
|
+
ControlPlaneClient,
|
|
36
|
+
)
|
|
37
|
+
from llama_deploy.core.schema.projects import ProjectSummary
|
|
38
|
+
from rich import print as rprint
|
|
39
|
+
from rich.table import Table
|
|
40
|
+
from rich.text import Text
|
|
41
|
+
|
|
42
|
+
from ..app import app, console
|
|
43
|
+
from ..config.schema import Auth, DeviceOIDC
|
|
44
|
+
from ..options import global_options, interactive_option
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Create sub-applications for organizing commands
|
|
48
|
+
@app.group(
|
|
49
|
+
help="Login to llama cloud control plane to manage deployments",
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
)
|
|
52
|
+
@global_options
|
|
53
|
+
def auth() -> None:
|
|
54
|
+
"""Login to llama cloud control plane"""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@auth.command("token")
|
|
59
|
+
@global_options
|
|
60
|
+
@click.option(
|
|
61
|
+
"--project-id",
|
|
62
|
+
help="Project ID to use for the login when creating non-interactively",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--api-key",
|
|
66
|
+
help="API key to use for the login when creating non-interactively",
|
|
67
|
+
)
|
|
68
|
+
@interactive_option
|
|
69
|
+
def create_api_key_profile(
|
|
70
|
+
project_id: str | None,
|
|
71
|
+
api_key: str | None,
|
|
72
|
+
interactive: bool,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Authenticate with an API key and create a profile in the current environment."""
|
|
75
|
+
try:
|
|
76
|
+
auth_svc = service.current_auth_service()
|
|
77
|
+
|
|
78
|
+
# Non-interactive mode: require both api-key and project-id
|
|
79
|
+
if not interactive:
|
|
80
|
+
if not api_key or not project_id:
|
|
81
|
+
raise click.ClickException(
|
|
82
|
+
"--api-key and --project-id are required in non-interactive mode"
|
|
83
|
+
)
|
|
84
|
+
created = auth_svc.create_profile_from_token(project_id, api_key)
|
|
85
|
+
rprint(
|
|
86
|
+
f"[green]Created API key profile '{created.name}' and set as current[/green]"
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Interactive mode: prompt for token (masked) and validate
|
|
91
|
+
token_value = api_key or _prompt_for_api_key()
|
|
92
|
+
projects = _prompt_validate_api_key_and_list_projects(auth_svc, token_value)
|
|
93
|
+
|
|
94
|
+
# Select or enter project ID
|
|
95
|
+
selected_project_id = project_id or _select_or_enter_project(
|
|
96
|
+
projects, auth_svc.env.requires_auth
|
|
97
|
+
)
|
|
98
|
+
if not selected_project_id:
|
|
99
|
+
rprint(f"[{WARNING}]No project selected[/]")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
# Create and set profile
|
|
103
|
+
created = auth_svc.create_profile_from_token(selected_project_id, token_value)
|
|
104
|
+
rprint(
|
|
105
|
+
f"[green]Created API key profile '{created.name}' and set as current[/green]"
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
109
|
+
raise click.Abort()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@auth.command("login")
|
|
113
|
+
@global_options
|
|
114
|
+
def device_login() -> None:
|
|
115
|
+
"""Login via web browser"""
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
created = _create_device_profile()
|
|
119
|
+
rprint(
|
|
120
|
+
f"[green]Created login profile '{created.name}' and set as current[/green]"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
125
|
+
raise click.Abort()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@auth.command("list")
|
|
129
|
+
@global_options
|
|
130
|
+
def list_profiles() -> None:
|
|
131
|
+
"""List all logged in users/tokens"""
|
|
132
|
+
try:
|
|
133
|
+
auth_svc = service.current_auth_service()
|
|
134
|
+
profiles = auth_svc.list_profiles()
|
|
135
|
+
current = auth_svc.get_current_profile()
|
|
136
|
+
|
|
137
|
+
if not profiles:
|
|
138
|
+
rprint(f"[{WARNING}]No profiles found[/]")
|
|
139
|
+
if auth_svc.env.requires_auth:
|
|
140
|
+
rprint("Create one with: [cyan]llamactl auth login[/cyan]")
|
|
141
|
+
else:
|
|
142
|
+
rprint("Create one with: [cyan]llamactl auth token[/cyan]")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
table = Table(show_edge=False, box=None, header_style=f"bold {HEADER_COLOR}")
|
|
146
|
+
table.add_column(" Name", style=PRIMARY_COL)
|
|
147
|
+
table.add_column("Active Project", style=MUTED_COL)
|
|
148
|
+
|
|
149
|
+
for profile in profiles:
|
|
150
|
+
text = Text()
|
|
151
|
+
if profile == current:
|
|
152
|
+
text.append("* ", style=ACTIVE_INDICATOR)
|
|
153
|
+
else:
|
|
154
|
+
text.append(" ")
|
|
155
|
+
text.append(profile.name)
|
|
156
|
+
active_project = profile.project_id or "-"
|
|
157
|
+
table.add_row(
|
|
158
|
+
text,
|
|
159
|
+
active_project,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
console.print(table)
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
166
|
+
raise click.Abort()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@auth.command("destroy", hidden=True)
|
|
170
|
+
@global_options
|
|
171
|
+
def destroy_database() -> None:
|
|
172
|
+
"""Destroy the database"""
|
|
173
|
+
if not questionary.confirm(
|
|
174
|
+
"Are you sure you want to destroy all of your local logins? This action cannot be undone."
|
|
175
|
+
).ask():
|
|
176
|
+
return
|
|
177
|
+
ConfigManager(init_database=False).destroy_database()
|
|
178
|
+
rprint("[green]Database destroyed[/green]")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@auth.command("show-db", hidden=True)
|
|
182
|
+
@global_options
|
|
183
|
+
def config_database() -> None:
|
|
184
|
+
"""Config the database"""
|
|
185
|
+
path = service.config_manager().db_path
|
|
186
|
+
rprint(f"[bold]{path}[/bold]")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@auth.command("switch")
|
|
190
|
+
@global_options
|
|
191
|
+
@click.argument("name", required=False)
|
|
192
|
+
@interactive_option
|
|
193
|
+
def switch_profile(name: str | None, interactive: bool) -> None:
|
|
194
|
+
"""Switch to a different profile"""
|
|
195
|
+
auth_svc = service.current_auth_service()
|
|
196
|
+
try:
|
|
197
|
+
selected_auth = _select_profile(auth_svc, name, interactive)
|
|
198
|
+
if not selected_auth:
|
|
199
|
+
rprint(f"[{WARNING}]No profile selected[/]")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
auth_svc.set_current_profile(selected_auth.name)
|
|
203
|
+
rprint(f"[green]Switched to profile '{selected_auth.name}'[/green]")
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
207
|
+
raise click.Abort()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@auth.command("logout")
|
|
211
|
+
@global_options
|
|
212
|
+
@click.argument("name", required=False)
|
|
213
|
+
@interactive_option
|
|
214
|
+
def delete_profile(name: str | None, interactive: bool) -> None:
|
|
215
|
+
"""Logout from a profile and wipe all associated data"""
|
|
216
|
+
try:
|
|
217
|
+
auth_svc = service.current_auth_service()
|
|
218
|
+
auth = _select_profile(auth_svc, name, interactive)
|
|
219
|
+
if not auth:
|
|
220
|
+
rprint(f"[{WARNING}]No profile selected[/]")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
if asyncio.run(auth_svc.delete_profile(auth.name)):
|
|
224
|
+
rprint(f"[green]Logged out from '{auth.name}'[/green]")
|
|
225
|
+
else:
|
|
226
|
+
rprint(f"[red]Profile '{auth.name}' not found[/red]")
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
230
|
+
raise click.Abort()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Very simple introspection: decode current token via provider JWKS
|
|
234
|
+
@auth.command("me", hidden=True)
|
|
235
|
+
@global_options
|
|
236
|
+
def me() -> None:
|
|
237
|
+
"""Print JWT claims for the current profile's token using provider JWKS.
|
|
238
|
+
|
|
239
|
+
Assumes the stored API key is a JWT (e.g., OIDC id_token).
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
auth_svc = service.current_auth_service()
|
|
243
|
+
profile = auth_svc.get_current_profile()
|
|
244
|
+
if not profile or not profile.device_oidc:
|
|
245
|
+
raise click.ClickException(
|
|
246
|
+
"No OIDC profile selected. Run `llamactl auth login` or switch to an existing OIDC profile."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
claims = asyncio.run(decode_jwt_claims_from_device_oidc(profile.device_oidc))
|
|
250
|
+
click.echo(json.dumps(claims, indent=2, sort_keys=True))
|
|
251
|
+
except Exception as e:
|
|
252
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
253
|
+
raise click.Abort()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Projects commands
|
|
257
|
+
@auth.command("project")
|
|
258
|
+
@click.argument("project_id", required=False)
|
|
259
|
+
@interactive_option
|
|
260
|
+
@global_options
|
|
261
|
+
def change_project(project_id: str | None, interactive: bool) -> None:
|
|
262
|
+
"""Change the active project for the current profile"""
|
|
263
|
+
profile = validate_authenticated_profile(interactive)
|
|
264
|
+
if project_id and profile.project_id == project_id:
|
|
265
|
+
return
|
|
266
|
+
auth_svc = service.current_auth_service()
|
|
267
|
+
if project_id:
|
|
268
|
+
if auth_svc.env.requires_auth:
|
|
269
|
+
projects = _list_projects(auth_svc)
|
|
270
|
+
if not next(
|
|
271
|
+
(project for project in projects if project.project_id == project_id),
|
|
272
|
+
None,
|
|
273
|
+
):
|
|
274
|
+
raise click.ClickException(f"Project {project_id} not found")
|
|
275
|
+
auth_svc.set_project(profile.name, project_id)
|
|
276
|
+
rprint(f"Set active project to [bold green]{project_id}[/]")
|
|
277
|
+
return
|
|
278
|
+
if not interactive:
|
|
279
|
+
raise click.ClickException(
|
|
280
|
+
"No --project-id provided. Run `llamactl auth project --help` for more information."
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
projects = _list_projects(auth_svc)
|
|
284
|
+
|
|
285
|
+
if not projects:
|
|
286
|
+
rprint(f"[{WARNING}]No projects found[/]")
|
|
287
|
+
return
|
|
288
|
+
result = questionary.select(
|
|
289
|
+
"Select a project",
|
|
290
|
+
choices=[
|
|
291
|
+
questionary.Choice(
|
|
292
|
+
title=f"{project.project_name} ({project.deployment_count} deployments)",
|
|
293
|
+
value=project.project_id,
|
|
294
|
+
)
|
|
295
|
+
for project in projects
|
|
296
|
+
]
|
|
297
|
+
+ (
|
|
298
|
+
[questionary.Choice(title="Create new project", value="__CREATE__")]
|
|
299
|
+
if not auth_svc.env.requires_auth
|
|
300
|
+
else []
|
|
301
|
+
),
|
|
302
|
+
).ask()
|
|
303
|
+
if result == "__CREATE__":
|
|
304
|
+
project_id = questionary.text("Enter project ID").ask()
|
|
305
|
+
result = project_id
|
|
306
|
+
if result:
|
|
307
|
+
selected_project = next(
|
|
308
|
+
(project for project in projects if project.project_id == result), None
|
|
309
|
+
)
|
|
310
|
+
name = selected_project.project_name if selected_project else result
|
|
311
|
+
auth_svc.set_project(profile.name, result)
|
|
312
|
+
rprint(f"Set active project to [bold {PRIMARY_COL}]{name}[/]")
|
|
313
|
+
else:
|
|
314
|
+
rprint(f"[{WARNING}]No project selected[/]")
|
|
315
|
+
except Exception as e:
|
|
316
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
317
|
+
raise click.Abort()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@auth.command("inject")
|
|
321
|
+
@global_options
|
|
322
|
+
@click.option(
|
|
323
|
+
"--env-file",
|
|
324
|
+
"env_file",
|
|
325
|
+
default=Path(".env"),
|
|
326
|
+
type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
|
|
327
|
+
help="Path to the .env file to write",
|
|
328
|
+
)
|
|
329
|
+
@interactive_option
|
|
330
|
+
def inject_env_vars(
|
|
331
|
+
env_file: Path,
|
|
332
|
+
interactive: bool,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Inject auth environment variables into a .env file.
|
|
335
|
+
|
|
336
|
+
Writes LLAMA_CLOUD_API_KEY, LLAMA_CLOUD_BASE_URL, and LLAMA_DEPLOY_PROJECT_ID
|
|
337
|
+
based on the current profile. Always overwrites and creates the file if missing.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
auth_svc = service.current_auth_service()
|
|
341
|
+
profile = auth_svc.get_current_profile()
|
|
342
|
+
if not profile:
|
|
343
|
+
if interactive:
|
|
344
|
+
profile = validate_authenticated_profile(True)
|
|
345
|
+
else:
|
|
346
|
+
raise click.ClickException(
|
|
347
|
+
"No profile configured. Run `llamactl auth token` to create a profile."
|
|
348
|
+
)
|
|
349
|
+
if not profile.api_key:
|
|
350
|
+
raise click.ClickException(
|
|
351
|
+
"Current profile is unauthenticated (missing API key)"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
vars = env_vars_from_profile(profile)
|
|
355
|
+
if not vars:
|
|
356
|
+
rprint(f"[{WARNING}]No variables to inject[/]")
|
|
357
|
+
return
|
|
358
|
+
env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
for key, value in vars.items():
|
|
360
|
+
set_key(str(env_file), key, value)
|
|
361
|
+
rel = os.path.relpath(env_file, Path.cwd())
|
|
362
|
+
rprint(
|
|
363
|
+
f"[green]Wrote environment variables: {', '.join(vars.keys())} to {rel}[/green]"
|
|
364
|
+
)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
367
|
+
raise click.Abort()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _auto_device_name() -> str:
|
|
371
|
+
try:
|
|
372
|
+
if sys.platform == "darwin": # macOS
|
|
373
|
+
return (
|
|
374
|
+
subprocess.check_output(["scutil", "--get", "ComputerName"])
|
|
375
|
+
.decode()
|
|
376
|
+
.strip()
|
|
377
|
+
)
|
|
378
|
+
elif sys.platform.startswith("win"):
|
|
379
|
+
return os.environ["COMPUTERNAME"]
|
|
380
|
+
else: # Linux / Unix
|
|
381
|
+
return platform.node()
|
|
382
|
+
except Exception:
|
|
383
|
+
return platform.node()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def _create_or_update_agent_api_key(auth_svc: AuthService, profile: Auth) -> None:
|
|
387
|
+
"""
|
|
388
|
+
Mutates and updates the profile with an agent API key if it does not exist or is invalid.
|
|
389
|
+
"""
|
|
390
|
+
if profile.api_key is not None:
|
|
391
|
+
async with PlatformAuthClient(profile.api_url, profile.api_key) as client:
|
|
392
|
+
try:
|
|
393
|
+
await client.me()
|
|
394
|
+
except httpx.HTTPStatusError as e:
|
|
395
|
+
if e.response.status_code == 401:
|
|
396
|
+
# must have been deleted
|
|
397
|
+
profile.api_key = None
|
|
398
|
+
profile.api_key_id = None
|
|
399
|
+
else:
|
|
400
|
+
raise
|
|
401
|
+
if profile.api_key is None:
|
|
402
|
+
async with auth_svc.profile_client(profile) as client:
|
|
403
|
+
name = f"{profile.name} llamactl on {profile.device_oidc.device_name if profile.device_oidc else 'unknown'}"
|
|
404
|
+
api_key = await client.create_agent_api_key(name)
|
|
405
|
+
profile.api_key = api_key.token
|
|
406
|
+
profile.api_key_id = api_key.id
|
|
407
|
+
auth_svc.update_profile(profile)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _create_device_profile() -> Auth:
|
|
411
|
+
auth_svc = service.current_auth_service()
|
|
412
|
+
if not auth_svc.env.requires_auth:
|
|
413
|
+
raise click.ClickException("This environment does not support authentication")
|
|
414
|
+
|
|
415
|
+
base_url = auth_svc.env.api_url.rstrip("/")
|
|
416
|
+
|
|
417
|
+
oidc_device = asyncio.run(_run_device_authentication(base_url))
|
|
418
|
+
|
|
419
|
+
# Obtain or prompt for project ID and create profile
|
|
420
|
+
projects = _list_projects(auth_svc, oidc_device.device_access_token)
|
|
421
|
+
selected_project_id = _select_or_enter_project(projects, True)
|
|
422
|
+
if not selected_project_id:
|
|
423
|
+
raise click.ClickException(
|
|
424
|
+
"Project is required. Does this user have access to any projects?"
|
|
425
|
+
)
|
|
426
|
+
created = auth_svc.create_or_update_profile_from_oidc(
|
|
427
|
+
selected_project_id, oidc_device
|
|
428
|
+
)
|
|
429
|
+
asyncio.run(_create_or_update_agent_api_key(auth_svc, created))
|
|
430
|
+
return created
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
async def _run_device_authentication(base_url: str) -> DeviceOIDC:
|
|
434
|
+
device_name = _auto_device_name()
|
|
435
|
+
# 1) Discover upstream and CLI client_id via client
|
|
436
|
+
async with PlatformAuthDiscoveryClient(base_url) as discovery:
|
|
437
|
+
disc = await discovery.oidc_discovery()
|
|
438
|
+
upstream = disc.discovery_url
|
|
439
|
+
client_ids = disc.client_ids or {}
|
|
440
|
+
client_ids_list = list(client_ids.values())
|
|
441
|
+
client_id = client_ids.get("cli") or (
|
|
442
|
+
client_ids_list[0] if len(client_ids_list) == 1 else None
|
|
443
|
+
)
|
|
444
|
+
if not client_id:
|
|
445
|
+
raise click.ClickException(
|
|
446
|
+
"Expected 'cli' Client ID not found from auth discovery"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# 2) Device flow via typed OIDC client
|
|
450
|
+
async with OIDCClient() as oidc:
|
|
451
|
+
provider = await oidc.fetch_provider_configuration(upstream)
|
|
452
|
+
device_endpoint = provider.device_authorization_endpoint
|
|
453
|
+
token_endpoint = provider.token_endpoint
|
|
454
|
+
if not device_endpoint or not token_endpoint:
|
|
455
|
+
raise click.ClickException("Device Authorization not supported by provider")
|
|
456
|
+
|
|
457
|
+
scope_value = " ".join(sorted({"openid", "profile", "email", "offline_access"}))
|
|
458
|
+
|
|
459
|
+
# 3) Start device authorization
|
|
460
|
+
da = await oidc.device_authorization(
|
|
461
|
+
device_endpoint,
|
|
462
|
+
DeviceAuthorizationRequest(client_id=client_id, scope=scope_value),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
rprint(
|
|
466
|
+
"[bold]complete authentication by visiting the verification URI and confirming the device:[/bold]"
|
|
467
|
+
)
|
|
468
|
+
if da.verification_uri:
|
|
469
|
+
rprint(
|
|
470
|
+
f"Verification URI: {da.verification_uri} (will open in your browser if supported)"
|
|
471
|
+
)
|
|
472
|
+
if da.user_code:
|
|
473
|
+
rprint(f"User Code: {da.user_code} to confirm the device")
|
|
474
|
+
if da.verification_uri_complete:
|
|
475
|
+
try:
|
|
476
|
+
webbrowser.open(da.verification_uri_complete)
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
# 4) Poll token endpoint
|
|
481
|
+
interval = int(da.interval or 5)
|
|
482
|
+
while True:
|
|
483
|
+
await asyncio.sleep(interval)
|
|
484
|
+
token = await oidc.token_with_device_code(
|
|
485
|
+
token_endpoint,
|
|
486
|
+
TokenRequestDeviceCode(
|
|
487
|
+
device_code=da.device_code,
|
|
488
|
+
client_id=client_id,
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
if token.error in {"authorization_pending", "slow_down"}:
|
|
492
|
+
if token.error == "slow_down":
|
|
493
|
+
interval += 5
|
|
494
|
+
continue
|
|
495
|
+
if token.error:
|
|
496
|
+
raise click.ClickException(
|
|
497
|
+
f"Token polling failed: {token.error} {token.error_description or ''}"
|
|
498
|
+
)
|
|
499
|
+
if token.id_token:
|
|
500
|
+
if not token.access_token:
|
|
501
|
+
raise click.ClickException(
|
|
502
|
+
"Device flow failed: token response missing access_token"
|
|
503
|
+
)
|
|
504
|
+
claims = await decode_jwt_claims(token.id_token, provider.jwks_uri)
|
|
505
|
+
email = claims.get("email")
|
|
506
|
+
if not email:
|
|
507
|
+
raise click.ClickException(
|
|
508
|
+
"Device flow failed: email not found in token"
|
|
509
|
+
)
|
|
510
|
+
user_id = claims.get("sub") or email
|
|
511
|
+
return DeviceOIDC(
|
|
512
|
+
device_name=device_name,
|
|
513
|
+
email=email,
|
|
514
|
+
user_id=user_id,
|
|
515
|
+
client_id=client_id,
|
|
516
|
+
discovery_url=upstream,
|
|
517
|
+
device_access_token=token.access_token,
|
|
518
|
+
device_refresh_token=token.refresh_token,
|
|
519
|
+
device_id_token=token.id_token,
|
|
520
|
+
)
|
|
521
|
+
raise click.ClickException("Device flow failed: unexpected token response")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def validate_authenticated_profile(interactive: bool) -> Auth:
|
|
525
|
+
"""Validate that the user is authenticated within the current environment.
|
|
526
|
+
|
|
527
|
+
- If there is a current profile, return it.
|
|
528
|
+
- If multiple profiles exist in the current environment, prompt to select in interactive mode.
|
|
529
|
+
- If none exist:
|
|
530
|
+
- If environment requires_auth: run token flow inline.
|
|
531
|
+
- Else: create profile without token after selecting a project.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
auth_svc = service.current_auth_service()
|
|
535
|
+
existing = auth_svc.get_current_profile()
|
|
536
|
+
if existing:
|
|
537
|
+
return existing
|
|
538
|
+
|
|
539
|
+
if not interactive:
|
|
540
|
+
raise click.ClickException(
|
|
541
|
+
"No profile configured. Run `llamactl auth token` to create a profile."
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Filter profiles by current environment
|
|
545
|
+
env_profiles = auth_svc.list_profiles()
|
|
546
|
+
current_env = auth_svc.env
|
|
547
|
+
|
|
548
|
+
if len(env_profiles) > 1:
|
|
549
|
+
# Prompt to select
|
|
550
|
+
choice: Auth | None = questionary.select(
|
|
551
|
+
"Select profile",
|
|
552
|
+
choices=[questionary.Choice(title=p.name, value=p) for p in env_profiles],
|
|
553
|
+
).ask()
|
|
554
|
+
if not choice:
|
|
555
|
+
raise click.ClickException("No profile selected")
|
|
556
|
+
auth_svc.set_current_profile(choice.name)
|
|
557
|
+
return choice
|
|
558
|
+
if len(env_profiles) == 1:
|
|
559
|
+
only = env_profiles[0]
|
|
560
|
+
auth_svc.set_current_profile(only.name)
|
|
561
|
+
return only
|
|
562
|
+
|
|
563
|
+
# No profiles exist for this env
|
|
564
|
+
if current_env.requires_auth:
|
|
565
|
+
# Inline token flow
|
|
566
|
+
created = _create_device_profile()
|
|
567
|
+
return created
|
|
568
|
+
else:
|
|
569
|
+
# No auth required: select project and create a default profile without token
|
|
570
|
+
project_id: str | None = questionary.text("Enter project ID").ask()
|
|
571
|
+
if not project_id:
|
|
572
|
+
raise click.ClickException("No project ID provided")
|
|
573
|
+
created = auth_svc.create_profile_from_token(project_id, None)
|
|
574
|
+
return created
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# -----------------------------
|
|
578
|
+
# Helpers for token/profile flow
|
|
579
|
+
# -----------------------------
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _prompt_for_api_key() -> str:
|
|
583
|
+
entered = questionary.password("Enter API key token to login").ask()
|
|
584
|
+
if entered:
|
|
585
|
+
return entered.strip()
|
|
586
|
+
raise click.ClickException("No API key entered")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _list_projects(
|
|
590
|
+
auth_svc: AuthService,
|
|
591
|
+
api_key: str | None = None,
|
|
592
|
+
) -> list[ProjectSummary]:
|
|
593
|
+
async def _run():
|
|
594
|
+
profile = auth_svc.get_current_profile()
|
|
595
|
+
async with ControlPlaneClient.ctx(
|
|
596
|
+
auth_svc.env.api_url,
|
|
597
|
+
api_key or (profile.api_key if profile else None),
|
|
598
|
+
None if api_key is not None else auth_svc.auth_middleware(profile),
|
|
599
|
+
) as client:
|
|
600
|
+
return await client.list_projects()
|
|
601
|
+
|
|
602
|
+
return asyncio.run(_run())
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _prompt_validate_api_key_and_list_projects(
|
|
606
|
+
auth_svc: AuthService, api_key: str
|
|
607
|
+
) -> list[ProjectSummary]:
|
|
608
|
+
try:
|
|
609
|
+
return _list_projects(auth_svc, api_key)
|
|
610
|
+
except httpx.HTTPStatusError as e:
|
|
611
|
+
if e.response.status_code == 401:
|
|
612
|
+
rprint("[red]Invalid API key. Please try again.[/red]")
|
|
613
|
+
return _prompt_validate_api_key_and_list_projects(
|
|
614
|
+
auth_svc, _prompt_for_api_key()
|
|
615
|
+
)
|
|
616
|
+
if e.response.status_code == 403:
|
|
617
|
+
rprint("[red]This environment requires a valid API key.[/red]")
|
|
618
|
+
return _prompt_validate_api_key_and_list_projects(
|
|
619
|
+
auth_svc, _prompt_for_api_key()
|
|
620
|
+
)
|
|
621
|
+
raise
|
|
622
|
+
except Exception as e:
|
|
623
|
+
raise click.ClickException(f"Failed to validate API key: {e}")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _select_or_enter_project(
|
|
627
|
+
projects: list[ProjectSummary], requires_auth: bool
|
|
628
|
+
) -> str | None:
|
|
629
|
+
if not projects:
|
|
630
|
+
return None
|
|
631
|
+
# select the only authorized project if there is only one
|
|
632
|
+
elif len(projects) == 1 and requires_auth:
|
|
633
|
+
return projects[0].project_id
|
|
634
|
+
else:
|
|
635
|
+
choice = questionary.select(
|
|
636
|
+
"Select a project",
|
|
637
|
+
choices=[
|
|
638
|
+
questionary.Choice(
|
|
639
|
+
title=f"{p.project_name} ({p.deployment_count} deployments)",
|
|
640
|
+
value=p.project_id,
|
|
641
|
+
)
|
|
642
|
+
for p in projects
|
|
643
|
+
],
|
|
644
|
+
).ask()
|
|
645
|
+
return choice
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _token_flow_for_env(auth_service: AuthService) -> Auth:
|
|
649
|
+
token_value = _prompt_for_api_key()
|
|
650
|
+
projects = _prompt_validate_api_key_and_list_projects(auth_service, token_value)
|
|
651
|
+
project_id = _select_or_enter_project(projects, auth_service.env.requires_auth)
|
|
652
|
+
if not project_id:
|
|
653
|
+
raise click.ClickException("No project selected")
|
|
654
|
+
created = auth_service.create_profile_from_token(project_id, token_value)
|
|
655
|
+
return created
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _select_profile(
|
|
659
|
+
auth_svc: AuthService, profile_name: str | None, is_interactive: bool
|
|
660
|
+
) -> Auth | None:
|
|
661
|
+
"""
|
|
662
|
+
Select a profile interactively if name not provided.
|
|
663
|
+
Returns the selected profile name or None if cancelled.
|
|
664
|
+
|
|
665
|
+
In non-interactive sessions, returns None if profile_name is not provided.
|
|
666
|
+
"""
|
|
667
|
+
if profile_name:
|
|
668
|
+
profile = auth_svc.get_profile(profile_name)
|
|
669
|
+
if profile:
|
|
670
|
+
return profile
|
|
671
|
+
|
|
672
|
+
# Don't attempt interactive selection in non-interactive sessions
|
|
673
|
+
if not is_interactive:
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
profiles = auth_svc.list_profiles()
|
|
678
|
+
|
|
679
|
+
if not profiles:
|
|
680
|
+
rprint(f"[{WARNING}]No profiles found[/]")
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
choices = []
|
|
684
|
+
current = auth_svc.get_current_profile()
|
|
685
|
+
|
|
686
|
+
for profile in profiles:
|
|
687
|
+
title = f"{profile.name} ({profile.api_url})"
|
|
688
|
+
if profile == current:
|
|
689
|
+
title += " [current]"
|
|
690
|
+
choices.append(questionary.Choice(title=title, value=profile))
|
|
691
|
+
|
|
692
|
+
return questionary.select("Select profile:", choices=choices).ask()
|
|
693
|
+
|
|
694
|
+
except Exception as e:
|
|
695
|
+
rprint(f"[red]Error loading profiles: {e}[/red]")
|
|
696
|
+
return None
|