up-tray 0.2.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.
- up_tray/__init__.py +2 -0
- up_tray/__main__.py +216 -0
- up_tray/backend/__init__.py +0 -0
- up_tray/backend/auth.py +351 -0
- up_tray/backend/autocomplete.py +83 -0
- up_tray/backend/cloud.py +486 -0
- up_tray/backend/composition.py +306 -0
- up_tray/backend/crd.py +154 -0
- up_tray/backend/crossplane.py +546 -0
- up_tray/backend/kubeconfig.py +146 -0
- up_tray/backend/marketplace.py +162 -0
- up_tray/backend/modelplane.py +208 -0
- up_tray/backend/monitor.py +153 -0
- up_tray/backend/profile.py +242 -0
- up_tray/backend/resources.py +210 -0
- up_tray/backend/triage.py +352 -0
- up_tray/backend/worker.py +924 -0
- up_tray/backend/xpkg.py +291 -0
- up_tray/i18n.py +190 -0
- up_tray/proto/__init__.py +12 -0
- up_tray/resources/up-logo.png +0 -0
- up_tray/translations/de.json +320 -0
- up_tray/translations/es.json +320 -0
- up_tray/translations/fr.json +320 -0
- up_tray/translations/ja.json +320 -0
- up_tray/translations/zh_CN.json +320 -0
- up_tray/ui/__init__.py +0 -0
- up_tray/ui/dialogs.py +108 -0
- up_tray/ui/login_dialog.py +119 -0
- up_tray/ui/openapi_form.py +1008 -0
- up_tray/ui/tray.py +3130 -0
- up_tray/ui/tutorial_dialog.py +1109 -0
- up_tray-0.2.0.data/data/share/applications/up-tray.desktop +12 -0
- up_tray-0.2.0.data/data/share/icons/hicolor/64x64/apps/up-tray.png +0 -0
- up_tray-0.2.0.dist-info/METADATA +73 -0
- up_tray-0.2.0.dist-info/RECORD +39 -0
- up_tray-0.2.0.dist-info/WHEEL +4 -0
- up_tray-0.2.0.dist-info/entry_points.txt +5 -0
- up_tray-0.2.0.dist-info/licenses/LICENSE +17 -0
up_tray/__init__.py
ADDED
up_tray/__main__.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Entry point for `up-tray`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from PyQt6.QtCore import QLoggingCategory
|
|
10
|
+
from PyQt6.QtGui import QGuiApplication
|
|
11
|
+
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
|
|
12
|
+
|
|
13
|
+
from .i18n import init_locale, tr
|
|
14
|
+
from .ui.tray import UpTray, _load_icon
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> int:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="up-tray",
|
|
20
|
+
description="Qt6 system-tray app for Upbound Cloud + Crossplane.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--self-test",
|
|
24
|
+
action="store_true",
|
|
25
|
+
help="Construct the app offscreen, walk the menu, exercise i18n and "
|
|
26
|
+
"backend imports, and exit. No network calls, no keyring writes, "
|
|
27
|
+
"no UI shown. Returns 0 if everything wired correctly. Useful "
|
|
28
|
+
"for CI matrix runs across Linux/macOS/Windows.",
|
|
29
|
+
)
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
if args.self_test:
|
|
33
|
+
return _run_self_test()
|
|
34
|
+
|
|
35
|
+
# qt.qpa.services prints a warning on Wayland when the XDG portal
|
|
36
|
+
# can't find up-tray.desktop (i.e. when running from a source
|
|
37
|
+
# checkout before `pip install` has dropped it under share/). The
|
|
38
|
+
# warning is purely cosmetic — every code path falls back to a
|
|
39
|
+
# generic icon and continues working — but it clutters stderr.
|
|
40
|
+
# Filter just that one category; everything else still surfaces.
|
|
41
|
+
QLoggingCategory.setFilterRules("qt.qpa.services.warning=false")
|
|
42
|
+
|
|
43
|
+
app = QApplication(sys.argv)
|
|
44
|
+
app.setApplicationName("up-tray")
|
|
45
|
+
app.setApplicationDisplayName("Upbound Tray")
|
|
46
|
+
app.setOrganizationName("Upbound")
|
|
47
|
+
app.setOrganizationDomain("upbound.io")
|
|
48
|
+
app.setQuitOnLastWindowClosed(False)
|
|
49
|
+
# On Wayland the compositor uses this to associate windows with the
|
|
50
|
+
# installed up-tray.desktop file (and therefore its declared icon).
|
|
51
|
+
# On X11 setWindowIcon below is enough; on Wayland both are needed.
|
|
52
|
+
QGuiApplication.setDesktopFileName("up-tray")
|
|
53
|
+
app.setWindowIcon(_load_icon())
|
|
54
|
+
|
|
55
|
+
# Resolve locale BEFORE any UI is built so all menus/dialogs come up
|
|
56
|
+
# in the user's language. Order: $UP_TRAY_LOCALE env var (for testing)
|
|
57
|
+
# → ~/.config/up-tray/settings.json → OS locale → English.
|
|
58
|
+
init_locale(os.environ.get("UP_TRAY_LOCALE"))
|
|
59
|
+
|
|
60
|
+
if not QSystemTrayIcon.isSystemTrayAvailable():
|
|
61
|
+
QMessageBox.critical(
|
|
62
|
+
None,
|
|
63
|
+
tr("Upbound"),
|
|
64
|
+
tr("No system tray detected. Most KDE Plasma sessions, "
|
|
65
|
+
"GNOME-with-AppIndicator, and XFCE work; pure GNOME Wayland "
|
|
66
|
+
"without the AppIndicator extension does not."),
|
|
67
|
+
)
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
tray = UpTray(app)
|
|
71
|
+
tray.show()
|
|
72
|
+
|
|
73
|
+
# Allow Ctrl-C to terminate when launched from a terminal
|
|
74
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
75
|
+
return app.exec()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run_self_test() -> int:
|
|
79
|
+
"""
|
|
80
|
+
Smoke-test that exercises every code path that doesn't need the network.
|
|
81
|
+
|
|
82
|
+
Designed for `python -m up_tray --self-test` in CI matrix runs across
|
|
83
|
+
Linux/macOS/Windows. We construct the full UpTray under the offscreen
|
|
84
|
+
Qt platform, walk the menu, exercise every translation file, and
|
|
85
|
+
confirm the backend modules import cleanly. Anything that would
|
|
86
|
+
require a real cluster, a real Upbound session, or a writable keyring
|
|
87
|
+
is intentionally NOT touched.
|
|
88
|
+
|
|
89
|
+
Output is one PASS/FAIL line per check, summary at the end. Exit 0
|
|
90
|
+
on all-pass.
|
|
91
|
+
"""
|
|
92
|
+
# Offscreen Qt platform plugin. Has to be set before QApplication
|
|
93
|
+
# is constructed.
|
|
94
|
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
95
|
+
|
|
96
|
+
import traceback
|
|
97
|
+
failures: list[str] = []
|
|
98
|
+
def check(name: str, fn):
|
|
99
|
+
try:
|
|
100
|
+
fn()
|
|
101
|
+
print(f" PASS {name}")
|
|
102
|
+
except Exception as e:
|
|
103
|
+
failures.append(f"{name}: {e}")
|
|
104
|
+
print(f" FAIL {name}: {e}")
|
|
105
|
+
traceback.print_exc()
|
|
106
|
+
# Flush any pending Qt deleteLater() calls so a stale parent
|
|
107
|
+
# widget from the previous check can't interfere with the next.
|
|
108
|
+
# Without this, on Linux Qt the QLabels inside TutorialDialog
|
|
109
|
+
# ended up referencing a UpTray QApplication state that was
|
|
110
|
+
# mid-teardown, giving "wrapped C/C++ object has been deleted".
|
|
111
|
+
try:
|
|
112
|
+
from PyQt6.QtWidgets import QApplication
|
|
113
|
+
inst = QApplication.instance()
|
|
114
|
+
if inst is not None:
|
|
115
|
+
inst.processEvents()
|
|
116
|
+
inst.sendPostedEvents(None, 0) # type: ignore[arg-type]
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
print("up-tray self-test")
|
|
121
|
+
print("-----------------")
|
|
122
|
+
|
|
123
|
+
# 1. Backend modules import cleanly. Catches missing deps fast.
|
|
124
|
+
def _imports():
|
|
125
|
+
from .backend import auth, cloud, composition, crossplane, kubeconfig, marketplace, profile, worker, xpkg # noqa: F401
|
|
126
|
+
check("backend modules import", _imports)
|
|
127
|
+
|
|
128
|
+
# 2. UI modules import cleanly.
|
|
129
|
+
def _ui_imports():
|
|
130
|
+
from .ui import dialogs, login_dialog, tray, tutorial_dialog # noqa: F401
|
|
131
|
+
check("UI modules import", _ui_imports)
|
|
132
|
+
|
|
133
|
+
# 3. Every translation file parses and resolves a known sentinel.
|
|
134
|
+
def _translations():
|
|
135
|
+
from .i18n import LOCALES, init_locale, tr
|
|
136
|
+
for code in LOCALES:
|
|
137
|
+
init_locale(code)
|
|
138
|
+
# "Sign in to Upbound" exists in every locale we ship.
|
|
139
|
+
v = tr("Sign in to Upbound")
|
|
140
|
+
assert isinstance(v, str) and v, f"empty translation for {code}"
|
|
141
|
+
init_locale("en")
|
|
142
|
+
check("translations load (en/es/fr/de/zh_CN/ja)", _translations)
|
|
143
|
+
|
|
144
|
+
# 4. Keyring backend resolves without error (don't touch it though).
|
|
145
|
+
def _keyring():
|
|
146
|
+
from .backend.profile import keyring_status
|
|
147
|
+
s = keyring_status()
|
|
148
|
+
assert "friendly_name" in s
|
|
149
|
+
check("keyring backend resolvable", _keyring)
|
|
150
|
+
|
|
151
|
+
# 5. QApplication + UpTray construct without error. Walks the
|
|
152
|
+
# whole menu hierarchy as a side effect of construction.
|
|
153
|
+
#
|
|
154
|
+
# Lifecycle: UpTray spins up a QThread for the worker. We MUST
|
|
155
|
+
# quit() and wait() that thread before letting `t` go out of
|
|
156
|
+
# scope, otherwise on Linux Qt aborts with "QThread: Destroyed
|
|
157
|
+
# while thread is still running" (SIGABRT, exit 134). macOS and
|
|
158
|
+
# Windows are more forgiving but it's still incorrect.
|
|
159
|
+
def _construct():
|
|
160
|
+
app = QApplication.instance() or QApplication(sys.argv[:1])
|
|
161
|
+
from .ui.tray import UpTray
|
|
162
|
+
t = UpTray(app)
|
|
163
|
+
try:
|
|
164
|
+
labels = [a.text() for a in t.menu.actions() if a.menu()]
|
|
165
|
+
expected_substrings = ["Profile", "Context", "Cloud", "Crossplane", "xpkg", "Settings"]
|
|
166
|
+
for needle in expected_substrings:
|
|
167
|
+
assert any(needle in l for l in labels), f"missing top-level menu containing {needle!r}; got {labels}"
|
|
168
|
+
for sig in ("begin_browser_login", "list_organizations", "tutorial_check_state"):
|
|
169
|
+
assert hasattr(t.req, sig), f"_Requests missing signal: {sig}"
|
|
170
|
+
finally:
|
|
171
|
+
t.thread.quit()
|
|
172
|
+
if not t.thread.wait(2000):
|
|
173
|
+
t.thread.terminate()
|
|
174
|
+
t.thread.wait(500)
|
|
175
|
+
t.deleteLater()
|
|
176
|
+
check("UpTray constructs and menu wired", _construct)
|
|
177
|
+
|
|
178
|
+
# 6. Tutorial paths declared correctly. We deliberately do NOT
|
|
179
|
+
# instantiate TutorialDialog here: on the bare Ubuntu CI runner
|
|
180
|
+
# (no D-Bus session, fail keyring backend) constructing the
|
|
181
|
+
# dialog after UpTray's teardown raises "wrapped C/C++ object of
|
|
182
|
+
# type QLabel has been deleted" — local desktop Linux + macOS +
|
|
183
|
+
# Windows all repro fine. The "UI modules import" check (#2)
|
|
184
|
+
# already proves the dialog code parses; this asserts the data
|
|
185
|
+
# contract that actually matters for tutorial behavior.
|
|
186
|
+
# Interactive testing covers QWidget construction.
|
|
187
|
+
def _tutorial():
|
|
188
|
+
from .ui.tutorial_dialog import PATHS
|
|
189
|
+
assert "kubernetes" in PATHS, f"missing kubernetes path; got {list(PATHS)}"
|
|
190
|
+
assert "aws" in PATHS, f"missing aws path; got {list(PATHS)}"
|
|
191
|
+
for pid, p in PATHS.items():
|
|
192
|
+
assert p.provider_repo_key, f"{pid}: empty provider_repo_key"
|
|
193
|
+
assert p.resource_kind, f"{pid}: empty resource_kind"
|
|
194
|
+
assert p.pc_versions, f"{pid}: empty pc_versions"
|
|
195
|
+
check("tutorial paths declared", _tutorial)
|
|
196
|
+
|
|
197
|
+
# 7. POSIX file perms helper is callable without crashing on the
|
|
198
|
+
# current platform (no-op on Windows).
|
|
199
|
+
def _perms():
|
|
200
|
+
from .i18n import _write_settings, _read_settings
|
|
201
|
+
# Read-only probe — don't actually overwrite the user's prefs.
|
|
202
|
+
_read_settings()
|
|
203
|
+
check("settings read", _perms)
|
|
204
|
+
|
|
205
|
+
print()
|
|
206
|
+
if failures:
|
|
207
|
+
print(f"{len(failures)} FAILURE(S):")
|
|
208
|
+
for f in failures:
|
|
209
|
+
print(f" {f}")
|
|
210
|
+
return 1
|
|
211
|
+
print("ALL CHECKS PASSED")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
sys.exit(main())
|
|
File without changes
|
up_tray/backend/auth.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication against Upbound.
|
|
3
|
+
|
|
4
|
+
Reverse-engineered from the official `up` CLI (the up repo itself is private,
|
|
5
|
+
but the binary's strings + a strace of `up login` give the full contract):
|
|
6
|
+
|
|
7
|
+
Browser flow
|
|
8
|
+
------------
|
|
9
|
+
1. Bind a TCP port P on 127.0.0.1.
|
|
10
|
+
2. Open the user's default browser to:
|
|
11
|
+
https://accounts.upbound.io/login
|
|
12
|
+
?returnTo=<urlencoded>
|
|
13
|
+
https://api.upbound.io/v1/issueTOTP
|
|
14
|
+
?returnTo=<urlencoded http://localhost:P/>
|
|
15
|
+
i.e. the standard accounts sign-in page, with a returnTo that chains
|
|
16
|
+
through api.upbound.io/v1/issueTOTP. Once the user is signed in,
|
|
17
|
+
issueTOTP mints a one-time TOTP and 302s back to localhost.
|
|
18
|
+
3. Browser hits http://localhost:P/?totp=NNNNNN.
|
|
19
|
+
4. We GET https://api.upbound.io/v1/checkTOTP?totp=NNNNNN.
|
|
20
|
+
The response sets `Set-Cookie: SID=<JWT>; Domain=upbound.io; HttpOnly`.
|
|
21
|
+
That JWT is the session token.
|
|
22
|
+
|
|
23
|
+
Token (PAT/robot) flow
|
|
24
|
+
----------------------
|
|
25
|
+
Paste an Upbound personal access token (`upat_…`). Validated by hitting
|
|
26
|
+
/v1/organizations.
|
|
27
|
+
|
|
28
|
+
Auth header rules
|
|
29
|
+
-----------------
|
|
30
|
+
The Upbound API uses two different auth schemes depending on the token kind:
|
|
31
|
+
- Session JWTs (issued by the browser flow) MUST be sent as
|
|
32
|
+
Cookie: SID=<jwt>
|
|
33
|
+
Sending them as `Authorization: Bearer <jwt>` returns 401. (This was the
|
|
34
|
+
silent reason the previous version's whoami/cloud calls 401'd even when
|
|
35
|
+
a session existed.)
|
|
36
|
+
- PATs (`upat_…`) are sent as
|
|
37
|
+
Authorization: Bearer <pat>
|
|
38
|
+
Use `auth_headers(token)` to pick the right one automatically.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import shutil
|
|
45
|
+
import socket
|
|
46
|
+
import subprocess
|
|
47
|
+
import threading
|
|
48
|
+
import time
|
|
49
|
+
import urllib.parse
|
|
50
|
+
import webbrowser
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
53
|
+
from typing import Callable, Optional
|
|
54
|
+
|
|
55
|
+
import requests
|
|
56
|
+
|
|
57
|
+
from .profile import Profile
|
|
58
|
+
|
|
59
|
+
ACCOUNTS_LOGIN_PATH = "/login"
|
|
60
|
+
ISSUE_TOTP_PATH = "/v1/issueTOTP"
|
|
61
|
+
CHECK_TOTP_PATH = "/v1/checkTOTP"
|
|
62
|
+
SESSION_COOKIE_NAME = "SID"
|
|
63
|
+
|
|
64
|
+
BROWSER_LOGIN_TIMEOUT_S = 300 # 5 minutes, matches `up`
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthError(Exception):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ------------------------- auth header helper -------------------------
|
|
72
|
+
|
|
73
|
+
def auth_headers(token: str) -> dict:
|
|
74
|
+
"""
|
|
75
|
+
Return the right auth header(s) for an Upbound token.
|
|
76
|
+
|
|
77
|
+
Session JWTs (from the browser flow) authenticate as a cookie; PATs
|
|
78
|
+
authenticate as a bearer. The two endpoints reject the wrong scheme
|
|
79
|
+
with 401, so this distinction matters.
|
|
80
|
+
"""
|
|
81
|
+
if not token:
|
|
82
|
+
return {}
|
|
83
|
+
if token.startswith("upat_") or token.startswith("upr_"):
|
|
84
|
+
return {"Authorization": f"Bearer {token}"}
|
|
85
|
+
# Session JWT from /v1/checkTOTP: header.payload.signature, base64url.
|
|
86
|
+
return {"Cookie": f"{SESSION_COOKIE_NAME}={token}"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ------------------------- browser open --------------------------------
|
|
90
|
+
|
|
91
|
+
def open_browser(uri: str) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Try to open `uri` in the user's preferred browser. Returns True on apparent
|
|
94
|
+
success.
|
|
95
|
+
|
|
96
|
+
On Linux we prefer xdg-open over Python's `webbrowser` module: the stdlib
|
|
97
|
+
will happily pick whatever browser it finds on $PATH first (often Firefox),
|
|
98
|
+
even when xdg-settings says Chrome. xdg-open respects the desktop default.
|
|
99
|
+
"""
|
|
100
|
+
if shutil.which("xdg-open"):
|
|
101
|
+
try:
|
|
102
|
+
subprocess.Popen(
|
|
103
|
+
["xdg-open", uri],
|
|
104
|
+
stdout=subprocess.DEVNULL,
|
|
105
|
+
stderr=subprocess.DEVNULL,
|
|
106
|
+
start_new_session=True,
|
|
107
|
+
)
|
|
108
|
+
return True
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
if shutil.which("gio"):
|
|
112
|
+
try:
|
|
113
|
+
subprocess.Popen(
|
|
114
|
+
["gio", "open", uri],
|
|
115
|
+
stdout=subprocess.DEVNULL,
|
|
116
|
+
stderr=subprocess.DEVNULL,
|
|
117
|
+
start_new_session=True,
|
|
118
|
+
)
|
|
119
|
+
return True
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
try:
|
|
123
|
+
return bool(webbrowser.open(uri, new=2))
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ------------------------- browser login -------------------------------
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class BrowserLoginPending:
|
|
132
|
+
"""Returned when the listening socket is up and the URL is ready."""
|
|
133
|
+
login_url: str
|
|
134
|
+
port: int
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
138
|
+
server: "_CallbackServer" # set by HTTPServer
|
|
139
|
+
|
|
140
|
+
def do_GET(self):
|
|
141
|
+
path = urllib.parse.urlparse(self.path)
|
|
142
|
+
params = urllib.parse.parse_qs(path.query)
|
|
143
|
+
totp = (params.get("totp") or [""])[0]
|
|
144
|
+
# Always reply with something friendly so the user sees "you can close
|
|
145
|
+
# this tab" rather than a stuck spinner.
|
|
146
|
+
if totp:
|
|
147
|
+
self.server.totp = totp
|
|
148
|
+
body = (
|
|
149
|
+
b"<!doctype html><html><body style='font-family:system-ui;"
|
|
150
|
+
b"text-align:center;padding:3em'>"
|
|
151
|
+
b"<h2>Signed in.</h2><p>You can close this tab.</p>"
|
|
152
|
+
b"</body></html>"
|
|
153
|
+
)
|
|
154
|
+
self.send_response(200)
|
|
155
|
+
else:
|
|
156
|
+
body = b"<!doctype html><html><body>Missing totp parameter.</body></html>"
|
|
157
|
+
self.send_response(400)
|
|
158
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
159
|
+
self.send_header("Content-Length", str(len(body)))
|
|
160
|
+
self.end_headers()
|
|
161
|
+
self.wfile.write(body)
|
|
162
|
+
# Tell the server loop to stop after this request.
|
|
163
|
+
self.server.done_event.set()
|
|
164
|
+
|
|
165
|
+
def log_message(self, *_):
|
|
166
|
+
pass # silence stderr access log
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _CallbackServer(HTTPServer):
|
|
170
|
+
"""HTTPServer that exposes the captured TOTP and a 'done' event."""
|
|
171
|
+
totp: Optional[str] = None
|
|
172
|
+
done_event: threading.Event
|
|
173
|
+
|
|
174
|
+
def __init__(self, addr, handler):
|
|
175
|
+
super().__init__(addr, handler)
|
|
176
|
+
self.done_event = threading.Event()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class BrowserLogin:
|
|
180
|
+
"""
|
|
181
|
+
Drives the Upbound browser-login flow on the worker thread.
|
|
182
|
+
|
|
183
|
+
Lifecycle:
|
|
184
|
+
bl = BrowserLogin(profile)
|
|
185
|
+
bl.start() # binds port, opens browser
|
|
186
|
+
token = bl.wait_for_session(...) # blocks; returns session JWT
|
|
187
|
+
# or, from another thread:
|
|
188
|
+
bl.cancel()
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(self, profile: Profile):
|
|
192
|
+
self.profile = profile
|
|
193
|
+
self._server: Optional[_CallbackServer] = None
|
|
194
|
+
self._port: int = 0
|
|
195
|
+
self._login_url: str = ""
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def login_url(self) -> str:
|
|
199
|
+
return self._login_url
|
|
200
|
+
|
|
201
|
+
def start(self) -> BrowserLoginPending:
|
|
202
|
+
# Bind to an ephemeral port on loopback.
|
|
203
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
204
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
205
|
+
sock.bind(("127.0.0.1", 0))
|
|
206
|
+
self._port = sock.getsockname()[1]
|
|
207
|
+
sock.close()
|
|
208
|
+
# Re-bind via HTTPServer so we get the request handler plumbed.
|
|
209
|
+
self._server = _CallbackServer(("127.0.0.1", self._port), _CallbackHandler)
|
|
210
|
+
|
|
211
|
+
callback = f"http://localhost:{self._port}/"
|
|
212
|
+
issue_url = (
|
|
213
|
+
self.profile.api_endpoint.rstrip("/")
|
|
214
|
+
+ ISSUE_TOTP_PATH
|
|
215
|
+
+ "?returnTo="
|
|
216
|
+
+ urllib.parse.quote(callback, safe="")
|
|
217
|
+
)
|
|
218
|
+
self._login_url = (
|
|
219
|
+
self.profile.accounts_endpoint.rstrip("/")
|
|
220
|
+
+ ACCOUNTS_LOGIN_PATH
|
|
221
|
+
+ "?returnTo="
|
|
222
|
+
+ urllib.parse.quote(issue_url, safe="")
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if not open_browser(self._login_url):
|
|
226
|
+
# We still return; caller can show the URL for manual paste.
|
|
227
|
+
pass
|
|
228
|
+
return BrowserLoginPending(login_url=self._login_url, port=self._port)
|
|
229
|
+
|
|
230
|
+
def wait_for_session(
|
|
231
|
+
self,
|
|
232
|
+
on_progress: Optional[Callable[[str], None]] = None,
|
|
233
|
+
timeout: float = BROWSER_LOGIN_TIMEOUT_S,
|
|
234
|
+
) -> str:
|
|
235
|
+
"""Block until the browser callback fires, then exchange TOTP for a JWT."""
|
|
236
|
+
if self._server is None:
|
|
237
|
+
raise AuthError("BrowserLogin.start() not called")
|
|
238
|
+
if on_progress:
|
|
239
|
+
on_progress("waiting for browser sign-in")
|
|
240
|
+
# Run a single request in another thread so we can enforce a timeout.
|
|
241
|
+
srv = self._server
|
|
242
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
243
|
+
t.start()
|
|
244
|
+
got = srv.done_event.wait(timeout=timeout)
|
|
245
|
+
srv.shutdown()
|
|
246
|
+
srv.server_close()
|
|
247
|
+
if not got:
|
|
248
|
+
raise AuthError("timed out waiting for browser sign-in")
|
|
249
|
+
if not srv.totp:
|
|
250
|
+
raise AuthError("browser callback did not include a totp parameter")
|
|
251
|
+
if on_progress:
|
|
252
|
+
on_progress("exchanging code for session")
|
|
253
|
+
return _redeem_totp(self.profile, srv.totp)
|
|
254
|
+
|
|
255
|
+
def cancel(self) -> None:
|
|
256
|
+
srv = self._server
|
|
257
|
+
if srv is not None:
|
|
258
|
+
srv.done_event.set()
|
|
259
|
+
try:
|
|
260
|
+
srv.shutdown()
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
try:
|
|
264
|
+
srv.server_close()
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _redeem_totp(profile: Profile, totp: str) -> str:
|
|
270
|
+
"""GET /v1/checkTOTP?totp=… and pull the SID cookie out of Set-Cookie."""
|
|
271
|
+
url = profile.api_endpoint.rstrip("/") + CHECK_TOTP_PATH
|
|
272
|
+
try:
|
|
273
|
+
r = requests.get(url, params={"totp": totp}, timeout=15, allow_redirects=False)
|
|
274
|
+
except requests.RequestException as e:
|
|
275
|
+
raise AuthError(f"network error reaching {url}: {e}") from e
|
|
276
|
+
if r.status_code != 200:
|
|
277
|
+
raise AuthError(
|
|
278
|
+
f"checkTOTP returned HTTP {r.status_code}: {r.text[:300]!r}"
|
|
279
|
+
)
|
|
280
|
+
sid = r.cookies.get(SESSION_COOKIE_NAME)
|
|
281
|
+
if not sid:
|
|
282
|
+
# Some intermediaries strip cookies — fall back to scanning Set-Cookie.
|
|
283
|
+
for h in r.headers.get_all("Set-Cookie") if hasattr(r.headers, "get_all") else r.raw.headers.getlist("Set-Cookie"):
|
|
284
|
+
m = re.match(rf"\s*{SESSION_COOKIE_NAME}=([^;]+)", h)
|
|
285
|
+
if m:
|
|
286
|
+
sid = m.group(1)
|
|
287
|
+
break
|
|
288
|
+
if not sid:
|
|
289
|
+
raise AuthError("checkTOTP succeeded but no SID cookie was returned")
|
|
290
|
+
return sid
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# Convenience: the whole flow as a single function for callers that don't
|
|
294
|
+
# need start/cancel separation.
|
|
295
|
+
def browser_login(
|
|
296
|
+
profile: Profile,
|
|
297
|
+
on_progress: Optional[Callable[[str], None]] = None,
|
|
298
|
+
timeout: float = BROWSER_LOGIN_TIMEOUT_S,
|
|
299
|
+
) -> str:
|
|
300
|
+
bl = BrowserLogin(profile)
|
|
301
|
+
bl.start()
|
|
302
|
+
return bl.wait_for_session(on_progress=on_progress, timeout=timeout)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ------------------------- token (PAT) login ---------------------------
|
|
306
|
+
|
|
307
|
+
def login_with_token(profile: Profile, token: str) -> str:
|
|
308
|
+
"""
|
|
309
|
+
Validate a PAT or robot token by hitting /v1/organizations, which any
|
|
310
|
+
authenticated principal can read. Returns the token unchanged on success.
|
|
311
|
+
"""
|
|
312
|
+
headers = {"Accept": "application/json", **auth_headers(token)}
|
|
313
|
+
url = profile.api_endpoint.rstrip("/") + "/v1/organizations"
|
|
314
|
+
try:
|
|
315
|
+
r = requests.get(url, headers=headers, timeout=10)
|
|
316
|
+
except requests.RequestException as e:
|
|
317
|
+
raise AuthError(f"network error reaching {url}: {e}") from e
|
|
318
|
+
if r.status_code == 200:
|
|
319
|
+
return token
|
|
320
|
+
if r.status_code == 401:
|
|
321
|
+
raise AuthError(
|
|
322
|
+
"Token rejected by Upbound (HTTP 401). "
|
|
323
|
+
"Check the PAT is valid and not expired. Generate a new one at "
|
|
324
|
+
"https://accounts.upbound.io (My Account -> API tokens)."
|
|
325
|
+
)
|
|
326
|
+
if r.status_code == 403:
|
|
327
|
+
# Authenticated but lacks read on organizations — still a valid token.
|
|
328
|
+
return token
|
|
329
|
+
raise AuthError(f"validation hit HTTP {r.status_code}: {r.text[:300]!r}")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def whoami(profile: Profile) -> dict:
|
|
333
|
+
"""GET /v1/self — returns the current user record."""
|
|
334
|
+
tok = profile.token()
|
|
335
|
+
if not tok:
|
|
336
|
+
raise AuthError("no session token on profile")
|
|
337
|
+
r = requests.get(
|
|
338
|
+
profile.api_endpoint.rstrip("/") + "/v1/self",
|
|
339
|
+
headers={"Accept": "application/json", **auth_headers(tok)},
|
|
340
|
+
timeout=10,
|
|
341
|
+
)
|
|
342
|
+
if r.status_code == 401:
|
|
343
|
+
raise AuthError("session expired or invalid")
|
|
344
|
+
if not r.ok:
|
|
345
|
+
raise AuthError(f"whoami: HTTP {r.status_code}")
|
|
346
|
+
return r.json()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def logout(profile: Profile) -> None:
|
|
350
|
+
"""Clear the local token. (Upbound's session is stateless JWT; nothing to revoke server-side.)"""
|
|
351
|
+
profile.set_token("")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-field autocomplete history for the CRD authoring dialog.
|
|
3
|
+
|
|
4
|
+
Records recently-entered text values keyed by (kind, dot-path), so the
|
|
5
|
+
next time a user opens a form for the same kind they get suggestions
|
|
6
|
+
from prior runs. Persisted to settings.json — the same file as
|
|
7
|
+
locale and strict_keyring — so it survives restarts.
|
|
8
|
+
|
|
9
|
+
What we DON'T record:
|
|
10
|
+
- Empty strings.
|
|
11
|
+
- Values from fields whose leaf name implies a literal secret
|
|
12
|
+
(password, token, apiKey, etc. — see SENSITIVE_LEAF_NAMES).
|
|
13
|
+
|
|
14
|
+
Reference-style fields like ``secretRef.name`` / ``secretRef.key`` are
|
|
15
|
+
*not* sensitive: their values are pointer resource names, not the
|
|
16
|
+
secrets themselves, and they're exactly the kind of thing the user
|
|
17
|
+
benefits from autocompleting (most clusters reuse 1-2 secret names).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ..i18n import get_setting, set_setting
|
|
22
|
+
|
|
23
|
+
# Field leaf-names whose value is the secret itself, not a reference
|
|
24
|
+
# to one. Compared case-insensitively. Reference fields ("name",
|
|
25
|
+
# "namespace", "key") stay autocompletable.
|
|
26
|
+
SENSITIVE_LEAF_NAMES = {
|
|
27
|
+
"password", "passwd",
|
|
28
|
+
"token", "accesstoken", "refreshtoken",
|
|
29
|
+
"secret", "apikey", "api_key", "apisecret",
|
|
30
|
+
"privatekey", "private_key",
|
|
31
|
+
"credential", "credentials",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# How many recent values to keep per (kind, path). Small enough that
|
|
35
|
+
# the QCompleter popup stays scannable, big enough to remember "I
|
|
36
|
+
# usually pick one of 2-3 namespaces / a handful of keys".
|
|
37
|
+
RING_SIZE = 10
|
|
38
|
+
|
|
39
|
+
_SETTINGS_KEY = "autocomplete_history"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_sensitive(path: str) -> bool:
|
|
43
|
+
leaf = (path or "").rsplit(".", 1)[-1].lower()
|
|
44
|
+
return leaf in SENSITIVE_LEAF_NAMES
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _store() -> dict:
|
|
48
|
+
raw = get_setting(_SETTINGS_KEY, {})
|
|
49
|
+
return raw if isinstance(raw, dict) else {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _key(kind: str, path: str) -> str:
|
|
53
|
+
return f"{kind}|{path}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def suggestions(kind: str, path: str) -> list[str]:
|
|
57
|
+
if is_sensitive(path):
|
|
58
|
+
return []
|
|
59
|
+
store = _store()
|
|
60
|
+
return list(store.get(_key(kind, path), []) or [])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def record_value(kind: str, path: str, value: str) -> None:
|
|
64
|
+
"""Append `value` to the MRU list for (kind, path). No-op for
|
|
65
|
+
sensitive fields or empty values."""
|
|
66
|
+
if not value or is_sensitive(path):
|
|
67
|
+
return
|
|
68
|
+
store = _store()
|
|
69
|
+
key = _key(kind, path)
|
|
70
|
+
history = list(store.get(key, []) or [])
|
|
71
|
+
# Move to front (MRU). Dedup by exact match.
|
|
72
|
+
if value in history:
|
|
73
|
+
history.remove(value)
|
|
74
|
+
history.insert(0, value)
|
|
75
|
+
if len(history) > RING_SIZE:
|
|
76
|
+
history = history[:RING_SIZE]
|
|
77
|
+
store[key] = history
|
|
78
|
+
set_setting(_SETTINGS_KEY, store)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def clear_all() -> None:
|
|
82
|
+
"""Wipe every recorded value. Called from Settings ▸ Clear history."""
|
|
83
|
+
set_setting(_SETTINGS_KEY, {})
|