vantage-cli 0.1.1__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.
Files changed (46) hide show
  1. vantage_cli/__init__.py +131 -0
  2. vantage_cli/apps/__init__.py +22 -0
  3. vantage_cli/apps/common.py +78 -0
  4. vantage_cli/apps/juju_localhost/__init__.py +17 -0
  5. vantage_cli/apps/juju_localhost/app.py +255 -0
  6. vantage_cli/apps/juju_localhost/bundle_yaml.py +143 -0
  7. vantage_cli/apps/microk8s/README.md +47 -0
  8. vantage_cli/apps/microk8s/__init__.py +3 -0
  9. vantage_cli/apps/microk8s/app.py +301 -0
  10. vantage_cli/apps/multipass_singlenode/__init__.py +12 -0
  11. vantage_cli/apps/multipass_singlenode/app.py +173 -0
  12. vantage_cli/apps/templates.py +178 -0
  13. vantage_cli/auth.py +429 -0
  14. vantage_cli/cache.py +143 -0
  15. vantage_cli/client.py +84 -0
  16. vantage_cli/command_base.py +63 -0
  17. vantage_cli/commands/__init__.py +1 -0
  18. vantage_cli/commands/clouds/__init__.py +20 -0
  19. vantage_cli/commands/clouds/add.py +81 -0
  20. vantage_cli/commands/clouds/delete.py +61 -0
  21. vantage_cli/commands/clouds/render.py +146 -0
  22. vantage_cli/commands/clouds/update.py +97 -0
  23. vantage_cli/commands/clusters/__init__.py +27 -0
  24. vantage_cli/commands/clusters/create.py +270 -0
  25. vantage_cli/commands/clusters/delete.py +101 -0
  26. vantage_cli/commands/clusters/get.py +30 -0
  27. vantage_cli/commands/clusters/list.py +84 -0
  28. vantage_cli/commands/clusters/render.py +233 -0
  29. vantage_cli/commands/clusters/schema.py +31 -0
  30. vantage_cli/commands/clusters/utils.py +248 -0
  31. vantage_cli/commands/profile/__init__.py +30 -0
  32. vantage_cli/commands/profile/crud.py +529 -0
  33. vantage_cli/commands/profile/render.py +55 -0
  34. vantage_cli/config.py +161 -0
  35. vantage_cli/constants.py +40 -0
  36. vantage_cli/exceptions.py +127 -0
  37. vantage_cli/format.py +39 -0
  38. vantage_cli/gql_client.py +655 -0
  39. vantage_cli/main.py +303 -0
  40. vantage_cli/render.py +56 -0
  41. vantage_cli/schemas.py +48 -0
  42. vantage_cli/time_loop.py +124 -0
  43. vantage_cli-0.1.1.dist-info/METADATA +30 -0
  44. vantage_cli-0.1.1.dist-info/RECORD +46 -0
  45. vantage_cli-0.1.1.dist-info/WHEEL +4 -0
  46. vantage_cli-0.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,178 @@
