ddpsrun 0.1.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.
ddpsrun/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """ddpsrun — submit a GPU job and get results back.
2
+
3
+ The command is `ddpsrun`. It talks to a gateway server over HTTPS and needs no
4
+ kubectl, no kubeconfig, and no cloud account. See `docs/00-overview.md`.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,281 @@
1
+ """Sign in through a browser, the way `aws sso login` does.
2
+
3
+ END-TO-END FLOW of this file:
4
+
5
+ 1. `login()` asks the server where its login page is (`GET /v1/login-config`).
6
+ A server with no user pool answers `enabled: false` and the caller falls
7
+ back to `--token`.
8
+ 2. It picks a free port from the registered list, and starts a one-request
9
+ HTTP server on it. That server exists only to catch one redirect.
10
+ 3. It generates the PKCE pair, opens the browser at Cognito's authorize URL,
11
+ and blocks.
12
+ 4. The person signs in. Cognito sends their browser to
13
+ http://localhost:<port>/callback?code=... The server in step 2 takes the
14
+ code, writes a page saying the tab can be closed, and stops.
15
+ 5. The code plus the PKCE verifier are exchanged for an id_token and a
16
+ refresh_token, which the caller stores.
17
+
18
+ WHY A LOCAL HTTP SERVER AT ALL. Cognito never hands the authorization code to a
19
+ person; it puts it in a redirect and the browser follows it. In a browser app
20
+ that redirect goes to the page's own URL. A CLI has no URL, so it makes one:
21
+ `http://localhost:<port>`, which is the only address a browser on the user's own
22
+ machine can reach that the CLI also controls (`docs/16-login.md` 16.4).
23
+
24
+ WHY THE PORT COMES FROM A LIST. Cognito compares `redirect_uri` against its
25
+ registered callback URLs character for character. A port nobody registered
26
+ cannot receive the code, so the CLI can only use ports the operator put in
27
+ `terraform/cognito`'s `callback_urls`.
28
+
29
+ WHY PKCE. Exchanging a code normally needs a client secret, and a CLI on a
30
+ laptop cannot hold one. PKCE replaces it with a random number this process
31
+ generates, keeps, and only reveals at the exchange.
32
+
33
+ Grep anchor: DDPSRUN-CLI-LOGIN
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import base64
39
+ import hashlib
40
+ import http.server
41
+ import json
42
+ import secrets
43
+ import socket
44
+ import threading
45
+ import urllib.error
46
+ import urllib.parse
47
+ import urllib.request
48
+ import webbrowser
49
+ from dataclasses import dataclass
50
+
51
+ # The ports `terraform/cognito` registers. Keep the two lists identical: a port
52
+ # here that is not registered there fails with redirect_mismatch, and a port
53
+ # registered there but missing here is simply never used.
54
+ CANDIDATE_PORTS = (51234, 51235, 51236, 51237)
55
+
56
+ HOW_LONG_TO_WAIT_SECONDS = 300
57
+
58
+
59
+ class LoginError(Exception):
60
+ """Signing in did not finish. The CLI prints this and exits non-zero."""
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Tokens:
65
+ """What a completed sign-in produced.
66
+
67
+ Attributes:
68
+ id_token: the JWT sent as `Authorization: Bearer`. Lives an hour.
69
+ refresh_token: buys a new id_token without another browser trip. Lives
70
+ 30 days, which is why it is the thing worth storing.
71
+ """
72
+
73
+ id_token: str
74
+ refresh_token: str
75
+
76
+
77
+ def _b64(raw: bytes) -> str:
78
+ """base64url with the padding stripped, which is what OAuth asks for."""
79
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
80
+
81
+
82
+ def _pkce() -> tuple[str, str]:
83
+ """Make the PKCE pair.
84
+
85
+ Returns:
86
+ `(verifier, challenge)`. The verifier never leaves this process until
87
+ the exchange; the challenge is its SHA-256 and is safe to send first.
88
+ """
89
+ verifier = _b64(secrets.token_bytes(32))
90
+ challenge = _b64(hashlib.sha256(verifier.encode()).digest())
91
+ return verifier, challenge
92
+
93
+
94
+ def _free_port() -> int:
95
+ """The first registered port nothing else is listening on.
96
+
97
+ Returns:
98
+ A port number from CANDIDATE_PORTS.
99
+
100
+ Raises:
101
+ LoginError: every registered port is busy. Naming them is the useful
102
+ part of the message: the fix is to stop whatever holds one.
103
+ """
104
+ for port in CANDIDATE_PORTS:
105
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
106
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
107
+ try:
108
+ probe.bind(("127.0.0.1", port))
109
+ return port
110
+ except OSError:
111
+ continue
112
+ listed = ", ".join(str(p) for p in CANDIDATE_PORTS)
113
+ raise LoginError(
114
+ f"every port this command may use is busy ({listed}). "
115
+ f"Close whatever is listening on one of them and try again."
116
+ )
117
+
118
+
119
+ DONE_PAGE = b"""<!doctype html><meta charset="utf-8">
120
+ <title>ddpsrun</title>
121
+ <body style="font-family:system-ui;padding:64px;text-align:center">
122
+ <h2>Signed in.</h2><p>You can close this tab and go back to the terminal.</p>
123
+ """
124
+
125
+ FAILED_PAGE = b"""<!doctype html><meta charset="utf-8">
126
+ <title>ddpsrun</title>
127
+ <body style="font-family:system-ui;padding:64px;text-align:center">
128
+ <h2>Sign-in did not complete.</h2><p>The terminal has the details.</p>
129
+ """
130
+
131
+
132
+ class _Catcher(http.server.BaseHTTPRequestHandler):
133
+ """Answers exactly one GET and records what came with it."""
134
+
135
+ code: str | None = None
136
+ error: str | None = None
137
+
138
+ def do_GET(self) -> None: # noqa: N802 (the name is http.server's)
139
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
140
+ _Catcher.code = (query.get("code") or [None])[0]
141
+ _Catcher.error = (query.get("error_description") or query.get("error") or [None])[0]
142
+ body = DONE_PAGE if _Catcher.code else FAILED_PAGE
143
+ self.send_response(200)
144
+ self.send_header("Content-Type", "text/html; charset=utf-8")
145
+ self.send_header("Content-Length", str(len(body)))
146
+ self.end_headers()
147
+ self.wfile.write(body)
148
+
149
+ def log_message(self, *args: object) -> None:
150
+ """Silence. The one line this server would print is noise in a CLI."""
151
+
152
+
153
+ def login_config(server: str, opener=urllib.request.urlopen) -> dict:
154
+ """Ask the server whether Cognito is configured and where its login page is.
155
+
156
+ Args:
157
+ server: the gateway URL.
158
+ opener: injected so tests do not need a network.
159
+
160
+ Returns:
161
+ The parsed body. `{"enabled": False}` when the server has no user pool,
162
+ and also when it is too old to know this route at all.
163
+ """
164
+ try:
165
+ with opener(f"{server.rstrip('/')}/v1/login-config", timeout=30) as response:
166
+ return json.loads(response.read().decode())
167
+ except urllib.error.HTTPError as exc:
168
+ if exc.code == 404:
169
+ return {"enabled": False}
170
+ raise LoginError(f"the server answered {exc.code} when asked how to sign in") from exc
171
+ except Exception as exc:
172
+ raise LoginError(f"could not reach {server}: {exc}") from exc
173
+
174
+
175
+ def exchange(config: dict, form: dict, opener=urllib.request.urlopen) -> Tokens:
176
+ """Trade something for tokens at Cognito's token endpoint.
177
+
178
+ Args:
179
+ config: what `login_config` returned.
180
+ form: the grant. Either an authorization_code with its verifier, or a
181
+ refresh_token.
182
+ opener: injected for tests.
183
+
184
+ Returns:
185
+ The tokens. On a refresh Cognito returns no new refresh_token, so that
186
+ field comes back empty and the caller keeps the one it already has.
187
+
188
+ Raises:
189
+ LoginError: Cognito refused, with its own message where it gave one.
190
+ """
191
+ body = urllib.parse.urlencode({"client_id": config["client_id"], **form}).encode()
192
+ request = urllib.request.Request(
193
+ f"{config['login_domain']}/oauth2/token",
194
+ data=body,
195
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
196
+ )
197
+ try:
198
+ with opener(request, timeout=30) as response:
199
+ parsed = json.loads(response.read().decode())
200
+ except urllib.error.HTTPError as exc:
201
+ detail = ""
202
+ try:
203
+ payload = json.loads(exc.read().decode())
204
+ detail = payload.get("error_description") or payload.get("error") or ""
205
+ except Exception:
206
+ pass
207
+ raise LoginError(f"Cognito refused the exchange: {detail or exc.code}") from exc
208
+ except Exception as exc:
209
+ raise LoginError(f"could not reach Cognito: {exc}") from exc
210
+
211
+ return Tokens(
212
+ id_token=parsed.get("id_token", ""),
213
+ refresh_token=parsed.get("refresh_token", ""),
214
+ )
215
+
216
+
217
+ def login(server: str, open_browser=webbrowser.open) -> Tokens:
218
+ """Run the whole browser round trip and return the tokens.
219
+
220
+ Args:
221
+ server: the gateway URL.
222
+ open_browser: injected so a test can assert on the URL instead of
223
+ actually opening a browser.
224
+
225
+ Returns:
226
+ The tokens.
227
+
228
+ Raises:
229
+ LoginError: the server has no Cognito, every port is busy, the person
230
+ closed the browser, or Cognito refused.
231
+ """
232
+ config = login_config(server)
233
+ if not config.get("enabled"):
234
+ raise LoginError(
235
+ "this server does not have browser sign-in configured. "
236
+ "Use `ddpsrun login --server ... --token ...` instead."
237
+ )
238
+
239
+ port = _free_port()
240
+ redirect_uri = f"http://localhost:{port}/callback"
241
+ verifier, challenge = _pkce()
242
+
243
+ _Catcher.code = None
244
+ _Catcher.error = None
245
+ httpd = http.server.HTTPServer(("127.0.0.1", port), _Catcher)
246
+ # One request and then stop. A server that kept listening would be a port
247
+ # left open on the user's machine for as long as the process lived.
248
+ thread = threading.Thread(target=httpd.handle_request, daemon=True)
249
+ thread.start()
250
+
251
+ query = urllib.parse.urlencode({
252
+ "client_id": config["client_id"],
253
+ "response_type": "code",
254
+ "scope": " ".join(config.get("scopes", ["openid", "email"])),
255
+ "redirect_uri": redirect_uri,
256
+ "code_challenge": challenge,
257
+ "code_challenge_method": "S256",
258
+ })
259
+ authorize_url = f"{config['login_domain']}/oauth2/authorize?{query}"
260
+
261
+ print("Opening your browser to sign in.")
262
+ print(f"If it did not open, go to:\n {authorize_url}\n")
263
+ open_browser(authorize_url)
264
+
265
+ thread.join(timeout=HOW_LONG_TO_WAIT_SECONDS)
266
+ httpd.server_close()
267
+
268
+ if _Catcher.error:
269
+ raise LoginError(f"sign-in was refused: {_Catcher.error}")
270
+ if not _Catcher.code:
271
+ raise LoginError(
272
+ f"nothing came back within {HOW_LONG_TO_WAIT_SECONDS} seconds. "
273
+ f"The browser tab has to finish before this command can."
274
+ )
275
+
276
+ return exchange(config, {
277
+ "grant_type": "authorization_code",
278
+ "code": _Catcher.code,
279
+ "redirect_uri": redirect_uri,
280
+ "code_verifier": verifier,
281
+ })