homecloud-sdk 0.3.2__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.
- homecloud_core/__init__.py +1 -0
- homecloud_core/account.py +23 -0
- homecloud_core/config.py +165 -0
- homecloud_core/context.py +323 -0
- homecloud_core/defaults.py +39 -0
- homecloud_core/env.py +49 -0
- homecloud_core/errors.py +56 -0
- homecloud_core/mfa.py +120 -0
- homecloud_core/progress_reader.py +27 -0
- homecloud_core/py.typed +0 -0
- homecloud_core/session.py +115 -0
- homecloud_core/signing.py +50 -0
- homecloud_core/so_paths.py +29 -0
- homecloud_core/transfer_state.py +65 -0
- homecloud_core/transport.py +388 -0
- homecloud_sdk/__init__.py +24 -0
- homecloud_sdk/client.py +177 -0
- homecloud_sdk/py.typed +0 -0
- homecloud_sdk/services.py +552 -0
- homecloud_sdk/so_parallel.py +38 -0
- homecloud_sdk-0.3.2.dist-info/METADATA +128 -0
- homecloud_sdk-0.3.2.dist-info/RECORD +24 -0
- homecloud_sdk-0.3.2.dist-info/WHEEL +5 -0
- homecloud_sdk-0.3.2.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal core — not part of the public SDK surface."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Automatic account resolution — users never pass account IDs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from homecloud_core.config import ProfileConfig
|
|
6
|
+
from homecloud_core.errors import NotConfiguredError
|
|
7
|
+
from homecloud_core.session import ProfileSession, set_active_account
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_account_id(profile: ProfileConfig, session: ProfileSession) -> str:
|
|
11
|
+
if session.active_account_id:
|
|
12
|
+
return session.active_account_id
|
|
13
|
+
if profile.default_account_id:
|
|
14
|
+
return profile.default_account_id
|
|
15
|
+
if session.last_used_account_id:
|
|
16
|
+
return session.last_used_account_id
|
|
17
|
+
raise NotConfiguredError(
|
|
18
|
+
"No active account for this profile. Run: homecloud configure or homecloud accounts switch"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def remember_account(profile_name: str, account_id: str) -> None:
|
|
23
|
+
set_active_account(profile_name, account_id)
|
homecloud_core/config.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Persistent credentials — Access Keys only, no session tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from homecloud_core.defaults import DEFAULT_PROFILE, platform_apex
|
|
11
|
+
from homecloud_core.env import env_config_dir, env_credentials_file
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def homecloud_dir() -> Path:
|
|
15
|
+
override = env_config_dir()
|
|
16
|
+
if override:
|
|
17
|
+
return Path(override).expanduser()
|
|
18
|
+
return Path.home() / ".homecloud"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def credentials_path() -> Path:
|
|
22
|
+
override = env_credentials_file()
|
|
23
|
+
if override:
|
|
24
|
+
return Path(override).expanduser()
|
|
25
|
+
return homecloud_dir() / "credentials"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ProfileConfig:
|
|
30
|
+
name: str
|
|
31
|
+
apex: str = platform_apex()
|
|
32
|
+
default_account_id: str | None = None
|
|
33
|
+
access_key_id: str | None = None
|
|
34
|
+
secret_access_key: str | None = None
|
|
35
|
+
|
|
36
|
+
def require_access_key(self) -> tuple[str, str, str]:
|
|
37
|
+
if not self.default_account_id:
|
|
38
|
+
raise ValueError("No account configured for this profile. Run: homecloud configure")
|
|
39
|
+
if not self.access_key_id or not self.secret_access_key:
|
|
40
|
+
raise ValueError("Access Key not configured. Run: homecloud configure")
|
|
41
|
+
return self.default_account_id, self.access_key_id, self.secret_access_key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CredentialsFile:
|
|
46
|
+
version: int
|
|
47
|
+
default_profile: str
|
|
48
|
+
profiles: dict[str, ProfileConfig]
|
|
49
|
+
|
|
50
|
+
def get_profile(self, name: str | None = None) -> ProfileConfig:
|
|
51
|
+
profile_name = name or self.default_profile
|
|
52
|
+
if profile_name not in self.profiles:
|
|
53
|
+
raise ValueError(f"Profile not found: {profile_name}")
|
|
54
|
+
return self.profiles[profile_name]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _profile_from_dict(name: str, data: dict[str, Any]) -> ProfileConfig:
|
|
58
|
+
return ProfileConfig(
|
|
59
|
+
name=name,
|
|
60
|
+
apex=data.get("apex", platform_apex()),
|
|
61
|
+
default_account_id=data.get("default_account_id"),
|
|
62
|
+
access_key_id=data.get("access_key_id"),
|
|
63
|
+
secret_access_key=data.get("secret_access_key"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _strip_legacy_session_fields(data: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
cleaned = dict(data)
|
|
69
|
+
cleaned.pop("access_token", None)
|
|
70
|
+
cleaned.pop("console_url", None)
|
|
71
|
+
cleaned.pop("mq_url", None)
|
|
72
|
+
cleaned.pop("so_url", None)
|
|
73
|
+
cleaned.pop("secrets_url", None)
|
|
74
|
+
return cleaned
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalize_raw(data: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
if "profiles" in data:
|
|
79
|
+
return {
|
|
80
|
+
"version": data.get("version", 2),
|
|
81
|
+
"default_profile": data.get("default_profile", DEFAULT_PROFILE),
|
|
82
|
+
"profiles": {
|
|
83
|
+
name: _strip_legacy_session_fields(profile_data)
|
|
84
|
+
for name, profile_data in data["profiles"].items()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
profile = _strip_legacy_session_fields(data)
|
|
89
|
+
return {
|
|
90
|
+
"version": data.get("version", 2),
|
|
91
|
+
"default_profile": data.get("default_profile", DEFAULT_PROFILE),
|
|
92
|
+
"profiles": {DEFAULT_PROFILE: profile},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_credentials(path: Path | None = None) -> CredentialsFile:
|
|
97
|
+
cred_path = path or credentials_path()
|
|
98
|
+
if not cred_path.exists():
|
|
99
|
+
raise FileNotFoundError(
|
|
100
|
+
f"Credentials file not found: {cred_path}. Run: homecloud configure"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
raw = json.loads(cred_path.read_text(encoding="utf-8"))
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
raise ValueError("Invalid credentials file: expected JSON object")
|
|
106
|
+
|
|
107
|
+
normalized = _normalize_raw(raw)
|
|
108
|
+
profiles = {
|
|
109
|
+
name: _profile_from_dict(name, profile_data)
|
|
110
|
+
for name, profile_data in normalized.get("profiles", {}).items()
|
|
111
|
+
}
|
|
112
|
+
if not profiles:
|
|
113
|
+
raise ValueError("No profiles found in credentials file")
|
|
114
|
+
|
|
115
|
+
return CredentialsFile(
|
|
116
|
+
version=int(normalized.get("version", 2)),
|
|
117
|
+
default_profile=normalized.get("default_profile", DEFAULT_PROFILE),
|
|
118
|
+
profiles=profiles,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_credentials(credentials: CredentialsFile, path: Path | None = None) -> Path:
|
|
123
|
+
cred_path = path or credentials_path()
|
|
124
|
+
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
payload = {
|
|
127
|
+
"version": credentials.version,
|
|
128
|
+
"default_profile": credentials.default_profile,
|
|
129
|
+
"profiles": {
|
|
130
|
+
name: {
|
|
131
|
+
"apex": profile.apex,
|
|
132
|
+
"default_account_id": profile.default_account_id,
|
|
133
|
+
"access_key_id": profile.access_key_id,
|
|
134
|
+
"secret_access_key": profile.secret_access_key,
|
|
135
|
+
}
|
|
136
|
+
for name, profile in credentials.profiles.items()
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
cred_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
141
|
+
try:
|
|
142
|
+
cred_path.chmod(0o600)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
return cred_path
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def upsert_profile(profile: ProfileConfig, *, make_default: bool = True) -> Path:
|
|
149
|
+
try:
|
|
150
|
+
credentials = load_credentials()
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
credentials = CredentialsFile(version=2, default_profile=profile.name, profiles={})
|
|
153
|
+
|
|
154
|
+
credentials.profiles[profile.name] = profile
|
|
155
|
+
if make_default:
|
|
156
|
+
credentials.default_profile = profile.name
|
|
157
|
+
return save_credentials(credentials)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def mask_secret(value: str | None) -> str:
|
|
161
|
+
if not value:
|
|
162
|
+
return ""
|
|
163
|
+
if len(value) <= 8:
|
|
164
|
+
return "****"
|
|
165
|
+
return f"{value[:4]}...{value[-4:]}"
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Runtime context — wires credentials, session, transport, and account resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from homecloud_core.account import remember_account, resolve_account_id
|
|
11
|
+
from homecloud_core.config import (
|
|
12
|
+
ProfileConfig,
|
|
13
|
+
load_credentials,
|
|
14
|
+
upsert_profile,
|
|
15
|
+
)
|
|
16
|
+
from homecloud_core.defaults import DEFAULT_PROFILE, platform_apex
|
|
17
|
+
from homecloud_core.env import (
|
|
18
|
+
env_access_key_id,
|
|
19
|
+
env_account_id,
|
|
20
|
+
env_apex,
|
|
21
|
+
env_profile,
|
|
22
|
+
env_secret_access_key,
|
|
23
|
+
)
|
|
24
|
+
from homecloud_core.errors import HomeCloudError, NotConfiguredError, NotLoggedInError
|
|
25
|
+
from homecloud_core.mfa import MfaResolver
|
|
26
|
+
from homecloud_core.session import (
|
|
27
|
+
get_access_token,
|
|
28
|
+
load_session,
|
|
29
|
+
migrate_legacy_token_from_credentials,
|
|
30
|
+
set_access_token,
|
|
31
|
+
)
|
|
32
|
+
from homecloud_core.transport import Transport
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CoreContext:
|
|
36
|
+
"""
|
|
37
|
+
Shared runtime for SDK and CLI.
|
|
38
|
+
|
|
39
|
+
Programmatic path (default): Access Key ID + Secret → data-plane SigV1.
|
|
40
|
+
Interactive path (CLI): optional console JWT via login / login_browser (+ MFA).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
profile_name: str | None = None,
|
|
46
|
+
*,
|
|
47
|
+
access_key_id: str | None = None,
|
|
48
|
+
secret_access_key: str | None = None,
|
|
49
|
+
account_id: str | None = None,
|
|
50
|
+
apex: str | None = None,
|
|
51
|
+
mfa_code: str | None = None,
|
|
52
|
+
mfa_prompt: Callable[[str], str] | None = None,
|
|
53
|
+
interactive_mfa: bool = False,
|
|
54
|
+
mfa_choose_method: Callable[[list[str], list[dict] | None], str] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
explicit = profile_name or env_profile()
|
|
57
|
+
try:
|
|
58
|
+
credentials = load_credentials()
|
|
59
|
+
self.profile_name = explicit or credentials.default_profile or DEFAULT_PROFILE
|
|
60
|
+
try:
|
|
61
|
+
self.profile = credentials.get_profile(self.profile_name)
|
|
62
|
+
except ValueError:
|
|
63
|
+
self.profile = ProfileConfig(name=self.profile_name)
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
self.profile_name = explicit or DEFAULT_PROFILE
|
|
66
|
+
self.profile = ProfileConfig(name=self.profile_name)
|
|
67
|
+
|
|
68
|
+
self._session = load_session().get(self.profile_name)
|
|
69
|
+
self._apply_env_overrides()
|
|
70
|
+
|
|
71
|
+
# Constructor credentials win over file + env (AWS-style).
|
|
72
|
+
if access_key_id:
|
|
73
|
+
self.profile.access_key_id = access_key_id
|
|
74
|
+
if secret_access_key:
|
|
75
|
+
self.profile.secret_access_key = secret_access_key
|
|
76
|
+
if account_id:
|
|
77
|
+
self.profile.default_account_id = account_id
|
|
78
|
+
self._account_id = account_id
|
|
79
|
+
else:
|
|
80
|
+
self._account_id = None
|
|
81
|
+
if apex:
|
|
82
|
+
self.profile.apex = apex.strip().rstrip("/")
|
|
83
|
+
|
|
84
|
+
self._mfa_prompt = mfa_prompt
|
|
85
|
+
self._interactive_mfa = interactive_mfa
|
|
86
|
+
self._mfa_choose_method = mfa_choose_method
|
|
87
|
+
self._mfa_resolver = MfaResolver(
|
|
88
|
+
mfa_code=mfa_code,
|
|
89
|
+
prompt=mfa_prompt,
|
|
90
|
+
interactive=interactive_mfa,
|
|
91
|
+
choose_method=mfa_choose_method,
|
|
92
|
+
)
|
|
93
|
+
self._transport = Transport(
|
|
94
|
+
apex=self.profile.apex,
|
|
95
|
+
access_key_id=self.profile.access_key_id,
|
|
96
|
+
secret_access_key=self.profile.secret_access_key,
|
|
97
|
+
access_token=self._session.access_token,
|
|
98
|
+
# MFA only when interactive CLI/tools opt in — never on default SDK path.
|
|
99
|
+
mfa_resolver=self._mfa_resolver if interactive_mfa or mfa_code else None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _apply_env_overrides(self) -> None:
|
|
103
|
+
if apex := env_apex():
|
|
104
|
+
self.profile.apex = apex
|
|
105
|
+
if account := env_account_id():
|
|
106
|
+
self.profile.default_account_id = account
|
|
107
|
+
if key_id := env_access_key_id():
|
|
108
|
+
self.profile.access_key_id = key_id
|
|
109
|
+
if secret := env_secret_access_key():
|
|
110
|
+
self.profile.secret_access_key = secret
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def transport(self) -> Transport:
|
|
114
|
+
return self._transport
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def has_access_key(self) -> bool:
|
|
118
|
+
return bool(self.profile.access_key_id and self.profile.secret_access_key)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def has_console_session(self) -> bool:
|
|
122
|
+
return bool(self._transport.access_token)
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
self._transport.close()
|
|
126
|
+
|
|
127
|
+
def require_access_key(self) -> None:
|
|
128
|
+
if not self.has_access_key:
|
|
129
|
+
raise NotConfiguredError(
|
|
130
|
+
"Access Key not configured. Pass access_key_id/secret_access_key, "
|
|
131
|
+
"set HOMECLOUD_ACCESS_KEY_ID / HC_ACCESS_KEY_ID, or run: homecloud configure"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def require_console_session(self) -> None:
|
|
135
|
+
if not self.has_console_session:
|
|
136
|
+
raise NotLoggedInError(
|
|
137
|
+
"This operation needs a console JWT (human session). "
|
|
138
|
+
"For automation use Access Key data-plane APIs instead. "
|
|
139
|
+
"Interactive: client.login(...) or homecloud login"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def account_id(self) -> str:
|
|
143
|
+
if self._account_id is not None:
|
|
144
|
+
return self._account_id
|
|
145
|
+
try:
|
|
146
|
+
account_id = resolve_account_id(self.profile, self._session)
|
|
147
|
+
except NotConfiguredError:
|
|
148
|
+
self.require_access_key()
|
|
149
|
+
account_id = self._transport.resolve_access_key_account_id()
|
|
150
|
+
remember_account(self.profile_name, account_id)
|
|
151
|
+
self._account_id = account_id
|
|
152
|
+
return account_id
|
|
153
|
+
|
|
154
|
+
def _apply_access_token(self, token: str) -> None:
|
|
155
|
+
set_access_token(self.profile_name, token)
|
|
156
|
+
self._transport.access_token = token
|
|
157
|
+
self._session = load_session().get(self.profile_name)
|
|
158
|
+
self._auto_select_account()
|
|
159
|
+
|
|
160
|
+
def login(self, username: str, password: str, *, mfa_code: str | None = None) -> None:
|
|
161
|
+
"""Interactive console login — not for unattended SDK/automation."""
|
|
162
|
+
if mfa_code or self._interactive_mfa:
|
|
163
|
+
self._mfa_resolver = MfaResolver(
|
|
164
|
+
mfa_code=mfa_code,
|
|
165
|
+
prompt=self._mfa_prompt,
|
|
166
|
+
interactive=self._interactive_mfa,
|
|
167
|
+
choose_method=self._mfa_choose_method,
|
|
168
|
+
)
|
|
169
|
+
self._transport.set_mfa_resolver(self._mfa_resolver)
|
|
170
|
+
|
|
171
|
+
body: dict[str, Any] = {"username": username, "password": password}
|
|
172
|
+
if mfa_code:
|
|
173
|
+
body["mfa_code"] = mfa_code
|
|
174
|
+
|
|
175
|
+
data = self._transport.console_request(
|
|
176
|
+
"POST",
|
|
177
|
+
"auth/login",
|
|
178
|
+
json=body,
|
|
179
|
+
require_auth=False,
|
|
180
|
+
)
|
|
181
|
+
token = data.get("access_token")
|
|
182
|
+
if not token:
|
|
183
|
+
raise HomeCloudError("Login failed")
|
|
184
|
+
self._apply_access_token(str(token))
|
|
185
|
+
|
|
186
|
+
def login_browser(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
open_browser: bool = True,
|
|
190
|
+
on_waiting: Callable[[str], None] | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Browser/passkey console login — interactive tools only."""
|
|
193
|
+
start = self._transport.console_request(
|
|
194
|
+
"POST",
|
|
195
|
+
"auth/cli/session",
|
|
196
|
+
require_auth=False,
|
|
197
|
+
)
|
|
198
|
+
session_id = start.get("session_id")
|
|
199
|
+
verification_uri = start.get("verification_uri")
|
|
200
|
+
if not session_id or not verification_uri:
|
|
201
|
+
raise HomeCloudError("Failed to start browser login session")
|
|
202
|
+
|
|
203
|
+
expires_in = int(start.get("expires_in") or 600)
|
|
204
|
+
interval = max(1, int(start.get("interval") or 2))
|
|
205
|
+
deadline = time.monotonic() + expires_in
|
|
206
|
+
|
|
207
|
+
if open_browser:
|
|
208
|
+
webbrowser.open(str(verification_uri))
|
|
209
|
+
if on_waiting:
|
|
210
|
+
on_waiting(str(verification_uri))
|
|
211
|
+
|
|
212
|
+
while time.monotonic() < deadline:
|
|
213
|
+
poll = self._transport.console_request(
|
|
214
|
+
"GET",
|
|
215
|
+
f"auth/cli/session/{session_id}",
|
|
216
|
+
require_auth=False,
|
|
217
|
+
_skip_mfa=True,
|
|
218
|
+
)
|
|
219
|
+
status = str(poll.get("status") or "")
|
|
220
|
+
if status == "complete":
|
|
221
|
+
token = poll.get("access_token")
|
|
222
|
+
if not token:
|
|
223
|
+
raise HomeCloudError("Browser login completed without access token")
|
|
224
|
+
self._apply_access_token(str(token))
|
|
225
|
+
return
|
|
226
|
+
if status == "expired":
|
|
227
|
+
raise HomeCloudError("Browser login session expired")
|
|
228
|
+
time.sleep(interval)
|
|
229
|
+
|
|
230
|
+
raise HomeCloudError("Browser login timed out")
|
|
231
|
+
|
|
232
|
+
def _auto_select_account(self) -> None:
|
|
233
|
+
if self._session.active_account_id or self.profile.default_account_id:
|
|
234
|
+
return
|
|
235
|
+
accounts = self.list_accounts()
|
|
236
|
+
if len(accounts) == 1:
|
|
237
|
+
remember_account(self.profile_name, str(accounts[0]["id"]))
|
|
238
|
+
|
|
239
|
+
def list_accounts(self) -> list[dict[str, Any]]:
|
|
240
|
+
self.require_console_session()
|
|
241
|
+
data = self._transport.console_request("GET", "accounts")
|
|
242
|
+
return data.get("items", data if isinstance(data, list) else [])
|
|
243
|
+
|
|
244
|
+
def switch_account(self, account_ref: str) -> None:
|
|
245
|
+
accounts = self.list_accounts()
|
|
246
|
+
match = next(
|
|
247
|
+
(
|
|
248
|
+
account
|
|
249
|
+
for account in accounts
|
|
250
|
+
if str(account.get("id")) == account_ref
|
|
251
|
+
or str(account.get("slug")) == account_ref
|
|
252
|
+
or str(account.get("name")) == account_ref
|
|
253
|
+
),
|
|
254
|
+
None,
|
|
255
|
+
)
|
|
256
|
+
if not match:
|
|
257
|
+
raise HomeCloudError(f"Account not found: {account_ref}")
|
|
258
|
+
remember_account(self.profile_name, str(match["id"]))
|
|
259
|
+
self._account_id = str(match["id"])
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def configure_profile(
|
|
263
|
+
*,
|
|
264
|
+
profile_name: str,
|
|
265
|
+
access_key_id: str,
|
|
266
|
+
secret_access_key: str,
|
|
267
|
+
default_account_id: str | None = None,
|
|
268
|
+
apex: str | None = None,
|
|
269
|
+
make_default: bool = True,
|
|
270
|
+
) -> None:
|
|
271
|
+
upsert_profile(
|
|
272
|
+
ProfileConfig(
|
|
273
|
+
name=profile_name,
|
|
274
|
+
apex=apex or platform_apex(),
|
|
275
|
+
access_key_id=access_key_id,
|
|
276
|
+
secret_access_key=secret_access_key,
|
|
277
|
+
default_account_id=default_account_id,
|
|
278
|
+
),
|
|
279
|
+
make_default=make_default,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def import_credentials_file(
|
|
284
|
+
raw: dict[str, Any],
|
|
285
|
+
*,
|
|
286
|
+
profile_name: str = DEFAULT_PROFILE,
|
|
287
|
+
) -> None:
|
|
288
|
+
migrate_legacy_token_from_credentials(profile_name, raw.get("access_token"))
|
|
289
|
+
CoreContext.configure_profile(
|
|
290
|
+
profile_name=profile_name,
|
|
291
|
+
access_key_id=raw["access_key_id"],
|
|
292
|
+
secret_access_key=raw["secret_access_key"],
|
|
293
|
+
default_account_id=raw.get("default_account_id"),
|
|
294
|
+
apex=raw.get("apex"),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def config_summary(self) -> dict[str, Any]:
|
|
298
|
+
from homecloud_core.config import credentials_path, load_credentials, mask_secret
|
|
299
|
+
from homecloud_core.session import session_path
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
load_credentials()
|
|
303
|
+
configured = True
|
|
304
|
+
except FileNotFoundError:
|
|
305
|
+
configured = False
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
"profile": self.profile_name,
|
|
309
|
+
"apex": self.profile.apex,
|
|
310
|
+
"credentials_file": str(credentials_path()),
|
|
311
|
+
"session_file": str(session_path()),
|
|
312
|
+
"configured": configured,
|
|
313
|
+
"has_access_key": self.has_access_key,
|
|
314
|
+
"has_console_session": self.has_console_session,
|
|
315
|
+
"access_key_id": self.profile.access_key_id,
|
|
316
|
+
"secret_access_key": mask_secret(self.profile.secret_access_key),
|
|
317
|
+
"logged_in": bool(get_access_token(self.profile_name)),
|
|
318
|
+
"has_account": bool(
|
|
319
|
+
self._account_id
|
|
320
|
+
or self._session.active_account_id
|
|
321
|
+
or self.profile.default_account_id
|
|
322
|
+
),
|
|
323
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Platform endpoint defaults — hidden from end users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from homecloud_core.env import env_apex
|
|
6
|
+
|
|
7
|
+
DEFAULT_APEX = "holab.abrdns.com"
|
|
8
|
+
DEFAULT_PROFILE = "default"
|
|
9
|
+
WHOAMI_PATH = "/access-key/whoami"
|
|
10
|
+
WHOAMI_ACCOUNT_SENTINEL = "-"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def platform_apex() -> str:
|
|
14
|
+
return env_apex() or DEFAULT_APEX
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def console_url(apex: str | None = None) -> str:
|
|
18
|
+
host = apex or platform_apex()
|
|
19
|
+
return f"https://console.{host}/api/v1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def console_web_url(apex: str | None = None) -> str:
|
|
23
|
+
host = apex or platform_apex()
|
|
24
|
+
return f"https://console.{host}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mq_url(apex: str | None = None) -> str:
|
|
28
|
+
host = apex or platform_apex()
|
|
29
|
+
return f"https://mq.{host}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def so_url(apex: str | None = None) -> str:
|
|
33
|
+
host = apex or platform_apex()
|
|
34
|
+
return f"https://so.{host}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def secrets_url(apex: str | None = None) -> str:
|
|
38
|
+
host = apex or platform_apex()
|
|
39
|
+
return f"https://secrets.{host}"
|
homecloud_core/env.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Environment variable helpers — HOMECLOUD_* canonical, HC_* short aliases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def env_first(*names: str) -> str | None:
|
|
9
|
+
"""Return the first non-empty env value among ``names`` (in order)."""
|
|
10
|
+
for name in names:
|
|
11
|
+
value = os.environ.get(name)
|
|
12
|
+
if value is not None and value.strip() != "":
|
|
13
|
+
return value
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def env_profile() -> str | None:
|
|
18
|
+
return env_first("HOMECLOUD_PROFILE", "HC_PROFILE")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def env_apex() -> str | None:
|
|
22
|
+
value = env_first("HOMECLOUD_APEX", "HC_APEX")
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
return value.strip().rstrip("/")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def env_account_id() -> str | None:
|
|
29
|
+
return env_first("HOMECLOUD_ACCOUNT_ID", "HC_ACCOUNT_ID")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def env_access_key_id() -> str | None:
|
|
33
|
+
return env_first("HOMECLOUD_ACCESS_KEY_ID", "HC_ACCESS_KEY_ID")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def env_secret_access_key() -> str | None:
|
|
37
|
+
return env_first("HOMECLOUD_SECRET_ACCESS_KEY", "HC_SECRET_ACCESS_KEY")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def env_config_dir() -> str | None:
|
|
41
|
+
return env_first("HOMECLOUD_CONFIG_DIR", "HC_CONFIG_DIR")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def env_credentials_file() -> str | None:
|
|
45
|
+
return env_first("HOMECLOUD_CREDENTIALS_FILE", "HC_CREDENTIALS_FILE")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def env_session_file() -> str | None:
|
|
49
|
+
return env_first("HOMECLOUD_SESSION_FILE", "HC_SESSION_FILE")
|
homecloud_core/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HomeCloudError(Exception):
|
|
7
|
+
def __init__(self, message: str, *, status_code: int | None = None, detail: Any = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.detail = detail
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def error_payload(self) -> dict[str, Any] | None:
|
|
14
|
+
"""Normalized `{code, message, details}` from either envelope or legacy detail dict."""
|
|
15
|
+
if not isinstance(self.detail, dict):
|
|
16
|
+
return None
|
|
17
|
+
error = self.detail.get("error")
|
|
18
|
+
if isinstance(error, dict) and error.get("code"):
|
|
19
|
+
details = error.get("details") if isinstance(error.get("details"), dict) else {}
|
|
20
|
+
return {
|
|
21
|
+
"code": str(error.get("code")),
|
|
22
|
+
"message": str(error.get("message") or error.get("code")),
|
|
23
|
+
"details": details,
|
|
24
|
+
}
|
|
25
|
+
if self.detail.get("code"):
|
|
26
|
+
details = {
|
|
27
|
+
key: value
|
|
28
|
+
for key, value in self.detail.items()
|
|
29
|
+
if key not in {"code", "message"}
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
"code": str(self.detail.get("code")),
|
|
33
|
+
"message": str(self.detail.get("message") or self.detail.get("code")),
|
|
34
|
+
"details": details,
|
|
35
|
+
}
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def error_code(self) -> str | None:
|
|
40
|
+
payload = self.error_payload
|
|
41
|
+
return payload["code"] if payload else None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def error_details(self) -> dict[str, Any]:
|
|
45
|
+
payload = self.error_payload
|
|
46
|
+
if not payload:
|
|
47
|
+
return {}
|
|
48
|
+
return dict(payload.get("details") or {})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotConfiguredError(HomeCloudError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NotLoggedInError(HomeCloudError):
|
|
56
|
+
pass
|