1
+ # © 2025 Vantage Compute, Inc. All rights reserved.
2
+ # Confidential and proprietary. Unauthorized use prohibited.
3
+ """Template engine for deployment configurations."""
4
+
5
+ import io
6
+ from typing import Any, Dict, List
7
+
8
+ from pydantic import BaseModel
9
+ from ruamel.yaml import YAML
10
+
11
+ from vantage_cli.exceptions import ConfigurationError
12
+
13
+
14
+ class DeploymentContext(BaseModel):
15
+ """Base context for deployment template generation."""
16
+
17
+ cluster_name: str
18
+ client_id: str
19
+ client_secret: str
20
+ base_api_url: str
21
+ oidc_domain: str
22
+ oidc_base_url: str
23
+ tunnel_api_url: str
24
+ jupyterhub_token: str
25
+
26
+
27
+ class CloudInitTemplate:
28
+ """Template engine for cloud-init configurations using proper YAML structure."""
29
+
30
+ def __init__(self):
31
+ """Initialize YAML processor with proper settings."""
32
+ self.yaml = YAML()
33
+ self.yaml.preserve_quotes = True
34
+ self.yaml.width = 4096
35
+
36
+ def generate_multipass_config(self, context: DeploymentContext) -> str:
37
+ """Generate cloud-init configuration for multipass instances."""
38
+ try:
39
+ # Build the cloud-config as a proper Python dictionary
40
+ cloud_config = {
41
+ "snap": {
42
+ "commands": {
43
+ 0: "snap refresh vantage-agent --channel=edge --classic",
44
+ 1: "snap refresh jobbergate-agent --channel=edge --classic",
45
+ }
46
+ },
47
+ "runcmd": self._build_runcmd_list(context),
48
+ }
49
+
50
+ # Convert to YAML string
51
+ stream = io.StringIO()
52
+ stream.write("#cloud-config\n")
53
+ self.yaml.dump(cloud_config, stream)
54
+ return stream.getvalue()
55
+
56
+ except (AttributeError, KeyError, TypeError) as e:
57
+ raise ConfigurationError(f"Failed to generate multipass cloud-init config: {e}")
58
+
59
+ def _build_runcmd_list(self, context: DeploymentContext) -> List[str]:
60
+ """Build the runcmd list for cloud-init."""
61
+ commands = []
62
+
63
+ # SLURM configuration commands
64
+ commands.extend(
65
+ [
66
+ 'sed -i "s|@HEADNODE_HOSTNAME@|$(hostname)|g" /etc/slurm/slurmdbd.conf',
67
+ "sed -i \"s|@HEADNODE_ADDRESS@|$(hostname -I | awk '{print $1}')|g\" /etc/slurm/slurm.conf",
68
+ 'sed -i "s|@HEADNODE_HOSTNAME@|$(hostname)|g" /etc/slurm/slurm.conf',
69
+ ]
70
+ )
71
+
72
+ # CPU configuration script
73
+ cpu_script = """
74
+ cpu_info=$(lscpu -J | jq)
75
+ CPUs=$(echo $cpu_info | jq -r '.lscpu | .[] | select(.field == "CPU(s):") | .data')
76
+ sed -i "s|@CPUs@|$CPUs|g" /etc/slurm/slurm.conf
77
+
78
+ THREADS_PER_CORE=$(echo $cpu_info | jq -r '.lscpu | .[] | select(.field == "Thread(s) per core:") | .data')
79
+ sed -i "s|@THREADS_PER_CORE@|$THREADS_PER_CORE|g" /etc/slurm/slurm.conf
80
+
81
+ CORES_PER_SOCKET=$(echo $cpu_info | jq -r '.lscpu | .[] | select(.field == "Core(s) per socket:") | .data')
82
+ sed -i "s|@CORES_PER_SOCKET@|$CORES_PER_SOCKET|g" /etc/slurm/slurm.conf
83
+
84
+ SOCKETS=$(echo $cpu_info | jq -r '.lscpu | .[] | select(.field == "Socket(s):") | .data')
85
+ sed -i "s|@SOCKETS@|$SOCKETS|g" /etc/slurm/slurm.conf
86
+
87
+ REAL_MEMORY=$(free -m | grep -oP '\\d+' | head -n 1)
88
+ sed -i "s|@REAL_MEMORY@|$REAL_MEMORY|g" /etc/slurm/slurm.conf""".strip()
89
+
90
+ commands.append(cpu_script)
91
+
92
+ # SLURM service restart commands
93
+ commands.extend(
94
+ [
95
+ "systemctl restart slurmdbd",
96
+ "sleep 10",
97
+ "systemctl restart slurmctld",
98
+ "systemctl restart slurmd",
99
+ "scontrol update NodeName=$(hostname) State=RESUME",
100
+ ]
101
+ )
102
+
103
+ # Agent configuration commands
104
+ commands.extend(self._generate_vantage_agent_cloud_init_snap_config(context))
105
+ commands.extend(self._generate_jobbergate_agent_cloud_init_snap_config(context))
106
+ commands.append("snap start vantage-agent.start --enable")
107
+ commands.append("snap start jobbergate-agent.start --enable")
108
+
109
+ # JupyterHub configuration commands
110
+ commands.extend(self._generate_jupyterhub_config(context))
111
+ commands.append("systemctl --now enable vantage-jupyterhub.service")
112
+
113
+ return commands
114
+
115
+ def _generate_agent_config(self, agent_name: str, context: DeploymentContext) -> List[str]:
116
+ """Generate base agent configuration commands."""
117
+ return [
118
+ f"snap set {agent_name} base-api-url={context.base_api_url}",
119
+ f"snap set {agent_name} oidc-domain={context.oidc_domain}",
120
+ f"snap set {agent_name} oidc-client-id={context.client_id}",
121
+ f"snap set {agent_name} oidc-client-secret={context.client_secret}",
122
+ f"snap set {agent_name} task-jobs-interval-seconds=10",
123
+ ]
124
+
125
+ def _generate_vantage_agent_cloud_init_snap_config(
126
+ self, context: DeploymentContext
127
+ ) -> List[str]:
128
+ """Generate vantage-agent specific cloud-init snap configuration."""
129
+ base_config = self._generate_agent_config("vantage-agent", context)
130
+ vantage_specific = [f"snap set vantage-agent cluster-name={context.cluster_name}"]
131
+ return base_config + vantage_specific
132
+
133
+ def _generate_jobbergate_agent_cloud_init_snap_config(
134
+ self, context: DeploymentContext
135
+ ) -> List[str]:
136
+ """Generate jobbergate-agent specific cloud-init snap configuration."""
137
+ base_config = self._generate_agent_config("jobbergate-agent", context)
138
+ jobbergate_specific = [
139
+ "snap set jobbergate-agent x-slurm-user-name=ubuntu",
140
+ "snap set jobbergate-agent influx-dsn=influxdb://slurm:rats@localhost:8086/slurm-job-metrics",
141
+ ]
142
+ return base_config + jobbergate_specific
143
+
144
+ def _generate_jupyterhub_config(self, context: DeploymentContext) -> List[str]:
145
+ """Generate JupyterHub configuration commands."""
146
+ return [
147
+ 'echo "JUPYTERHUB_VENV_DIR=/srv/vantage-nfs/vantage-jupyterhub" >> /etc/default/vantage-jupyterhub',
148
+ f'echo "OIDC_CLIENT_ID={context.client_id}" >> /etc/default/vantage-jupyterhub',
149
+ f'echo "OIDC_CLIENT_SECRET={context.client_secret}" >> /etc/default/vantage-jupyterhub',
150
+ f'echo "JUPYTERHUB_TOKEN={context.jupyterhub_token}" >> /etc/default/vantage-jupyterhub',
151
+ f'echo "OIDC_BASE_URL={context.oidc_base_url}" >> /etc/default/vantage-jupyterhub',
152
+ f'echo "TUNNEL_API_URL={context.tunnel_api_url}" >> /etc/default/vantage-jupyterhub',
153
+ f'echo "VANTAGE_API_URL={context.base_api_url}" >> /etc/default/vantage-jupyterhub',
154
+ f'echo "OIDC_DOMAIN={context.oidc_domain}" >> /etc/default/vantage-jupyterhub',
155
+ ]
156
+
157
+
158
+ class JujuBundleTemplate:
159
+ """Template engine for Juju bundle configurations."""
160
+
161
+ @staticmethod
162
+ def generate_bundle_config(context: DeploymentContext) -> Dict[str, Any]:
163
+ """Generate Juju bundle configuration."""
164
+ # This would contain the bundle configuration
165
+ # For now, returning a basic structure
166
+ return {
167
+ "bundle": "vantage-jupyterhub-slurm",
168
+ "applications": {
169
+ "vantage-jupyterhub": {
170
+ "charm": "vantage-jupyterhub",
171
+ "options": {
172
+ "client_id": context.client_id,
173
+ "oidc_domain": context.oidc_domain,
174
+ "base_api_url": context.base_api_url,
175
+ },
176
+ }
177
+ },
178
+ }
vantage_cli/auth.py ADDED
@@ -0,0 +1,429 @@
1
+ """Authentication and authorization functionality for the Vantage CLI."""
2
+
3
+ import asyncio
4
+ import datetime
5
+ import json
6
+ from typing import Any, Union
7
+
8
+ import httpx
9
+ import snick
10
+ import typer
11
+ from jose import jwt
12
+ from jose.exceptions import ExpiredSignatureError
13
+ from loguru import logger
14
+ from pydantic import ValidationError
15
+
16
+ from vantage_cli.cache import load_tokens_from_cache, save_tokens_to_cache
17
+ from vantage_cli.client import make_oauth_request
18
+ from vantage_cli.config import Settings
19
+ from vantage_cli.constants import USER_CONFIG_FILE
20
+ from vantage_cli.exceptions import Abort
21
+ from vantage_cli.format import terminal_message
22
+ from vantage_cli.schemas import CliContext, DeviceCodeData, IdentityData, Persona, TokenSet
23
+ from vantage_cli.time_loop import TimeLoop
24
+
25
+
26
+ def extract_persona(
27
+ profile: str, token_set: TokenSet | None = None, settings: Union["Settings", None] = None
28
+ ):
29
+ """Extract user persona from cached tokens or provided token set."""
30
+ if token_set is None:
31
+ token_set = load_tokens_from_cache(profile)
32
+
33
+ # Check token expiration and refresh if needed before attempting validation
34
+ token_set = refresh_token_if_needed(profile, token_set)
35
+
36
+ # Now validate and extract identity from the (potentially refreshed) token
37
+ identity_data = validate_token_and_extract_identity(token_set)
38
+
39
+ logger.debug(f"Persona created with identity_data: {identity_data}")
40
+
41
+ save_tokens_to_cache(profile, token_set)
42
+
43
+ return Persona(
44
+ token_set=token_set,
45
+ identity_data=identity_data,
46
+ )
47
+
48
+
49
+ def validate_token_and_extract_identity(token_set: TokenSet) -> IdentityData:
50
+ """Validate access token and extract user identity information."""
51
+ logger.debug("Validating access token")
52
+
53
+ token_file_is_empty = not token_set.access_token
54
+ if token_file_is_empty:
55
+ logger.debug("Access token file exists but it is empty")
56
+ raise Abort(
57
+ """
58
+ Access token file exists but it is empty.
59
+
60
+ Please try logging in again.
61
+ """,
62
+ subject="Empty access token file",
63
+ log_message="Empty access token file",
64
+ )
65
+
66
+ with Abort.handle_errors(
67
+ """
68
+ There was an unknown error while validating the access token.
69
+
70
+ Please try logging in again.
71
+ """,
72
+ ignore_exc_class=ExpiredSignatureError, # Will be handled in calling context
73
+ raise_kwargs={
74
+ "subject": "Invalid access token",
75
+ "log_message": "Unknown error while validating access access token",
76
+ },
77
+ ):
78
+ token_data = jwt.decode(
79
+ token_set.access_token,
80
+ "", # Empty key is acceptable when verify_signature is False
81
+ options={
82
+ "verify_signature": False,
83
+ "verify_aud": False,
84
+ "verify_exp": True,
85
+ },
86
+ )
87
+
88
+ logger.debug("Extracting identity data from the access token")
89
+ with Abort.handle_errors(
90
+ """
91
+ There was an error extracting the user's identity from the access token.
92
+
93
+ Please try logging in again.
94
+ """,
95
+ handle_exc_class=ValidationError,
96
+ raise_kwargs={
97
+ "subject": "Missing user data",
98
+ "log_message": "Token data could not be extracted to identity",
99
+ },
100
+ ):
101
+ identity = IdentityData(
102
+ email=token_data.get("email"),
103
+ client_id=token_data.get("azp") or "unknown",
104
+ )
105
+
106
+ return identity
107
+
108
+
109
+ def is_token_expired(token: str, buffer_seconds: int = 60) -> bool:
110
+ """Check if a JWT token is expired or will expire within buffer_seconds.
111
+
112
+ Args:
113
+ token: JWT access token
114
+ buffer_seconds: Number of seconds before actual expiry to consider token expired
115
+
116
+ Returns:
117
+ True if token is expired or will expire soon, False otherwise
118
+ """
119
+ try:
120
+ # Decode token without verification to get expiration
121
+ token_data = jwt.decode(
122
+ token,
123
+ "", # Empty key is acceptable when verify_signature is False
124
+ options={
125
+ "verify_signature": False,
126
+ "verify_aud": False,
127
+ "verify_exp": False, # Don't verify expiration here, we want to check manually
128
+ },
129
+ )
130
+
131
+ if "exp" not in token_data:
132
+ logger.debug("Token does not contain expiration claim")
133
+ return True # Consider token expired if no expiration claim
134
+
135
+ exp_timestamp = token_data["exp"]
136
+ exp_datetime = datetime.datetime.fromtimestamp(exp_timestamp)
137
+ now_with_buffer = datetime.datetime.now() + datetime.timedelta(seconds=buffer_seconds)
138
+
139
+ is_expired = exp_datetime <= now_with_buffer
140
+
141
+ if is_expired:
142
+ logger.debug(
143
+ f"Token expired or will expire soon. Expires at: {exp_datetime}, Current time + buffer: {now_with_buffer}"
144
+ )
145
+ else:
146
+ logger.debug(
147
+ f"Token is valid. Expires at: {exp_datetime}, Current time + buffer: {now_with_buffer}"
148
+ )
149
+
150
+ return is_expired
151
+
152
+ except Exception as e:
153
+ logger.debug(f"Error checking token expiration: {e}")
154
+ return True # Consider token expired if we can't parse it
155
+
156
+
157
+ def refresh_token_if_needed(profile: str, token_set: TokenSet) -> TokenSet:
158
+ """Check if the access token is expired and refresh it if needed.
159
+
160
+ Args:
161
+ profile: Profile name for token caching
162
+ token_set: Current token set
163
+
164
+ Returns:
165
+ Updated token set with refreshed tokens if refresh was needed
166
+ """
167
+ if not token_set.access_token:
168
+ logger.debug("No access token available")
169
+ return token_set
170
+
171
+ if not is_token_expired(token_set.access_token):
172
+ logger.debug("Access token is still valid")
173
+ return token_set
174
+
175
+ logger.debug("Access token is expired, attempting refresh")
176
+
177
+ if not token_set.refresh_token:
178
+ logger.debug("No refresh token available")
179
+ raise Abort(
180
+ "The access token is expired and no refresh token is available. Please log in again.",
181
+ subject="Token expired",
182
+ log_message="Token expired and no refresh token available",
183
+ )
184
+
185
+ try:
186
+ # Load settings for the refresh operation
187
+ if USER_CONFIG_FILE.exists():
188
+ settings_all_profiles = json.loads(USER_CONFIG_FILE.read_text())
189
+ settings_values = settings_all_profiles.get(profile, {})
190
+
191
+ settings = Settings(**settings_values)
192
+ else:
193
+ # Use default settings if no config file exists
194
+ settings = Settings()
195
+
196
+ # Attempt to refresh the token
197
+ refresh_success = refresh_access_token_standalone(token_set, settings)
198
+
199
+ if refresh_success:
200
+ logger.debug("Successfully refreshed access token")
201
+ # Save the updated tokens to cache
202
+ save_tokens_to_cache(profile, token_set)
203
+ return token_set
204
+ else:
205
+ raise Exception("Token refresh returned False")
206
+
207
+ except Exception as e:
208
+ logger.error(f"Failed to refresh token: {e}")
209
+ raise Abort(
210
+ "Failed to refresh the expired access token. Please log in again.",
211
+ subject="Token refresh failed",
212
+ log_message=f"Token refresh failed: {e}",
213
+ )
214
+
215
+
216
+ def init_persona(ctx: typer.Context, token_set: TokenSet | None = None):
217
+ """Initialize persona from cached tokens or provided token set."""
218
+ if token_set is None:
219
+ token_set = load_tokens_from_cache(profile=ctx.obj.profile)
220
+
221
+ try:
222
+ identity_data = validate_token_and_extract_identity(token_set)
223
+ except ExpiredSignatureError:
224
+ Abort.require_condition(
225
+ token_set.refresh_token is not None,
226
+ "The auth token is expired. Please retrieve a new and log in again.",
227
+ raise_kwargs={
228
+ "subject": "Expired access token",
229
+ },
230
+ )
231
+
232
+ logger.debug("The access token is expired. Attempting to refresh token")
233
+ # Use standalone refresh since this function is not async
234
+ if hasattr(ctx.obj, "settings") and ctx.obj.settings:
235
+ settings = ctx.obj.settings
236
+ else:
237
+ settings = Settings() # Use default settings
238
+ refresh_success = refresh_access_token_standalone(token_set, settings)
239
+ if not refresh_success:
240
+ raise Exception("Failed to refresh access token")
241
+ identity_data = validate_token_and_extract_identity(token_set)
242
+
243
+ logger.debug(f"Persona created with identity_data: {identity_data}")
244
+
245
+ save_tokens_to_cache(ctx.obj.profile, token_set)
246
+
247
+ return Persona(
248
+ token_set=token_set,
249
+ identity_data=identity_data,
250
+ )
251
+
252
+
253
+ def refresh_access_token_standalone(token_set: TokenSet, settings: "Settings") -> bool:
254
+ """Attempt to fetch a new access token given a refresh token.
255
+
256
+ Returns True if refresh was successful, False otherwise.
257
+ Sets the access token in-place.
258
+ """
259
+ if not token_set.refresh_token:
260
+ return False
261
+
262
+ url = f"{settings.oidc_base_url}/realms/vantage/protocol/openid-connect/token"
263
+ logger.debug(f"Requesting refreshed access token from {url}")
264
+
265
+ try:
266
+ with httpx.Client() as client:
267
+ response = client.post(
268
+ url,
269
+ data={
270
+ "client_id": settings.oidc_client_id,
271
+ "grant_type": "refresh_token",
272
+ "refresh_token": token_set.refresh_token,
273
+ },
274
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
275
+ timeout=30.0,
276
+ )
277
+ response.raise_for_status()
278
+
279
+ token_data = response.json()
280
+ token_set.access_token = token_data["access_token"]
281
+
282
+ # Update refresh token if provided
283
+ if "refresh_token" in token_data:
284
+ token_set.refresh_token = token_data["refresh_token"]
285
+
286
+ logger.debug("Successfully refreshed access token")
287
+ return True
288
+
289
+ except Exception as e:
290
+ logger.error(f"Failed to refresh token: {e}")
291
+ return False
292
+
293
+
294
+ async def refresh_access_token(ctx: CliContext, token_set: TokenSet):
295
+ """Attempt to fetch a new access token given a refresh token in a token_set.
296
+
297
+ Sets the access token in-place.
298
+
299
+ If refresh fails, notify the user that they need to log in again.
300
+ """
301
+ if ctx.client is None:
302
+ raise RuntimeError("HTTP client not initialized")
303
+ if ctx.settings is None:
304
+ raise RuntimeError("Settings not initialized")
305
+
306
+ url = "/realms/vantage/protocol/openid-connect/token"
307
+ logger.debug(f"Requesting refreshed access token from {url}")
308
+
309
+ refreshed_token_set: TokenSet = await make_oauth_request(
310
+ ctx.client,
311
+ url,
312
+ data={
313
+ "client_id": ctx.settings.oidc_client_id,
314
+ "grant_type": "refresh_token",
315
+ "refresh_token": token_set.refresh_token,
316
+ },
317
+ response_model_cls=TokenSet,
318
+ abort_message="The auth token could not be refreshed. Please try logging in again.",
319
+ abort_subject="EXPIRED ACCESS TOKEN",
320
+ )
321
+
322
+ token_set.access_token = refreshed_token_set.access_token
323
+
324
+
325
+ async def fetch_auth_tokens(ctx: CliContext) -> TokenSet:
326
+ """Fetch an access token (and possibly a refresh token) from Auth0.
327
+
328
+ Prints out a URL for the user to use to authenticate and polls the token endpoint to fetch it
329
+ when the browser-based process finishes.
330
+ """
331
+ if ctx.client is None:
332
+ raise RuntimeError("HTTP client not initialized")
333
+ if ctx.settings is None:
334
+ raise RuntimeError("Settings not initialized")
335
+
336
+ device_path = "/realms/vantage/protocol/openid-connect/auth/device"
337
+ token_path = "/realms/vantage/protocol/openid-connect/token"
338
+
339
+ device_code_data: DeviceCodeData = await make_oauth_request(
340
+ ctx.client,
341
+ device_path,
342
+ data={
343
+ "client_id": ctx.settings.oidc_client_id,
344
+ },
345
+ response_model_cls=DeviceCodeData,
346
+ abort_message=(
347
+ """
348
+ There was a problem retrieving a device verification code from
349
+ the auth provider
350
+ """
351
+ ),
352
+ abort_subject="COULD NOT RETRIEVE TOKEN",
353
+ )
354
+
355
+ max_poll_time = 5 * 60 # 5 minutes
356
+ terminal_message(
357
+ f"""
358
+ To complete login, please open the following link in a browser:
359
+
360
+ {device_code_data.verification_uri_complete}
361
+
362
+ Waiting up to {max_poll_time / 60} minutes for you to complete the process...
363
+ """,
364
+ subject="Waiting for login",
365
+ )
366
+
367
+ for tick in TimeLoop(
368
+ ctx.settings.oidc_max_poll_time,
369
+ message="Waiting for web login",
370
+ ):
371
+ # For polling, we need to handle error responses as dict, not TokenSet
372
+ response_data: dict[str, Any] = {}
373
+ try:
374
+ # Attempt to get a successful token response
375
+ token_data = await make_oauth_request(
376
+ ctx.client,
377
+ token_path,
378
+ data={
379
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
380
+ "device_code": device_code_data.device_code,
381
+ "client_id": ctx.settings.oidc_client_id,
382
+ },
383
+ response_model_cls=TokenSet,
384
+ abort_message="IGNORE", # We'll handle errors manually
385
+ abort_subject="IGNORE",
386
+ )
387
+ return token_data
388
+ except Exception:
389
+ # If it fails, make a raw request to get the error details
390
+ response = await ctx.client.post(
391
+ f"{ctx.settings.oidc_base_url}{token_path}",
392
+ data={
393
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
394
+ "device_code": device_code_data.device_code,
395
+ "client_id": ctx.settings.oidc_client_id,
396
+ },
397
+ )
398
+ response_data = response.json()
399
+
400
+ if "error" in response_data:
401
+ if response_data["error"] == "authorization_pending":
402
+ logger.debug(f"Token fetch attempt #{tick.counter} failed")
403
+ logger.debug(f"Will try again in {device_code_data.interval} seconds")
404
+ await asyncio.sleep(device_code_data.interval)
405
+ elif response_data["error"] == "slow_down":
406
+ logger.debug(f"Server requested slow down on attempt #{tick.counter}")
407
+ logger.debug(f"Will try again in {device_code_data.interval * 2} seconds")
408
+ await asyncio.sleep(device_code_data.interval * 2)
409
+ else:
410
+ # TODO: Test this failure condition
411
+ raise Abort(
412
+ snick.unwrap(
413
+ """
414
+ There was a problem retrieving a device verification code
415
+ from the auth provider:
416
+ Unexpected failure retrieving access token.
417
+ """
418
+ ),
419
+ subject="Unexpected error",
420
+ log_message=f"Unexpected error response: {response_data}",
421
+ )
422
+ else:
423
+ return TokenSet(**response_data)
424
+
425
+ raise Abort(
426
+ "Login process was not completed in time. Please try again.",
427
+ subject="Timed out",
428
+ log_message="Timed out while waiting for user to complete login",
429
+ )