altasigma 3.12.0__tar.gz → 3.12.0.dev1__tar.gz
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.
- {altasigma-3.12.0/altasigma.egg-info → altasigma-3.12.0.dev1}/PKG-INFO +1 -1
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/config/config.py +4 -1
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/credentials/credential_utils.py +199 -14
- altasigma-3.12.0.dev1/altasigma/git_helper.py +98 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/io/data_management.py +4 -4
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/job_supervisor_http.py +4 -4
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/spark_session/spark_session_util.py +1 -1
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/utils/notebook.py +3 -1
- {altasigma-3.12.0 → altasigma-3.12.0.dev1/altasigma.egg-info}/PKG-INFO +1 -1
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma.egg-info/SOURCES.txt +2 -0
- altasigma-3.12.0.dev1/altasigma.egg-info/entry_points.txt +2 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/setup.py +6 -1
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/LICENSE +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/MANIFEST.in +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/README_pypi.md +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/config/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/config/http_session.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/credentials/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/credentials/token_refresher.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/initialize.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/io/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/io/augurdata.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/io/files.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/job_supervisor_abstract.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/job_supervisor_dev_mock.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/job_supervisor_helpers.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/reports.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/progress_reporter/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/progress_reporter/progress_reporter.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/spark_session/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/utils/__init__.py +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma.egg-info/dependency_links.txt +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma.egg-info/requires.txt +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma.egg-info/top_level.txt +0 -0
- {altasigma-3.12.0 → altasigma-3.12.0.dev1}/setup.cfg +0 -0
|
@@ -273,7 +273,10 @@ class ModuleConfig:
|
|
|
273
273
|
except Exception as e:
|
|
274
274
|
logger.warning(f"Unexpected error deleting environment variable CLIENT_SECRET: {e}")
|
|
275
275
|
|
|
276
|
-
|
|
276
|
+
# The job-token-manager sidecar owns the token lifecycle when present;
|
|
277
|
+
# only keep the mounted refresh_token alive when we're the refresher.
|
|
278
|
+
if not os.environ.get("AS_TOKEN_SOCKET"):
|
|
279
|
+
TokenRefresher().schedule_refresh()
|
|
277
280
|
|
|
278
281
|
|
|
279
282
|
module_config = None
|
|
@@ -7,9 +7,11 @@ It supports both interactive authentication flows for notebooks and automated
|
|
|
7
7
|
authentication for production environments.
|
|
8
8
|
"""
|
|
9
9
|
import base64
|
|
10
|
+
import http.client
|
|
10
11
|
import json
|
|
11
12
|
import logging
|
|
12
13
|
import os
|
|
14
|
+
import socket
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from time import sleep
|
|
15
17
|
|
|
@@ -21,6 +23,56 @@ from ..config.http_session import _http_session
|
|
|
21
23
|
|
|
22
24
|
logger = logging.getLogger(__name__)
|
|
23
25
|
|
|
26
|
+
# When set, the job runs alongside the job-token-manager sidecar, which performs the
|
|
27
|
+
# broker token exchange. The package reads the delegated user access token from the
|
|
28
|
+
# sidecar over this Unix domain socket; git credentials are fetched from the dashboard
|
|
29
|
+
# with that token.
|
|
30
|
+
_AS_TOKEN_SOCKET_ENV = "AS_TOKEN_SOCKET"
|
|
31
|
+
_SIDECAR_TIMEOUT_SECONDS = 15
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _UDSConnection(http.client.HTTPConnection):
|
|
35
|
+
"""HTTPConnection that dials a Unix domain socket instead of a TCP host.
|
|
36
|
+
|
|
37
|
+
Lets us speak plain HTTP to the token sidecar (which listens on a local UDS)
|
|
38
|
+
using only the standard library — no extra dependency, and no TCP port is
|
|
39
|
+
ever opened.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, socket_path: str):
|
|
43
|
+
super().__init__("localhost", timeout=_SIDECAR_TIMEOUT_SECONDS)
|
|
44
|
+
self._socket_path = socket_path
|
|
45
|
+
|
|
46
|
+
def connect(self):
|
|
47
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
48
|
+
sock.settimeout(self.timeout)
|
|
49
|
+
sock.connect(self._socket_path)
|
|
50
|
+
self.sock = sock
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sidecar_get(path: str) -> dict:
|
|
54
|
+
"""GET a JSON document from the token sidecar over the UDS."""
|
|
55
|
+
conn = _UDSConnection(os.environ[_AS_TOKEN_SOCKET_ENV])
|
|
56
|
+
try:
|
|
57
|
+
conn.request("GET", path)
|
|
58
|
+
response = conn.getresponse()
|
|
59
|
+
body = response.read()
|
|
60
|
+
if response.status != 200:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"token sidecar returned {response.status} for {path}: "
|
|
63
|
+
f"{body.decode('utf-8', 'replace')}")
|
|
64
|
+
return json.loads(body)
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sidecar_access_token() -> str:
|
|
70
|
+
"""Fetch the delegated user access token from the token sidecar."""
|
|
71
|
+
access_token = _sidecar_get("/user-token").get("access_token")
|
|
72
|
+
if not isinstance(access_token, str) or not access_token:
|
|
73
|
+
raise RuntimeError("token sidecar returned an empty or malformed access_token")
|
|
74
|
+
return access_token
|
|
75
|
+
|
|
24
76
|
|
|
25
77
|
def _decode_jwt_azp(token: str) -> str:
|
|
26
78
|
"""
|
|
@@ -55,6 +107,95 @@ def _decode_jwt_azp(token: str) -> str:
|
|
|
55
107
|
return ''
|
|
56
108
|
|
|
57
109
|
|
|
110
|
+
class ReauthRequired(Exception):
|
|
111
|
+
"""The dashboard responded that the user must (re)complete the git OAuth flow.
|
|
112
|
+
|
|
113
|
+
Headless callers (e.g. the notebook-git-api sidecar via the
|
|
114
|
+
``ALTASIGMA_ACCESS_TOKEN`` env var) cannot perform that flow themselves —
|
|
115
|
+
they should propagate this exception to the layer that owns the user's UI
|
|
116
|
+
so it can surface ``authorize_url``. The interactive notebook path catches
|
|
117
|
+
this exception and polls until the user completes the flow in the browser.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, authorize_url: str):
|
|
121
|
+
self.authorize_url = authorize_url
|
|
122
|
+
super().__init__(
|
|
123
|
+
f"git OAuth required; complete the flow at {authorize_url}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _fetch_git_credentials(host_url: str, access_token: str) -> tuple[str, str]:
|
|
128
|
+
"""
|
|
129
|
+
Mint a fresh per-user git access token via the dashboard's git-oauth2 endpoint
|
|
130
|
+
and shape it into the (username, password) pair git's credential helper
|
|
131
|
+
protocol expects.
|
|
132
|
+
|
|
133
|
+
Pure HTTP — never blocks, never polls, never prints. The interactive
|
|
134
|
+
polling-on-reauth loop lives in ``_await_git_oauth_completion`` (used by the
|
|
135
|
+
notebook flow in ``CredentialUtils.get_git_credentials``); headless callers
|
|
136
|
+
receive ``ReauthRequired`` and decide for themselves how to surface it.
|
|
137
|
+
|
|
138
|
+
Consumers: ``CredentialUtils.get_git_credentials`` (this package, notebook
|
|
139
|
+
path) and the ``notebook-git-api`` sidecar (imports this symbol directly).
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
host_url (str): The AltaSigma installation host URL (no trailing slash needed).
|
|
143
|
+
access_token (str): A Keycloak bearer authorised for the dashboard.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
tuple: (username, password)
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ReauthRequired: User has no usable grant; carries the authorize URL.
|
|
150
|
+
Exception: Any other non-200 response.
|
|
151
|
+
"""
|
|
152
|
+
headers = {'Authorization': f'Bearer {access_token}'}
|
|
153
|
+
url = f'{host_url}/api/workbench/git-oauth2/access-token'
|
|
154
|
+
response = _http_session().get(url, headers=headers)
|
|
155
|
+
|
|
156
|
+
if _is_reauth_required(response):
|
|
157
|
+
# User has no usable grant (never connected, revoked, or expired refresh).
|
|
158
|
+
raise ReauthRequired(response.json()["details"]["authorizeUrl"])
|
|
159
|
+
|
|
160
|
+
if response.status_code == 200:
|
|
161
|
+
# Gitea-only for now: token goes in the password field, username can be
|
|
162
|
+
# anything non-empty. When other providers (GitLab, Bitbucket, ...) are
|
|
163
|
+
# added, the dashboard should return both fields and the helper should
|
|
164
|
+
# just forward them.
|
|
165
|
+
return "oauth2", response.json()["accessToken"]
|
|
166
|
+
|
|
167
|
+
raise Exception(
|
|
168
|
+
f"ERROR: Unexpected response from dashboard: {response.status_code}, {response.text}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_reauth_required(response) -> bool:
|
|
172
|
+
"""True if the response is the dashboard's 409 reauth-required signal."""
|
|
173
|
+
if response.status_code != 409:
|
|
174
|
+
return False
|
|
175
|
+
try:
|
|
176
|
+
return response.json().get("details", {}).get("reason") == "reauth_required"
|
|
177
|
+
except ValueError:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _await_git_oauth_completion(host_url: str, access_token: str, authorize_url: str) -> tuple[str, str]:
|
|
182
|
+
"""Interactive notebook helper: show the OAuth authorize URL once, then
|
|
183
|
+
poll the dashboard until the user completes the flow in the browser."""
|
|
184
|
+
# Plain URL — OSC 8 escapes show as garbage in Jupyter `!` cell output.
|
|
185
|
+
display_or_print(
|
|
186
|
+
msg_notebook=HTML(
|
|
187
|
+
f'Please authorize git access: '
|
|
188
|
+
f'<a href="{authorize_url}" target="_blank">{authorize_url}</a>'),
|
|
189
|
+
msg_other=f"Please authorize git access:\n{authorize_url}")
|
|
190
|
+
|
|
191
|
+
while True:
|
|
192
|
+
sleep(2)
|
|
193
|
+
try:
|
|
194
|
+
return _fetch_git_credentials(host_url, access_token)
|
|
195
|
+
except ReauthRequired:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
|
|
58
199
|
class CredentialUtils:
|
|
59
200
|
"""
|
|
60
201
|
Manages authentication and data source credential access.
|
|
@@ -114,7 +255,7 @@ class CredentialUtils:
|
|
|
114
255
|
- (accessKey, secretKey, expiresAt) for key-based sources
|
|
115
256
|
- (username, password, expiresAt) for password-based sources
|
|
116
257
|
"""
|
|
117
|
-
|
|
258
|
+
access_token = self._existing_token()
|
|
118
259
|
header_get_cred = {'Authorization': f'Bearer {access_token}'}
|
|
119
260
|
data_sources_request = _http_session().get(f'{self.dashboard_api_host}/dataman/datasources',
|
|
120
261
|
headers=header_get_cred)
|
|
@@ -146,6 +287,33 @@ class CredentialUtils:
|
|
|
146
287
|
raise Exception(
|
|
147
288
|
f"ERROR: Unexpected response from dataman: {data_sources_request.status_code}, {data_sources_request.text}")
|
|
148
289
|
|
|
290
|
+
def get_git_credentials(self) -> tuple[str, str]:
|
|
291
|
+
"""
|
|
292
|
+
Retrieve git credentials for the user's connected provider from the AltaSigma dashboard.
|
|
293
|
+
|
|
294
|
+
Acquires a Keycloak bearer via the same token flow as `get_credentials`,
|
|
295
|
+
then asks the dashboard to mint a fresh per-user git access token for the
|
|
296
|
+
single dashboard-configured provider. If the user hasn't completed the
|
|
297
|
+
git OAuth flow yet (or their grant has expired), prints the authorize URL
|
|
298
|
+
and polls the dashboard until they complete the flow in the browser.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
tuple: (username, password)
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
Exception: If the dashboard returns any non-200 / non-reauth status.
|
|
305
|
+
"""
|
|
306
|
+
access_token = self._existing_token()
|
|
307
|
+
try:
|
|
308
|
+
username, password = _fetch_git_credentials(self.dashboard_api_host, access_token)
|
|
309
|
+
except ReauthRequired as e:
|
|
310
|
+
username, password = _await_git_oauth_completion(
|
|
311
|
+
self.dashboard_api_host, access_token, e.authorize_url)
|
|
312
|
+
display_or_print(
|
|
313
|
+
msg_notebook=HTML("Successfully obtained git credentials"),
|
|
314
|
+
msg_other="Successfully obtained git credentials")
|
|
315
|
+
return username, password
|
|
316
|
+
|
|
149
317
|
def _refresh_token(self, refresh_token: str) -> tuple[str, str]:
|
|
150
318
|
"""
|
|
151
319
|
Refresh an existing authentication token.
|
|
@@ -204,7 +372,7 @@ class CredentialUtils:
|
|
|
204
372
|
else:
|
|
205
373
|
raise Exception(f"Could not obtain a new refresh token. {token_response}")
|
|
206
374
|
|
|
207
|
-
def _existing_token(self) ->
|
|
375
|
+
def _existing_token(self) -> str:
|
|
208
376
|
"""
|
|
209
377
|
Check for and validate existing authentication tokens.
|
|
210
378
|
|
|
@@ -217,11 +385,24 @@ class CredentialUtils:
|
|
|
217
385
|
before refreshing to reduce unnecessary API calls.
|
|
218
386
|
|
|
219
387
|
Returns:
|
|
220
|
-
|
|
388
|
+
str: The delegated user access token.
|
|
221
389
|
|
|
222
390
|
Raises:
|
|
223
391
|
Exception: If token retrieval or refresh fails, or if device auth cannot proceed
|
|
224
392
|
"""
|
|
393
|
+
# When running as a job with the token sidecar, the sidecar owns the
|
|
394
|
+
# token exchange and refresh. Read the delegated user token from it and
|
|
395
|
+
# never touch the mounted refresh token; the sidecar refreshes upstream.
|
|
396
|
+
#
|
|
397
|
+
# TODO: if the workbench ever runs the job-token-manager sidecar too, then
|
|
398
|
+
# AS_TOKEN_SOCKET is always set and the self-managed *token* machinery below
|
|
399
|
+
# becomes dead code: the device-auth flow, the mounted-refresh-token path,
|
|
400
|
+
# `_refresh_token`, and the `TokenRefresher` thread. At that point this
|
|
401
|
+
# function collapses to the sidecar read. (Git credentials are unaffected —
|
|
402
|
+
# `_fetch_git_credentials` fetches them from the dashboard with the token,
|
|
403
|
+
# whether or not the sidecar is present.)
|
|
404
|
+
if os.environ.get(_AS_TOKEN_SOCKET_ENV):
|
|
405
|
+
return _sidecar_access_token()
|
|
225
406
|
current_client_id = os.environ['CLIENT_ID']
|
|
226
407
|
# check if there are any saved tokens
|
|
227
408
|
# For a deployment restart we expect to find a current token from the previous container (realtime module only). Normal jobs don't restart
|
|
@@ -252,10 +433,10 @@ class CredentialUtils:
|
|
|
252
433
|
os.remove(self._TMP_FILE_PATH)
|
|
253
434
|
if current_client_id == "altasigma-frontend":
|
|
254
435
|
raise RuntimeError("Device auth flow cannot be initiated with client ID 'altasigma-frontend'")
|
|
255
|
-
return self._device_auth_flow()
|
|
436
|
+
return self._device_auth_flow()[0]
|
|
256
437
|
|
|
257
438
|
try:
|
|
258
|
-
return self._refresh_token(refresh_token)
|
|
439
|
+
return self._refresh_token(refresh_token)[0]
|
|
259
440
|
except Exception as e:
|
|
260
441
|
os.remove(self._TMP_FILE_PATH)
|
|
261
442
|
raise e
|
|
@@ -271,11 +452,11 @@ class CredentialUtils:
|
|
|
271
452
|
# This is unexpected and a recovery is unlikely. Since we expect it to be a read-only mounted file, we can't delete it.
|
|
272
453
|
raise Exception(f"Refresh token is missing from {self._SECRET_FILE_PATH}")
|
|
273
454
|
else:
|
|
274
|
-
return self._refresh_token(refresh_token)
|
|
455
|
+
return self._refresh_token(refresh_token)[0]
|
|
275
456
|
# Expected case for the first call from the workbench with the device flow auth
|
|
276
457
|
if current_client_id == "altasigma-frontend":
|
|
277
458
|
raise RuntimeError("Device auth flow cannot be initiated with client ID 'altasigma-frontend'")
|
|
278
|
-
return self._device_auth_flow()
|
|
459
|
+
return self._device_auth_flow()[0]
|
|
279
460
|
|
|
280
461
|
def _write_tokens_to_file(self, access_token: str, refresh_token: str):
|
|
281
462
|
"""
|
|
@@ -332,10 +513,11 @@ class CredentialUtils:
|
|
|
332
513
|
device_response = response.json()
|
|
333
514
|
device_code = device_response["device_code"]
|
|
334
515
|
authorization_url = device_response["verification_uri_complete"]
|
|
516
|
+
# URL on its own line so terminals don't soft-wrap it mid-token.
|
|
335
517
|
display_or_print(
|
|
336
518
|
msg_notebook=HTML(
|
|
337
519
|
f'Please authenticate using the following link: <a href="{authorization_url}" target="_blank">{authorization_url}</a>'),
|
|
338
|
-
msg_other=f'Please authenticate using the following link
|
|
520
|
+
msg_other=f'Please authenticate using the following link:\n{authorization_url}')
|
|
339
521
|
else:
|
|
340
522
|
raise Exception(f"ERROR: Unexpected response {response.status_code}, {response.text}")
|
|
341
523
|
|
|
@@ -363,13 +545,16 @@ class CredentialUtils:
|
|
|
363
545
|
break
|
|
364
546
|
elif response.status_code == 400 and response.json().get('error') == 'authorization_pending':
|
|
365
547
|
if is_in_notebook():
|
|
548
|
+
# Re-render URL + animated dots so the cell stays compact
|
|
549
|
+
# (single output, not appended on every poll).
|
|
366
550
|
clear_output(wait=True)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
551
|
+
display_or_print(msg_notebook=HTML(
|
|
552
|
+
f'Please authenticate using the following link: <a href="{authorization_url}" target="_blank">{authorization_url}</a>'),
|
|
553
|
+
msg_other=f'Please authenticate using the following link: {authorization_url}')
|
|
554
|
+
display_or_print(
|
|
555
|
+
msg_notebook=HTML(f"Authorization pending {'.' * i}"),
|
|
556
|
+
msg_other=f"Authorization pending {'.' * i}")
|
|
557
|
+
# Non-notebook: URL was printed once before the polling loop; stay quiet here.
|
|
373
558
|
i = (i + 1) % 4
|
|
374
559
|
else:
|
|
375
560
|
raise Exception(
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git credential helper for the AltaSigma workbench.
|
|
3
|
+
|
|
4
|
+
Implements the git-credential-helper protocol (see `gitcredentials(7)`) so that
|
|
5
|
+
git can authenticate against per-user Gitea hosts using credentials fetched on
|
|
6
|
+
demand from the AltaSigma dashboard.
|
|
7
|
+
|
|
8
|
+
Installed as the console script `git-credential-altasigma`. Once a user runs
|
|
9
|
+
`git config credential.helper altasigma`, git will resolve that to this script
|
|
10
|
+
on PATH and invoke it with `get`, `store`, or `erase` subcommands.
|
|
11
|
+
|
|
12
|
+
The helper resolves the dashboard bearer through ``CredentialUtils``
|
|
13
|
+
(refresh-token cache + Keycloak refresh / device flow), so it is intended for
|
|
14
|
+
interactive (notebook) callers where the device-auth URL can be shown to a
|
|
15
|
+
human. Headless callers (e.g. the notebook-git-api sidecar) should call
|
|
16
|
+
``altasigma.credentials.credential_utils._fetch_git_credentials`` directly from
|
|
17
|
+
Python with the inbound bearer so ``ReauthRequired`` can be surfaced as a
|
|
18
|
+
structured error.
|
|
19
|
+
|
|
20
|
+
Stdout is reserved exclusively for the two `username=...` / `password=...` lines
|
|
21
|
+
that git expects on success. Everything else (logs, errors) goes to stderr, and
|
|
22
|
+
any failure exits non-zero with empty stdout so git treats it as "no credentials"
|
|
23
|
+
and aborts cleanly.
|
|
24
|
+
"""
|
|
25
|
+
import logging
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from altasigma import CredentialUtils
|
|
29
|
+
|
|
30
|
+
logging.basicConfig(
|
|
31
|
+
stream=sys.stderr,
|
|
32
|
+
level=logging.WARNING,
|
|
33
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
34
|
+
)
|
|
35
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
36
|
+
logging.getLogger("requests").setLevel(logging.WARNING)
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_git_input(stream) -> dict:
|
|
41
|
+
"""Parse git's credential-helper key=value stdin format into a dict.
|
|
42
|
+
|
|
43
|
+
Per gitcredentials(7), input is a sequence of `key=value` lines terminated
|
|
44
|
+
by a blank line. We tolerate EOF in place of the blank line.
|
|
45
|
+
"""
|
|
46
|
+
fields = {}
|
|
47
|
+
for raw_line in stream:
|
|
48
|
+
line = raw_line.rstrip("\n").rstrip("\r")
|
|
49
|
+
if line == "":
|
|
50
|
+
break
|
|
51
|
+
key, sep, value = line.partition("=")
|
|
52
|
+
if sep != "=":
|
|
53
|
+
continue
|
|
54
|
+
fields[key] = value
|
|
55
|
+
return fields
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
if len(sys.argv) != 2:
|
|
60
|
+
logger.error("expected exactly one subcommand (get/store/erase)")
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
subcommand = sys.argv[1]
|
|
64
|
+
|
|
65
|
+
if subcommand in ("store", "erase"):
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
if subcommand != "get":
|
|
69
|
+
logger.error("unknown subcommand: %s", subcommand)
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
fields = _parse_git_input(sys.stdin)
|
|
73
|
+
host = fields.get("host")
|
|
74
|
+
if not host:
|
|
75
|
+
logger.error("git credential request missing required 'host' field")
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
# Redirect stdout -> stderr while fetching credentials so that any user-facing
|
|
79
|
+
# prints from CredentialUtils (e.g. the device-flow URL, "Authorization pending")
|
|
80
|
+
# don't pollute git's credential-helper input. Git parses each line on the
|
|
81
|
+
# helper's stdout as `key=value`; anything else makes git warn and close the pipe.
|
|
82
|
+
real_stdout = sys.stdout
|
|
83
|
+
sys.stdout = sys.stderr
|
|
84
|
+
try:
|
|
85
|
+
username, password = CredentialUtils().get_git_credentials()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
logger.error("failed to obtain git credentials for host %s: %s", host, exc)
|
|
88
|
+
return 1
|
|
89
|
+
finally:
|
|
90
|
+
sys.stdout = real_stdout
|
|
91
|
+
|
|
92
|
+
real_stdout.write(f"username={username}\n")
|
|
93
|
+
real_stdout.write(f"password={password}\n")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
|
@@ -232,7 +232,7 @@ def get_datasource_by_code(datasource_code: str) -> DataSource:
|
|
|
232
232
|
Raises:
|
|
233
233
|
HTTPError: If the API request fails.
|
|
234
234
|
"""
|
|
235
|
-
access_token
|
|
235
|
+
access_token = _credential_utils()._existing_token()
|
|
236
236
|
|
|
237
237
|
response = _http_session().get(
|
|
238
238
|
f"http://{_data_man_config().api_host}:{_data_man_config().api_port}/dataman/datasource/{datasource_code}",
|
|
@@ -255,7 +255,7 @@ def get_datasource(datasource_name: str) -> DataSource:
|
|
|
255
255
|
ValueError: If no data source with the specified name exists.
|
|
256
256
|
RuntimeError: If the API request fails.
|
|
257
257
|
"""
|
|
258
|
-
access_token
|
|
258
|
+
access_token = _credential_utils()._existing_token()
|
|
259
259
|
|
|
260
260
|
data_sources_request = _http_session().get(
|
|
261
261
|
f"http://{_data_man_config().api_host}:{_data_man_config().api_port}/dataman/datasources",
|
|
@@ -287,7 +287,7 @@ def get_s3_credentials(datasource_code: str) -> S3Credentials:
|
|
|
287
287
|
Raises:
|
|
288
288
|
HTTPError: If the credential request fails.
|
|
289
289
|
"""
|
|
290
|
-
access_token
|
|
290
|
+
access_token = _credential_utils()._existing_token()
|
|
291
291
|
response = _http_session().get(
|
|
292
292
|
f"http://{_data_man_config().api_host}:{_data_man_config().api_port}/dataman/s3/{datasource_code}/credentials",
|
|
293
293
|
headers={"Authorization": f"Bearer {access_token}"})
|
|
@@ -311,7 +311,7 @@ def get_cassandra_credentials(datasource_code: str) -> CassandraCredentials:
|
|
|
311
311
|
HTTPError: If the credential request fails.
|
|
312
312
|
Exception: If the response contains errors.
|
|
313
313
|
"""
|
|
314
|
-
access_token
|
|
314
|
+
access_token = _credential_utils()._existing_token()
|
|
315
315
|
response = _http_session().get(
|
|
316
316
|
f"http://{_data_man_config().api_host}:{_data_man_config().api_port}/dataman/cassandra/{datasource_code}/credentials",
|
|
317
317
|
headers={"Authorization": f"Bearer {access_token}"})
|
|
@@ -102,7 +102,7 @@ class JobSupervisorHTTP(JobSupervisorAbstract):
|
|
|
102
102
|
}
|
|
103
103
|
data = json.dumps(data_dict)
|
|
104
104
|
logger.debug(f"Request body: {data}")
|
|
105
|
-
access_token
|
|
105
|
+
access_token = _credential_utils()._existing_token()
|
|
106
106
|
response = _http_session().post(
|
|
107
107
|
url, data=data, headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
|
108
108
|
)
|
|
@@ -164,7 +164,7 @@ class JobSupervisorHTTP(JobSupervisorAbstract):
|
|
|
164
164
|
"message": reason,
|
|
165
165
|
}
|
|
166
166
|
data = json.dumps(data_dict)
|
|
167
|
-
access_token
|
|
167
|
+
access_token = _credential_utils()._existing_token()
|
|
168
168
|
response = _http_session().post(
|
|
169
169
|
url, data=data, headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
|
170
170
|
)
|
|
@@ -213,7 +213,7 @@ class JobSupervisorHTTP(JobSupervisorAbstract):
|
|
|
213
213
|
data_dict, default=lambda x: x.__dict__
|
|
214
214
|
) # To decode objects as dicts
|
|
215
215
|
logger.debug(f"Learning report data string: {data}")
|
|
216
|
-
access_token
|
|
216
|
+
access_token = _credential_utils()._existing_token()
|
|
217
217
|
response = _http_session().post(
|
|
218
218
|
url, data=data, headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
|
219
219
|
)
|
|
@@ -259,7 +259,7 @@ class JobSupervisorHTTP(JobSupervisorAbstract):
|
|
|
259
259
|
data = json.dumps(data_dict)
|
|
260
260
|
logger.debug(f"Request body: {data}")
|
|
261
261
|
|
|
262
|
-
access_token
|
|
262
|
+
access_token = _credential_utils()._existing_token()
|
|
263
263
|
response = _http_session().post(
|
|
264
264
|
url, data=data, headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
|
265
265
|
)
|
|
@@ -36,7 +36,7 @@ def _get_datasource_info(data_source_name: str):
|
|
|
36
36
|
ValueError: If the datasource cannot be retrieved or does not exist.
|
|
37
37
|
"""
|
|
38
38
|
host_url = os.environ['HOST_URL']
|
|
39
|
-
access_token
|
|
39
|
+
access_token = _credential_utils()._existing_token()
|
|
40
40
|
|
|
41
41
|
data_sources_request = _http_session().get(f"{host_url}/dataman/datasources",
|
|
42
42
|
headers={"Authorization": f"Bearer {access_token}"})
|
|
@@ -61,4 +61,6 @@ def display_or_print(msg_notebook, msg_other):
|
|
|
61
61
|
if is_in_notebook():
|
|
62
62
|
display(msg_notebook)
|
|
63
63
|
else:
|
|
64
|
-
|
|
64
|
+
# flush=True so output reaches the parent (e.g. git invoking the credential helper)
|
|
65
|
+
# immediately, instead of sitting in Python's pipe buffer until process exit.
|
|
66
|
+
print(msg_other, flush=True)
|
|
@@ -3,10 +3,12 @@ MANIFEST.in
|
|
|
3
3
|
README_pypi.md
|
|
4
4
|
setup.py
|
|
5
5
|
altasigma/__init__.py
|
|
6
|
+
altasigma/git_helper.py
|
|
6
7
|
altasigma/initialize.py
|
|
7
8
|
altasigma.egg-info/PKG-INFO
|
|
8
9
|
altasigma.egg-info/SOURCES.txt
|
|
9
10
|
altasigma.egg-info/dependency_links.txt
|
|
11
|
+
altasigma.egg-info/entry_points.txt
|
|
10
12
|
altasigma.egg-info/requires.txt
|
|
11
13
|
altasigma.egg-info/top_level.txt
|
|
12
14
|
altasigma/config/__init__.py
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="altasigma",
|
|
5
|
-
version="3.12.0",
|
|
5
|
+
version="3.12.0-dev1",
|
|
6
6
|
author="AltaSigma GmbH",
|
|
7
7
|
author_email="pypi@altasigma.com",
|
|
8
8
|
packages=find_packages(exclude=['altasigma.test', 'altasigma.test.*']),
|
|
@@ -18,6 +18,11 @@ setup(
|
|
|
18
18
|
"Programming Language :: Python :: 3.10",
|
|
19
19
|
"Programming Language :: Python :: 3.11",
|
|
20
20
|
],
|
|
21
|
+
entry_points={
|
|
22
|
+
"console_scripts": [
|
|
23
|
+
"git-credential-altasigma=altasigma.git_helper:main",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
21
26
|
# Broadest possible ranges
|
|
22
27
|
install_requires=[
|
|
23
28
|
# Seems stable enough that a 3 release would probably affect us
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/job_supervisor_abstract.py
RENAMED
|
File without changes
|
{altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/job_supervisor_dev_mock.py
RENAMED
|
File without changes
|
{altasigma-3.12.0 → altasigma-3.12.0.dev1}/altasigma/jobsupervisor/job_supervisor_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|