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.
- vantage_cli/__init__.py +131 -0
- vantage_cli/apps/__init__.py +22 -0
- vantage_cli/apps/common.py +78 -0
- vantage_cli/apps/juju_localhost/__init__.py +17 -0
- vantage_cli/apps/juju_localhost/app.py +255 -0
- vantage_cli/apps/juju_localhost/bundle_yaml.py +143 -0
- vantage_cli/apps/microk8s/README.md +47 -0
- vantage_cli/apps/microk8s/__init__.py +3 -0
- vantage_cli/apps/microk8s/app.py +301 -0
- vantage_cli/apps/multipass_singlenode/__init__.py +12 -0
- vantage_cli/apps/multipass_singlenode/app.py +173 -0
- vantage_cli/apps/templates.py +178 -0
- vantage_cli/auth.py +429 -0
- vantage_cli/cache.py +143 -0
- vantage_cli/client.py +84 -0
- vantage_cli/command_base.py +63 -0
- vantage_cli/commands/__init__.py +1 -0
- vantage_cli/commands/clouds/__init__.py +20 -0
- vantage_cli/commands/clouds/add.py +81 -0
- vantage_cli/commands/clouds/delete.py +61 -0
- vantage_cli/commands/clouds/render.py +146 -0
- vantage_cli/commands/clouds/update.py +97 -0
- vantage_cli/commands/clusters/__init__.py +27 -0
- vantage_cli/commands/clusters/create.py +270 -0
- vantage_cli/commands/clusters/delete.py +101 -0
- vantage_cli/commands/clusters/get.py +30 -0
- vantage_cli/commands/clusters/list.py +84 -0
- vantage_cli/commands/clusters/render.py +233 -0
- vantage_cli/commands/clusters/schema.py +31 -0
- vantage_cli/commands/clusters/utils.py +248 -0
- vantage_cli/commands/profile/__init__.py +30 -0
- vantage_cli/commands/profile/crud.py +529 -0
- vantage_cli/commands/profile/render.py +55 -0
- vantage_cli/config.py +161 -0
- vantage_cli/constants.py +40 -0
- vantage_cli/exceptions.py +127 -0
- vantage_cli/format.py +39 -0
- vantage_cli/gql_client.py +655 -0
- vantage_cli/main.py +303 -0
- vantage_cli/render.py +56 -0
- vantage_cli/schemas.py +48 -0
- vantage_cli/time_loop.py +124 -0
- vantage_cli-0.1.1.dist-info/METADATA +30 -0
- vantage_cli-0.1.1.dist-info/RECORD +46 -0
- vantage_cli-0.1.1.dist-info/WHEEL +4 -0
- 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
|
+
)
|