esp-gsp-tools 0.1.0__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.
- esp_gsp_tools-0.1.0/.gitignore +3 -0
- esp_gsp_tools-0.1.0/PKG-INFO +70 -0
- esp_gsp_tools-0.1.0/README.md +53 -0
- esp_gsp_tools-0.1.0/pyproject.toml +35 -0
- esp_gsp_tools-0.1.0/src/gsp/__init__.py +3 -0
- esp_gsp_tools-0.1.0/src/gsp/__main__.py +9 -0
- esp_gsp_tools-0.1.0/src/gsp/errors.py +7 -0
- esp_gsp_tools-0.1.0/src/gsp/execute.py +94 -0
- esp_gsp_tools-0.1.0/src/gsp/keys.py +16 -0
- esp_gsp_tools-0.1.0/src/gsp/manager.py +361 -0
- esp_gsp_tools-0.1.0/src/gsp/manifest.py +74 -0
- esp_gsp_tools-0.1.0/src/gsp/platforms.py +72 -0
- esp_gsp_tools-0.1.0/src/gsp/products.py +47 -0
- esp_gsp_tools-0.1.0/src/gsp/verify.py +21 -0
- esp_gsp_tools-0.1.0/tests/test_execute.py +48 -0
- esp_gsp_tools-0.1.0/tests/test_manager.py +59 -0
- esp_gsp_tools-0.1.0/tests/test_manifest.py +33 -0
- esp_gsp_tools-0.1.0/tests/test_products.py +14 -0
- esp_gsp_tools-0.1.0/tests/test_verify.py +21 -0
- esp_gsp_tools-0.1.0/uv.lock +312 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: esp-gsp-tools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resolve, verify, cache, and run pinned esp-gsp-tools releases
|
|
5
|
+
Author: Espressif Systems
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Python: <3.15,>=3.10
|
|
15
|
+
Requires-Dist: cryptography>=44.0.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# esp-gsp-tools
|
|
19
|
+
|
|
20
|
+
`esp-gsp-tools` resolves, verifies, caches, and runs a pinned release of one
|
|
21
|
+
of the supported products:
|
|
22
|
+
|
|
23
|
+
| Product name | Product |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `gspc` | GSPC |
|
|
26
|
+
| `sim` | simulator |
|
|
27
|
+
|
|
28
|
+
Product names are case-insensitive.
|
|
29
|
+
|
|
30
|
+
## Run a product
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
python -m gsp.execute --version 0.2.4 gspc pack scene.json -o bundle.gspb
|
|
34
|
+
python -m gsp.execute --no-auto-cache sim --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
All arguments after the product name are passed to that product unchanged.
|
|
38
|
+
`--version` overrides `GSP_VERSION`. A missing cached version is downloaded
|
|
39
|
+
automatically unless `--no-auto-cache` or
|
|
40
|
+
`GSP_TOOLS_NO_AUTO_DOWNLOAD=1` is used.
|
|
41
|
+
|
|
42
|
+
The direct executable variables are `GSPC_EXECUTABLE` and
|
|
43
|
+
`GSP_SIM_EXECUTABLE`; either takes precedence over the cache and version.
|
|
44
|
+
|
|
45
|
+
## Manage the cache
|
|
46
|
+
|
|
47
|
+
The manager requires a product for every version-specific operation. Both
|
|
48
|
+
`VERSION PRODUCT` and `PRODUCT VERSION` are accepted:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
python -m gsp.manager install 0.2.4 gspc
|
|
52
|
+
python -m gsp.manager fetch sim 0.2.4
|
|
53
|
+
python -m gsp.manager reinstall 0.2.4 GSPC
|
|
54
|
+
python -m gsp.manager path 0.2.4 sim
|
|
55
|
+
python -m gsp.manager remove 0.2.4 gspc
|
|
56
|
+
python -m gsp.manager list sim
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The cache defaults to `~/.espressif/esp-gsp-tools` and can be changed with
|
|
60
|
+
`GSP_TOOLS_CACHE_DIR` or `--cache-dir`. `GSP_TOOLS_BASE_URL` overrides the
|
|
61
|
+
single release root URL. The manager appends the product directory (`gspc` or
|
|
62
|
+
`sim`) before the version path. Release archives are authenticated with the
|
|
63
|
+
configured Ed25519 public keys as described by the manifest.
|
|
64
|
+
|
|
65
|
+
If a product name is not supported, the command explains that the installed
|
|
66
|
+
`esp-gsp-tools` version may be outdated and suggests:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
python -m pip install -U esp-gsp-tools
|
|
70
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# esp-gsp-tools
|
|
2
|
+
|
|
3
|
+
`esp-gsp-tools` resolves, verifies, caches, and runs a pinned release of one
|
|
4
|
+
of the supported products:
|
|
5
|
+
|
|
6
|
+
| Product name | Product |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `gspc` | GSPC |
|
|
9
|
+
| `sim` | simulator |
|
|
10
|
+
|
|
11
|
+
Product names are case-insensitive.
|
|
12
|
+
|
|
13
|
+
## Run a product
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
python -m gsp.execute --version 0.2.4 gspc pack scene.json -o bundle.gspb
|
|
17
|
+
python -m gsp.execute --no-auto-cache sim --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
All arguments after the product name are passed to that product unchanged.
|
|
21
|
+
`--version` overrides `GSP_VERSION`. A missing cached version is downloaded
|
|
22
|
+
automatically unless `--no-auto-cache` or
|
|
23
|
+
`GSP_TOOLS_NO_AUTO_DOWNLOAD=1` is used.
|
|
24
|
+
|
|
25
|
+
The direct executable variables are `GSPC_EXECUTABLE` and
|
|
26
|
+
`GSP_SIM_EXECUTABLE`; either takes precedence over the cache and version.
|
|
27
|
+
|
|
28
|
+
## Manage the cache
|
|
29
|
+
|
|
30
|
+
The manager requires a product for every version-specific operation. Both
|
|
31
|
+
`VERSION PRODUCT` and `PRODUCT VERSION` are accepted:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
python -m gsp.manager install 0.2.4 gspc
|
|
35
|
+
python -m gsp.manager fetch sim 0.2.4
|
|
36
|
+
python -m gsp.manager reinstall 0.2.4 GSPC
|
|
37
|
+
python -m gsp.manager path 0.2.4 sim
|
|
38
|
+
python -m gsp.manager remove 0.2.4 gspc
|
|
39
|
+
python -m gsp.manager list sim
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The cache defaults to `~/.espressif/esp-gsp-tools` and can be changed with
|
|
43
|
+
`GSP_TOOLS_CACHE_DIR` or `--cache-dir`. `GSP_TOOLS_BASE_URL` overrides the
|
|
44
|
+
single release root URL. The manager appends the product directory (`gspc` or
|
|
45
|
+
`sim`) before the version path. Release archives are authenticated with the
|
|
46
|
+
configured Ed25519 public keys as described by the manifest.
|
|
47
|
+
|
|
48
|
+
If a product name is not supported, the command explains that the installed
|
|
49
|
+
`esp-gsp-tools` version may be outdated and suggests:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
python -m pip install -U esp-gsp-tools
|
|
53
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "esp-gsp-tools"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Resolve, verify, cache, and run pinned esp-gsp-tools releases"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<3.15"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Espressif Systems" }]
|
|
13
|
+
dependencies = ["cryptography>=44.0.0"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
esp-gsp-tools = "gsp.manager:main"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8.3"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/gsp"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
addopts = "-q"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class GspManagerError(RuntimeError):
|
|
2
|
+
"""A recoverable user-facing manager error."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# Keep the old import available for callers that used the initial package
|
|
6
|
+
# while the public module is migrated from ``gspc`` to ``gsp``.
|
|
7
|
+
GspcManagerError = GspManagerError
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Resolve and execute one product from the esp-gsp-tools distribution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .errors import GspManagerError
|
|
12
|
+
from .manager import GspManager, load_public_keys
|
|
13
|
+
from .products import get_product
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _automatic_download_disabled() -> bool:
|
|
20
|
+
value = os.environ.get("GSP_TOOLS_NO_AUTO_DOWNLOAD", "")
|
|
21
|
+
return value.strip().lower() in _TRUE_VALUES
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def executable_from_environment(
|
|
25
|
+
product: str,
|
|
26
|
+
*,
|
|
27
|
+
version: str | None = None,
|
|
28
|
+
no_auto_cache: bool = False,
|
|
29
|
+
) -> Path:
|
|
30
|
+
selected = get_product(product)
|
|
31
|
+
direct_path = os.environ.get(selected.executable_environment)
|
|
32
|
+
if direct_path:
|
|
33
|
+
executable = Path(direct_path)
|
|
34
|
+
if not executable.is_file():
|
|
35
|
+
raise GspManagerError(
|
|
36
|
+
f"{selected.executable_environment} does not point to a file: {executable}"
|
|
37
|
+
)
|
|
38
|
+
return executable
|
|
39
|
+
|
|
40
|
+
requested_version = (version or os.environ.get("GSP_VERSION", "")).strip()
|
|
41
|
+
if not requested_version:
|
|
42
|
+
raise GspManagerError(
|
|
43
|
+
"set --version, GSP_VERSION, or the product executable environment variable"
|
|
44
|
+
)
|
|
45
|
+
manager = GspManager(product=selected.key, public_keys=load_public_keys(None, []))
|
|
46
|
+
executable = manager.cached(requested_version)
|
|
47
|
+
if executable is not None:
|
|
48
|
+
return executable
|
|
49
|
+
if no_auto_cache or _automatic_download_disabled():
|
|
50
|
+
raise GspManagerError(
|
|
51
|
+
f"{selected.display_name} {requested_version} is not cached and automatic download is disabled; "
|
|
52
|
+
f"run `python -m gsp.manager install {requested_version} {selected.key}` first"
|
|
53
|
+
)
|
|
54
|
+
return manager.resolve(requested_version)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parser() -> argparse.ArgumentParser:
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
prog="python -m gsp.execute",
|
|
60
|
+
description="Run a selected esp-gsp-tools product.",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument("--version", help="exact product version; defaults to GSP_VERSION")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--no-auto-cache",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="do not download a missing version automatically",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("product", metavar="PRODUCT")
|
|
69
|
+
parser.add_argument("arguments", nargs=argparse.REMAINDER, metavar="ARGS")
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main(argv: list[str] | None = None) -> int:
|
|
74
|
+
parser = _parser()
|
|
75
|
+
options = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
76
|
+
executable: Path | None = None
|
|
77
|
+
try:
|
|
78
|
+
selected = get_product(options.product)
|
|
79
|
+
executable = executable_from_environment(
|
|
80
|
+
selected.key,
|
|
81
|
+
version=options.version,
|
|
82
|
+
no_auto_cache=options.no_auto_cache,
|
|
83
|
+
)
|
|
84
|
+
return subprocess.run([str(executable), *options.arguments], check=False).returncode
|
|
85
|
+
except GspManagerError as exc:
|
|
86
|
+
sys.stderr.write(f"gsp.execute: error: {exc}\n")
|
|
87
|
+
return 2
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
sys.stderr.write(f"gsp.execute: error: cannot run {executable}: {exc}\n")
|
|
90
|
+
return 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Trusted GSPC release signing keys.
|
|
2
|
+
|
|
3
|
+
Populate this mapping when the release signing key is provisioned. Keeping
|
|
4
|
+
the registry explicit makes key IDs auditable and permits overlapping keys
|
|
5
|
+
during a rotation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
BUILTIN_PUBLIC_KEYS: Final[dict[str, str]] = {
|
|
13
|
+
"gspc-release": "uSVHnMpv03VPGL5FMQm2USELARKeCPRdGsezyqQlQ74=",
|
|
14
|
+
"gsp-sim-release": "CbVrcYYSPjDt6J7N5v4h80SCcizC8ZW+d6D2Q8Y43iU=",
|
|
15
|
+
"fa": "S2WEykJ/6KvoIcFGQ9tpa4TkzJrVwwPIddoqko9GNDM="
|
|
16
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import tarfile
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import zipfile
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator, Mapping
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
from urllib.request import Request, urlopen
|
|
19
|
+
|
|
20
|
+
from .errors import GspManagerError, GspcManagerError
|
|
21
|
+
from .keys import BUILTIN_PUBLIC_KEYS
|
|
22
|
+
from .manifest import Artifact, parse_manifest
|
|
23
|
+
from .platforms import HostPlatform, executable_name
|
|
24
|
+
from .products import Product, get_product
|
|
25
|
+
from .verify import verify_ed25519
|
|
26
|
+
|
|
27
|
+
DEFAULT_CACHE_DIR = Path.home() / ".espressif" / "esp-gsp-tools"
|
|
28
|
+
DEFAULT_BASE_URL = "https://dl.espressif.com/AE/gsp/"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_cache_dir() -> Path:
|
|
32
|
+
return Path(os.environ.get("GSP_TOOLS_CACHE_DIR") or os.environ.get("GSPC_CACHE_DIR", DEFAULT_CACHE_DIR))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def default_base_url() -> str:
|
|
36
|
+
return (os.environ.get("GSP_TOOLS_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GspManager:
|
|
40
|
+
def __init__(self, product: str = "gspc", *, base_url: str | None = None, cache_dir: Path | None = None, public_keys: Mapping[str, str] | None = None) -> None:
|
|
41
|
+
self.product = get_product(product)
|
|
42
|
+
self.base_url = (base_url or default_base_url()).rstrip("/")
|
|
43
|
+
if urlparse(self.base_url).scheme != "https":
|
|
44
|
+
raise GspManagerError("release base URL must use HTTPS")
|
|
45
|
+
self.cache_dir = cache_dir or default_cache_dir()
|
|
46
|
+
self.public_keys = dict(BUILTIN_PUBLIC_KEYS)
|
|
47
|
+
if public_keys:
|
|
48
|
+
self.public_keys.update(public_keys)
|
|
49
|
+
|
|
50
|
+
def resolve(self, version: str, *, reinstall: bool = False) -> Path:
|
|
51
|
+
_validate_version(version)
|
|
52
|
+
host = HostPlatform.detect()
|
|
53
|
+
destination = self._destination(version, host)
|
|
54
|
+
expected_binary = destination / "bin" / executable_name(self.product.binary, host)
|
|
55
|
+
with _cache_lock(destination.with_name(destination.name + ".lock")):
|
|
56
|
+
if reinstall and destination.exists():
|
|
57
|
+
shutil.rmtree(destination)
|
|
58
|
+
metadata_path = destination / "metadata.json"
|
|
59
|
+
if expected_binary.is_file() and metadata_path.is_file():
|
|
60
|
+
self._validate_installed(expected_binary, version)
|
|
61
|
+
return expected_binary
|
|
62
|
+
if destination.exists():
|
|
63
|
+
shutil.rmtree(destination)
|
|
64
|
+
artifact, manifest_url = self._fetch_artifact(version, host)
|
|
65
|
+
installed = self._install(destination, artifact, version, host)
|
|
66
|
+
metadata_path = installed.parent.parent / "metadata.json"
|
|
67
|
+
metadata_path.write_text(
|
|
68
|
+
json.dumps({"version": version, "manifest_url": manifest_url, "artifact": artifact.raw}, indent=2) + "\n",
|
|
69
|
+
encoding="utf-8",
|
|
70
|
+
)
|
|
71
|
+
return installed
|
|
72
|
+
|
|
73
|
+
def cached(self, version: str) -> Path | None:
|
|
74
|
+
"""Return a verified cached compiler for this host, without networking."""
|
|
75
|
+
_validate_version(version)
|
|
76
|
+
host = HostPlatform.detect()
|
|
77
|
+
destination = self._destination(version, host)
|
|
78
|
+
binary = destination / "bin" / executable_name(self.product.binary, host)
|
|
79
|
+
if not binary.is_file() or not (destination / "metadata.json").is_file():
|
|
80
|
+
return None
|
|
81
|
+
self._validate_installed(binary, version)
|
|
82
|
+
return binary
|
|
83
|
+
|
|
84
|
+
def remove(self, version: str) -> bool:
|
|
85
|
+
"""Remove exactly one cached version for the current host."""
|
|
86
|
+
_validate_version(version)
|
|
87
|
+
host = HostPlatform.detect()
|
|
88
|
+
destination = self._destination(version, host)
|
|
89
|
+
with _cache_lock(destination.with_name(destination.name + ".lock")):
|
|
90
|
+
if not destination.exists():
|
|
91
|
+
return False
|
|
92
|
+
shutil.rmtree(destination)
|
|
93
|
+
try:
|
|
94
|
+
destination.parent.rmdir()
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
def list_cached(self) -> list[tuple[str, str, str, Path]]:
|
|
100
|
+
"""Return (product, version, platform, executable) records installed."""
|
|
101
|
+
if not self.cache_dir.is_dir():
|
|
102
|
+
return []
|
|
103
|
+
records: list[tuple[str, str, str, Path]] = []
|
|
104
|
+
for metadata in sorted((self.cache_dir / self.product.key).glob("v*/*/metadata.json")):
|
|
105
|
+
try:
|
|
106
|
+
version = json.loads(metadata.read_text(encoding="utf-8"))["version"]
|
|
107
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
108
|
+
continue
|
|
109
|
+
binary_dir = metadata.parent / "bin"
|
|
110
|
+
binaries = list(binary_dir.glob(f"{self.product.binary}*")) if binary_dir.is_dir() else []
|
|
111
|
+
if len(binaries) == 1 and isinstance(version, str):
|
|
112
|
+
records.append((self.product.key, version, metadata.parent.name, binaries[0]))
|
|
113
|
+
return records
|
|
114
|
+
|
|
115
|
+
def _destination(self, version: str, host: HostPlatform) -> Path:
|
|
116
|
+
return self.cache_dir / self.product.key / f"v{version}" / f"{host.os}-{host.arch}"
|
|
117
|
+
|
|
118
|
+
def _fetch_artifact(self, version: str, host: HostPlatform) -> tuple[Artifact, str]:
|
|
119
|
+
manifest_url = f"{self.base_url}/{self.product.key}/v{version}/manifest.json"
|
|
120
|
+
artifact = parse_manifest(
|
|
121
|
+
_download(manifest_url), requested_version=version, manifest_url=manifest_url, host=host
|
|
122
|
+
)
|
|
123
|
+
if artifact.key_id not in self.public_keys:
|
|
124
|
+
raise GspcManagerError(
|
|
125
|
+
f"artifact uses unknown signing key {artifact.key_id!r}; "
|
|
126
|
+
"configure that public key before installing"
|
|
127
|
+
)
|
|
128
|
+
return artifact, manifest_url
|
|
129
|
+
|
|
130
|
+
def _install(self, destination: Path, artifact: Artifact, version: str, host: HostPlatform) -> Path:
|
|
131
|
+
archive = _download(artifact.archive_url)
|
|
132
|
+
signature = _download(artifact.signature_url)
|
|
133
|
+
verify_ed25519(archive, signature, self.public_keys[artifact.key_id], encoding=artifact.signature_encoding)
|
|
134
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}-", dir=destination.parent))
|
|
136
|
+
try:
|
|
137
|
+
extracted = temporary / "extracted"
|
|
138
|
+
extracted.mkdir()
|
|
139
|
+
_safe_extract(archive, artifact.archive_format, extracted)
|
|
140
|
+
binary_source = _safe_child(extracted, executable_name(artifact.binary, host))
|
|
141
|
+
if not binary_source.is_file():
|
|
142
|
+
raise GspcManagerError(f"release archive does not contain declared binary {artifact.binary!r}")
|
|
143
|
+
# The manifest controls the path inside the archive, but the cache
|
|
144
|
+
# exposes a stable compiler name to CMake and future cache reads.
|
|
145
|
+
binary_destination = temporary / "bin" / executable_name(self.product.binary, host)
|
|
146
|
+
binary_destination.parent.mkdir()
|
|
147
|
+
shutil.copy2(binary_source, binary_destination)
|
|
148
|
+
if host.os != "windows":
|
|
149
|
+
binary_destination.chmod(binary_destination.stat().st_mode | stat.S_IXUSR)
|
|
150
|
+
self._validate_installed(binary_destination, version)
|
|
151
|
+
os.replace(temporary, destination)
|
|
152
|
+
return destination / "bin" / binary_destination.name
|
|
153
|
+
except Exception:
|
|
154
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _validate_installed(binary: Path, expected_version: str) -> None:
|
|
159
|
+
try:
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
[str(binary), "--version"], check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
|
162
|
+
)
|
|
163
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
164
|
+
raise GspcManagerError(f"cannot execute verified GSPC at {binary}: {exc}") from exc
|
|
165
|
+
tokens = result.stdout.split()
|
|
166
|
+
if expected_version not in tokens:
|
|
167
|
+
raise GspcManagerError(
|
|
168
|
+
f"verified GSPC reports {result.stdout.strip()!r}, expected version {expected_version!r}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Compatibility alias for code using the initial single-product API.
|
|
173
|
+
GspcManager = GspManager
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_public_keys(path: Path | None, overrides: list[str]) -> dict[str, str]:
|
|
177
|
+
keys: dict[str, str] = {}
|
|
178
|
+
configured_path = path or (Path(os.environ["GSPC_PUBLIC_KEYS_FILE"]) if "GSPC_PUBLIC_KEYS_FILE" in os.environ else None)
|
|
179
|
+
if configured_path:
|
|
180
|
+
try:
|
|
181
|
+
parsed = json.loads(configured_path.read_text(encoding="utf-8"))
|
|
182
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
183
|
+
raise GspcManagerError(f"cannot load public keys from {configured_path}: {exc}") from exc
|
|
184
|
+
if not isinstance(parsed, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in parsed.items()):
|
|
185
|
+
raise GspcManagerError("public-key JSON must be an object mapping key IDs to Base64 keys")
|
|
186
|
+
keys.update(parsed)
|
|
187
|
+
for item in overrides:
|
|
188
|
+
key_id, separator, encoded = item.partition("=")
|
|
189
|
+
if not separator or not key_id or not encoded:
|
|
190
|
+
raise GspcManagerError("--public-key must have the form KEY_ID=BASE64_KEY")
|
|
191
|
+
try:
|
|
192
|
+
if len(base64.b64decode(encoded, validate=True)) != 32:
|
|
193
|
+
raise ValueError
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
raise GspcManagerError(f"public key {key_id!r} is not a Base64 Ed25519 public key") from exc
|
|
196
|
+
keys[key_id] = encoded
|
|
197
|
+
return keys
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _validate_version(version: str) -> None:
|
|
201
|
+
if not version or version != version.strip() or "/" in version or "\\" in version:
|
|
202
|
+
raise GspcManagerError(f"invalid GSPC version: {version!r}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _download(url: str) -> bytes:
|
|
206
|
+
try:
|
|
207
|
+
request = Request(url, headers={"User-Agent": "gspc/0.1"})
|
|
208
|
+
with urlopen(request, timeout=30) as response:
|
|
209
|
+
return response.read()
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise GspcManagerError(f"failed to download {url}: {exc}") from exc
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _safe_child(root: Path, relative: str) -> Path:
|
|
215
|
+
path = (root / relative).resolve()
|
|
216
|
+
resolved_root = root.resolve()
|
|
217
|
+
if path != resolved_root and resolved_root not in path.parents:
|
|
218
|
+
raise GspcManagerError(f"unsafe archive path: {relative!r}")
|
|
219
|
+
return path
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _safe_extract(data: bytes, archive_format: str, destination: Path) -> None:
|
|
223
|
+
if archive_format == "tar.gz":
|
|
224
|
+
with tarfile.open(fileobj=_BytesReader(data), mode="r:gz") as archive:
|
|
225
|
+
for member in archive.getmembers():
|
|
226
|
+
if member.isdir():
|
|
227
|
+
_safe_child(destination, member.name).mkdir(parents=True, exist_ok=True)
|
|
228
|
+
continue
|
|
229
|
+
if member.issym() or member.islnk() or not member.isfile():
|
|
230
|
+
raise GspcManagerError(f"unsupported member in GSPC archive: {member.name}")
|
|
231
|
+
target = _safe_child(destination, member.name)
|
|
232
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
source = archive.extractfile(member)
|
|
234
|
+
if source is None:
|
|
235
|
+
raise GspcManagerError(f"cannot extract GSPC archive member: {member.name}")
|
|
236
|
+
with source, target.open("wb") as output:
|
|
237
|
+
shutil.copyfileobj(source, output)
|
|
238
|
+
else:
|
|
239
|
+
with zipfile.ZipFile(_BytesReader(data)) as archive:
|
|
240
|
+
for member in archive.infolist():
|
|
241
|
+
if member.is_dir():
|
|
242
|
+
continue
|
|
243
|
+
target = _safe_child(destination, member.filename)
|
|
244
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
with archive.open(member), target.open("wb") as output:
|
|
246
|
+
shutil.copyfileobj(member, output)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _BytesReader:
|
|
250
|
+
"""Small seekable adapter keeping archive handling independent of files."""
|
|
251
|
+
|
|
252
|
+
def __init__(self, data: bytes) -> None:
|
|
253
|
+
import io
|
|
254
|
+
|
|
255
|
+
self._buffer = io.BytesIO(data)
|
|
256
|
+
|
|
257
|
+
def __getattr__(self, name: str):
|
|
258
|
+
return getattr(self._buffer, name)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@contextmanager
|
|
262
|
+
def _cache_lock(lock_path: Path) -> Iterator[None]:
|
|
263
|
+
deadline = time.monotonic() + 60
|
|
264
|
+
while True:
|
|
265
|
+
try:
|
|
266
|
+
lock_path.mkdir(parents=True)
|
|
267
|
+
break
|
|
268
|
+
except FileExistsError:
|
|
269
|
+
if time.monotonic() >= deadline:
|
|
270
|
+
raise GspcManagerError(f"timed out waiting for GSPC cache lock {lock_path}")
|
|
271
|
+
time.sleep(0.1)
|
|
272
|
+
try:
|
|
273
|
+
yield
|
|
274
|
+
finally:
|
|
275
|
+
lock_path.rmdir()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parser() -> argparse.ArgumentParser:
|
|
279
|
+
parser = argparse.ArgumentParser(
|
|
280
|
+
prog="python -m gsp.manager",
|
|
281
|
+
description="Manage the local cache of signed, pinned esp-gsp-tools releases.",
|
|
282
|
+
)
|
|
283
|
+
parser.add_argument("--base-url", help="release server base URL")
|
|
284
|
+
parser.add_argument("--cache-dir", type=Path, help="directory for verified releases")
|
|
285
|
+
parser.add_argument("--public-keys-file", type=Path, help="JSON mapping of key IDs to Base64 Ed25519 public keys")
|
|
286
|
+
parser.add_argument("--public-key", action="append", default=[], metavar="ID=BASE64", help="additional trusted Ed25519 public key; may be repeated")
|
|
287
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
288
|
+
for name, help_text in (
|
|
289
|
+
("install", "install VERSION if it is not already cached"),
|
|
290
|
+
("fetch", "download, verify, and cache VERSION for offline use"),
|
|
291
|
+
("reinstall", "discard and install VERSION again"),
|
|
292
|
+
("path", "print the verified cached executable path for VERSION"),
|
|
293
|
+
("remove", "remove the cached VERSION for this host"),
|
|
294
|
+
):
|
|
295
|
+
command = commands.add_parser(name, help=help_text)
|
|
296
|
+
command.add_argument("first", metavar="VERSION|PRODUCT")
|
|
297
|
+
command.add_argument("second", metavar="PRODUCT|VERSION")
|
|
298
|
+
list_command = commands.add_parser("list", help="list cached releases")
|
|
299
|
+
list_command.add_argument("product", nargs="?", default="gspc", metavar="PRODUCT")
|
|
300
|
+
return parser
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _version_and_product(first: str, second: str) -> tuple[str, str]:
|
|
304
|
+
"""Accept both PRODUCT VERSION and the historical VERSION PRODUCT order."""
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
get_product(first)
|
|
308
|
+
except GspManagerError:
|
|
309
|
+
get_product(second)
|
|
310
|
+
return first, second
|
|
311
|
+
return second, first
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _normalize_manager_argv(argv: list[str] | None) -> list[str] | None:
|
|
315
|
+
"""Also accept ``gsp.manager PRODUCT COMMAND VERSION`` for convenience."""
|
|
316
|
+
|
|
317
|
+
raw = list(argv) if argv is not None else None
|
|
318
|
+
if raw and raw[0].strip().lower() in {"gspc", "sim"} and len(raw) >= 2:
|
|
319
|
+
product, command, rest = raw[0], raw[1], raw[2:]
|
|
320
|
+
if command == "list":
|
|
321
|
+
return [command, product, *rest]
|
|
322
|
+
if command in {"install", "fetch", "reinstall", "path", "remove"}:
|
|
323
|
+
return [command, *rest, product]
|
|
324
|
+
return raw
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main(argv: list[str] | None = None) -> int:
|
|
328
|
+
parser = _parser()
|
|
329
|
+
options = parser.parse_args(_normalize_manager_argv(argv))
|
|
330
|
+
try:
|
|
331
|
+
product = options.product if options.command == "list" else _version_and_product(options.first, options.second)[1]
|
|
332
|
+
manager = GspManager(
|
|
333
|
+
product=product,
|
|
334
|
+
base_url=options.base_url,
|
|
335
|
+
cache_dir=options.cache_dir,
|
|
336
|
+
public_keys=load_public_keys(options.public_keys_file, options.public_key),
|
|
337
|
+
)
|
|
338
|
+
if options.command == "list":
|
|
339
|
+
for product_name, version, platform_name, executable in manager.list_cached():
|
|
340
|
+
print(f"{product_name}\t{version}\t{platform_name}\t{executable}")
|
|
341
|
+
return 0
|
|
342
|
+
version, _ = _version_and_product(options.first, options.second)
|
|
343
|
+
if options.command == "path":
|
|
344
|
+
executable = manager.cached(version)
|
|
345
|
+
if executable is None:
|
|
346
|
+
raise GspManagerError(f"{manager.product.display_name} {version} is not cached for this host")
|
|
347
|
+
print(executable)
|
|
348
|
+
return 0
|
|
349
|
+
if options.command == "remove":
|
|
350
|
+
if not manager.remove(version):
|
|
351
|
+
raise GspManagerError(f"{manager.product.display_name} {version} is not cached for this host")
|
|
352
|
+
return 0
|
|
353
|
+
executable = manager.resolve(version, reinstall=options.command == "reinstall")
|
|
354
|
+
print(executable)
|
|
355
|
+
return 0
|
|
356
|
+
except GspcManagerError as exc:
|
|
357
|
+
parser.exit(2, f"gsp.manager: error: {exc}\n")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
raise SystemExit(main())
|