mascope_cli 2026.7.28__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.
- mascope_cli/__init__.py +1 -0
- mascope_cli/checkout.py +38 -0
- mascope_cli/cmd/__init__.py +10 -0
- mascope_cli/cmd/agent.py +25 -0
- mascope_cli/cmd/backend.py +19 -0
- mascope_cli/cmd/cert.py +64 -0
- mascope_cli/cmd/demo/__init__.py +2 -0
- mascope_cli/cmd/demo/_fetch.py +147 -0
- mascope_cli/cmd/demo/_rebuild.py +148 -0
- mascope_cli/cmd/demo/_seed.py +170 -0
- mascope_cli/cmd/demo/build_bundle.py +493 -0
- mascope_cli/cmd/demo/bundles.py +220 -0
- mascope_cli/cmd/demo/main.py +564 -0
- mascope_cli/cmd/demo/verify.py +135 -0
- mascope_cli/cmd/dev/__init__.py +2 -0
- mascope_cli/cmd/dev/db/__init__.py +2 -0
- mascope_cli/cmd/dev/db/backup.py +268 -0
- mascope_cli/cmd/dev/db/main.py +646 -0
- mascope_cli/cmd/dev/db/scripts.py +188 -0
- mascope_cli/cmd/dev/docker.py +248 -0
- mascope_cli/cmd/dev/main.py +548 -0
- mascope_cli/cmd/dev/migrate.py +365 -0
- mascope_cli/cmd/dev/redis.py +246 -0
- mascope_cli/cmd/dev/tools.py +53 -0
- mascope_cli/cmd/env/__init__.py +2 -0
- mascope_cli/cmd/env/_create.py +104 -0
- mascope_cli/cmd/env/_paths.py +172 -0
- mascope_cli/cmd/env/_ssh.py +228 -0
- mascope_cli/cmd/env/_sync.py +613 -0
- mascope_cli/cmd/env/main.py +354 -0
- mascope_cli/cmd/fleet.py +181 -0
- mascope_cli/cmd/init.py +145 -0
- mascope_cli/cmd/instance.py +152 -0
- mascope_cli/cmd/lib.py +65 -0
- mascope_cli/cmd/logs.py +190 -0
- mascope_cli/cmd/prod/__init__.py +2 -0
- mascope_cli/cmd/prod/auto_update.py +451 -0
- mascope_cli/cmd/prod/cli_drift.py +135 -0
- mascope_cli/cmd/prod/db/__init__.py +2 -0
- mascope_cli/cmd/prod/db/backup.py +277 -0
- mascope_cli/cmd/prod/db/main.py +448 -0
- mascope_cli/cmd/prod/db/scripts.py +275 -0
- mascope_cli/cmd/prod/doctor.py +313 -0
- mascope_cli/cmd/prod/main.py +1061 -0
- mascope_cli/cmd/prod/preflight.py +269 -0
- mascope_cli/cmd/prod/release_manifest.py +134 -0
- mascope_cli/cmd/test.py +463 -0
- mascope_cli/data/base.mascope.toml +178 -0
- mascope_cli/data/dev.mascope.toml +24 -0
- mascope_cli/data/docker-compose.demo.yaml +145 -0
- mascope_cli/data/docker-compose.dev.yaml +90 -0
- mascope_cli/data/docker-compose.yaml +215 -0
- mascope_cli/data/prod.mascope.toml +78 -0
- mascope_cli/docker.py +29 -0
- mascope_cli/home.py +45 -0
- mascope_cli/instance.py +294 -0
- mascope_cli/main.py +184 -0
- mascope_cli/pg/__init__.py +3 -0
- mascope_cli/pg/admin.py +632 -0
- mascope_cli/pg/utils.py +228 -0
- mascope_cli/runtime.py +74 -0
- mascope_cli/version.py +38 -0
- mascope_cli-2026.7.28.dist-info/METADATA +91 -0
- mascope_cli-2026.7.28.dist-info/RECORD +66 -0
- mascope_cli-2026.7.28.dist-info/WHEEL +4 -0
- mascope_cli-2026.7.28.dist-info/entry_points.txt +3 -0
mascope_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
mascope_cli/checkout.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Monorepo source-checkout detection.
|
|
3
|
+
|
|
4
|
+
The CLI has two audiences: developers running it editable from the monorepo,
|
|
5
|
+
and operators running it from a wheel. Developer commands (`dev`, `test`,
|
|
6
|
+
`agent`, `backend`) drive the source tree itself and are only registered when
|
|
7
|
+
the CLI actually runs from a checkout.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def source_checkout(anchor: Path | None = None) -> Path | None:
|
|
14
|
+
"""
|
|
15
|
+
Repo root of the Mascope checkout the CLI runs from, if any.
|
|
16
|
+
|
|
17
|
+
An editable install resolves ``mascope_cli`` inside the checkout
|
|
18
|
+
(``<root>/tooling/cli/src/mascope_cli``); a wheel install resolves it in
|
|
19
|
+
site-packages, where walking up never lands in the monorepo.
|
|
20
|
+
|
|
21
|
+
:param anchor: Location of the mascope_cli package; defaults to the
|
|
22
|
+
imported package. Overridable for testing.
|
|
23
|
+
:type anchor: Path, optional
|
|
24
|
+
:return: The checkout root, or None when not running from source.
|
|
25
|
+
:rtype: Path | None
|
|
26
|
+
"""
|
|
27
|
+
if anchor is None:
|
|
28
|
+
import mascope_cli
|
|
29
|
+
|
|
30
|
+
anchor = Path(mascope_cli.__file__).resolve().parent
|
|
31
|
+
# <root>/tooling/cli/src/mascope_cli -> root is 4 levels up.
|
|
32
|
+
parents = anchor.parents
|
|
33
|
+
if len(parents) < 4:
|
|
34
|
+
return None
|
|
35
|
+
root = parents[3]
|
|
36
|
+
if (root / "pyproject.toml").is_file() and (root / "tooling" / "cli").is_dir():
|
|
37
|
+
return root
|
|
38
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# flake8: noqa
|
|
2
|
+
# Operator command groups — always available, wheel and checkout alike.
|
|
3
|
+
# Developer groups (dev, test, agent, backend) are imported by main.py only
|
|
4
|
+
# when running from a source checkout: they drive the monorepo itself and
|
|
5
|
+
# pull in the `dev` extra's dependencies (alembic, sqlalchemy, psycopg2).
|
|
6
|
+
from .cert import cert_app
|
|
7
|
+
from .demo import demo_app
|
|
8
|
+
from .env import env_app
|
|
9
|
+
from .logs import logs_app
|
|
10
|
+
from .prod import prod_app
|
mascope_cli/cmd/agent.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
|
|
6
|
+
from mascope_cli.cmd import lib
|
|
7
|
+
from mascope_cli.runtime import runtime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
agent_app = typer.Typer(add_help_option=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@agent_app.callback(
|
|
14
|
+
invoke_without_command=True, context_settings={"ignore_unknown_options": True}
|
|
15
|
+
)
|
|
16
|
+
def main(args: Annotated[Optional[List[str]], typer.Argument()] = None) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Run a Mascope agent
|
|
19
|
+
"""
|
|
20
|
+
if len(args) != 1:
|
|
21
|
+
runtime.logger.error("mascope agent requires exactly 1 argument: file or tof")
|
|
22
|
+
return
|
|
23
|
+
lib.run(
|
|
24
|
+
command=f"uv run mascope-{args[0]}-agent", cwd=runtime.path("agents", args[0])
|
|
25
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
|
|
6
|
+
import mascope_cli.cmd.lib as lib
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
backend_app = typer.Typer(add_help_option=False)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@backend_app.callback(
|
|
13
|
+
invoke_without_command=True, context_settings={"ignore_unknown_options": True}
|
|
14
|
+
)
|
|
15
|
+
def main(args: Annotated[Optional[List[str]], typer.Argument()] = None) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Run a Mascope backend service.
|
|
18
|
+
"""
|
|
19
|
+
lib.run(command=f"uv run mascope-backend {' '.join(args)}")
|
mascope_cli/cmd/cert.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
import mascope_cli.cmd.lib as lib
|
|
7
|
+
from mascope_cli.runtime import runtime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
cert_app = typer.Typer()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@cert_app.callback()
|
|
14
|
+
def main():
|
|
15
|
+
"""
|
|
16
|
+
Manage Mascope SSL certificates
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cert_app.command()
|
|
21
|
+
def gen():
|
|
22
|
+
"""
|
|
23
|
+
Generate a self-signed SSL certificate
|
|
24
|
+
|
|
25
|
+
Writes mascope.app.key + mascope.app.pem into .runtime/secrets/. Use for
|
|
26
|
+
local HTTPS prod testing or a small LAN deployment (clients see a
|
|
27
|
+
self-signed warning). For a localhost-only trial, prefer the HTTP demo
|
|
28
|
+
stack (docker-compose.demo.yaml) instead.
|
|
29
|
+
"""
|
|
30
|
+
secrets_dir = Path(runtime.path(".runtime", "secrets"))
|
|
31
|
+
secrets_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
key_path = secrets_dir / "mascope.app.key"
|
|
34
|
+
pem_path = secrets_dir / "mascope.app.pem"
|
|
35
|
+
|
|
36
|
+
# Docker creates an empty DIRECTORY at a missing `file:` secret source, which
|
|
37
|
+
# then blocks openssl from writing the file. Clear any such placeholders.
|
|
38
|
+
for path in (key_path, pem_path):
|
|
39
|
+
if path.is_dir():
|
|
40
|
+
shutil.rmtree(path)
|
|
41
|
+
|
|
42
|
+
# Use forward-slash (posix) paths: lib.run shlex-splits the command, which
|
|
43
|
+
# mangles Windows backslashes. openssl accepts forward slashes on Windows.
|
|
44
|
+
command = (
|
|
45
|
+
"openssl req "
|
|
46
|
+
+ "-x509 "
|
|
47
|
+
+ "-nodes "
|
|
48
|
+
+ '-subj "/CN=mascope.app" '
|
|
49
|
+
+ '-addext "subjectAltName=DNS:mascope.app" '
|
|
50
|
+
+ "-days 365 "
|
|
51
|
+
+ "-newkey rsa:2048 "
|
|
52
|
+
+ f'-keyout "{key_path.as_posix()}" '
|
|
53
|
+
+ f'-out "{pem_path.as_posix()}"'
|
|
54
|
+
)
|
|
55
|
+
lib.run(command)
|
|
56
|
+
|
|
57
|
+
if pem_path.is_file() and key_path.is_file():
|
|
58
|
+
runtime.logger.success(f"Wrote self-signed certificate to {secrets_dir}")
|
|
59
|
+
else:
|
|
60
|
+
runtime.logger.error(
|
|
61
|
+
"Certificate generation did not produce both files. On Windows you "
|
|
62
|
+
"may need to set OPENSSL_CONF, e.g. "
|
|
63
|
+
'$env:OPENSSL_CONF="C:\\Program Files\\Git\\usr\\ssl\\openssl.cnf".'
|
|
64
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Download and verify a published demo bundle into the local cache.
|
|
3
|
+
|
|
4
|
+
Pure download/extract/verify logic - no Typer concerns. The CLI command in
|
|
5
|
+
``main.py`` wraps these helpers with user-facing messaging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import tarfile
|
|
10
|
+
import urllib.request
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from mascope_cli.cmd.demo import bundles
|
|
15
|
+
from mascope_cli.runtime import runtime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_archive(archive: Path, dest: Path) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Extract a ``.zip`` or ``.tar.gz`` archive into ``dest``.
|
|
21
|
+
|
|
22
|
+
The archive is expected to contain the bundle contents at its top level
|
|
23
|
+
(``manifest.json``, ``raw/``, ``snapshot/`` ...). If it instead wraps them in
|
|
24
|
+
a single top-level directory, that directory's contents are flattened into
|
|
25
|
+
``dest`` so the layout is consistent regardless of how it was packed.
|
|
26
|
+
|
|
27
|
+
:param archive: Path to the downloaded archive.
|
|
28
|
+
:param dest: Target bundle directory (created/overwritten by the caller).
|
|
29
|
+
:raises ValueError: If the archive format is unsupported.
|
|
30
|
+
"""
|
|
31
|
+
staging = dest.parent / f"{dest.name}.staging"
|
|
32
|
+
if staging.exists():
|
|
33
|
+
shutil.rmtree(staging)
|
|
34
|
+
staging.mkdir(parents=True)
|
|
35
|
+
|
|
36
|
+
if zipfile.is_zipfile(archive):
|
|
37
|
+
with zipfile.ZipFile(archive) as zf:
|
|
38
|
+
zf.extractall(staging)
|
|
39
|
+
elif tarfile.is_tarfile(archive):
|
|
40
|
+
with tarfile.open(archive) as tf:
|
|
41
|
+
tf.extractall(staging)
|
|
42
|
+
else:
|
|
43
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
44
|
+
raise ValueError(f"Unsupported archive format: {archive.name}")
|
|
45
|
+
|
|
46
|
+
# Flatten a single wrapping directory if present.
|
|
47
|
+
entries = list(staging.iterdir())
|
|
48
|
+
root = entries[0] if len(entries) == 1 and entries[0].is_dir() else staging
|
|
49
|
+
|
|
50
|
+
if dest.exists():
|
|
51
|
+
shutil.rmtree(dest)
|
|
52
|
+
shutil.move(str(root), str(dest))
|
|
53
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _download(url: str, dest: Path) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Stream a URL to a local file, logging coarse progress.
|
|
59
|
+
|
|
60
|
+
:param url: Source URL.
|
|
61
|
+
:param dest: Destination file path (parent created automatically).
|
|
62
|
+
:raises OSError: On network/IO failure (propagated from urllib).
|
|
63
|
+
"""
|
|
64
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
runtime.logger.info(f"Downloading {url}")
|
|
66
|
+
|
|
67
|
+
with urllib.request.urlopen(url) as response: # noqa: S310 - trusted Zenodo URL
|
|
68
|
+
total = int(response.headers.get("Content-Length", 0))
|
|
69
|
+
read = 0
|
|
70
|
+
next_mark = 10
|
|
71
|
+
with dest.open("wb") as out:
|
|
72
|
+
for chunk in iter(lambda: response.read(1 << 20), b""):
|
|
73
|
+
out.write(chunk)
|
|
74
|
+
read += len(chunk)
|
|
75
|
+
if total:
|
|
76
|
+
pct = read * 100 // total
|
|
77
|
+
if pct >= next_mark:
|
|
78
|
+
runtime.logger.info(f" ...{pct}%")
|
|
79
|
+
next_mark = pct - (pct % 10) + 10
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fetch(version: str | None = None, force: bool = False) -> Path:
|
|
83
|
+
"""
|
|
84
|
+
Ensure a demo bundle is present and verified in the local cache.
|
|
85
|
+
|
|
86
|
+
If already cached and intact, returns immediately unless ``force`` is set.
|
|
87
|
+
Otherwise downloads the archive from the registered URL, extracts it, and
|
|
88
|
+
verifies every file against the manifest checksums.
|
|
89
|
+
|
|
90
|
+
:param version: Bundle version tag. Defaults to the registry default.
|
|
91
|
+
:param force: Re-download even if a valid cached copy exists.
|
|
92
|
+
:raises RuntimeError: If the bundle has no published URL, the download
|
|
93
|
+
fails, or checksum verification fails.
|
|
94
|
+
:return: Path to the verified bundle directory.
|
|
95
|
+
"""
|
|
96
|
+
bundle = bundles.get_bundle(version)
|
|
97
|
+
dest = bundles.bundle_dir(version)
|
|
98
|
+
|
|
99
|
+
if bundles.is_cached(version) and not force:
|
|
100
|
+
problems = bundles.verify_manifest(version)
|
|
101
|
+
if not problems:
|
|
102
|
+
runtime.logger.success(f"Demo bundle '{bundle.version}' already cached")
|
|
103
|
+
return dest
|
|
104
|
+
runtime.logger.warning(
|
|
105
|
+
f"Cached bundle '{bundle.version}' failed verification; re-fetching"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if not bundle.url:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"Demo bundle '{bundle.version}' has no published download URL yet. "
|
|
111
|
+
"Build one locally with 'mascope demo snapshot', or set its URL in "
|
|
112
|
+
"tooling/cli/src/mascope_cli/cmd/demo/bundles.py once published."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
archive = bundles.cache_root() / f"{bundle.version}{_suffix(bundle.url)}"
|
|
116
|
+
_download(bundle.url, archive)
|
|
117
|
+
|
|
118
|
+
if bundle.archive_md5:
|
|
119
|
+
actual = bundles.md5_file(archive)
|
|
120
|
+
# Case-insensitive: hashlib emits lowercase, but a checksum pasted from
|
|
121
|
+
# Zenodo (or PowerShell's Get-FileHash) may be uppercase.
|
|
122
|
+
if actual.lower() != bundle.archive_md5.lower():
|
|
123
|
+
raise RuntimeError(
|
|
124
|
+
f"Archive checksum mismatch for '{bundle.version}': "
|
|
125
|
+
f"expected {bundle.archive_md5[:12]}..., got {actual[:12]}..."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
runtime.logger.info("Extracting bundle...")
|
|
129
|
+
_extract_archive(archive, dest)
|
|
130
|
+
archive.unlink(missing_ok=True)
|
|
131
|
+
|
|
132
|
+
problems = bundles.verify_manifest(version)
|
|
133
|
+
if problems:
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
"Bundle verification failed after download:\n - " + "\n - ".join(problems)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
runtime.logger.success(f"Demo bundle '{bundle.version}' fetched and verified")
|
|
139
|
+
return dest
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _suffix(url: str) -> str:
|
|
143
|
+
"""Return the archive suffix (``.zip`` or ``.tar.gz``) implied by a URL."""
|
|
144
|
+
lowered = url.lower()
|
|
145
|
+
if lowered.endswith(".tar.gz") or lowered.endswith(".tgz"):
|
|
146
|
+
return ".tar.gz"
|
|
147
|
+
return ".zip"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rebuild path: ingest the bundle's raw files through the real upload pipeline.
|
|
3
|
+
|
|
4
|
+
Instead of dropping files into the file-converter's watch directory (which has
|
|
5
|
+
no auth/context and never registers a file context), this uploads each raw file
|
|
6
|
+
to the real ``/api/sample/files/upload`` endpoint - exactly what the File Agent
|
|
7
|
+
does. The endpoint registers the file context (auth) and writes the file to
|
|
8
|
+
``filestreams``, where the file-converter ingests it and runs the full
|
|
9
|
+
``RawProcessor`` -> peak detection -> matching pipeline.
|
|
10
|
+
|
|
11
|
+
Uploads are driven from a background thread because the app is launched in the
|
|
12
|
+
foreground; the thread waits for the backend HTTP server, then uploads.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mascope_cli.cmd.demo import bundles
|
|
20
|
+
from mascope_cli.cmd.demo._seed import DEMO_ENV # noqa: F401 - re-exported convenience
|
|
21
|
+
from mascope_cli.runtime import runtime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# The fixed file-agent token + service name the demo seeds (mirror
|
|
25
|
+
# server seed_demo.DEMO_TOKENS["file-agent"]). Using the file-agent service
|
|
26
|
+
# replicates the File Agent's exact upload request.
|
|
27
|
+
_UPLOAD_TOKEN = "mascope_demo_file_agent_token"
|
|
28
|
+
_UPLOAD_SERVICE = "file-agent"
|
|
29
|
+
_UPLOAD_PATH = "sample/files/upload"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _raw_dir(version: str | None, source_dir: "Path | None") -> Path:
|
|
33
|
+
"""Resolve the bundle's raw/ directory (published cache or local override)."""
|
|
34
|
+
root = Path(source_dir) if source_dir else bundles.bundle_dir(version)
|
|
35
|
+
src = root / "raw"
|
|
36
|
+
if not src.is_dir():
|
|
37
|
+
raise FileNotFoundError(f"No raw/ directory found at: {src}")
|
|
38
|
+
return src
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _wait_for_backend(url: str, timeout: float = 300.0, poll: float = 2.0) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Poll the backend until it serves HTTP.
|
|
44
|
+
|
|
45
|
+
Probes a public, always-present endpoint (the first-owner status route)
|
|
46
|
+
rather than the OpenAPI schema, which is disabled outside dev mode.
|
|
47
|
+
|
|
48
|
+
:param url: Backend base URL (e.g. ``http://localhost:8090``).
|
|
49
|
+
:param timeout: Max seconds to wait.
|
|
50
|
+
:param poll: Seconds between attempts.
|
|
51
|
+
:return: ``True`` once ready, ``False`` on timeout.
|
|
52
|
+
"""
|
|
53
|
+
import requests
|
|
54
|
+
|
|
55
|
+
deadline = time.monotonic() + timeout
|
|
56
|
+
while time.monotonic() < deadline:
|
|
57
|
+
try:
|
|
58
|
+
resp = requests.get(f"{url}/api/users/first-owner/status", timeout=5)
|
|
59
|
+
if resp.status_code == 200:
|
|
60
|
+
return True
|
|
61
|
+
except requests.RequestException:
|
|
62
|
+
pass
|
|
63
|
+
time.sleep(poll)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def upload_raw(version: str | None = None, source_dir: "Path | None" = None) -> int:
|
|
68
|
+
"""
|
|
69
|
+
Upload the bundle's raw files to the real sample-file upload endpoint.
|
|
70
|
+
|
|
71
|
+
Replicates the File Agent's request (bearer ``file-agent`` token +
|
|
72
|
+
``X-Service-Name: file-agent``) so the server registers context, stores the
|
|
73
|
+
file, and the converter ingests it.
|
|
74
|
+
|
|
75
|
+
:param version: Bundle version tag. Defaults to the registry default.
|
|
76
|
+
:param source_dir: Local bundle directory override (see module docstring).
|
|
77
|
+
:return: Number of files successfully uploaded.
|
|
78
|
+
"""
|
|
79
|
+
import mascope_sdk
|
|
80
|
+
from mascope_sdk import api_post_file
|
|
81
|
+
from mascope_sdk.exceptions import MascopeError
|
|
82
|
+
|
|
83
|
+
src = _raw_dir(version, source_dir)
|
|
84
|
+
url = f"http://localhost:{runtime.meta.api_port}"
|
|
85
|
+
|
|
86
|
+
# Match the File Agent's service header for the upload request.
|
|
87
|
+
mascope_sdk.SERVICE_NAME = _UPLOAD_SERVICE
|
|
88
|
+
|
|
89
|
+
raw_files = sorted(p for p in src.iterdir() if p.suffix.lower() == ".raw")
|
|
90
|
+
total = len(raw_files)
|
|
91
|
+
runtime.logger.info(f"Uploading {total} raw file(s) to {url}/api/{_UPLOAD_PATH}")
|
|
92
|
+
|
|
93
|
+
uploaded = 0
|
|
94
|
+
for i, raw in enumerate(raw_files, 1):
|
|
95
|
+
try:
|
|
96
|
+
api_post_file(
|
|
97
|
+
url=url,
|
|
98
|
+
path=_UPLOAD_PATH,
|
|
99
|
+
access_token=_UPLOAD_TOKEN,
|
|
100
|
+
filepath=str(raw),
|
|
101
|
+
)
|
|
102
|
+
uploaded += 1
|
|
103
|
+
except MascopeError as e:
|
|
104
|
+
runtime.logger.error(f"Upload of {raw.name} failed: {e}")
|
|
105
|
+
if i % 25 == 0 or i == total:
|
|
106
|
+
runtime.logger.info(f" uploaded {uploaded}/{total}")
|
|
107
|
+
|
|
108
|
+
if uploaded == total:
|
|
109
|
+
runtime.logger.success(f"Uploaded all {total} raw file(s); ingestion proceeds")
|
|
110
|
+
else:
|
|
111
|
+
runtime.logger.warning(
|
|
112
|
+
f"Uploaded {uploaded}/{total} raw file(s); {total - uploaded} failed "
|
|
113
|
+
"(see errors above)"
|
|
114
|
+
)
|
|
115
|
+
return uploaded
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def upload_raw_deferred(
|
|
119
|
+
version: str | None = None, source_dir: "Path | None" = None
|
|
120
|
+
) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Upload raw files once the backend is serving, from a background daemon thread.
|
|
123
|
+
|
|
124
|
+
The app is launched in the foreground, so this defers the uploads to a thread
|
|
125
|
+
that first waits for the backend HTTP server to come up.
|
|
126
|
+
|
|
127
|
+
:param version: Bundle version tag. Defaults to the registry default.
|
|
128
|
+
:param source_dir: Local bundle directory override.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def _worker() -> None:
|
|
132
|
+
url = f"http://localhost:{runtime.meta.api_port}"
|
|
133
|
+
if not _wait_for_backend(url):
|
|
134
|
+
runtime.logger.error(
|
|
135
|
+
"Backend did not become ready in time; skipping raw upload. "
|
|
136
|
+
"Once it is up, re-run 'mascope demo --rebuild' or upload manually."
|
|
137
|
+
)
|
|
138
|
+
return
|
|
139
|
+
try:
|
|
140
|
+
upload_raw(version, source_dir=source_dir)
|
|
141
|
+
except Exception as e: # noqa: BLE001 - background thread, just log
|
|
142
|
+
runtime.logger.error(f"Deferred raw upload failed: {e}")
|
|
143
|
+
|
|
144
|
+
threading.Thread(target=_worker, name="demo-upload-raw", daemon=True).start()
|
|
145
|
+
runtime.logger.info(
|
|
146
|
+
"Raw files will be uploaded to the server once the backend is ready; "
|
|
147
|
+
"ingestion then proceeds in the background."
|
|
148
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Seed the demo runtime env from a cached bundle's database snapshot.
|
|
3
|
+
|
|
4
|
+
This is the "instant" path: restore the pre-built ``pg_dump`` into the
|
|
5
|
+
``mascope_demo`` database and copy the bundle's filestore tree into the demo
|
|
6
|
+
env so the backend can serve the stored files. No instrument pipeline runs.
|
|
7
|
+
|
|
8
|
+
The slower ``--rebuild`` path (ingest raw through the real file-converter) lives
|
|
9
|
+
in ``_rebuild.py`` and is what the reproducibility test drives.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from mascope_cli.cmd.demo import bundles
|
|
18
|
+
from mascope_cli.pg import dirs, drop_database, pg_restore
|
|
19
|
+
from mascope_cli.runtime import runtime
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# The demo always uses a dedicated env so it never touches a developer's work.
|
|
23
|
+
DEMO_ENV = "demo"
|
|
24
|
+
_MODE = "dev"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def env_dir(env: str = DEMO_ENV) -> Path:
|
|
28
|
+
"""Return the runtime directory for an env (may not exist yet)."""
|
|
29
|
+
return Path(os.environ["MASCOPE_PATH"]) / ".runtime" / "env" / env
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve(version: str | None, source_dir: "Path | None") -> tuple[Path, dict]:
|
|
33
|
+
"""Resolve the bundle root and load its manifest (published cache or local)."""
|
|
34
|
+
if source_dir:
|
|
35
|
+
root = Path(source_dir)
|
|
36
|
+
manifest_path = root / bundles.MANIFEST_NAME
|
|
37
|
+
if not manifest_path.is_file():
|
|
38
|
+
raise FileNotFoundError(
|
|
39
|
+
f"No {bundles.MANIFEST_NAME} in {root}. A snapshot restore needs a "
|
|
40
|
+
"built bundle; for a raw-only local directory use 'mascope demo "
|
|
41
|
+
"--rebuild --local <dir>'."
|
|
42
|
+
)
|
|
43
|
+
return root, json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
44
|
+
return bundles.bundle_dir(version), bundles.load_manifest(version)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def has_block(
|
|
48
|
+
block: str, version: str | None = None, source_dir: "Path | None" = None
|
|
49
|
+
) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Report whether a manifest block (``"seed"`` or ``"snapshot"``) is available.
|
|
52
|
+
|
|
53
|
+
Returns ``False`` (rather than raising) when there is no manifest - e.g. a
|
|
54
|
+
raw-only ``--local`` directory - so callers can fall back gracefully.
|
|
55
|
+
|
|
56
|
+
:param block: Manifest key, ``"seed"`` or ``"snapshot"``.
|
|
57
|
+
:param version: Bundle version tag. Defaults to the registry default.
|
|
58
|
+
:param source_dir: Local bundle directory override.
|
|
59
|
+
:return: ``True`` if the block's dump exists on disk.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
root, manifest = _resolve(version, source_dir)
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return False
|
|
65
|
+
dump_rel = manifest.get(block, {}).get("dump")
|
|
66
|
+
return bool(dump_rel) and (root / dump_rel).is_file()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _restore_block(
|
|
70
|
+
block: str, version: str | None = None, source_dir: "Path | None" = None
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Restore a manifest dump block into the ``mascope_demo`` database.
|
|
74
|
+
|
|
75
|
+
Stages the dump into the dev backups directory (bind-mounted into the
|
|
76
|
+
PostgreSQL container), then drops/recreates the demo database and restores
|
|
77
|
+
into it. Assumes the dev PostgreSQL container is running and the demo env
|
|
78
|
+
exists.
|
|
79
|
+
|
|
80
|
+
:param block: Manifest key, ``"seed"`` (reference data) or ``"snapshot"``
|
|
81
|
+
(full state).
|
|
82
|
+
:param version: Bundle version tag. Defaults to the registry default.
|
|
83
|
+
:param source_dir: Local bundle directory override.
|
|
84
|
+
:raises FileNotFoundError: If the bundle or the block's dump is missing.
|
|
85
|
+
:raises RuntimeError: If the restore fails.
|
|
86
|
+
"""
|
|
87
|
+
# Lazy import: keeps `mascope demo fetch` from pulling the dev.db graph.
|
|
88
|
+
from mascope_cli.cmd.dev.db import create_database
|
|
89
|
+
|
|
90
|
+
root, manifest = _resolve(version, source_dir)
|
|
91
|
+
dump_rel = manifest.get(block, {}).get("dump")
|
|
92
|
+
if not dump_rel:
|
|
93
|
+
raise FileNotFoundError(
|
|
94
|
+
f"Bundle manifest has no '{block}' dump. Build it with "
|
|
95
|
+
f"'mascope demo snapshot --{block if block == 'seed' else 'update'}'."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
src_dump = root / dump_rel
|
|
99
|
+
if not src_dump.is_file():
|
|
100
|
+
raise FileNotFoundError(f"{block} dump not found: {src_dump}")
|
|
101
|
+
|
|
102
|
+
db_cfg = runtime.full_config.backend.database
|
|
103
|
+
container = db_cfg.get_postgres_container_name(mode=_MODE)
|
|
104
|
+
database = db_cfg.get_postgres_database_name(DEMO_ENV)
|
|
105
|
+
|
|
106
|
+
# Stage the dump where pg_restore expects it (bind-mounted backups dir).
|
|
107
|
+
backups_dir, mount = dirs(transfer=False, mode=_MODE)
|
|
108
|
+
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
staged = backups_dir / src_dump.name
|
|
110
|
+
shutil.copy2(src_dump, staged)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
runtime.logger.info(f"Dropping and recreating '{database}'...")
|
|
114
|
+
drop_database(container, db_cfg.user, database)
|
|
115
|
+
if not create_database(DEMO_ENV):
|
|
116
|
+
raise RuntimeError(f"Failed to recreate database '{database}'")
|
|
117
|
+
|
|
118
|
+
runtime.logger.info(f"Restoring {block} into '{database}'...")
|
|
119
|
+
pg_restore(container, db_cfg.user, database, staged, mount)
|
|
120
|
+
finally:
|
|
121
|
+
staged.unlink(missing_ok=True)
|
|
122
|
+
|
|
123
|
+
runtime.logger.success(f"Demo database '{database}' restored from {block}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def restore_snapshot(
|
|
127
|
+
version: str | None = None, source_dir: "Path | None" = None
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Restore the full ``snapshot`` dump (instant path). See :func:`_restore_block`."""
|
|
130
|
+
_restore_block("snapshot", version, source_dir)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def restore_seed(version: str | None = None, source_dir: "Path | None" = None) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Restore the reference ``seed`` dump (ionization modes, instrument config,
|
|
136
|
+
calibration/diagnostic collections, demo user) before a rebuild ingests raw.
|
|
137
|
+
See :func:`_restore_block`.
|
|
138
|
+
"""
|
|
139
|
+
_restore_block("seed", version, source_dir)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def restore_filestore(
|
|
143
|
+
version: str | None = None, source_dir: "Path | None" = None
|
|
144
|
+
) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Copy the bundle's filestore tree into the demo env's filestore directory.
|
|
147
|
+
|
|
148
|
+
Mirrors ``snapshot/filestore/`` from the bundle into
|
|
149
|
+
``.runtime/env/demo/filestore/`` so the restored database rows resolve to
|
|
150
|
+
real files on disk. Existing demo filestore contents are replaced.
|
|
151
|
+
|
|
152
|
+
:param version: Bundle version tag. Defaults to the registry default.
|
|
153
|
+
:param source_dir: Local bundle directory to use instead of the published cache.
|
|
154
|
+
"""
|
|
155
|
+
root, manifest = _resolve(version, source_dir)
|
|
156
|
+
snapshot = manifest.get("snapshot", {})
|
|
157
|
+
filestore_rel = snapshot.get("filestore", "snapshot/filestore")
|
|
158
|
+
src = root / filestore_rel
|
|
159
|
+
|
|
160
|
+
if not src.is_dir():
|
|
161
|
+
runtime.logger.warning(
|
|
162
|
+
f"Bundle has no filestore tree at {src}; skipping filestore restore"
|
|
163
|
+
)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
dest = env_dir() / "filestore"
|
|
167
|
+
if dest.exists():
|
|
168
|
+
shutil.rmtree(dest)
|
|
169
|
+
shutil.copytree(src, dest)
|
|
170
|
+
runtime.logger.success(f"Demo filestore restored to {dest}")
|