soreplicator 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.
Files changed (35) hide show
  1. soreplicator/__init__.py +3 -0
  2. soreplicator/client/__init__.py +3 -0
  3. soreplicator/client/cli.py +154 -0
  4. soreplicator/client/config.py +89 -0
  5. soreplicator/client/storage.py +225 -0
  6. soreplicator/server/__init__.py +5 -0
  7. soreplicator/server/app.py +247 -0
  8. soreplicator/server/auth.py +41 -0
  9. soreplicator/server/config.py +74 -0
  10. soreplicator/server/gateway.py +278 -0
  11. soreplicator/server/static/app.css +1 -0
  12. soreplicator/server/static/app.js +65 -0
  13. soreplicator/server/static/apple-touch-icon.png +0 -0
  14. soreplicator/server/static/favicon-32.png +0 -0
  15. soreplicator/server/static/htmx.min.js +1 -0
  16. soreplicator/server/static/icon-192.png +0 -0
  17. soreplicator/server/static/icon-512.png +0 -0
  18. soreplicator/server/static/site.webmanifest +19 -0
  19. soreplicator/server/static/sore-logo-64.png +0 -0
  20. soreplicator/server/static/sore_pixel.png +0 -0
  21. soreplicator/server/templates/admin.html +12 -0
  22. soreplicator/server/templates/base.html +30 -0
  23. soreplicator/server/templates/credentials.html +5 -0
  24. soreplicator/server/templates/error.html +5 -0
  25. soreplicator/server/templates/fragments/admin_table.html +19 -0
  26. soreplicator/server/templates/fragments/credentials_panel.html +10 -0
  27. soreplicator/server/templates/fragments/provisioning_error.html +3 -0
  28. soreplicator/server/templates/provisioning_error.html +5 -0
  29. soreplicator/server/templates/setup.html +24 -0
  30. soreplicator/server/templates.py +133 -0
  31. soreplicator-0.1.0.dist-info/METADATA +221 -0
  32. soreplicator-0.1.0.dist-info/RECORD +35 -0
  33. soreplicator-0.1.0.dist-info/WHEEL +5 -0
  34. soreplicator-0.1.0.dist-info/entry_points.txt +3 -0
  35. soreplicator-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Simons Observatory REplicator."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ """SORE command-line client."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,154 @@
