mascope_cli 2026.7.28__tar.gz
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-2026.7.28/PKG-INFO +91 -0
- mascope_cli-2026.7.28/README.md +63 -0
- mascope_cli-2026.7.28/pyproject.toml +51 -0
- mascope_cli-2026.7.28/src/mascope_cli/__init__.py +1 -0
- mascope_cli-2026.7.28/src/mascope_cli/checkout.py +38 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/__init__.py +10 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/agent.py +25 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/backend.py +19 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/cert.py +64 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/__init__.py +2 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/_fetch.py +147 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/_rebuild.py +148 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/_seed.py +170 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/build_bundle.py +493 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/bundles.py +220 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/main.py +564 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/verify.py +135 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/__init__.py +2 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/__init__.py +2 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/backup.py +268 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/main.py +646 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/scripts.py +188 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/docker.py +248 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/main.py +548 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/migrate.py +365 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/redis.py +246 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/tools.py +53 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/env/__init__.py +2 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_create.py +104 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_paths.py +172 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_ssh.py +228 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_sync.py +613 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/env/main.py +354 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/fleet.py +181 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/init.py +145 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/instance.py +152 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/lib.py +65 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/logs.py +190 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/__init__.py +2 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/auto_update.py +451 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/cli_drift.py +135 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/__init__.py +2 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/backup.py +277 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/main.py +448 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/scripts.py +275 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/doctor.py +313 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/main.py +1061 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/preflight.py +269 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/release_manifest.py +134 -0
- mascope_cli-2026.7.28/src/mascope_cli/cmd/test.py +463 -0
- mascope_cli-2026.7.28/src/mascope_cli/data/base.mascope.toml +178 -0
- mascope_cli-2026.7.28/src/mascope_cli/data/dev.mascope.toml +24 -0
- mascope_cli-2026.7.28/src/mascope_cli/data/docker-compose.demo.yaml +145 -0
- mascope_cli-2026.7.28/src/mascope_cli/data/docker-compose.dev.yaml +90 -0
- mascope_cli-2026.7.28/src/mascope_cli/data/docker-compose.yaml +215 -0
- mascope_cli-2026.7.28/src/mascope_cli/data/prod.mascope.toml +78 -0
- mascope_cli-2026.7.28/src/mascope_cli/docker.py +29 -0
- mascope_cli-2026.7.28/src/mascope_cli/home.py +45 -0
- mascope_cli-2026.7.28/src/mascope_cli/instance.py +294 -0
- mascope_cli-2026.7.28/src/mascope_cli/main.py +184 -0
- mascope_cli-2026.7.28/src/mascope_cli/pg/__init__.py +3 -0
- mascope_cli-2026.7.28/src/mascope_cli/pg/admin.py +632 -0
- mascope_cli-2026.7.28/src/mascope_cli/pg/utils.py +228 -0
- mascope_cli-2026.7.28/src/mascope_cli/runtime.py +74 -0
- mascope_cli-2026.7.28/src/mascope_cli/version.py +38 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mascope_cli
|
|
3
|
+
Version: 2026.7.28
|
|
4
|
+
Summary: Mascope's operations CLI: install, launch and back up a Mascope deployment.
|
|
5
|
+
Author: Karsa Oy
|
|
6
|
+
Author-email: Karsa Oy <support@karsa.fi>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
9
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
10
|
+
Requires-Dist: mascope-runtime[logs]>=2026.7.28
|
|
11
|
+
Requires-Dist: typer>=0.27.0,<0.28
|
|
12
|
+
Requires-Dist: rich>=15.0.0,<16
|
|
13
|
+
Requires-Dist: pandas>=2.0.0 ; extra == 'demo'
|
|
14
|
+
Requires-Dist: requests>=2.32.0,<3 ; extra == 'demo'
|
|
15
|
+
Requires-Dist: alembic>=1.18.1 ; extra == 'dev'
|
|
16
|
+
Requires-Dist: sqlalchemy~=2.0 ; extra == 'dev'
|
|
17
|
+
Requires-Dist: psycopg2-binary>=2.9.12 ; extra == 'dev'
|
|
18
|
+
Requires-Dist: pyyaml>=6,<7 ; extra == 'dev'
|
|
19
|
+
Maintainer: Oskari Kausiala
|
|
20
|
+
Maintainer-email: Oskari Kausiala <oskari.kausiala@karsa.fi>
|
|
21
|
+
Requires-Python: >=3.12, <3.13
|
|
22
|
+
Project-URL: homepage, https://karsa.fi/
|
|
23
|
+
Project-URL: repository, https://github.com/karsa-oy/mascope
|
|
24
|
+
Project-URL: Bug Tracker, https://github.com/karsa-oy/mascope/issues
|
|
25
|
+
Provides-Extra: demo
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Mascope CLI
|
|
30
|
+
|
|
31
|
+
The operations CLI for [Mascope](https://karsa.fi/), Karsa's mass-spectrometry
|
|
32
|
+
analysis platform. It installs, launches, backs up and manages a Mascope
|
|
33
|
+
deployment — the application itself runs as Docker containers pulled from the
|
|
34
|
+
project's registry.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Python 3.12
|
|
39
|
+
- Docker (with the compose plugin)
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install mascope-cli
|
|
45
|
+
|
|
46
|
+
mascope init # create a runtime home: config, compose files, secrets
|
|
47
|
+
mascope cert gen # self-signed TLS cert (or install real certs)
|
|
48
|
+
mascope prod up # pull and start the production stack
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`mascope init` creates the runtime home at `~/.mascope`
|
|
52
|
+
(`%LOCALAPPDATA%\Mascope` on Windows) — configuration TOMLs you can edit,
|
|
53
|
+
docker compose files, and generated application secrets. Set `MASCOPE_PATH`
|
|
54
|
+
to place it elsewhere. Re-running `init` is safe: existing files are kept and
|
|
55
|
+
secrets are never regenerated.
|
|
56
|
+
|
|
57
|
+
To try Mascope without a deployment, the self-contained demo stack ships in
|
|
58
|
+
the runtime home as well:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
docker compose -f docker-compose.demo.yaml up -d
|
|
62
|
+
# then open http://localhost:8080 — login demo@mascope.app / mascope-demo
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Everyday operations
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
mascope prod ps # container status
|
|
69
|
+
mascope prod logs --follow # stream logs
|
|
70
|
+
mascope prod update # update the stack to the latest release
|
|
71
|
+
mascope prod db backup # dump the database
|
|
72
|
+
mascope prod db restore --yes # restore the latest dump
|
|
73
|
+
mascope logs query --grep error # search the application's file logs
|
|
74
|
+
mascope env list # manage runtime environments
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Run `mascope --help` or `mascope <command> --help` for the full reference.
|
|
78
|
+
|
|
79
|
+
`mascope prod update` pulls the target release images, restarts the stack
|
|
80
|
+
with them and runs any pending database migrations (preceded by an automatic
|
|
81
|
+
pre-migration dump). Move to a specific release with
|
|
82
|
+
`mascope prod update --version v1.2.0`, or pin the deployed version for all
|
|
83
|
+
commands with `MASCOPE_VERSION`; without a pin the stack follows the
|
|
84
|
+
`latest` release images.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
The Mascope source checkout exposes additional developer commands (`dev`,
|
|
89
|
+
`test`, `agent`, `backend`) that operate on the monorepo; they are not part
|
|
90
|
+
of the standalone install. See the
|
|
91
|
+
[repository](https://github.com/karsa-oy/mascope) for the developer guide.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Mascope CLI
|
|
2
|
+
|
|
3
|
+
The operations CLI for [Mascope](https://karsa.fi/), Karsa's mass-spectrometry
|
|
4
|
+
analysis platform. It installs, launches, backs up and manages a Mascope
|
|
5
|
+
deployment — the application itself runs as Docker containers pulled from the
|
|
6
|
+
project's registry.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Python 3.12
|
|
11
|
+
- Docker (with the compose plugin)
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install mascope-cli
|
|
17
|
+
|
|
18
|
+
mascope init # create a runtime home: config, compose files, secrets
|
|
19
|
+
mascope cert gen # self-signed TLS cert (or install real certs)
|
|
20
|
+
mascope prod up # pull and start the production stack
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`mascope init` creates the runtime home at `~/.mascope`
|
|
24
|
+
(`%LOCALAPPDATA%\Mascope` on Windows) — configuration TOMLs you can edit,
|
|
25
|
+
docker compose files, and generated application secrets. Set `MASCOPE_PATH`
|
|
26
|
+
to place it elsewhere. Re-running `init` is safe: existing files are kept and
|
|
27
|
+
secrets are never regenerated.
|
|
28
|
+
|
|
29
|
+
To try Mascope without a deployment, the self-contained demo stack ships in
|
|
30
|
+
the runtime home as well:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
docker compose -f docker-compose.demo.yaml up -d
|
|
34
|
+
# then open http://localhost:8080 — login demo@mascope.app / mascope-demo
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Everyday operations
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
mascope prod ps # container status
|
|
41
|
+
mascope prod logs --follow # stream logs
|
|
42
|
+
mascope prod update # update the stack to the latest release
|
|
43
|
+
mascope prod db backup # dump the database
|
|
44
|
+
mascope prod db restore --yes # restore the latest dump
|
|
45
|
+
mascope logs query --grep error # search the application's file logs
|
|
46
|
+
mascope env list # manage runtime environments
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run `mascope --help` or `mascope <command> --help` for the full reference.
|
|
50
|
+
|
|
51
|
+
`mascope prod update` pulls the target release images, restarts the stack
|
|
52
|
+
with them and runs any pending database migrations (preceded by an automatic
|
|
53
|
+
pre-migration dump). Move to a specific release with
|
|
54
|
+
`mascope prod update --version v1.2.0`, or pin the deployed version for all
|
|
55
|
+
commands with `MASCOPE_VERSION`; without a pin the stack follows the
|
|
56
|
+
`latest` release images.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
The Mascope source checkout exposes additional developer commands (`dev`,
|
|
61
|
+
`test`, `agent`, `backend`) that operate on the monorepo; they are not part
|
|
62
|
+
of the standalone install. See the
|
|
63
|
+
[repository](https://github.com/karsa-oy/mascope) for the developer guide.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mascope_cli"
|
|
3
|
+
version = "2026.7.28"
|
|
4
|
+
description = "Mascope's operations CLI: install, launch and back up a Mascope deployment."
|
|
5
|
+
classifiers = [
|
|
6
|
+
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
7
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
8
|
+
]
|
|
9
|
+
authors = [{ name = "Karsa Oy", email = "support@karsa.fi" }]
|
|
10
|
+
maintainers = [
|
|
11
|
+
{ name = "Oskari Kausiala", email = "oskari.kausiala@karsa.fi" },
|
|
12
|
+
]
|
|
13
|
+
license.text = "Apache-2.0"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
requires-python = ">=3.12,<3.13"
|
|
16
|
+
# Operator surface only (init, prod, env, demo, logs, cert). Developer
|
|
17
|
+
# commands live behind the `dev` extra and a source-checkout gate in main.py.
|
|
18
|
+
dependencies = [
|
|
19
|
+
# >=2026.7.28: `logs query` passes the service/json_output kwargs added
|
|
20
|
+
# to RuntimeLogging.query in that release.
|
|
21
|
+
"mascope_runtime[logs]>=2026.7.28",
|
|
22
|
+
"typer>=0.27.0,<0.28",
|
|
23
|
+
"rich>=15.0.0,<16",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
mascope = "mascope_cli.main:app"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
# `mascope dev migrate` / `mascope dev db` / `mascope fleet` — monorepo
|
|
31
|
+
# development only.
|
|
32
|
+
dev = [
|
|
33
|
+
"alembic>=1.18.1",
|
|
34
|
+
"sqlalchemy~=2.0",
|
|
35
|
+
"psycopg2-binary>=2.9.12",
|
|
36
|
+
"pyyaml>=6,<7",
|
|
37
|
+
]
|
|
38
|
+
# `mascope demo build-bundle` / `verify` peak comparison (imported lazily).
|
|
39
|
+
demo = [
|
|
40
|
+
"pandas>=2.0.0",
|
|
41
|
+
"requests>=2.32.0,<3",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
homepage = "https://karsa.fi/"
|
|
46
|
+
repository = "https://github.com/karsa-oy/mascope"
|
|
47
|
+
"Bug Tracker" = "https://github.com/karsa-oy/mascope/issues"
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.11.32,<0.12"]
|
|
51
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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
|
|
@@ -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)}")
|
|
@@ -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
|
+
)
|