alab-cli 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.
- alab/__init__.py +5 -0
- alab/__main__.py +4 -0
- alab/auth.py +242 -0
- alab/cli.py +638 -0
- alab/configs.py +614 -0
- alab/context.py +188 -0
- alab/db.py +283 -0
- alab/docker_platform.py +22 -0
- alab/errors.py +66 -0
- alab/home.py +82 -0
- alab/ids.py +43 -0
- alab/migrations/1_initial.sql +512 -0
- alab/proc.py +38 -0
- alab/registry.py +253 -0
- alab/rendering.py +88 -0
- alab/runner.py +2316 -0
- alab/services.py +10555 -0
- alab/source_import.py +306 -0
- alab/timeutil.py +33 -0
- alab_cli-0.1.0.dist-info/METADATA +333 -0
- alab_cli-0.1.0.dist-info/RECORD +25 -0
- alab_cli-0.1.0.dist-info/WHEEL +5 -0
- alab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- alab_cli-0.1.0.dist-info/licenses/LICENSE +9 -0
- alab_cli-0.1.0.dist-info/top_level.txt +1 -0
alab/__init__.py
ADDED
alab/__main__.py
ADDED
alab/auth.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import secrets
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .db import canonical_json, contract_json_obj, one
|
|
12
|
+
from .errors import AlabError
|
|
13
|
+
from .ids import new_id, random_suffix
|
|
14
|
+
from .timeutil import utc_now
|
|
15
|
+
|
|
16
|
+
ROOT_PREFIX = "alab_root_v1_"
|
|
17
|
+
ADMIN_PREFIX = "alab_admin_v1_"
|
|
18
|
+
TOKEN_PREFIX = "alab_token_v1_"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Actor:
|
|
23
|
+
actor_type: str
|
|
24
|
+
credential_id: str | None = None
|
|
25
|
+
project_id: str | None = None
|
|
26
|
+
exp_id: str | None = None
|
|
27
|
+
token_mode: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _invalid_credential() -> AlabError:
|
|
31
|
+
return AlabError("AUTH_DENIED", "invalid credential")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _secret() -> str:
|
|
35
|
+
return secrets.token_hex(32)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _verifier(secret: str, salt: bytes) -> bytes:
|
|
39
|
+
return hmac.new(salt, secret.encode("utf-8"), sha256).digest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def credential_metadata_obj(
|
|
43
|
+
text: str,
|
|
44
|
+
*,
|
|
45
|
+
credential_type: str,
|
|
46
|
+
token_mode: str | None = None,
|
|
47
|
+
registered_path_hash: str | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
allowed_keys = {"schema_version", "display_label"}
|
|
50
|
+
required_keys: set[str] = set()
|
|
51
|
+
if credential_type == "admin":
|
|
52
|
+
allowed_keys.add("role")
|
|
53
|
+
required_keys.add("role")
|
|
54
|
+
elif credential_type == "token":
|
|
55
|
+
allowed_keys.update({"token_mode", "created_for_path_hash"})
|
|
56
|
+
required_keys.update({"token_mode", "created_for_path_hash"})
|
|
57
|
+
elif credential_type != "root":
|
|
58
|
+
raise AlabError("STORAGE_ERROR", f"unknown credential_type for metadata: {credential_type}")
|
|
59
|
+
|
|
60
|
+
value = contract_json_obj(
|
|
61
|
+
text,
|
|
62
|
+
label="credentials.metadata_json",
|
|
63
|
+
allowed_keys=allowed_keys,
|
|
64
|
+
required_keys=required_keys,
|
|
65
|
+
)
|
|
66
|
+
display_label = value.get("display_label")
|
|
67
|
+
if display_label is not None and not isinstance(display_label, str):
|
|
68
|
+
raise AlabError("STORAGE_ERROR", "credentials.metadata_json display_label must be a string")
|
|
69
|
+
if credential_type == "admin" and value.get("role") != "admin":
|
|
70
|
+
raise AlabError("STORAGE_ERROR", "credentials.metadata_json role must be admin")
|
|
71
|
+
if credential_type == "token":
|
|
72
|
+
if value.get("token_mode") != token_mode:
|
|
73
|
+
raise AlabError("STORAGE_ERROR", "credentials.metadata_json token_mode does not match credential row")
|
|
74
|
+
if value.get("created_for_path_hash") != registered_path_hash:
|
|
75
|
+
raise AlabError("STORAGE_ERROR", "credentials.metadata_json created_for_path_hash does not match credential row")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _default_credential_metadata(
|
|
80
|
+
*,
|
|
81
|
+
credential_type: str,
|
|
82
|
+
token_mode: str | None,
|
|
83
|
+
registered_path_hash: str | None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
if credential_type == "root":
|
|
86
|
+
return {"schema_version": 1}
|
|
87
|
+
if credential_type == "admin":
|
|
88
|
+
return {"schema_version": 1, "role": "admin"}
|
|
89
|
+
if credential_type == "token":
|
|
90
|
+
return {
|
|
91
|
+
"schema_version": 1,
|
|
92
|
+
"token_mode": token_mode,
|
|
93
|
+
"created_for_path_hash": registered_path_hash,
|
|
94
|
+
}
|
|
95
|
+
raise AlabError("STORAGE_ERROR", f"unknown credential_type for metadata: {credential_type}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_credential(
|
|
99
|
+
conn,
|
|
100
|
+
*,
|
|
101
|
+
credential_type: str,
|
|
102
|
+
project_id: str | None = None,
|
|
103
|
+
exp_id: str | None = None,
|
|
104
|
+
token_mode: str | None = None,
|
|
105
|
+
registered_path_hash: str | None = None,
|
|
106
|
+
metadata: dict | None = None,
|
|
107
|
+
) -> tuple[str, str]:
|
|
108
|
+
credential_id = new_id("cred", credential_type)
|
|
109
|
+
secret = _secret()
|
|
110
|
+
prefix = {"root": ROOT_PREFIX, "admin": ADMIN_PREFIX, "token": TOKEN_PREFIX}[credential_type]
|
|
111
|
+
raw = f"{prefix}{credential_id}_{secret}"
|
|
112
|
+
salt = secrets.token_bytes(32)
|
|
113
|
+
now = utc_now()
|
|
114
|
+
metadata_payload = metadata or _default_credential_metadata(
|
|
115
|
+
credential_type=credential_type,
|
|
116
|
+
token_mode=token_mode,
|
|
117
|
+
registered_path_hash=registered_path_hash,
|
|
118
|
+
)
|
|
119
|
+
metadata_json = canonical_json(
|
|
120
|
+
credential_metadata_obj(
|
|
121
|
+
canonical_json(metadata_payload),
|
|
122
|
+
credential_type=credential_type,
|
|
123
|
+
token_mode=token_mode,
|
|
124
|
+
registered_path_hash=registered_path_hash,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
conn.execute(
|
|
128
|
+
"""
|
|
129
|
+
INSERT INTO credentials(
|
|
130
|
+
credential_id, credential_type, project_id, exp_id, token_mode, registered_path_hash,
|
|
131
|
+
status, salt, verifier_hash, created_at, revoked_at, metadata_json
|
|
132
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL, ?)
|
|
133
|
+
""",
|
|
134
|
+
(
|
|
135
|
+
credential_id,
|
|
136
|
+
credential_type,
|
|
137
|
+
project_id,
|
|
138
|
+
exp_id,
|
|
139
|
+
token_mode,
|
|
140
|
+
registered_path_hash,
|
|
141
|
+
salt,
|
|
142
|
+
_verifier(secret, salt),
|
|
143
|
+
now,
|
|
144
|
+
metadata_json,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
return credential_id, raw
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_raw_credential(raw: str) -> tuple[str, str, str]:
|
|
151
|
+
for kind, prefix in [("root", ROOT_PREFIX), ("admin", ADMIN_PREFIX), ("token", TOKEN_PREFIX)]:
|
|
152
|
+
if raw.startswith(prefix):
|
|
153
|
+
rest = raw[len(prefix) :]
|
|
154
|
+
credential_id, sep, secret = rest.rpartition("_")
|
|
155
|
+
if not sep or not credential_id or not secret:
|
|
156
|
+
raise _invalid_credential()
|
|
157
|
+
return kind, credential_id, secret
|
|
158
|
+
raise _invalid_credential()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def verify_raw_credential(
|
|
162
|
+
conn,
|
|
163
|
+
raw: str,
|
|
164
|
+
*,
|
|
165
|
+
required: str | tuple[str, ...] | None = None,
|
|
166
|
+
project_id: str | None = None,
|
|
167
|
+
exp_id: str | None = None,
|
|
168
|
+
token_mode: str | None = None,
|
|
169
|
+
path_hash: str | None = None,
|
|
170
|
+
) -> Actor:
|
|
171
|
+
kind, credential_id, secret = parse_raw_credential(raw.strip())
|
|
172
|
+
row = one(conn, "SELECT * FROM credentials WHERE credential_id = ?", (credential_id,))
|
|
173
|
+
if row is None:
|
|
174
|
+
raise _invalid_credential()
|
|
175
|
+
if row["credential_type"] != kind or row["status"] != "active":
|
|
176
|
+
raise _invalid_credential()
|
|
177
|
+
allowed = (required,) if isinstance(required, str) else required
|
|
178
|
+
if allowed and kind not in allowed:
|
|
179
|
+
raise _invalid_credential()
|
|
180
|
+
if not hmac.compare_digest(bytes(row["verifier_hash"]), _verifier(secret, bytes(row["salt"]))):
|
|
181
|
+
raise _invalid_credential()
|
|
182
|
+
credential_metadata_obj(
|
|
183
|
+
row["metadata_json"],
|
|
184
|
+
credential_type=row["credential_type"],
|
|
185
|
+
token_mode=row["token_mode"],
|
|
186
|
+
registered_path_hash=row["registered_path_hash"],
|
|
187
|
+
)
|
|
188
|
+
if project_id and row["project_id"] and row["project_id"] != project_id:
|
|
189
|
+
raise _invalid_credential()
|
|
190
|
+
if kind == "admin" and project_id and row["project_id"] != project_id:
|
|
191
|
+
raise _invalid_credential()
|
|
192
|
+
if kind == "token":
|
|
193
|
+
if project_id and row["project_id"] != project_id:
|
|
194
|
+
raise _invalid_credential()
|
|
195
|
+
if exp_id and row["exp_id"] != exp_id:
|
|
196
|
+
raise _invalid_credential()
|
|
197
|
+
if token_mode and row["token_mode"] != token_mode:
|
|
198
|
+
raise _invalid_credential()
|
|
199
|
+
if path_hash and row["registered_path_hash"] != path_hash:
|
|
200
|
+
raise _invalid_credential()
|
|
201
|
+
return Actor(
|
|
202
|
+
actor_type=kind,
|
|
203
|
+
credential_id=row["credential_id"],
|
|
204
|
+
project_id=row["project_id"],
|
|
205
|
+
exp_id=row["exp_id"],
|
|
206
|
+
token_mode=row["token_mode"],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def read_token(path: Path) -> str:
|
|
211
|
+
token_path = path / ".alab" / "token"
|
|
212
|
+
try:
|
|
213
|
+
raw = token_path.read_text(encoding="utf-8")
|
|
214
|
+
except FileNotFoundError as exc:
|
|
215
|
+
raise AlabError("AUTH_REQUIRED", "token file not found") from exc
|
|
216
|
+
return raw.rstrip("\n")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def token_permission_warning(path: Path) -> str | None:
|
|
220
|
+
token_path = path / ".alab" / "token"
|
|
221
|
+
try:
|
|
222
|
+
mode = stat.S_IMODE(token_path.stat().st_mode)
|
|
223
|
+
except OSError:
|
|
224
|
+
return None
|
|
225
|
+
if mode & 0o077:
|
|
226
|
+
return "TOKEN_FILE_PERMISSIONS"
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def write_token(path: Path, raw_token: str) -> None:
|
|
231
|
+
token_dir = path / ".alab"
|
|
232
|
+
token_dir.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
token_path = token_dir / "token"
|
|
234
|
+
token_path.write_text(raw_token + "\n", encoding="utf-8")
|
|
235
|
+
try:
|
|
236
|
+
token_path.chmod(0o600)
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def new_home_id() -> str:
|
|
242
|
+
return f"home-{random_suffix()}"
|