Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ expect_frames_absent: ["CAN Dropped"]
Single-machine manifests (`mcu:` at the top level) are the one-machine case.
Models resolve through your account; no checkout of ours is needed. A model can
require a minimum engine version; if yours is older, the failure names the
version and the fix (`simantic install engine --force`).
version and the fix (use a newer engine build).

Three more keys cover fixtures that need files or peers on the host side:
`sparse_files` creates blank files of a given size before the run (a blank SD
Expand Down
3 changes: 1 addition & 2 deletions docs/session-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ medium), `sd-card` (`bus`, `size` such as `"16MiB"`, `image`: a formatted card i
list cannot express. Needs engine 0.5.17+.

A model can require a newer engine than the one installed (the STM32H7 parts
need 0.6.3). `Sim` then raises `SimError` naming the version it needs; update
with `simantic install engine --force`.
need 0.6.3). `Sim` then raises `SimError` naming the version it needs; use a newer engine build (the C# engine is no longer published).

`symbols_elf=` (single-machine form) / `symbolsElfPath` (scenario machine
entries — same key as the CLI's scenario YAML, so a scenario dict is
Expand Down
10 changes: 4 additions & 6 deletions src/simantic/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,11 @@ def _auth(args) -> int:


def _install(args) -> int:
names = args.binary or [name for name, _ in BINARIES] + [install.ENGINE_KEY, install.RUST_ENGINE_KEY]
names = args.binary or [name for name, _ in BINARIES] + [install.RUST_ENGINE_KEY]
failures = 0
for name in names:
try:
if name == install.ENGINE_KEY:
path = install.install_engine(force=args.force, channel=args.channel)
elif name == install.RUST_ENGINE_KEY:
if name == install.RUST_ENGINE_KEY:
path = install.install_rust_engine(force=args.force, channel=args.channel)
else:
path = install.install(name, force=args.force, channel=args.channel)
Expand Down Expand Up @@ -74,7 +72,7 @@ def _status(args) -> int:
except BinaryNotFound:
print(f" {name}: not found (run `simantic install {name}`)")
engine = install.installed_engine()
print(f" engine: {engine if engine else 'not found (fetched on first use, or `simantic install engine`)'}")
print(f" engine: {engine if engine else 'not found (no longer installable; local builds only)'}")
rust = install.installed_rust_engine()
missing = 'not found (fetched on first use of backend="rust", or `simantic install engine-rust`)'
print(f" engine-rust: {rust if rust else missing}")
Expand Down Expand Up @@ -138,7 +136,7 @@ def main(argv: list[str] | None = None) -> int:
p_auth.set_defaults(func=_auth)

p_install = sub.add_parser("install", help="download simulator binaries")
p_install.add_argument("binary", nargs="*", help="defaults to all known binaries and both engines")
p_install.add_argument("binary", nargs="*", help="defaults to `sim` and `engine-rust`")
p_install.add_argument(
"--force", action="store_true", help="re-download even if already present"
)
Expand Down
14 changes: 4 additions & 10 deletions src/simantic/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def check_engine_version(d: Path) -> None:
have = ".".join(str(p) for p in found)
raise EngineTooOld(
f"engine {have} at {d} is older than {want}, which this package needs. "
f"Run `simantic install engine --force` to fetch the current one."
f"Use a newer engine build."
)


Expand All @@ -73,9 +73,8 @@ def engine_dir(explicit: str | os.PathLike[str] | None = None, *, fetch: bool =

Order: an explicit path, $SIMANTIC_ENGINE_DIR, a development `sim` whose
publish directory is beside it ($SIMANTIC_SIM), then the managed install
under ~/.simantic/engine. When nothing is there, the engine is fetched
from the public release — so the first `Sim(...)` after `pip install`
just works.
under ~/.simantic/engine. The C# engine is no longer published or
fetched; the Rust engine is the installable one.
"""
candidates = []
if explicit is not None:
Expand All @@ -92,13 +91,8 @@ def engine_dir(explicit: str | os.PathLike[str] | None = None, *, fetch: bool =
managed = install.installed_engine()
if managed is not None:
return managed
if fetch:
try:
return install.install_engine()
except install.InstallError as exc:
raise EngineNotFound(f"could not fetch the engine: {exc}") from None
raise EngineNotFound(
f"Simantic.Core.dll not found. Run `simantic install engine`, or set ${ENV_DIR}."
f"Simantic.Core.dll not found. Set ${ENV_DIR}."
)


Expand Down
121 changes: 77 additions & 44 deletions src/simantic/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

releases/<product>/<channel>.json
{"version": "0.4.0",
"artifacts": {"osx-arm64": {"url": ..., "sha256": ...}, ...}}
"artifacts": {"osx-arm64": {"path": ..., "sha256": ...}, ...}}

The channel is just the manifest name, so publishing a pre-release means
uploading a second pointer beside latest.json rather than standing up
Expand All @@ -15,7 +15,9 @@
written into site-packages: an installed package may be read-only, and a
binary there would vanish on the next upgrade.

Releases are public objects, keyed by version, so fetching needs no account.
The releases bucket is private. Every read goes through the `get-release` edge
function: the object path is traded, with the stored personal access token
(`simantic auth`), for a 300 s signed URL, and that is what is downloaded.
Trust comes from two checks: the manifest is signed with a release key whose
public half is below, and every artifact is verified against the checksum the
manifest gives for it. A manifest that is unsigned, or an artifact without a
Expand Down Expand Up @@ -44,17 +46,69 @@
from dataclasses import dataclass
from pathlib import Path

from . import auth

RELEASES_URL = "https://drjdhqfvrttolueolzif.supabase.co/storage/v1/object/public/releases"

SUPABASE_ORIGIN = "https://drjdhqfvrttolueolzif.supabase.co"

def releases_url() -> str:
"""Where manifests are served from. $SIMANTIC_RELEASES_URL overrides.

Overridable so a release can be rehearsed against a staging host (or a
plain directory served over HTTP) before it is published.
"""
return os.environ.get("SIMANTIC_RELEASES_URL", RELEASES_URL).rstrip("/")
def checked_path(path) -> str:
"""A manifest `path` is a bare object path in the releases bucket; anything
else (empty, absolute, scheme, `..`) is refused before it reaches get-release."""
if (
not isinstance(path, str)
or not path
or path.startswith("/")
or "\\" in path
or "://" in path
or ".." in path.split("/")
):
raise InstallError(f"refusing invalid release path: {path!r}")
return path


def signed_url(path: str, *, timeout: float = 30) -> str:
"""Trade the stored token for a signed URL to `path` via get-release."""
path = checked_path(path)
credentials = auth.load() # NotAuthenticated says to run `simantic auth`
request = urllib.request.Request(
f"{SUPABASE_ORIGIN}/functions/v1/get-release?path={urllib.parse.quote(path)}",
headers={"Authorization": f"Bearer {credentials.api_key}"},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
url = json.loads(response.read()).get("url")
except urllib.error.HTTPError as exc:
try:
message = json.loads(exc.read()).get("error", "")
except (ValueError, AttributeError, OSError):
message = ""
if exc.code == 401:
raise InstallError(
"release server rejected your token; run `simantic auth` to log in again"
) from None
if exc.code == 403:
raise InstallError(f"release access denied: {message or 'account not approved'}") from None
if exc.code == 404:
raise InstallError(f"no release object at {path!r}") from None
raise InstallError(f"release signing failed (HTTP {exc.code})") from None
except urllib.error.URLError as exc:
raise InstallError(f"cannot reach the release server: {exc.reason}") from None
except ValueError as exc:
raise InstallError(f"release server returned invalid JSON: {exc}") from None
if not isinstance(url, str) or not url.startswith("https://"):
raise InstallError("release server returned no https signed URL")
return url


def _release_url(path: str) -> str:
"""The URL to fetch for a bucket object path. $SIMANTIC_RELEASES_URL
(staging, a local directory served over HTTP) is read directly as
<base>/<path>; the real bucket goes through get-release."""
base = os.environ.get("SIMANTIC_RELEASES_URL")
if base is not None:
return f"{base.rstrip('/')}/{checked_path(path)}"
return signed_url(path)


#: The manifest to read. $SIMANTIC_CHANNEL selects a pre-release channel.
Expand Down Expand Up @@ -133,12 +187,12 @@ def _verified_manifest(body: bytes) -> dict:
raise InstallError(f"release manifest is not valid JSON: {exc}") from None


def _fetch_manifest(url: str, missing: str, timeout: float) -> dict:
def _fetch_manifest(path: str, missing: str, timeout: float) -> dict:
try:
with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) as response:
with urllib.request.urlopen(urllib.request.Request(_release_url(path)), timeout=timeout) as response:
body = response.read()
except urllib.error.HTTPError as exc:
raise InstallError(f"{missing} (HTTP {exc.code} from {url})") from None
raise InstallError(f"{missing} (HTTP {exc.code} from {path})") from None
except urllib.error.URLError as exc:
raise InstallError(f"cannot reach the release server: {exc.reason}") from None
return _verified_manifest(body)
Expand All @@ -147,7 +201,7 @@ def _fetch_manifest(url: str, missing: str, timeout: float) -> dict:
@dataclass(frozen=True)
class Artifact:
version: str
url: str
path: str
sha256: str


Expand Down Expand Up @@ -189,7 +243,7 @@ def current_rid() -> str:

#: Binary name -> release product prefix.
PRODUCTS = {
"sim": "cli",
"sim": "pyrite",
}


Expand All @@ -202,15 +256,16 @@ def fetch_manifest(
f"unknown binary {binary!r}; expected one of {sorted(PRODUCTS)}"
)
channel = channel or default_channel()
url = f"{releases_url()}/{product}/{channel}.signed.json"
return _fetch_manifest(url, f"no published releases for {binary!r}", timeout)
path = f"{product}/{channel}.signed.json"
return _fetch_manifest(path, f"no published releases for {binary!r}", timeout)


def resolve(
binary: str, *, rid: str | None = None, channel: str | None = None
) -> Artifact:
"""The artifact this machine should download."""
return _resolve(fetch_manifest(binary, channel=channel), binary, rid)
# pyrite keys artifacts `<binary>-<rid>`, e.g. `sim-osx-arm64`.
return _resolve(fetch_manifest(binary, channel=channel), binary, f"{binary}-{rid or current_rid()}")


def _resolve(manifest: dict, binary: str, rid: str | None) -> Artifact:
Expand All @@ -221,7 +276,7 @@ def _resolve(manifest: dict, binary: str, rid: str | None) -> Artifact:

rid = rid or current_rid()
entry = artifacts.get(rid)
if not entry or not entry.get("url"):
if not entry or not entry.get("path"):
available = ", ".join(sorted(artifacts)) or "none"
raise InstallError(
f"no {rid} build in {binary} release {version} (available: {available})"
Expand All @@ -231,12 +286,12 @@ def _resolve(manifest: dict, binary: str, rid: str | None) -> Artifact:
raise InstallError(
f"{rid} build in {binary} release {version} has no sha256; refusing to install"
)
return Artifact(version=version, url=entry["url"], sha256=sha256.strip().lower())
return Artifact(version=version, path=checked_path(entry["path"]), sha256=sha256.strip().lower())


def download(artifact: Artifact, *, timeout: float = 300) -> bytes:
"""Fetch the artifact and verify its checksum before it is trusted."""
request = urllib.request.Request(artifact.url)
request = urllib.request.Request(_release_url(artifact.path))
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = response.read()
Expand Down Expand Up @@ -321,9 +376,6 @@ def install(binary: str, *, force: bool = False, channel: str | None = None) ->

# -- the engine: Simantic.Core + a private .NET runtime, for in-process use ----

ENGINE_KEY = "engine"


def engine_root() -> Path:
"""Where engine releases live: ~/.simantic/engine/<version>/."""
return simantic_home() / "engine"
Expand Down Expand Up @@ -362,25 +414,6 @@ def _version_dir(root: Path, version: str) -> Path:
return target


def install_engine(*, force: bool = False, channel: str | None = None) -> Path:
"""Download the engine for this machine into engine_root()/<version>.

One zip from the `sim` release manifest, key `engine-<rid>`: the
Simantic.Core publish directory plus a private .NET runtime under
`dotnet/`, laid out exactly as published.
"""
artifact = resolve("sim", rid=f"{ENGINE_KEY}-{current_rid()}", channel=channel)
target = _version_dir(engine_root(), artifact.version)
if (target / "Simantic.Core.dll").exists() and not force:
return target

payload = download(artifact)
if not payload.startswith(b"PK\x03\x04"):
raise InstallError("engine artifact is not a zip archive")
_unpack_zip(payload, target)
return target


# -- the Rust engine: the simantic_rust extension module ----------------------

RUST_ENGINE_KEY = "engine-rust"
Expand Down Expand Up @@ -422,8 +455,8 @@ def installed_rust_engine() -> Path | None:

def fetch_rust_manifest(*, channel: str | None = None, timeout: float = 30) -> dict:
channel = channel or default_channel()
url = f"{releases_url()}/{RUST_ENGINE_PRODUCT}/{channel}.signed.json"
return _fetch_manifest(url, "no published Rust engine", timeout)
path = f"{RUST_ENGINE_PRODUCT}/{channel}.signed.json"
return _fetch_manifest(path, "no published Rust engine", timeout)


def install_rust_engine(*, force: bool = False, channel: str | None = None) -> Path:
Expand Down
6 changes: 3 additions & 3 deletions src/simantic/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def _start_error(exc: Exception) -> str:
text = str(exc)
if "Could not find suitable constructor" in text:
return ("could not start the simulation: this model needs a newer engine "
"than the one installed.\n\n simantic install engine --force\n\n"
"than the one installed.\n\n (the C# engine is no longer published; use a newer build)\n\n"
+ text.splitlines()[0])
return f"could not start the simulation: {text}"

Expand All @@ -171,7 +171,7 @@ def _needs_newer_engine(mcus, engine: Path) -> str | None:
continue
if needed > have:
return (f"{mcu} needs engine {want} or newer; "
f"{'.'.join(map(str, have))} is installed.\n\n simantic install engine --force")
f"{'.'.join(map(str, have))} is installed.\n\n (the C# engine is no longer published; use a newer build)")
return None

class Sim:
Expand Down Expand Up @@ -521,7 +521,7 @@ def _fragment(self, ns, parts, overlay) -> str | None:
text = ""
if parts:
if not hasattr(ns, "Parts"):
raise SimError("parts= needs engine 0.5.17 or newer: run `simantic install engine`")
raise SimError("parts= needs engine 0.5.17 or newer: use a newer engine build")
parts = [_resolve_paths(p, self._base) for p in parts]
text = ns.Parts.Fragment(json.dumps(parts))
if overlay:
Expand Down
Loading
Loading