1
+ """The ``sore`` command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+ from urllib.parse import urlsplit
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from soreplicator.client.config import (
15
+ ClientConfig,
16
+ config_path,
17
+ load_config,
18
+ save_config,
19
+ )
20
+ from soreplicator.client.storage import StorageError, delete, upload
21
+
22
+ DEFAULT_ENDPOINT = "https://sore.simonsobservatory.org"
23
+
24
+
25
+ def _human_bytes(value: int) -> str:
26
+ size = float(value)
27
+ for unit in ("B", "KB", "MB", "GB", "TB"):
28
+ if size < 1000 or unit == "TB":
29
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
30
+ size /= 1000
31
+ return f"{size:.1f} TB"
32
+
33
+
34
+ class ProgressBar:
35
+ def __init__(self, total: int, stream: TextIO = sys.stderr, width: int = 28):
36
+ self.total = total
37
+ self.stream = stream
38
+ self.width = width
39
+ self.enabled = stream.isatty()
40
+ self.rendered = False
41
+ if self.enabled:
42
+ self.update(0, total)
43
+
44
+ def update(self, uploaded: int, total: int) -> None:
45
+ if not self.enabled:
46
+ return
47
+ fraction = 1.0 if total == 0 else min(1.0, uploaded / total)
48
+ filled = round(self.width * fraction)
49
+ bar = "#" * filled + "-" * (self.width - filled)
50
+ self.stream.write(
51
+ f"\rUploading [{bar}] {fraction:6.1%} "
52
+ f"{_human_bytes(uploaded)} / {_human_bytes(total)}"
53
+ )
54
+ self.stream.flush()
55
+ self.rendered = True
56
+
57
+ def close(self) -> None:
58
+ if self.rendered:
59
+ self.stream.write("\n")
60
+ self.stream.flush()
61
+ self.rendered = False
62
+
63
+
64
+ def build_parser() -> argparse.ArgumentParser:
65
+ parser = argparse.ArgumentParser(
66
+ prog="sore", description="Upload immutable files to the SORE replica service."
67
+ )
68
+ actions = parser.add_mutually_exclusive_group(required=True)
69
+ actions.add_argument(
70
+ "--setup", action="store_true", help="store gateway credentials"
71
+ )
72
+ actions.add_argument("-i", "--input", type=Path, help="local file to upload")
73
+ actions.add_argument(
74
+ "-d", "--delete", metavar="REMOTE_PATH", help="delete an object"
75
+ )
76
+ parser.add_argument("-o", "--output", help="remote path for an upload")
77
+ parser.add_argument(
78
+ "-f", "--force", action="store_true", help="skip delete confirmation"
79
+ )
80
+ parser.add_argument(
81
+ "--config", type=Path, default=config_path(), help=argparse.SUPPRESS
82
+ )
83
+ return parser
84
+
85
+
86
+ def _setup(path: Path) -> int:
87
+ endpoint = (
88
+ input(f"Gateway endpoint [{DEFAULT_ENDPOINT}]: ").strip() or DEFAULT_ENDPOINT
89
+ )
90
+ parsed = urlsplit(endpoint)
91
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
92
+ raise ValueError("endpoint must be an absolute HTTP(S) URL")
93
+ hostname = parsed.hostname or ""
94
+ is_loopback = hostname in {"localhost", "127.0.0.1", "::1"} or hostname.endswith(
95
+ ".localhost"
96
+ )
97
+ if parsed.scheme != "https" and not is_loopback:
98
+ raise ValueError(
99
+ "credentials may only be sent over HTTPS (except on localhost)"
100
+ )
101
+ username = input("Username: ").strip()
102
+ password = getpass.getpass("Password: ")
103
+ if not password:
104
+ raise ValueError("password cannot be empty")
105
+ destination = save_config(
106
+ ClientConfig(endpoint=endpoint, username=username, password=password), path
107
+ )
108
+ print(f"Credentials saved to {destination} with mode 600.")
109
+ return 0
110
+
111
+
112
+ def _confirm_delete(remote_path: str) -> bool:
113
+ answer = input(f"Delete {remote_path!r} permanently? [y/N] ").strip().lower()
114
+ return answer in {"y", "yes"}
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> int:
118
+ parser = build_parser()
119
+ args = parser.parse_args(argv)
120
+ try:
121
+ if args.setup:
122
+ if args.output or args.force:
123
+ parser.error("--setup cannot be combined with -o or -f")
124
+ return _setup(args.config)
125
+
126
+ config = load_config(args.config)
127
+ if args.input:
128
+ if not args.output:
129
+ parser.error("-o/--output is required with -i/--input")
130
+ if args.force:
131
+ parser.error("-f/--force is only valid with -d/--delete")
132
+ progress = ProgressBar(args.input.stat().st_size)
133
+ try:
134
+ upload(config, args.input, args.output, progress.update)
135
+ finally:
136
+ progress.close()
137
+ print(f"Uploaded {args.input} to sore/{config.username}/{args.output}")
138
+ return 0
139
+
140
+ if args.output:
141
+ parser.error("-o/--output is only valid with -i/--input")
142
+ if not args.force and not _confirm_delete(args.delete):
143
+ print("Delete cancelled.")
144
+ return 0
145
+ delete(config, args.delete)
146
+ print(f"Deleted sore/{config.username}/{args.delete}")
147
+ return 0
148
+ except (OSError, PermissionError, ValueError, ValidationError, StorageError) as exc:
149
+ print(f"sore: {exc}", file=sys.stderr)
150
+ return 1
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())
@@ -0,0 +1,89 @@
1
+ """Secure local configuration storage for the SORE client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import stat
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, SecretStr, field_validator
13
+
14
+
15
+ class ClientConfig(BaseModel):
16
+ model_config = ConfigDict(extra="forbid")
17
+
18
+ endpoint: AnyHttpUrl
19
+ username: str
20
+ password: SecretStr
21
+
22
+ @field_validator("username")
23
+ @classmethod
24
+ def normalize_username(cls, value: str) -> str:
25
+ value = value.strip().lower()
26
+ if not re.fullmatch(r"[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}", value):
27
+ raise ValueError("username is not a valid GitHub/S3 bucket name")
28
+ return value
29
+
30
+ @field_validator("endpoint")
31
+ @classmethod
32
+ def require_secure_endpoint(cls, value: AnyHttpUrl) -> AnyHttpUrl:
33
+ hostname = value.host or ""
34
+ is_loopback = hostname in {
35
+ "localhost",
36
+ "127.0.0.1",
37
+ "::1",
38
+ } or hostname.endswith(".localhost")
39
+ if value.scheme != "https" and not is_loopback:
40
+ raise ValueError("endpoint must use HTTPS except on localhost")
41
+ return value
42
+
43
+
44
+ def config_path() -> Path:
45
+ base = os.environ.get("XDG_CONFIG_HOME")
46
+ return (Path(base) if base else Path.home() / ".config") / "sore.json"
47
+
48
+
49
+ def save_config(config: ClientConfig, path: Path | None = None) -> Path:
50
+ destination = path or config_path()
51
+ destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
52
+ if destination.is_symlink():
53
+ raise ValueError(f"refusing to overwrite symlink: {destination}")
54
+ payload = json.dumps(
55
+ {
56
+ "endpoint": str(config.endpoint).rstrip("/"),
57
+ "username": config.username,
58
+ "password": config.password.get_secret_value(),
59
+ },
60
+ indent=2,
61
+ )
62
+ descriptor, temporary_name = tempfile.mkstemp(
63
+ prefix=".sore-", suffix=".tmp", dir=destination.parent, text=True
64
+ )
65
+ temporary = Path(temporary_name)
66
+ try:
67
+ os.fchmod(descriptor, 0o600)
68
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
69
+ handle.write(payload)
70
+ handle.write("\n")
71
+ handle.flush()
72
+ os.fsync(handle.fileno())
73
+ os.replace(temporary, destination)
74
+ destination.chmod(0o600)
75
+ finally:
76
+ temporary.unlink(missing_ok=True)
77
+ return destination
78
+
79
+
80
+ def load_config(path: Path | None = None) -> ClientConfig:
81
+ source = path or config_path()
82
+ if source.is_symlink():
83
+ raise ValueError(f"refusing to read symlink: {source}")
84
+ mode = stat.S_IMODE(source.stat().st_mode)
85
+ if mode & 0o077:
86
+ raise PermissionError(
87
+ f"{source} has unsafe permissions {mode:o}; run chmod 600 {source}"
88
+ )
89
+ return ClientConfig.model_validate_json(source.read_text(encoding="utf-8"))
@@ -0,0 +1,225 @@
1
+ """S3 operations used by the command-line client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from math import ceil
9
+ from pathlib import Path, PurePosixPath
10
+
11
+ import boto3
12
+ from botocore.config import Config
13
+ from botocore.exceptions import BotoCoreError, ClientError
14
+
15
+ from soreplicator.client.config import ClientConfig
16
+
17
+
18
+ class StorageError(RuntimeError):
19
+ pass
20
+
21
+
22
+ ProgressCallback = Callable[[int, int], None]
23
+
24
+ MIB = 1024 * 1024
25
+ MULTIPART_THRESHOLD = 16 * MIB
26
+ DEFAULT_PART_SIZE = 8 * MIB
27
+ MIN_PART_SIZE = 5 * MIB
28
+ MAX_PARTS = 10_000
29
+ MAX_CONCURRENCY = 4
30
+
31
+
32
+ def normalize_key(value: str) -> str:
33
+ if (
34
+ not value
35
+ or value.startswith("/")
36
+ or "\\" in value
37
+ or any(ord(character) < 32 or ord(character) == 127 for character in value)
38
+ ):
39
+ raise ValueError("remote path must be a non-empty relative POSIX path")
40
+ if any(part in {"", ".", ".."} for part in value.split("/")):
41
+ raise ValueError("remote path cannot contain empty, '.' or '..' components")
42
+ path = PurePosixPath(value)
43
+ normalized = str(path)
44
+ if len(normalized.encode("utf-8")) > 1024:
45
+ raise ValueError("remote path is longer than S3's 1024-byte limit")
46
+ return normalized
47
+
48
+
49
+ def _client(config: ClientConfig):
50
+ return boto3.client(
51
+ "s3",
52
+ endpoint_url=str(config.endpoint).rstrip("/"),
53
+ aws_access_key_id=config.username,
54
+ aws_secret_access_key=config.password.get_secret_value(),
55
+ config=Config(
56
+ signature_version="s3v4",
57
+ s3={"addressing_style": "path"},
58
+ retries={"mode": "standard", "max_attempts": 4},
59
+ ),
60
+ )
61
+
62
+
63
+ def _is_conflict(exc: ClientError) -> bool:
64
+ code = exc.response.get("Error", {}).get("Code", "")
65
+ status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
66
+ return code in {"PreconditionFailed", "ConditionalRequestConflict"} or status in {
67
+ 409,
68
+ 412,
69
+ }
70
+
71
+
72
+ def _is_missing(exc: ClientError) -> bool:
73
+ code = exc.response.get("Error", {}).get("Code", "")
74
+ status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
75
+ return code in {"404", "NoSuchKey", "NotFound"} or status == 404
76
+
77
+
78
+ def _part_size(file_size: int) -> int:
79
+ required = ceil(file_size / MAX_PARTS)
80
+ target = max(DEFAULT_PART_SIZE, MIN_PART_SIZE, required)
81
+ return ceil(target / MIB) * MIB
82
+
83
+
84
+ def _upload_part(
85
+ client,
86
+ source: Path,
87
+ bucket: str,
88
+ key: str,
89
+ upload_id: str,
90
+ part_number: int,
91
+ offset: int,
92
+ length: int,
93
+ ) -> tuple[int, str, int]:
94
+ with source.open("rb") as handle:
95
+ handle.seek(offset)
96
+ body = handle.read(length)
97
+ if len(body) != length:
98
+ raise StorageError(f"input changed while it was being uploaded: {source}")
99
+ response = client.upload_part(
100
+ Bucket=bucket,
101
+ Key=key,
102
+ UploadId=upload_id,
103
+ PartNumber=part_number,
104
+ Body=body,
105
+ ContentLength=length,
106
+ )
107
+ return part_number, response["ETag"], length
108
+
109
+
110
+ def _multipart_upload(
111
+ client,
112
+ source: Path,
113
+ bucket: str,
114
+ key: str,
115
+ file_size: int,
116
+ progress: ProgressCallback | None,
117
+ ) -> None:
118
+ upload_id: str | None = None
119
+ completed = False
120
+ try:
121
+ upload_id = client.create_multipart_upload(Bucket=bucket, Key=key)["UploadId"]
122
+ part_size = _part_size(file_size)
123
+ part_count = ceil(file_size / part_size)
124
+ uploaded = 0
125
+ parts: list[dict[str, int | str]] = []
126
+ workers = min(MAX_CONCURRENCY, part_count)
127
+ with ThreadPoolExecutor(max_workers=workers) as executor:
128
+ futures = []
129
+ for index in range(part_count):
130
+ offset = index * part_size
131
+ length = min(part_size, file_size - offset)
132
+ futures.append(
133
+ executor.submit(
134
+ _upload_part,
135
+ client,
136
+ source,
137
+ bucket,
138
+ key,
139
+ upload_id,
140
+ index + 1,
141
+ offset,
142
+ length,
143
+ )
144
+ )
145
+ try:
146
+ for future in as_completed(futures):
147
+ part_number, etag, length = future.result()
148
+ parts.append({"PartNumber": part_number, "ETag": etag})
149
+ uploaded += length
150
+ if progress:
151
+ progress(uploaded, file_size)
152
+ except Exception:
153
+ for future in futures:
154
+ future.cancel()
155
+ raise
156
+
157
+ parts.sort(key=lambda part: int(part["PartNumber"]))
158
+ client.complete_multipart_upload(
159
+ Bucket=bucket,
160
+ Key=key,
161
+ UploadId=upload_id,
162
+ MultipartUpload={"Parts": parts},
163
+ IfNoneMatch="*",
164
+ )
165
+ completed = True
166
+ finally:
167
+ if upload_id and not completed:
168
+ try:
169
+ client.abort_multipart_upload(
170
+ Bucket=bucket, Key=key, UploadId=upload_id
171
+ )
172
+ except (BotoCoreError, ClientError):
173
+ pass
174
+
175
+
176
+ def upload(
177
+ config: ClientConfig,
178
+ source: Path,
179
+ destination: str,
180
+ progress: ProgressCallback | None = None,
181
+ ) -> None:
182
+ if not source.is_file():
183
+ raise ValueError(f"input is not a regular file: {source}")
184
+ key = normalize_key(destination)
185
+ file_size = source.stat().st_size
186
+ client = _client(config)
187
+ try:
188
+ if file_size >= MULTIPART_THRESHOLD:
189
+ _multipart_upload(client, source, config.username, key, file_size, progress)
190
+ else:
191
+ with source.open("rb") as handle:
192
+ client.put_object(
193
+ Bucket=config.username,
194
+ Key=key,
195
+ Body=handle,
196
+ ContentLength=os.fstat(handle.fileno()).st_size,
197
+ IfNoneMatch="*",
198
+ )
199
+ if progress:
200
+ progress(file_size, file_size)
201
+ except ClientError as exc:
202
+ code = exc.response.get("Error", {}).get("Code", "")
203
+ detail = exc.response.get("Error", {}).get("Message", "")
204
+ if _is_conflict(exc):
205
+ raise StorageError(
206
+ f"{key} already exists; SORE never overwrites objects"
207
+ ) from exc
208
+ raise StorageError(f"upload failed ({code or 'gateway error'}, {detail or 'no detail'})") from exc
209
+ except BotoCoreError as exc:
210
+ raise StorageError("upload failed because the gateway is unavailable") from exc
211
+
212
+
213
+ def delete(config: ClientConfig, destination: str) -> None:
214
+ key = normalize_key(destination)
215
+ client = _client(config)
216
+ try:
217
+ client.head_object(Bucket=config.username, Key=key)
218
+ client.delete_object(Bucket=config.username, Key=key)
219
+ except ClientError as exc:
220
+ if _is_missing(exc):
221
+ raise StorageError(f"{key} does not exist") from exc
222
+ code = exc.response.get("Error", {}).get("Code", "gateway error")
223
+ raise StorageError(f"delete failed ({code})") from exc
224
+ except BotoCoreError as exc:
225
+ raise StorageError("delete failed because the gateway is unavailable") from exc
@@ -0,0 +1,5 @@
1
+ """SORE credential provisioning web service."""
2
+
3
+ from soreplicator.server.app import create_app
4
+
5
+ __all__ = ["create_app"]