caland 0.5.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.
- caland/__init__.py +0 -0
- caland/__main__.py +4 -0
- caland/app.py +183 -0
- caland/application/__init__.py +22 -0
- caland/application/dotenv.py +52 -0
- caland/application/loading.py +138 -0
- caland/application/onboarding.py +106 -0
- caland/application/read_model.py +76 -0
- caland/application/workspace.py +177 -0
- caland/domain/__init__.py +55 -0
- caland/domain/errors.py +12 -0
- caland/domain/host.py +15 -0
- caland/domain/models.py +120 -0
- caland/domain/permissions.py +72 -0
- caland/domain/ports.py +55 -0
- caland/domain/secret_store.py +30 -0
- caland/formerly.py +50 -0
- caland/infrastructure/__init__.py +23 -0
- caland/infrastructure/bundle.py +84 -0
- caland/infrastructure/config.py +13 -0
- caland/infrastructure/connector.py +47 -0
- caland/infrastructure/databricks.py +159 -0
- caland/infrastructure/profiles.py +66 -0
- caland/infrastructure/settings.py +76 -0
- caland/interface/__init__.py +0 -0
- caland/interface/modals.py +932 -0
- caland/interface/screens/__init__.py +0 -0
- caland/interface/screens/login.py +245 -0
- caland/interface/screens/main.py +1107 -0
- caland/interface/sorting.py +59 -0
- caland/interface/theme.py +137 -0
- caland/interface/web/__init__.py +134 -0
- caland/interface/web/gate.py +107 -0
- caland/interface/web/opening.py +54 -0
- caland/interface/web/server.py +328 -0
- caland/interface/web/static/page.css +110 -0
- caland/interface/web/static/page.html +94 -0
- caland/interface/web/static/page.js +425 -0
- caland/interface/web/views.py +86 -0
- caland/interface/widgets.py +473 -0
- caland/styles.tcss +371 -0
- caland-0.5.0.dist-info/METADATA +219 -0
- caland-0.5.0.dist-info/RECORD +46 -0
- caland-0.5.0.dist-info/WHEEL +4 -0
- caland-0.5.0.dist-info/entry_points.txt +4 -0
- caland-0.5.0.dist-info/licenses/LICENSE +202 -0
caland/__init__.py
ADDED
|
File without changes
|
caland/__main__.py
ADDED
caland/app.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Caland — a terminal Databricks secret manager.
|
|
2
|
+
|
|
3
|
+
The App is the composition root: it builds the infrastructure adapters, wires
|
|
4
|
+
them into the `OnboardingService`, installs the theme, and hands off to
|
|
5
|
+
`MainScreen`. Domain logic lives in `caland.domain`, use-cases in
|
|
6
|
+
`caland.application`, adapters in `caland.infrastructure`; UI in
|
|
7
|
+
`caland.interface`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from textual.app import App
|
|
13
|
+
|
|
14
|
+
from .application import OnboardingService, WorkspaceService
|
|
15
|
+
from .domain import Settings, SettingsStore
|
|
16
|
+
from .infrastructure import (
|
|
17
|
+
DatabricksBundleStore,
|
|
18
|
+
DatabricksCfgProfileStore,
|
|
19
|
+
DatabricksConnector,
|
|
20
|
+
JsonSettingsStore,
|
|
21
|
+
)
|
|
22
|
+
from .interface.screens.main import MainScreen
|
|
23
|
+
from .interface.theme import CALAND_THEMES
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EphemeralSettingsStore:
|
|
27
|
+
"""Fallback `SettingsStore`: keeps preferences for this process only.
|
|
28
|
+
|
|
29
|
+
The default for programmatic use (tests, embedding) so constructing the app
|
|
30
|
+
never touches the user's real config; `main()` passes the JSON store.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._settings = Settings()
|
|
35
|
+
|
|
36
|
+
def load(self) -> Settings:
|
|
37
|
+
return self._settings
|
|
38
|
+
|
|
39
|
+
def save(self, settings: Settings) -> None:
|
|
40
|
+
self._settings = settings
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CalandApp(App[None]):
|
|
44
|
+
CSS_PATH = "styles.tcss"
|
|
45
|
+
TITLE = "Caland"
|
|
46
|
+
# `q` quits from the browse/login screens only (they bind it themselves) —
|
|
47
|
+
# an app-level binding would bubble up from dialogs and quit mid-confirm.
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
onboarding: OnboardingService | None = None,
|
|
52
|
+
session: WorkspaceService | None = None,
|
|
53
|
+
read_only: bool = False,
|
|
54
|
+
settings_store: SettingsStore | None = None,
|
|
55
|
+
profile: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self._onboarding = onboarding
|
|
59
|
+
self._initial_session = session
|
|
60
|
+
self.read_only = read_only
|
|
61
|
+
self._settings_store = settings_store or EphemeralSettingsStore()
|
|
62
|
+
self.settings = self._settings_store.load()
|
|
63
|
+
self._profile = profile # workspace to auto-connect to (--profile)
|
|
64
|
+
|
|
65
|
+
def get_theme_variable_defaults(self) -> dict[str, str]:
|
|
66
|
+
# The stylesheet is parsed under Textual's default theme before ours is
|
|
67
|
+
# applied, so the per-section accents must resolve there too. Each
|
|
68
|
+
# Caland theme overrides these with its own values.
|
|
69
|
+
return {
|
|
70
|
+
**super().get_theme_variable_defaults(),
|
|
71
|
+
"scopes-color": "#8b7cff",
|
|
72
|
+
"secrets-color": "#4ec9e0",
|
|
73
|
+
"detail-color": "#e0b24a",
|
|
74
|
+
"value-color": "#5fd39a",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def save_settings(self) -> None:
|
|
78
|
+
self._settings_store.save(self.settings)
|
|
79
|
+
|
|
80
|
+
def _remember_theme(self, theme: str) -> None:
|
|
81
|
+
self.settings.theme = theme
|
|
82
|
+
self.save_settings()
|
|
83
|
+
|
|
84
|
+
def on_mount(self) -> None:
|
|
85
|
+
for theme in CALAND_THEMES:
|
|
86
|
+
self.register_theme(theme)
|
|
87
|
+
# apply the persisted theme (fall back if it no longer exists), then
|
|
88
|
+
# remember every change the user makes via the palette
|
|
89
|
+
wanted = self.settings.theme
|
|
90
|
+
self.theme = wanted if wanted in self.available_themes else "caland"
|
|
91
|
+
self.watch(self, "theme", self._remember_theme, init=False)
|
|
92
|
+
onboarding = self._onboarding or OnboardingService(
|
|
93
|
+
DatabricksConnector(),
|
|
94
|
+
DatabricksCfgProfileStore(),
|
|
95
|
+
DatabricksBundleStore(),
|
|
96
|
+
)
|
|
97
|
+
self.push_screen(
|
|
98
|
+
MainScreen(
|
|
99
|
+
onboarding,
|
|
100
|
+
self._initial_session,
|
|
101
|
+
read_only=self.read_only,
|
|
102
|
+
settings=self.settings,
|
|
103
|
+
save_settings=self.save_settings,
|
|
104
|
+
auto_connect=self._profile,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_USAGE = """\
|
|
110
|
+
caland — a keyboard-driven terminal UI for managing Databricks secrets.
|
|
111
|
+
|
|
112
|
+
usage: caland [WORKSPACE] [--profile NAME] [--read-only] [--version] [--help]
|
|
113
|
+
caland --page [WORKSPACE] [--no-open]
|
|
114
|
+
|
|
115
|
+
WORKSPACE / --profile NAME
|
|
116
|
+
connect straight to a discovered workspace (a
|
|
117
|
+
~/.databrickscfg profile or bundle target) and skip
|
|
118
|
+
the picker
|
|
119
|
+
--read-only browse, reveal, and copy — but disable every mutation
|
|
120
|
+
(safe for poking around production)
|
|
121
|
+
--page a preview: caland as a page in your browser, served from
|
|
122
|
+
this machine only. It browses, shows and copies; it does
|
|
123
|
+
not change anything yet
|
|
124
|
+
--no-open with --page: print the link instead of opening a browser
|
|
125
|
+
|
|
126
|
+
Run with no arguments to launch the TUI. Inside: ? for help, ctrl+p for the
|
|
127
|
+
command palette, q to quit.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main() -> None:
|
|
132
|
+
import sys
|
|
133
|
+
|
|
134
|
+
args = sys.argv[1:]
|
|
135
|
+
if {"-V", "--version"} & set(args):
|
|
136
|
+
from importlib.metadata import version
|
|
137
|
+
|
|
138
|
+
print(f"caland {version('caland')}")
|
|
139
|
+
return
|
|
140
|
+
if {"-h", "--help"} & set(args):
|
|
141
|
+
print(_USAGE, end="")
|
|
142
|
+
return
|
|
143
|
+
profile: str | None = None
|
|
144
|
+
if "--profile" in args:
|
|
145
|
+
i = args.index("--profile")
|
|
146
|
+
if i + 1 >= len(args):
|
|
147
|
+
print("error: --profile needs a workspace name", file=sys.stderr)
|
|
148
|
+
raise SystemExit(2)
|
|
149
|
+
profile = args[i + 1]
|
|
150
|
+
else: # a bare positional is the workspace name
|
|
151
|
+
positional = [a for a in args if not a.startswith("-")]
|
|
152
|
+
if positional:
|
|
153
|
+
profile = positional[0]
|
|
154
|
+
if "--page" in args:
|
|
155
|
+
raise SystemExit(_page(profile, args))
|
|
156
|
+
CalandApp(
|
|
157
|
+
read_only="--read-only" in args,
|
|
158
|
+
settings_store=JsonSettingsStore(),
|
|
159
|
+
profile=profile,
|
|
160
|
+
).run()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _page(profile: str | None, args: list[str]) -> int:
|
|
164
|
+
"""`caland --page`: the same workspace, as a page in the browser."""
|
|
165
|
+
from importlib.metadata import version
|
|
166
|
+
|
|
167
|
+
from .interface import web
|
|
168
|
+
|
|
169
|
+
onboarding = OnboardingService(
|
|
170
|
+
DatabricksConnector(), DatabricksCfgProfileStore(), DatabricksBundleStore()
|
|
171
|
+
)
|
|
172
|
+
return web.run(
|
|
173
|
+
onboarding,
|
|
174
|
+
profile,
|
|
175
|
+
read_only="--read-only" in args,
|
|
176
|
+
settings=JsonSettingsStore().load(),
|
|
177
|
+
version=version("caland"),
|
|
178
|
+
open_browser="--no-open" not in args,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Application layer — use-cases that orchestrate the domain.
|
|
2
|
+
|
|
3
|
+
Depends only on `domain`; never on infrastructure or the UI. The read model
|
|
4
|
+
(`WorkspaceCache`) is pure in-memory app state, so it lives here too.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .dotenv import format_dotenv, parse_dotenv
|
|
8
|
+
from .loading import Loader, Progress
|
|
9
|
+
from .onboarding import Connection, OnboardingService
|
|
10
|
+
from .read_model import WorkspaceCache
|
|
11
|
+
from .workspace import WorkspaceService
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Connection",
|
|
15
|
+
"Loader",
|
|
16
|
+
"OnboardingService",
|
|
17
|
+
"Progress",
|
|
18
|
+
"WorkspaceCache",
|
|
19
|
+
"WorkspaceService",
|
|
20
|
+
"format_dotenv",
|
|
21
|
+
"parse_dotenv",
|
|
22
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Parse and format .env content for bulk secret import/export.
|
|
2
|
+
|
|
3
|
+
Pure text transforms — no I/O. Values with newlines or other awkward characters
|
|
4
|
+
are written JSON-quoted and read back the same way, so multiline secrets
|
|
5
|
+
(PEM keys etc.) round-trip.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
# values that can be written bare, without quoting
|
|
14
|
+
_BARE = re.compile(r"^[A-Za-z0-9_@%+=:,./-]*$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_dotenv(text: str) -> dict[str, str]:
|
|
18
|
+
"""KEY=VALUE lines. Blank lines and #-comments are ignored, an `export `
|
|
19
|
+
prefix is dropped, and single/double/JSON-style quotes are stripped."""
|
|
20
|
+
entries: dict[str, str] = {}
|
|
21
|
+
for line in text.splitlines():
|
|
22
|
+
line = line.strip()
|
|
23
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
24
|
+
continue
|
|
25
|
+
key, _, value = line.partition("=")
|
|
26
|
+
key = key.strip()
|
|
27
|
+
if key.startswith("export "):
|
|
28
|
+
key = key.removeprefix("export ").strip()
|
|
29
|
+
value = value.strip()
|
|
30
|
+
if value[:1] == '"' and value[-1:] == '"' and len(value) >= 2:
|
|
31
|
+
try:
|
|
32
|
+
value = json.loads(value) # unescape \n etc.
|
|
33
|
+
except ValueError:
|
|
34
|
+
value = value[1:-1]
|
|
35
|
+
elif value[:1] == "'" and value[-1:] == "'" and len(value) >= 2:
|
|
36
|
+
value = value[1:-1]
|
|
37
|
+
if key:
|
|
38
|
+
entries[key] = value
|
|
39
|
+
return entries
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_dotenv(pairs: list[tuple[str, str]], *, redact: bool = False) -> str:
|
|
43
|
+
"""Render pairs as .env lines; `redact` writes keys only (a template)."""
|
|
44
|
+
lines = []
|
|
45
|
+
for key, value in pairs:
|
|
46
|
+
if redact:
|
|
47
|
+
lines.append(f"{key}=")
|
|
48
|
+
elif _BARE.match(value):
|
|
49
|
+
lines.append(f"{key}={value}")
|
|
50
|
+
else:
|
|
51
|
+
lines.append(f"{key}={json.dumps(value)}")
|
|
52
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Loader — connect to a workspace and warm it, in the background.
|
|
2
|
+
|
|
3
|
+
The use-case behind "the page is there before the workspace is": something that
|
|
4
|
+
shows a workspace starts a `Loader`, draws at once, and asks `progress()` how far
|
|
5
|
+
it is. Scopes are warmed eight at a time — enough that hundreds are ready in
|
|
6
|
+
seconds, few enough not to hammer the API.
|
|
7
|
+
|
|
8
|
+
It does no I/O of its own: `connect` hands it a `WorkspaceService`, and every
|
|
9
|
+
call goes through that.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from dataclasses import dataclass, replace
|
|
18
|
+
|
|
19
|
+
from ..domain import AuthError, StoreError
|
|
20
|
+
from .workspace import WorkspaceService
|
|
21
|
+
|
|
22
|
+
#: How many scopes are asked for at the same time.
|
|
23
|
+
WORKERS = 8
|
|
24
|
+
|
|
25
|
+
CONNECTING = "connecting"
|
|
26
|
+
LOADING = "loading"
|
|
27
|
+
READY = "ready"
|
|
28
|
+
FAILED = "failed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Progress:
|
|
33
|
+
"""How far a `Loader` is. `version` goes up with every change, so that who
|
|
34
|
+
asks can tell whether there is anything new."""
|
|
35
|
+
|
|
36
|
+
phase: str = CONNECTING
|
|
37
|
+
done: int = 0
|
|
38
|
+
total: int = 0
|
|
39
|
+
error: str = ""
|
|
40
|
+
version: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Loader:
|
|
44
|
+
def __init__(
|
|
45
|
+
self, connect: Callable[[], WorkspaceService], workers: int = WORKERS
|
|
46
|
+
) -> None:
|
|
47
|
+
self._connect = connect
|
|
48
|
+
self._workers = workers
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
self._progress = Progress()
|
|
51
|
+
self._service: WorkspaceService | None = None
|
|
52
|
+
# one run at a time: a refresh asked for during a load waits its turn
|
|
53
|
+
self._turn = threading.Lock()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def service(self) -> WorkspaceService | None:
|
|
57
|
+
"""The workspace, once connected."""
|
|
58
|
+
return self._service
|
|
59
|
+
|
|
60
|
+
def progress(self) -> Progress:
|
|
61
|
+
with self._lock:
|
|
62
|
+
return self._progress
|
|
63
|
+
|
|
64
|
+
# -- starting work --------------------------------------------------
|
|
65
|
+
def start(self) -> threading.Thread:
|
|
66
|
+
"""Connect, sign in and warm every scope. Returns at once."""
|
|
67
|
+
return self._run(self._load)
|
|
68
|
+
|
|
69
|
+
def refresh(self, scope: str | None = None) -> threading.Thread | None:
|
|
70
|
+
"""Read one scope again, or with no name the whole workspace. Returns at
|
|
71
|
+
once; nothing happens when there is no workspace to read."""
|
|
72
|
+
if self._service is None:
|
|
73
|
+
return None
|
|
74
|
+
if scope is None:
|
|
75
|
+
return self._run(self._warm)
|
|
76
|
+
return self._run(lambda: self._warm_one(scope))
|
|
77
|
+
|
|
78
|
+
def _run(self, work: Callable[[], None]) -> threading.Thread:
|
|
79
|
+
def in_turn() -> None:
|
|
80
|
+
with self._turn:
|
|
81
|
+
work()
|
|
82
|
+
|
|
83
|
+
thread = threading.Thread(target=in_turn, name="caland-loader", daemon=True)
|
|
84
|
+
thread.start()
|
|
85
|
+
return thread
|
|
86
|
+
|
|
87
|
+
# -- the work -------------------------------------------------------
|
|
88
|
+
def _set(self, **changes: object) -> None:
|
|
89
|
+
with self._lock:
|
|
90
|
+
now = self._progress
|
|
91
|
+
self._progress = replace(now, **changes, version=now.version + 1)
|
|
92
|
+
|
|
93
|
+
def _one_more(self) -> None:
|
|
94
|
+
with self._lock:
|
|
95
|
+
now = self._progress
|
|
96
|
+
self._progress = replace(now, done=now.done + 1, version=now.version + 1)
|
|
97
|
+
|
|
98
|
+
def _load(self) -> None:
|
|
99
|
+
self._set(phase=CONNECTING, done=0, total=0, error="")
|
|
100
|
+
try:
|
|
101
|
+
service = self._connect()
|
|
102
|
+
identity = service.authenticate()
|
|
103
|
+
except (AuthError, StoreError) as exc:
|
|
104
|
+
self._set(phase=FAILED, error=str(exc))
|
|
105
|
+
return
|
|
106
|
+
if not identity.authenticated:
|
|
107
|
+
self._set(phase=FAILED, error=identity.error or "Access denied.")
|
|
108
|
+
return
|
|
109
|
+
self._service = service
|
|
110
|
+
self._warm()
|
|
111
|
+
|
|
112
|
+
def _warm(self) -> None:
|
|
113
|
+
service = self._service
|
|
114
|
+
if service is None:
|
|
115
|
+
return
|
|
116
|
+
self._set(phase=LOADING, done=0, total=0, error="")
|
|
117
|
+
try:
|
|
118
|
+
scopes = service.load_scopes()
|
|
119
|
+
except StoreError as exc:
|
|
120
|
+
self._set(phase=FAILED, error=str(exc))
|
|
121
|
+
return
|
|
122
|
+
self._set(total=len(scopes))
|
|
123
|
+
|
|
124
|
+
def warm(name: str) -> None:
|
|
125
|
+
service.warm_scope(name) # never raises: a scope it can't read is empty
|
|
126
|
+
self._one_more()
|
|
127
|
+
|
|
128
|
+
# each scope is written under its own key, so the writes don't race
|
|
129
|
+
with ThreadPoolExecutor(max_workers=self._workers) as pool:
|
|
130
|
+
list(pool.map(warm, [scope.name for scope in scopes]))
|
|
131
|
+
self._set(phase=READY)
|
|
132
|
+
|
|
133
|
+
def _warm_one(self, scope: str) -> None:
|
|
134
|
+
service = self._service
|
|
135
|
+
if service is None or service.scope(scope) is None:
|
|
136
|
+
return
|
|
137
|
+
service.refresh_scope(scope)
|
|
138
|
+
self._set() # nothing but the version: there is something new to read
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""OnboardingService — connection/login use-cases.
|
|
2
|
+
|
|
3
|
+
Depends only on domain ports (`WorkspaceConnector`, `ProfileStore`, `BundleStore`);
|
|
4
|
+
the composition root injects concrete infrastructure adapters. Produces
|
|
5
|
+
ready-to-use `WorkspaceService` instances so the UI never touches the store or
|
|
6
|
+
the SDK.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..domain import (
|
|
14
|
+
SOURCE_PROFILE,
|
|
15
|
+
AuthError,
|
|
16
|
+
BundleStore,
|
|
17
|
+
ProfileStore,
|
|
18
|
+
Workspace,
|
|
19
|
+
WorkspaceConnector,
|
|
20
|
+
)
|
|
21
|
+
from .workspace import WorkspaceService
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Connection:
|
|
26
|
+
"""A live workspace session plus the host needed to persist it as a profile."""
|
|
27
|
+
|
|
28
|
+
service: WorkspaceService
|
|
29
|
+
host: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _on_offer(workspaces: list[Workspace]) -> str:
|
|
33
|
+
names = ", ".join(workspace.name for workspace in workspaces)
|
|
34
|
+
return f"There is: {names}." if names else "There is none to choose from."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OnboardingService:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
connector: WorkspaceConnector,
|
|
41
|
+
profiles: ProfileStore,
|
|
42
|
+
bundle: BundleStore | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._connector = connector
|
|
45
|
+
self._profiles = profiles
|
|
46
|
+
self._bundle = bundle
|
|
47
|
+
|
|
48
|
+
# -- discovery ------------------------------------------------------
|
|
49
|
+
def available_workspaces(self) -> list[Workspace]:
|
|
50
|
+
"""Every workspace we can offer, each tagged with where it came from: the
|
|
51
|
+
bundle target (default) first, then the ~/.databrickscfg profiles."""
|
|
52
|
+
workspaces: list[Workspace] = []
|
|
53
|
+
seen: set[str] = set()
|
|
54
|
+
bundle = self._bundle.discover() if self._bundle else None
|
|
55
|
+
if bundle:
|
|
56
|
+
workspaces.append(bundle)
|
|
57
|
+
seen.add(bundle.host_label)
|
|
58
|
+
for ws in self._profiles.discover():
|
|
59
|
+
if ws.host_label and ws.host_label in seen:
|
|
60
|
+
continue # already offered by the bundle
|
|
61
|
+
seen.add(ws.host_label)
|
|
62
|
+
workspaces.append(ws)
|
|
63
|
+
return workspaces
|
|
64
|
+
|
|
65
|
+
def choose(self, name: str | None = None) -> Workspace:
|
|
66
|
+
"""The workspace that was asked for by name — or, with no name, the one
|
|
67
|
+
there is no doubt about: the bundle's default, or the only one there is.
|
|
68
|
+
|
|
69
|
+
Raises `AuthError` saying what there is to choose from otherwise.
|
|
70
|
+
"""
|
|
71
|
+
workspaces = self.available_workspaces()
|
|
72
|
+
if name:
|
|
73
|
+
for workspace in workspaces:
|
|
74
|
+
if workspace.name == name:
|
|
75
|
+
return workspace
|
|
76
|
+
raise AuthError(f"No workspace “{name}” found. {_on_offer(workspaces)}")
|
|
77
|
+
default = next((w for w in workspaces if w.default), None)
|
|
78
|
+
if default:
|
|
79
|
+
return default
|
|
80
|
+
if len(workspaces) == 1:
|
|
81
|
+
return workspaces[0]
|
|
82
|
+
if not workspaces:
|
|
83
|
+
raise AuthError(
|
|
84
|
+
"No workspace found: there is no databricks.yml here and no profile "
|
|
85
|
+
"in ~/.databrickscfg."
|
|
86
|
+
)
|
|
87
|
+
raise AuthError(f"Which workspace? {_on_offer(workspaces)}")
|
|
88
|
+
|
|
89
|
+
def save_profile(self, name: str, host: str) -> None:
|
|
90
|
+
self._profiles.save(name, host)
|
|
91
|
+
|
|
92
|
+
# -- connection use-cases -------------------------------------------
|
|
93
|
+
def connect(self, workspace: Workspace) -> Connection:
|
|
94
|
+
"""Connect to a chosen workspace. A saved profile uses its stored auth; a
|
|
95
|
+
bundle target or manual URL signs in through the browser (OAuth)."""
|
|
96
|
+
if workspace.source == SOURCE_PROFILE and workspace.profile:
|
|
97
|
+
return self.connect_profile(workspace.profile)
|
|
98
|
+
return self.connect_url(workspace.host)
|
|
99
|
+
|
|
100
|
+
def connect_profile(self, profile: str) -> Connection:
|
|
101
|
+
c = self._connector.connect_profile(profile)
|
|
102
|
+
return Connection(WorkspaceService(c.store, c.label))
|
|
103
|
+
|
|
104
|
+
def connect_url(self, host: str) -> Connection:
|
|
105
|
+
c = self._connector.connect_url(host)
|
|
106
|
+
return Connection(WorkspaceService(c.store, c.label), host=c.host)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""WorkspaceCache — the in-memory read model the UI renders from.
|
|
2
|
+
|
|
3
|
+
A projection that the application service warms up front (US-14) and keeps in
|
|
4
|
+
sync on writes. Pure data + bookkeeping; it holds no business rules (those live
|
|
5
|
+
in the domain) and does no I/O.
|
|
6
|
+
|
|
7
|
+
Strategy (US-14/16):
|
|
8
|
+
* On connect the service warms scopes -> secret metadata -> ACLs in the
|
|
9
|
+
background.
|
|
10
|
+
* Secret *values* are NOT bulk-loaded; they are fetched lazily on reveal and
|
|
11
|
+
cached thereafter, so sensitive material isn't pulled into memory needlessly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from ..domain import Acl, Identity, Scope, Secret
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class WorkspaceCache:
|
|
23
|
+
label: str
|
|
24
|
+
identity: Identity = field(default_factory=Identity)
|
|
25
|
+
|
|
26
|
+
scopes: list[Scope] = field(default_factory=list)
|
|
27
|
+
secrets: dict[str, list[Secret]] = field(default_factory=dict)
|
|
28
|
+
acls: dict[str, list[Acl]] = field(default_factory=dict)
|
|
29
|
+
values: dict[tuple[str, str], str] = field(default_factory=dict)
|
|
30
|
+
# scopes whose secrets we could list ⇒ the user holds at least READ on them
|
|
31
|
+
readable: set[str] = field(default_factory=set)
|
|
32
|
+
|
|
33
|
+
scopes_loaded: bool = False
|
|
34
|
+
warm_error: str = ""
|
|
35
|
+
|
|
36
|
+
# -- lookups ------------------------------------------------------------
|
|
37
|
+
def secrets_for(self, scope: str) -> list[Secret]:
|
|
38
|
+
return self.secrets.get(scope, [])
|
|
39
|
+
|
|
40
|
+
def acls_for(self, scope: str) -> list[Acl]:
|
|
41
|
+
return self.acls.get(scope, [])
|
|
42
|
+
|
|
43
|
+
def cached_value(self, scope: str, key: str) -> str | None:
|
|
44
|
+
return self.values.get((scope, key))
|
|
45
|
+
|
|
46
|
+
def set_value(self, scope: str, key: str, value: str) -> None:
|
|
47
|
+
self.values[(scope, key)] = value
|
|
48
|
+
|
|
49
|
+
# -- mutation keeping the read model + UI consistent --------------------
|
|
50
|
+
def upsert_secret(self, secret: Secret) -> None:
|
|
51
|
+
rows = self.secrets.setdefault(secret.scope, [])
|
|
52
|
+
for i, existing in enumerate(rows):
|
|
53
|
+
if existing.key == secret.key:
|
|
54
|
+
rows[i] = secret
|
|
55
|
+
break
|
|
56
|
+
else:
|
|
57
|
+
rows.append(secret)
|
|
58
|
+
rows.sort(key=lambda s: s.key.lower())
|
|
59
|
+
|
|
60
|
+
def remove_secret(self, scope: str, key: str) -> None:
|
|
61
|
+
self.secrets[scope] = [s for s in self.secrets.get(scope, []) if s.key != key]
|
|
62
|
+
self.values.pop((scope, key), None)
|
|
63
|
+
|
|
64
|
+
def add_scope(self, scope: Scope) -> None:
|
|
65
|
+
if not any(s.name == scope.name for s in self.scopes):
|
|
66
|
+
self.scopes.append(scope)
|
|
67
|
+
self.scopes.sort(key=lambda s: s.name.lower())
|
|
68
|
+
self.secrets.setdefault(scope.name, [])
|
|
69
|
+
self.acls.setdefault(scope.name, [])
|
|
70
|
+
|
|
71
|
+
def remove_scope(self, name: str) -> None:
|
|
72
|
+
self.scopes = [s for s in self.scopes if s.name != name]
|
|
73
|
+
self.secrets.pop(name, None)
|
|
74
|
+
self.acls.pop(name, None)
|
|
75
|
+
self.readable.discard(name)
|
|
76
|
+
self.values = {k: v for k, v in self.values.items() if k[0] != name}
|