chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.server.app import build_app
|
||||
from mirage.server.registry import WorkspaceRegistry
|
||||
|
||||
__all__ = ["WorkspaceRegistry", "build_app"]
|
||||
@@ -0,0 +1,148 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from mirage.server.auth import (AuthConfig, AuthMiddleware, AuthMode,
|
||||
resolve_auth_config)
|
||||
from mirage.server.daemon_config import (read_daemon_table,
|
||||
validate_daemon_table)
|
||||
from mirage.server.host_validation import (HostHeaderMiddleware,
|
||||
resolve_allowed_hosts)
|
||||
from mirage.server.jobs import JobTable
|
||||
from mirage.server.paths import (mirage_home, pid_file_path,
|
||||
snapshot_root_path, version_root_path)
|
||||
from mirage.server.registry import WorkspaceRegistry
|
||||
from mirage.server.routers import (execute, health, jobs, sessions, versions,
|
||||
workspaces)
|
||||
from mirage.server.version.backend import LocalBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _write_pid_file(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(str(os.getpid()))
|
||||
|
||||
|
||||
def _remove_pid_file(path: Path) -> None:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.debug("could not remove pid file %s", path)
|
||||
|
||||
|
||||
async def _watch_exit(exit_event: asyncio.Event) -> None:
|
||||
"""Send SIGTERM to self when ``exit_event`` is set.
|
||||
|
||||
Lets uvicorn handle its own graceful shutdown sequence.
|
||||
"""
|
||||
try:
|
||||
await exit_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
logger.info("exit event tripped; sending SIGTERM to self")
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
_write_pid_file(app.state.pid_file)
|
||||
exit_task = asyncio.create_task(_watch_exit(app.state.exit_event))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
exit_task.cancel()
|
||||
await app.state.registry.close_all()
|
||||
_remove_pid_file(app.state.pid_file)
|
||||
|
||||
|
||||
def build_app(idle_grace_seconds: float = 30.0,
|
||||
exit_event: asyncio.Event | None = None,
|
||||
allowed_hosts: list[str] | None = None,
|
||||
auth_config: AuthConfig | None = None,
|
||||
version_root: str | Path | None = None,
|
||||
snapshot_root: str | Path | None = None,
|
||||
pid_file: str | Path | None = None) -> FastAPI:
|
||||
"""Construct a daemon FastAPI app.
|
||||
|
||||
The workspace registry is created eagerly so the app is usable
|
||||
even without ASGI lifespan events firing (e.g. inside an
|
||||
``httpx.ASGITransport`` test client).
|
||||
|
||||
Args:
|
||||
idle_grace_seconds (float): seconds to wait after the last
|
||||
workspace is removed before signalling shutdown.
|
||||
exit_event (asyncio.Event | None): event the registry trips
|
||||
when the idle timer fires. The runner of this app should
|
||||
await it and shut uvicorn down. Defaults to a fresh event.
|
||||
allowed_hosts (list[str] | None): host allowlist for the
|
||||
``Host`` header. ``None`` (default) reads
|
||||
``$MIRAGE_ALLOWED_HOSTS`` (CSV) or falls back to
|
||||
loopback-only (``127.0.0.1``, ``localhost``, ``::1``).
|
||||
Pass ``["*"]`` to disable enforcement (only safe behind
|
||||
a trusted reverse proxy).
|
||||
auth_config (AuthConfig | None): bearer/JWT auth config.
|
||||
``None`` (default) resolves from ``MIRAGE_AUTH_MODE`` env
|
||||
and the mode-specific ``MIRAGE_*`` env vars.
|
||||
version_root (str | Path | None): git repos root. ``None``
|
||||
(default) uses ``$MIRAGE_HOME/repos`` (or ``~/.mirage/repos``).
|
||||
snapshot_root (str | Path | None): snapshot root. ``None``
|
||||
(default) uses ``$MIRAGE_HOME/snapshots``.
|
||||
pid_file (str | Path | None): daemon pid file path. ``None``
|
||||
(default) resolves ``$MIRAGE_PID_FILE`` then
|
||||
``$MIRAGE_HOME/daemon.pid`` (or ``~/.mirage/daemon.pid``).
|
||||
|
||||
Returns:
|
||||
FastAPI: configured app with all routers mounted.
|
||||
"""
|
||||
validate_daemon_table(read_daemon_table(mirage_home()))
|
||||
app = FastAPI(title="Mirage daemon", version="0.1", lifespan=_lifespan)
|
||||
hosts = resolve_allowed_hosts(allowed_hosts)
|
||||
if "*" not in hosts:
|
||||
app.add_middleware(HostHeaderMiddleware, allowed_hosts=hosts)
|
||||
auth = auth_config if auth_config is not None else resolve_auth_config()
|
||||
if auth.mode == AuthMode.LOCAL and auth.local_token is None:
|
||||
logger.warning(
|
||||
"daemon starting without bearer auth; anyone who can reach "
|
||||
"it can drive it. Set MIRAGE_AUTH_TOKEN or use a non-local "
|
||||
"MIRAGE_AUTH_MODE to enforce authentication.")
|
||||
app.add_middleware(AuthMiddleware, config=auth)
|
||||
app.state.allowed_hosts = hosts
|
||||
app.state.auth_config = auth
|
||||
app.state.started_at = time.time()
|
||||
app.state.exit_event = exit_event or asyncio.Event()
|
||||
app.state.registry = WorkspaceRegistry(
|
||||
idle_grace_seconds=idle_grace_seconds,
|
||||
exit_event=app.state.exit_event,
|
||||
)
|
||||
app.state.jobs = JobTable()
|
||||
app.state.pid_file = pid_file_path(pid_file)
|
||||
app.state.version_backend = LocalBackend(version_root_path(version_root))
|
||||
app.state.snapshot_root = snapshot_root_path(snapshot_root)
|
||||
app.include_router(workspaces.router)
|
||||
app.include_router(versions.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(execute.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(health.router)
|
||||
return app
|
||||
@@ -0,0 +1,32 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.server.auth.config import (AuthConfig, AuthMode, JWTConfig,
|
||||
resolve_auth_config,
|
||||
resolve_local_token)
|
||||
from mirage.server.auth.middleware import AuthMiddleware
|
||||
from mirage.server.auth.storage import (default_token_file, ensure_token_file,
|
||||
read_token_file)
|
||||
|
||||
__all__ = [
|
||||
"AuthConfig",
|
||||
"AuthMiddleware",
|
||||
"AuthMode",
|
||||
"JWTConfig",
|
||||
"default_token_file",
|
||||
"ensure_token_file",
|
||||
"read_token_file",
|
||||
"resolve_auth_config",
|
||||
"resolve_local_token",
|
||||
]
|
||||
@@ -0,0 +1,208 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from mirage.server.auth import storage as _storage
|
||||
from mirage.server.daemon_config import read_daemon_table
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
ENV_AUTH_MODE = "MIRAGE_AUTH_MODE"
|
||||
ENV_AUTH_TOKEN = "MIRAGE_AUTH_TOKEN"
|
||||
ENV_JWT_PUBKEY = "MIRAGE_JWT_PUBKEY"
|
||||
ENV_JWT_PUBKEY_FILE = "MIRAGE_JWT_PUBKEY_FILE"
|
||||
ENV_JWT_ALG = "MIRAGE_JWT_ALG"
|
||||
ENV_JWT_ISSUER = "MIRAGE_JWT_ISSUER"
|
||||
ENV_JWT_AUDIENCE = "MIRAGE_JWT_AUDIENCE"
|
||||
ENV_JWT_AUTHORIZED_PARTIES = "MIRAGE_JWT_AUTHORIZED_PARTIES"
|
||||
ENV_JWT_CLOCK_SKEW = "MIRAGE_JWT_CLOCK_SKEW_SECONDS"
|
||||
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 5
|
||||
|
||||
|
||||
class AuthMode(StrEnum):
|
||||
LOCAL = "local"
|
||||
TOKEN = "token"
|
||||
JWT = "jwt"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JWTConfig:
|
||||
key: str
|
||||
algorithm: str
|
||||
issuer: str | None = None
|
||||
audience: str | None = None
|
||||
authorized_parties: tuple[str, ...] = field(default_factory=tuple)
|
||||
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
mode: AuthMode
|
||||
local_token: str | None = None
|
||||
bearer_token: str | None = None
|
||||
jwt: JWTConfig | None = None
|
||||
|
||||
|
||||
def resolve_local_token(
|
||||
env: Mapping[str, str] | None = None,
|
||||
token_file: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve the local-mode bearer token via env > file > None.
|
||||
|
||||
Args:
|
||||
env (Mapping[str, str] | None): environment to read
|
||||
``MIRAGE_AUTH_TOKEN`` from. Defaults to ``os.environ``.
|
||||
token_file (Path | None): location of the token file.
|
||||
Defaults to ``default_token_file()``.
|
||||
|
||||
Returns:
|
||||
str | None: resolved token, or ``None`` if no source provides one.
|
||||
"""
|
||||
e = env if env is not None else os.environ
|
||||
val = e.get(ENV_AUTH_TOKEN, "").strip()
|
||||
if val:
|
||||
return val
|
||||
path = (token_file
|
||||
if token_file is not None else _storage.default_token_file())
|
||||
return _storage.read_token_file(path)
|
||||
|
||||
|
||||
def _read_jwt_key(env: Mapping[str, str]) -> str:
|
||||
inline = env.get(ENV_JWT_PUBKEY, "").strip()
|
||||
if inline:
|
||||
return inline
|
||||
path = env.get(ENV_JWT_PUBKEY_FILE, "").strip()
|
||||
if path:
|
||||
return Path(path).read_text()
|
||||
raise RuntimeError(
|
||||
f"mode=jwt requires {ENV_JWT_PUBKEY} or {ENV_JWT_PUBKEY_FILE}")
|
||||
|
||||
|
||||
def _parse_csv(value: str) -> tuple[str, ...]:
|
||||
return tuple(p.strip() for p in value.split(",") if p.strip())
|
||||
|
||||
|
||||
_CONFIG_ENV_KEYS = {
|
||||
"auth_mode": ENV_AUTH_MODE,
|
||||
"jwt_alg": ENV_JWT_ALG,
|
||||
"jwt_issuer": ENV_JWT_ISSUER,
|
||||
"jwt_audience": ENV_JWT_AUDIENCE,
|
||||
"jwt_pubkey_file": ENV_JWT_PUBKEY_FILE,
|
||||
"jwt_clock_skew": ENV_JWT_CLOCK_SKEW,
|
||||
"jwt_authorized_parties": ENV_JWT_AUTHORIZED_PARTIES,
|
||||
}
|
||||
|
||||
|
||||
def _merge_config_table(env: Mapping[str, str],
|
||||
table: Mapping[str, object]) -> dict[str, str]:
|
||||
"""Fold config.toml auth keys under their env names, env winning.
|
||||
|
||||
Only non-secret keys have config counterparts: the raw
|
||||
``MIRAGE_AUTH_TOKEN`` and inline ``MIRAGE_JWT_PUBKEY`` stay
|
||||
env-only (use the token file / ``jwt_pubkey_file`` instead).
|
||||
|
||||
Args:
|
||||
env (Mapping[str, str]): the process environment view.
|
||||
table (Mapping[str, object]): the ``[daemon]`` config table.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: env copy with config fallbacks applied.
|
||||
"""
|
||||
merged = dict(env)
|
||||
for cfg_key, env_name in _CONFIG_ENV_KEYS.items():
|
||||
if merged.get(env_name, "").strip():
|
||||
continue
|
||||
value = table.get(cfg_key)
|
||||
if value is not None and str(value).strip():
|
||||
merged[env_name] = str(value)
|
||||
return merged
|
||||
|
||||
|
||||
def resolve_auth_config(
|
||||
env: Mapping[str, str] | None = None,
|
||||
token_file: Path | None = None,
|
||||
table: Mapping[str, object] | None = None,
|
||||
) -> AuthConfig:
|
||||
"""Resolve daemon auth configuration from environment and config.
|
||||
|
||||
Per key the environment variable wins over the ``[daemon]`` table
|
||||
in ``config.toml``, which wins over the default.
|
||||
|
||||
Args:
|
||||
env (Mapping[str, str] | None): environment to read from.
|
||||
Defaults to ``os.environ``.
|
||||
token_file (Path | None): override the local-mode token file
|
||||
location. Defaults to ``default_token_file()``.
|
||||
table (Mapping[str, object] | None): the ``[daemon]`` config
|
||||
table. Defaults to reading ``$MIRAGE_HOME/config.toml``
|
||||
when ``env`` is also defaulted; an explicit ``env`` with no
|
||||
``table`` stays hermetic and reads no file.
|
||||
|
||||
Returns:
|
||||
AuthConfig: resolved configuration.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if required settings are missing for the chosen mode.
|
||||
"""
|
||||
if table is None:
|
||||
table = read_daemon_table(mirage_home()) if env is None else {}
|
||||
e = _merge_config_table(env if env is not None else os.environ, table)
|
||||
raw_mode = (e.get(ENV_AUTH_MODE, "")
|
||||
or AuthMode.LOCAL.value).strip().lower()
|
||||
try:
|
||||
mode = AuthMode(raw_mode)
|
||||
except ValueError as exc:
|
||||
valid = ", ".join(m.value for m in AuthMode)
|
||||
raise RuntimeError(
|
||||
f"{ENV_AUTH_MODE} must be one of ({valid}), got {raw_mode!r}"
|
||||
) from exc
|
||||
|
||||
if mode == AuthMode.LOCAL:
|
||||
return AuthConfig(
|
||||
mode=mode,
|
||||
local_token=resolve_local_token(env=e, token_file=token_file),
|
||||
)
|
||||
|
||||
if mode == AuthMode.TOKEN:
|
||||
token = e.get(ENV_AUTH_TOKEN, "").strip()
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
f"mode=token requires {ENV_AUTH_TOKEN} to be set")
|
||||
return AuthConfig(mode=mode, bearer_token=token)
|
||||
|
||||
key = _read_jwt_key(e)
|
||||
alg = e.get(ENV_JWT_ALG, "").strip()
|
||||
if not alg:
|
||||
raise RuntimeError(f"mode=jwt requires {ENV_JWT_ALG} (e.g. RS256)")
|
||||
issuer = (e.get(ENV_JWT_ISSUER) or "").strip() or None
|
||||
audience = (e.get(ENV_JWT_AUDIENCE) or "").strip() or None
|
||||
azp = _parse_csv(e.get(ENV_JWT_AUTHORIZED_PARTIES, ""))
|
||||
skew_raw = (e.get(ENV_JWT_CLOCK_SKEW) or "").strip()
|
||||
skew = int(skew_raw) if skew_raw else DEFAULT_CLOCK_SKEW_SECONDS
|
||||
return AuthConfig(
|
||||
mode=mode,
|
||||
jwt=JWTConfig(
|
||||
key=key,
|
||||
algorithm=alg,
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
authorized_parties=azp,
|
||||
clock_skew_seconds=skew,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
from mirage.server.auth.config import JWTConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWTVerificationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def verify_jwt(token: str, cfg: JWTConfig) -> dict[str, Any]:
|
||||
"""Verify a JWT against ``cfg`` and return its claims on success.
|
||||
|
||||
Performs signature verification, algorithm pinning, mandatory
|
||||
``exp`` check, and (when configured) ``iss``/``aud``/``azp``
|
||||
checks. ``typ`` header, if present, must be ``"JWT"``.
|
||||
|
||||
Args:
|
||||
token (str): the raw bearer value (already stripped of any
|
||||
``Bearer `` prefix).
|
||||
cfg (JWTConfig): verification parameters.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: validated claims.
|
||||
|
||||
Raises:
|
||||
JWTVerificationError: any failure (signature, algorithm,
|
||||
``exp``, ``iss``, ``aud``, ``azp``, ``typ``).
|
||||
"""
|
||||
try:
|
||||
claims = pyjwt.decode(
|
||||
token,
|
||||
cfg.key,
|
||||
algorithms=[cfg.algorithm],
|
||||
audience=cfg.audience,
|
||||
issuer=cfg.issuer,
|
||||
options={"require": ["exp"]},
|
||||
leeway=cfg.clock_skew_seconds,
|
||||
)
|
||||
except pyjwt.PyJWTError as e:
|
||||
raise JWTVerificationError(f"JWT rejected: {e}") from e
|
||||
try:
|
||||
header = pyjwt.get_unverified_header(token)
|
||||
except pyjwt.PyJWTError as e:
|
||||
raise JWTVerificationError(f"JWT header unreadable: {e}") from e
|
||||
typ = header.get("typ")
|
||||
if typ is not None and typ != "JWT":
|
||||
raise JWTVerificationError(
|
||||
f"JWT typ header must be 'JWT' when present, got {typ!r}")
|
||||
if cfg.authorized_parties:
|
||||
azp = claims.get("azp")
|
||||
if azp not in cfg.authorized_parties:
|
||||
raise JWTVerificationError(
|
||||
f"JWT azp {azp!r} not in authorized_parties")
|
||||
return claims
|
||||
@@ -0,0 +1,101 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import re
|
||||
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from mirage.server.auth.config import AuthConfig, AuthMode
|
||||
from mirage.server.auth.jwt import JWTVerificationError, verify_jwt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BEARER_PREFIX = "Bearer "
|
||||
HEALTH_PATHS: frozenset[str] = frozenset({"/v1/health"})
|
||||
_JWT_SHAPE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""ASGI middleware enforcing the daemon's auth mode.
|
||||
|
||||
Args:
|
||||
app (ASGIApp): downstream ASGI app.
|
||||
config (AuthConfig): resolved auth configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, config: AuthConfig) -> None:
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive,
|
||||
send: Send) -> None:
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if scope.get("path") in HEALTH_PATHS:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
cfg = self.config
|
||||
if cfg.mode == AuthMode.LOCAL and cfg.local_token is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
token = self._extract_bearer(scope)
|
||||
if token is None:
|
||||
await self._unauthorized(scope, receive, send,
|
||||
"missing bearer token")
|
||||
return
|
||||
|
||||
if self.config.mode == AuthMode.JWT:
|
||||
if not _JWT_SHAPE.match(token):
|
||||
await self._unauthorized(scope, receive, send,
|
||||
"token shape is not a JWT")
|
||||
return
|
||||
try:
|
||||
verify_jwt(token, self.config.jwt)
|
||||
except JWTVerificationError as e:
|
||||
logger.debug("JWT rejected: %s", e)
|
||||
await self._unauthorized(scope, receive, send, str(e))
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
expected = (self.config.local_token if self.config.mode
|
||||
== AuthMode.LOCAL else self.config.bearer_token)
|
||||
if expected is None or not hmac.compare_digest(token, expected):
|
||||
await self._unauthorized(scope, receive, send, "bearer mismatch")
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
@staticmethod
|
||||
def _extract_bearer(scope: Scope) -> str | None:
|
||||
headers = dict(scope.get("headers") or [])
|
||||
raw = headers.get(b"authorization", b"").decode("latin-1")
|
||||
if not raw.startswith(BEARER_PREFIX):
|
||||
return None
|
||||
value = raw[len(BEARER_PREFIX):].strip()
|
||||
return value or None
|
||||
|
||||
async def _unauthorized(self, scope: Scope, receive: Receive, send: Send,
|
||||
reason: str) -> None:
|
||||
client = scope.get("client") or ("?", 0)
|
||||
logger.warning("rejecting request from %s:%s: %s", client[0],
|
||||
client[1], reason)
|
||||
response = PlainTextResponse("Unauthorized",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"})
|
||||
await response(scope, receive, send)
|
||||
@@ -0,0 +1,57 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
|
||||
def default_token_file() -> Path:
|
||||
"""Default auth token location, under :func:`mirage_home`."""
|
||||
return mirage_home() / "auth_token"
|
||||
|
||||
|
||||
def read_token_file(path: Path) -> str | None:
|
||||
"""Read the token file if present, returning None when missing.
|
||||
|
||||
Args:
|
||||
path (Path): location of the token file.
|
||||
|
||||
Returns:
|
||||
str | None: stripped file contents, or None if the file does not exist.
|
||||
"""
|
||||
if not path.exists():
|
||||
return None
|
||||
return path.read_text().strip()
|
||||
|
||||
|
||||
def ensure_token_file(path: Path) -> str:
|
||||
"""Read the token file if present, else mint and persist a fresh one.
|
||||
|
||||
Args:
|
||||
path (Path): location of the token file.
|
||||
|
||||
Returns:
|
||||
str: the resolved bearer token.
|
||||
"""
|
||||
existing = read_token_file(path)
|
||||
if existing is not None:
|
||||
return existing
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token = secrets.token_urlsafe(32)
|
||||
path.write_text(token)
|
||||
os.chmod(path, 0o600)
|
||||
return token
|
||||
@@ -0,0 +1,88 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mirage import Workspace
|
||||
from mirage.resource.history import HISTORY_PREFIX
|
||||
from mirage.resource.registry import build_resource
|
||||
from mirage.workspace.snapshot import requires_resource_override, to_state_dict
|
||||
from mirage.workspace.snapshot.utils import norm_mount_prefix
|
||||
|
||||
|
||||
def _build_override_resources(override: dict[str, Any] | None) -> dict:
|
||||
if not override:
|
||||
return {}
|
||||
if "mounts" not in override:
|
||||
return {}
|
||||
out: dict = {}
|
||||
for prefix, block in override["mounts"].items():
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
resource_name = block.get("resource")
|
||||
config = block.get("config") or {}
|
||||
if resource_name is None:
|
||||
continue
|
||||
out[norm_mount_prefix(prefix)] = build_resource(resource_name, config)
|
||||
return out
|
||||
|
||||
|
||||
def _existing_redacted_resources(ws: Workspace, state: dict,
|
||||
skip: set[str]) -> dict:
|
||||
auto_prefixes = {"/dev/", norm_mount_prefix(HISTORY_PREFIX)}
|
||||
prefix_to_resource = {
|
||||
m.prefix: m.resource
|
||||
for m in ws._registry.mounts() if m.prefix not in auto_prefixes
|
||||
}
|
||||
out: dict = {}
|
||||
for m in state["mounts"]:
|
||||
prefix = m["prefix"]
|
||||
if norm_mount_prefix(prefix) in skip:
|
||||
continue
|
||||
if requires_resource_override(m) and prefix in prefix_to_resource:
|
||||
out[prefix] = prefix_to_resource[prefix]
|
||||
return out
|
||||
|
||||
|
||||
async def clone_workspace_with_override(src_ws: Workspace,
|
||||
override: dict[str, Any]
|
||||
| None) -> Workspace:
|
||||
"""Snapshot ``src_ws`` and rebuild a fresh workspace from state.
|
||||
|
||||
Behavior:
|
||||
* Local resources (RAM, Disk) are reconstructed fresh, so the
|
||||
clone's writes never touch the original's data.
|
||||
* Remote resources (S3, Redis, GDrive, ...) that redact secrets
|
||||
or connection material are reused from the original by default
|
||||
-- they share connection pools and bucket data.
|
||||
* If ``override`` supplies a fresh resource for a prefix, that
|
||||
resource replaces the reused one -- e.g. point the clone at
|
||||
a different S3 bucket.
|
||||
|
||||
Args:
|
||||
src_ws (Workspace): the source workspace.
|
||||
override (dict[str, Any] | None): partial workspace config
|
||||
with ``mounts: {<prefix>: {resource, config}}`` entries to
|
||||
swap.
|
||||
|
||||
Returns:
|
||||
Workspace: a new, independent workspace.
|
||||
"""
|
||||
state = await to_state_dict(src_ws)
|
||||
override_resources = _build_override_resources(override)
|
||||
existing = _existing_redacted_resources(src_ws,
|
||||
state,
|
||||
skip=set(override_resources))
|
||||
merged = {**existing, **override_resources}
|
||||
return await Workspace._from_state(state, resources=merged)
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
|
||||
from mirage.server.app import build_app
|
||||
from mirage.server.env import ENV_IDLE_GRACE_SECONDS
|
||||
|
||||
_idle_grace = float(os.environ.get(ENV_IDLE_GRACE_SECONDS, "30"))
|
||||
|
||||
app = build_app(idle_grace_seconds=_idle_grace)
|
||||
@@ -0,0 +1,90 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED_KEYS = frozenset({
|
||||
"url",
|
||||
"socket",
|
||||
"auth_token",
|
||||
"auth_mode",
|
||||
"allowed_hosts",
|
||||
"jwt_alg",
|
||||
"jwt_issuer",
|
||||
"jwt_audience",
|
||||
"jwt_pubkey_file",
|
||||
"jwt_clock_skew",
|
||||
"jwt_authorized_parties",
|
||||
"idle_grace_seconds",
|
||||
"port",
|
||||
"pid_file",
|
||||
"version_root",
|
||||
"snapshot_root",
|
||||
})
|
||||
NUMERIC_KEYS = frozenset({"idle_grace_seconds", "jwt_clock_skew", "port"})
|
||||
|
||||
|
||||
class DaemonConfigError(Exception):
|
||||
"""Raised when config.toml's ``[daemon]`` table is unusable."""
|
||||
|
||||
|
||||
def validate_daemon_table(table: dict) -> None:
|
||||
"""Reject unknown keys or wrong-typed values in a ``[daemon]`` table.
|
||||
|
||||
Args:
|
||||
table (dict): parsed ``[daemon]`` table.
|
||||
|
||||
Raises:
|
||||
DaemonConfigError: naming every offending key.
|
||||
"""
|
||||
unknown = sorted(set(table) - ALLOWED_KEYS)
|
||||
if unknown:
|
||||
raise DaemonConfigError(
|
||||
"config.toml: the following [daemon] keys don't match any "
|
||||
f"configuration option: {', '.join(unknown)}")
|
||||
bad_types = sorted(
|
||||
k for k, v in table.items()
|
||||
if (k in NUMERIC_KEYS and not isinstance(v, (int, float))) or (
|
||||
k not in NUMERIC_KEYS and not isinstance(v, str)))
|
||||
if bad_types:
|
||||
raise DaemonConfigError(
|
||||
"config.toml: the following [daemon] keys have the wrong "
|
||||
f"type: {', '.join(bad_types)}")
|
||||
|
||||
|
||||
def read_daemon_table(home: Path) -> dict:
|
||||
"""Read the ``[daemon]`` table from ``home/config.toml``.
|
||||
|
||||
Args:
|
||||
home (Path): the ``.mirage`` base directory. The config file is
|
||||
``home/config.toml``.
|
||||
|
||||
Returns:
|
||||
dict: the ``[daemon]`` table, or ``{}`` if the file or table is
|
||||
absent.
|
||||
|
||||
Raises:
|
||||
DaemonConfigError: the file exists but is not valid TOML.
|
||||
"""
|
||||
path = home / "config.toml"
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
raise DaemonConfigError(f"malformed {path}: {e}") from e
|
||||
table = data.get("daemon", {})
|
||||
return table if isinstance(table, dict) else {}
|
||||
@@ -0,0 +1,21 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
ENV_ALLOWED_HOSTS = "MIRAGE_ALLOWED_HOSTS"
|
||||
ENV_IDLE_GRACE_SECONDS = "MIRAGE_IDLE_GRACE_SECONDS"
|
||||
ENV_DAEMON_PORT = "MIRAGE_DAEMON_PORT"
|
||||
ENV_VERSION_ROOT = "MIRAGE_VERSION_ROOT"
|
||||
ENV_SNAPSHOT_ROOT = "MIRAGE_SNAPSHOT_ROOT"
|
||||
ENV_HOME = "MIRAGE_HOME"
|
||||
ENV_PID_FILE = "MIRAGE_PID_FILE"
|
||||
@@ -0,0 +1,141 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from mirage.server.daemon_config import read_daemon_table
|
||||
from mirage.server.env import ENV_ALLOWED_HOSTS
|
||||
from mirage.server.host_validation_constants import (DEFAULT_ALLOWED_HOSTS,
|
||||
HOST_PATTERN)
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_allowed_hosts(value: str | None) -> list[str]:
|
||||
"""Parse a CSV ``MIRAGE_ALLOWED_HOSTS`` value into a host list.
|
||||
|
||||
Empty / missing values fall back to ``DEFAULT_ALLOWED_HOSTS``.
|
||||
|
||||
Args:
|
||||
value (str | None): raw env var value.
|
||||
|
||||
Returns:
|
||||
list[str]: parsed host list.
|
||||
"""
|
||||
if value is None:
|
||||
return list(DEFAULT_ALLOWED_HOSTS)
|
||||
items = [h.strip() for h in value.split(",") if h.strip()]
|
||||
return items or list(DEFAULT_ALLOWED_HOSTS)
|
||||
|
||||
|
||||
def resolve_allowed_hosts(
|
||||
allowed_hosts: Iterable[str] | None = None) -> list[str]:
|
||||
"""Resolve allowed hosts from explicit arg or env var.
|
||||
|
||||
Args:
|
||||
allowed_hosts (Iterable[str] | None): explicit list. If
|
||||
``None``, falls back to ``$MIRAGE_ALLOWED_HOSTS`` env var,
|
||||
then the ``allowed_hosts`` key in ``config.toml``
|
||||
``[daemon]`` (comma-separated), then
|
||||
``DEFAULT_ALLOWED_HOSTS``.
|
||||
|
||||
Returns:
|
||||
list[str]: resolved host list.
|
||||
"""
|
||||
if allowed_hosts is not None:
|
||||
return list(allowed_hosts)
|
||||
raw = os.environ.get(ENV_ALLOWED_HOSTS)
|
||||
if not raw:
|
||||
from_config = read_daemon_table(mirage_home()).get("allowed_hosts")
|
||||
raw = str(from_config) if from_config else None
|
||||
return parse_allowed_hosts(raw)
|
||||
|
||||
|
||||
def strip_port(raw_host: str) -> str:
|
||||
"""Strip the port (and IPv6 brackets) from a Host header value.
|
||||
|
||||
Returns the bare host only when the value matches one of
|
||||
``host`` / ``host:digits`` / ``[host]`` / ``[host]:digits``; any
|
||||
other (malformed) input is returned unchanged so the allowlist
|
||||
comparison fails closed.
|
||||
|
||||
Args:
|
||||
raw_host (str): raw Host header value.
|
||||
|
||||
Returns:
|
||||
str: bare host, or the raw input when malformed.
|
||||
"""
|
||||
match = HOST_PATTERN.match(raw_host)
|
||||
if match is None:
|
||||
return raw_host
|
||||
return match.group(1) or match.group(2) or raw_host
|
||||
|
||||
|
||||
def is_host_allowed(raw_host: str | None, allowed: list[str]) -> bool:
|
||||
"""Decide whether a Host header value is in the allowlist.
|
||||
|
||||
Args:
|
||||
raw_host (str | None): raw Host header value.
|
||||
allowed (list[str]): allowlist; ``"*"`` accepts any host.
|
||||
|
||||
Returns:
|
||||
bool: True when the port-stripped host is allowed.
|
||||
"""
|
||||
if "*" in allowed:
|
||||
return True
|
||||
if not raw_host:
|
||||
return False
|
||||
return strip_port(raw_host) in allowed
|
||||
|
||||
|
||||
class HostHeaderMiddleware:
|
||||
"""ASGI middleware that 400s requests with disallowed Host headers.
|
||||
|
||||
Replaces Starlette's TrustedHostMiddleware so we can log on
|
||||
rejection. Port is stripped before comparison (parity with
|
||||
Starlette).
|
||||
|
||||
Args:
|
||||
app (ASGIApp): downstream ASGI app.
|
||||
allowed_hosts (list[str]): exact hosts to accept. ``"*"`` in
|
||||
the list disables enforcement.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, allowed_hosts: list[str]) -> None:
|
||||
self.app = app
|
||||
self.allowed_hosts = allowed_hosts
|
||||
self.allow_any = "*" in allowed_hosts
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive,
|
||||
send: Send) -> None:
|
||||
if scope["type"] not in ("http", "websocket") or self.allow_any:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
headers = dict(scope.get("headers") or [])
|
||||
raw_host = headers.get(b"host", b"").decode("latin-1")
|
||||
if is_host_allowed(raw_host, self.allowed_hosts):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
client = scope.get("client") or ("?", 0)
|
||||
logger.warning(
|
||||
"rejecting request from %s:%s: Host=%r not in allowlist %s",
|
||||
client[0], client[1], raw_host, self.allowed_hosts)
|
||||
response = PlainTextResponse("Invalid host header", status_code=400)
|
||||
await response(scope, receive, send)
|
||||
@@ -0,0 +1,19 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import re
|
||||
|
||||
DEFAULT_ALLOWED_HOSTS: tuple[str, ...] = ("127.0.0.1", "localhost", "::1")
|
||||
|
||||
HOST_PATTERN = re.compile(r"^(?:\[([^\]]+)\]|([^:]+))(?::\d+)?$")
|
||||
@@ -0,0 +1,44 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mirage.io.types import IOResult
|
||||
|
||||
|
||||
async def io_result_to_dict(result: IOResult | object) -> dict[str, Any]:
|
||||
"""Materialize an IOResult into a JSON-friendly dict.
|
||||
|
||||
Falls back to ``result.model_dump()`` for ``ProvisionResult`` (which
|
||||
uses pydantic), so the same helper handles both ``mirage provision``
|
||||
and normal execute outputs.
|
||||
|
||||
Args:
|
||||
result (IOResult | object): the workspace.execute return value.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: serializable response payload.
|
||||
"""
|
||||
if isinstance(result, IOResult):
|
||||
stdout = await result.materialize_stdout()
|
||||
stderr = await result.materialize_stderr()
|
||||
return {
|
||||
"kind": "io",
|
||||
"exit_code": result.exit_code,
|
||||
"stdout": stdout.decode(errors="replace"),
|
||||
"stderr": stderr.decode(errors="replace"),
|
||||
}
|
||||
if hasattr(result, "model_dump"):
|
||||
return {"kind": "provision", **result.model_dump()}
|
||||
return {"kind": "raw", "value": str(result)}
|
||||
@@ -0,0 +1,156 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
def new_job_id() -> str:
|
||||
"""Mint a fresh job id of the form ``job_<16 hex chars>``."""
|
||||
return f"job_{secrets.token_hex(8)}"
|
||||
|
||||
|
||||
class JobEntry:
|
||||
"""One in-flight or completed daemon-level execute job."""
|
||||
|
||||
def __init__(self, job_id: str, workspace_id: str, command: str) -> None:
|
||||
self.id = job_id
|
||||
self.workspace_id = workspace_id
|
||||
self.command = command
|
||||
self.status: JobStatus = JobStatus.PENDING
|
||||
self.result: Any = None
|
||||
self.error: str | None = None
|
||||
self.submitted_at: float = time.time()
|
||||
self.started_at: float | None = None
|
||||
self.finished_at: float | None = None
|
||||
self._future: concurrent.futures.Future | None = None
|
||||
self._done_event: asyncio.Event = asyncio.Event()
|
||||
|
||||
|
||||
class JobTable:
|
||||
"""Daemon-wide table of execute jobs.
|
||||
|
||||
Tracks both sync and background jobs so the CLI can query
|
||||
progress, wait, and cancel uniformly. Sync calls register the job
|
||||
in pending/running and complete it before returning.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._jobs: dict[str, JobEntry] = {}
|
||||
|
||||
def __contains__(self, job_id: str) -> bool:
|
||||
return job_id in self._jobs
|
||||
|
||||
def get(self, job_id: str) -> JobEntry:
|
||||
if job_id not in self._jobs:
|
||||
raise KeyError(job_id)
|
||||
return self._jobs[job_id]
|
||||
|
||||
def list(self, workspace_id: str | None = None) -> list[JobEntry]:
|
||||
if workspace_id is None:
|
||||
return list(self._jobs.values())
|
||||
return [
|
||||
j for j in self._jobs.values() if j.workspace_id == workspace_id
|
||||
]
|
||||
|
||||
def submit(self, workspace_id: str, command: str,
|
||||
schedule: Callable[[Awaitable], concurrent.futures.Future],
|
||||
coro_factory: Callable[[], Awaitable]) -> JobEntry:
|
||||
"""Register a job and start running it on the workspace loop.
|
||||
|
||||
Args:
|
||||
workspace_id (str): workspace this job belongs to.
|
||||
command (str): user-visible command string for display.
|
||||
schedule (Callable): function that takes a coroutine and
|
||||
returns a ``concurrent.futures.Future`` representing
|
||||
its execution on the workspace loop. Typically
|
||||
``functools.partial(asyncio.run_coroutine_threadsafe,
|
||||
loop=runner.loop)`` -- but the runner's
|
||||
``call``-equivalent is fine too.
|
||||
coro_factory (Callable): zero-arg callable that builds the
|
||||
coroutine to schedule. Called once.
|
||||
|
||||
Returns:
|
||||
JobEntry: the registered entry. Inspect ``entry._future``
|
||||
to await or cancel.
|
||||
"""
|
||||
job_id = new_job_id()
|
||||
entry = JobEntry(job_id, workspace_id, command)
|
||||
self._jobs[job_id] = entry
|
||||
coro = coro_factory()
|
||||
fut = schedule(coro)
|
||||
entry._future = fut
|
||||
entry.status = JobStatus.RUNNING
|
||||
entry.started_at = time.time()
|
||||
loop = asyncio.get_running_loop()
|
||||
callback = functools.partial(self._dispatch_done, entry, loop)
|
||||
fut.add_done_callback(callback)
|
||||
return entry
|
||||
|
||||
def _dispatch_done(self, entry: JobEntry, loop: asyncio.AbstractEventLoop,
|
||||
fut: concurrent.futures.Future) -> None:
|
||||
loop.call_soon_threadsafe(self._on_done, entry, fut)
|
||||
|
||||
def _on_done(self, entry: JobEntry,
|
||||
fut: concurrent.futures.Future) -> None:
|
||||
entry.finished_at = time.time()
|
||||
if fut.cancelled():
|
||||
entry.status = JobStatus.CANCELED
|
||||
else:
|
||||
exc = fut.exception()
|
||||
if exc is not None:
|
||||
entry.status = JobStatus.FAILED
|
||||
entry.error = f"{type(exc).__name__}: {exc}"
|
||||
else:
|
||||
entry.status = JobStatus.DONE
|
||||
entry.result = fut.result()
|
||||
entry._done_event.set()
|
||||
|
||||
async def wait(self,
|
||||
job_id: str,
|
||||
timeout: float | None = None) -> JobEntry:
|
||||
entry = self.get(job_id)
|
||||
if entry.status in (JobStatus.DONE, JobStatus.FAILED,
|
||||
JobStatus.CANCELED):
|
||||
return entry
|
||||
try:
|
||||
await asyncio.wait_for(entry._done_event.wait(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return entry
|
||||
return entry
|
||||
|
||||
def cancel(self, job_id: str) -> bool:
|
||||
entry = self.get(job_id)
|
||||
if entry.status in (JobStatus.DONE, JobStatus.FAILED,
|
||||
JobStatus.CANCELED):
|
||||
return False
|
||||
if entry._future is None:
|
||||
return False
|
||||
return entry._future.cancel()
|
||||
@@ -0,0 +1,155 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from mirage.server.daemon_config import read_daemon_table
|
||||
from mirage.server.env import (ENV_HOME, ENV_PID_FILE, ENV_SNAPSHOT_ROOT,
|
||||
ENV_VERSION_ROOT)
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _absolute(value: str | Path) -> Path:
|
||||
return Path(os.path.abspath(value))
|
||||
|
||||
|
||||
def mirage_home() -> Path:
|
||||
"""Resolve the base directory backing the ``.mirage`` data tree.
|
||||
|
||||
Priority: ``$MIRAGE_HOME`` if set, else ``~/.mirage``. A relative
|
||||
override is absolutized against the current working directory so
|
||||
the daemon and later CLI invocations agree on one location.
|
||||
|
||||
Returns:
|
||||
Path: absolute base directory for the pid file, log file,
|
||||
auth token, config, repos, and snapshots.
|
||||
"""
|
||||
override = os.environ.get(ENV_HOME)
|
||||
return _absolute(override) if override else Path.home() / ".mirage"
|
||||
|
||||
|
||||
def pid_file_path(explicit: str | Path | None = None) -> Path:
|
||||
"""Resolve the daemon pid file location.
|
||||
|
||||
Priority: ``explicit`` argument, then ``$MIRAGE_PID_FILE``, then the
|
||||
``pid_file`` key in ``config.toml`` ``[daemon]``, then ``daemon.pid``
|
||||
under :func:`mirage_home`.
|
||||
|
||||
Args:
|
||||
explicit (str | Path | None): caller-supplied override.
|
||||
|
||||
Returns:
|
||||
Path: the resolved absolute pid file path.
|
||||
"""
|
||||
if explicit is not None:
|
||||
return _absolute(explicit)
|
||||
override = os.environ.get(ENV_PID_FILE)
|
||||
if override:
|
||||
return _absolute(override)
|
||||
home = mirage_home()
|
||||
from_config = read_daemon_table(home).get("pid_file")
|
||||
if from_config:
|
||||
return _absolute(from_config)
|
||||
return home / "daemon.pid"
|
||||
|
||||
|
||||
def version_root_path(explicit: str | Path | None = None) -> Path:
|
||||
"""Resolve the git repos root.
|
||||
|
||||
Priority: ``explicit`` argument, then ``$MIRAGE_VERSION_ROOT``, then the
|
||||
``version_root`` key in ``config.toml`` ``[daemon]``, then ``repos``
|
||||
under :func:`mirage_home`.
|
||||
|
||||
Args:
|
||||
explicit (str | Path | None): caller-supplied override.
|
||||
|
||||
Returns:
|
||||
Path: the resolved absolute repos root.
|
||||
"""
|
||||
if explicit is not None:
|
||||
return _absolute(explicit)
|
||||
override = os.environ.get(ENV_VERSION_ROOT)
|
||||
if override:
|
||||
return _absolute(override)
|
||||
home = mirage_home()
|
||||
from_config = read_daemon_table(home).get("version_root")
|
||||
if from_config:
|
||||
return _absolute(from_config)
|
||||
return home / "repos"
|
||||
|
||||
|
||||
def snapshot_root_path(explicit: str | Path | None = None) -> Path:
|
||||
"""Resolve the snapshot root.
|
||||
|
||||
Priority: ``explicit`` argument, then ``$MIRAGE_SNAPSHOT_ROOT``, then the
|
||||
``snapshot_root`` key in ``config.toml`` ``[daemon]``, then ``snapshots``
|
||||
under :func:`mirage_home`.
|
||||
|
||||
Args:
|
||||
explicit (str | Path | None): caller-supplied override.
|
||||
|
||||
Returns:
|
||||
Path: the resolved absolute snapshot root.
|
||||
"""
|
||||
if explicit is not None:
|
||||
return _absolute(explicit)
|
||||
override = os.environ.get(ENV_SNAPSHOT_ROOT)
|
||||
if override:
|
||||
return _absolute(override)
|
||||
home = mirage_home()
|
||||
from_config = read_daemon_table(home).get("snapshot_root")
|
||||
if from_config:
|
||||
return _absolute(from_config)
|
||||
return home / "snapshots"
|
||||
|
||||
|
||||
class PathOutsideRootError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_within_root(root: str | Path, user_path: str) -> Path:
|
||||
"""Resolve user_path against root and assert it stays within root.
|
||||
|
||||
Args:
|
||||
root (str | Path): the trusted directory paths are confined to.
|
||||
user_path (str): a request-supplied path, relative or absolute.
|
||||
|
||||
Returns:
|
||||
Path: the resolved absolute path, guaranteed to be root itself
|
||||
or a descendant of root.
|
||||
"""
|
||||
resolved_root = os.path.realpath(str(root))
|
||||
resolved = os.path.realpath(os.path.join(resolved_root, user_path))
|
||||
if os.path.commonpath([resolved_root, resolved]) != resolved_root:
|
||||
raise PathOutsideRootError(
|
||||
f"path escapes the configured root: {user_path}")
|
||||
return Path(resolved)
|
||||
|
||||
|
||||
def validate_path_segment(segment: str) -> str:
|
||||
"""Assert segment is a single safe path component.
|
||||
|
||||
Args:
|
||||
segment (str): a request-supplied identifier used as one path
|
||||
component (no separators, not . or ..).
|
||||
|
||||
Returns:
|
||||
str: the validated segment.
|
||||
"""
|
||||
if segment in (".", "..") or _SAFE_SEGMENT_RE.match(segment) is None:
|
||||
raise PathOutsideRootError(f"invalid path segment: {segment}")
|
||||
return segment
|
||||
@@ -0,0 +1,164 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
from mirage import Workspace, WorkspaceRunner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def new_workspace_id() -> str:
|
||||
"""Mint a fresh workspace id of the form ``ws_<16 hex chars>``.
|
||||
|
||||
Returns:
|
||||
str: opaque, URL-safe, collision-resistant id.
|
||||
"""
|
||||
return f"ws_{secrets.token_hex(8)}"
|
||||
|
||||
|
||||
class WorkspaceEntry:
|
||||
|
||||
def __init__(self, workspace_id: str, runner: WorkspaceRunner) -> None:
|
||||
self.id = workspace_id
|
||||
self.runner = runner
|
||||
self.created_at = time.time()
|
||||
|
||||
|
||||
class WorkspaceRegistry:
|
||||
"""In-memory map of workspace_id -> WorkspaceRunner.
|
||||
|
||||
Owns the lifecycle for each workspace inside the daemon process:
|
||||
register on create, drop on delete, and trip an idle-shutdown
|
||||
event when the registry empties for ``idle_grace_seconds``.
|
||||
|
||||
Threading: the underlying ``dict`` is mutated only from the FastAPI
|
||||
server loop (the same loop the registry is constructed on), so no
|
||||
external lock is required.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
idle_grace_seconds: float = 30.0,
|
||||
exit_event: asyncio.Event | None = None) -> None:
|
||||
"""Construct an empty registry.
|
||||
|
||||
Args:
|
||||
idle_grace_seconds (float): seconds to wait after the last
|
||||
workspace is removed before signalling exit. ``0``
|
||||
means exit immediately on empty.
|
||||
exit_event (asyncio.Event | None): event to set when the
|
||||
idle timer fires. Defaults to a fresh event.
|
||||
"""
|
||||
self._entries: dict[str, WorkspaceEntry] = {}
|
||||
self.idle_grace_seconds = idle_grace_seconds
|
||||
self.exit_event = exit_event or asyncio.Event()
|
||||
self._idle_task: asyncio.Task | None = None
|
||||
|
||||
def __contains__(self, workspace_id: str) -> bool:
|
||||
return workspace_id in self._entries
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def get(self, workspace_id: str) -> WorkspaceEntry:
|
||||
if workspace_id not in self._entries:
|
||||
raise KeyError(workspace_id)
|
||||
return self._entries[workspace_id]
|
||||
|
||||
def list(self) -> list[WorkspaceEntry]:
|
||||
return list(self._entries.values())
|
||||
|
||||
def items(self) -> Iterable[tuple[str, WorkspaceEntry]]:
|
||||
return self._entries.items()
|
||||
|
||||
def add(self,
|
||||
workspace: Workspace,
|
||||
workspace_id: str | None = None) -> WorkspaceEntry:
|
||||
"""Wrap ``workspace`` in a runner and register it.
|
||||
|
||||
Args:
|
||||
workspace (Workspace): freshly-constructed workspace.
|
||||
workspace_id (str | None): explicit id, or None to auto-mint.
|
||||
|
||||
Returns:
|
||||
WorkspaceEntry: the registered entry.
|
||||
|
||||
Raises:
|
||||
ValueError: ``workspace_id`` is already registered.
|
||||
"""
|
||||
wid = workspace_id or new_workspace_id()
|
||||
if wid in self._entries:
|
||||
raise ValueError(f"workspace id already exists: {wid!r}")
|
||||
runner = WorkspaceRunner(workspace)
|
||||
entry = WorkspaceEntry(wid, runner)
|
||||
self._entries[wid] = entry
|
||||
self._cancel_idle_timer()
|
||||
return entry
|
||||
|
||||
async def remove(self, workspace_id: str) -> WorkspaceEntry:
|
||||
"""Stop the runner for ``workspace_id`` and drop it.
|
||||
|
||||
Args:
|
||||
workspace_id (str): id to remove.
|
||||
|
||||
Returns:
|
||||
WorkspaceEntry: the removed entry (after its runner is
|
||||
stopped).
|
||||
|
||||
Raises:
|
||||
KeyError: ``workspace_id`` is not registered.
|
||||
"""
|
||||
if workspace_id not in self._entries:
|
||||
raise KeyError(workspace_id)
|
||||
entry = self._entries.pop(workspace_id)
|
||||
await entry.runner.stop()
|
||||
if not self._entries:
|
||||
self._start_idle_timer()
|
||||
return entry
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""Stop every runner. Used at daemon shutdown."""
|
||||
self._cancel_idle_timer()
|
||||
ids = list(self._entries)
|
||||
for wid in ids:
|
||||
entry = self._entries.pop(wid)
|
||||
try:
|
||||
await entry.runner.stop()
|
||||
except Exception:
|
||||
logger.exception("error stopping runner for %s", wid)
|
||||
|
||||
def _start_idle_timer(self) -> None:
|
||||
if self.idle_grace_seconds <= 0:
|
||||
self.exit_event.set()
|
||||
return
|
||||
if self._idle_task is not None and not self._idle_task.done():
|
||||
return
|
||||
self._idle_task = asyncio.create_task(self._idle_wait())
|
||||
|
||||
def _cancel_idle_timer(self) -> None:
|
||||
if self._idle_task is not None and not self._idle_task.done():
|
||||
self._idle_task.cancel()
|
||||
self._idle_task = None
|
||||
|
||||
async def _idle_wait(self) -> None:
|
||||
try:
|
||||
await asyncio.sleep(self.idle_grace_seconds)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not self._entries:
|
||||
self.exit_event.set()
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,163 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mirage.server.io_serde import io_result_to_dict
|
||||
from mirage.server.jobs import JobEntry, JobStatus
|
||||
|
||||
router = APIRouter(prefix="/v1/workspaces/{workspace_id}/execute")
|
||||
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
command: str
|
||||
session_id: str | None = None
|
||||
provision: bool = False
|
||||
agent_id: str | None = None
|
||||
|
||||
|
||||
class BackgroundResponse(BaseModel):
|
||||
job_id: str
|
||||
workspace_id: str
|
||||
submitted_at: float
|
||||
|
||||
|
||||
def _require_entry(request: Request, workspace_id: str):
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
return registry.get(workspace_id)
|
||||
|
||||
|
||||
def _build_execute_kwargs(req: ExecuteRequest, stdin: bytes | None) -> dict:
|
||||
kwargs: dict[str, Any] = {
|
||||
"command": req.command,
|
||||
"provision": req.provision,
|
||||
}
|
||||
if req.session_id is not None:
|
||||
kwargs["session_id"] = req.session_id
|
||||
if req.agent_id is not None:
|
||||
kwargs["agent_id"] = req.agent_id
|
||||
if stdin is not None:
|
||||
kwargs["stdin"] = stdin
|
||||
return kwargs
|
||||
|
||||
|
||||
def _make_coro_factory(runner, kwargs: dict):
|
||||
return functools.partial(_invoke_execute, runner, kwargs)
|
||||
|
||||
|
||||
async def _invoke_execute(runner, kwargs: dict):
|
||||
return await runner.ws.execute(**kwargs)
|
||||
|
||||
|
||||
def _schedule_on_runner(runner, coro):
|
||||
return asyncio.run_coroutine_threadsafe(coro, runner.loop)
|
||||
|
||||
|
||||
def _job_to_dict(entry: JobEntry,
|
||||
result_dict: dict | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": entry.id,
|
||||
"workspace_id": entry.workspace_id,
|
||||
"command": entry.command,
|
||||
"status": entry.status.value,
|
||||
"submitted_at": entry.submitted_at,
|
||||
"started_at": entry.started_at,
|
||||
"finished_at": entry.finished_at,
|
||||
"result": result_dict,
|
||||
"error": entry.error,
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def execute(
|
||||
workspace_id: str,
|
||||
request: Request,
|
||||
background: bool = Query(False),
|
||||
) -> Response:
|
||||
entry = _require_entry(request, workspace_id)
|
||||
job_table = request.app.state.jobs
|
||||
content_type = request.headers.get("content-type", "")
|
||||
req_obj, stdin_bytes = await _parse_execute_body(request, content_type)
|
||||
schedule = functools.partial(_schedule_on_runner, entry.runner)
|
||||
job = job_table.submit(
|
||||
workspace_id=workspace_id,
|
||||
command=req_obj.command,
|
||||
schedule=schedule,
|
||||
coro_factory=_make_coro_factory(
|
||||
entry.runner,
|
||||
_build_execute_kwargs(req_obj, stdin_bytes),
|
||||
),
|
||||
)
|
||||
if background:
|
||||
return Response(
|
||||
content=BackgroundResponse(
|
||||
job_id=job.id,
|
||||
workspace_id=workspace_id,
|
||||
submitted_at=job.submitted_at,
|
||||
).model_dump_json(),
|
||||
media_type="application/json",
|
||||
status_code=202,
|
||||
headers={"X-Mirage-Job-Id": job.id},
|
||||
)
|
||||
await job_table.wait(job.id)
|
||||
if job.status == JobStatus.CANCELED:
|
||||
raise HTTPException(status_code=499, detail="job canceled")
|
||||
if job.status == JobStatus.FAILED:
|
||||
raise HTTPException(status_code=500,
|
||||
detail=job.error or "execute failed")
|
||||
result_dict = await io_result_to_dict(job.result)
|
||||
return Response(
|
||||
content=json.dumps(result_dict),
|
||||
media_type="application/json",
|
||||
status_code=200,
|
||||
headers={"X-Mirage-Job-Id": job.id},
|
||||
)
|
||||
|
||||
|
||||
async def _parse_execute_body(
|
||||
request: Request,
|
||||
content_type: str) -> tuple[ExecuteRequest, bytes | None]:
|
||||
if content_type.startswith("multipart/"):
|
||||
form = await request.form()
|
||||
request_part = form.get("request")
|
||||
if request_part is None:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="multipart body missing 'request' part")
|
||||
if hasattr(request_part, "read"):
|
||||
req_text = (await request_part.read()).decode("utf-8")
|
||||
else:
|
||||
req_text = str(request_part)
|
||||
try:
|
||||
req_obj = ExecuteRequest.model_validate(json.loads(req_text))
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"bad request part: {e}")
|
||||
stdin_part = form.get("stdin")
|
||||
stdin_bytes: bytes | None = None
|
||||
if stdin_part is not None:
|
||||
if hasattr(stdin_part, "read"):
|
||||
stdin_bytes = await stdin_part.read()
|
||||
else:
|
||||
stdin_bytes = str(stdin_part).encode("utf-8")
|
||||
return req_obj, stdin_bytes
|
||||
body = await request.json()
|
||||
return ExecuteRequest.model_validate(body), None
|
||||
@@ -0,0 +1,50 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mirage.server.schemas import HealthResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ShutdownResponse(BaseModel):
|
||||
status: str
|
||||
pid: int
|
||||
|
||||
|
||||
@router.get("/v1/health", response_model=HealthResponse)
|
||||
async def health(request: Request) -> HealthResponse:
|
||||
started_at = request.app.state.started_at
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
workspaces=len(request.app.state.registry),
|
||||
uptime_s=round(time.time() - started_at, 3),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/v1/shutdown", response_model=ShutdownResponse)
|
||||
async def shutdown(request: Request) -> ShutdownResponse:
|
||||
"""Trip the exit event so the daemon shuts down gracefully.
|
||||
|
||||
The ``_watch_exit`` background task in the lifespan picks this up
|
||||
and sends SIGTERM to the process. uvicorn handles the rest of the
|
||||
shutdown sequence (close connections, run lifespan finally block).
|
||||
"""
|
||||
request.app.state.exit_event.set()
|
||||
return ShutdownResponse(status="shutting_down", pid=os.getpid())
|
||||
@@ -0,0 +1,117 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mirage.server.io_serde import io_result_to_dict
|
||||
from mirage.server.jobs import JobEntry
|
||||
|
||||
router = APIRouter(prefix="/v1/jobs")
|
||||
|
||||
|
||||
class JobBrief(BaseModel):
|
||||
job_id: str
|
||||
workspace_id: str
|
||||
command: str
|
||||
status: str
|
||||
submitted_at: float
|
||||
started_at: float | None = None
|
||||
finished_at: float | None = None
|
||||
|
||||
|
||||
class JobDetail(JobBrief):
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class WaitRequest(BaseModel):
|
||||
timeout_s: float | None = None
|
||||
|
||||
|
||||
class CancelResponse(BaseModel):
|
||||
job_id: str
|
||||
canceled: bool
|
||||
|
||||
|
||||
def _to_brief(entry: JobEntry) -> JobBrief:
|
||||
return JobBrief(
|
||||
job_id=entry.id,
|
||||
workspace_id=entry.workspace_id,
|
||||
command=entry.command,
|
||||
status=entry.status.value,
|
||||
submitted_at=entry.submitted_at,
|
||||
started_at=entry.started_at,
|
||||
finished_at=entry.finished_at,
|
||||
)
|
||||
|
||||
|
||||
async def _to_detail(entry: JobEntry) -> JobDetail:
|
||||
result_dict: dict[str, Any] | None = None
|
||||
if entry.result is not None:
|
||||
result_dict = await io_result_to_dict(entry.result)
|
||||
return JobDetail(
|
||||
job_id=entry.id,
|
||||
workspace_id=entry.workspace_id,
|
||||
command=entry.command,
|
||||
status=entry.status.value,
|
||||
submitted_at=entry.submitted_at,
|
||||
started_at=entry.started_at,
|
||||
finished_at=entry.finished_at,
|
||||
result=result_dict,
|
||||
error=entry.error,
|
||||
)
|
||||
|
||||
|
||||
def _require_job(request: Request, job_id: str) -> JobEntry:
|
||||
table = request.app.state.jobs
|
||||
if job_id not in table:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
return table.get(job_id)
|
||||
|
||||
|
||||
@router.get("", response_model=list[JobBrief])
|
||||
async def list_jobs(
|
||||
request: Request, workspace_id: str | None = Query(None)
|
||||
) -> list[JobBrief]: # noqa: E125
|
||||
return [
|
||||
_to_brief(j)
|
||||
for j in request.app.state.jobs.list(workspace_id=workspace_id)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=JobDetail)
|
||||
async def get_job(job_id: str, request: Request) -> JobDetail:
|
||||
return await _to_detail(_require_job(request, job_id))
|
||||
|
||||
|
||||
@router.post("/{job_id}/wait", response_model=JobDetail)
|
||||
async def wait_job(job_id: str, req: WaitRequest,
|
||||
request: Request) -> JobDetail:
|
||||
table = request.app.state.jobs
|
||||
if job_id not in table:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
entry = await table.wait(job_id, timeout=req.timeout_s)
|
||||
return await _to_detail(entry)
|
||||
|
||||
|
||||
@router.delete("/{job_id}", response_model=CancelResponse)
|
||||
async def cancel_job(job_id: str, request: Request) -> CancelResponse:
|
||||
table = request.app.state.jobs
|
||||
if job_id not in table:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
canceled = table.cancel(job_id)
|
||||
return CancelResponse(job_id=job_id, canceled=canceled)
|
||||
@@ -0,0 +1,74 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/v1/workspaces/{workspace_id}/sessions")
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
session_id: str | None = None
|
||||
allowed_mounts: list[str] | None = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
session_id: str
|
||||
cwd: str
|
||||
|
||||
|
||||
class DeleteSessionResponse(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
def _require_entry(request: Request, workspace_id: str):
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
return registry.get(workspace_id)
|
||||
|
||||
|
||||
@router.post("", response_model=SessionResponse, status_code=201)
|
||||
async def create_session(workspace_id: str, req: CreateSessionRequest,
|
||||
request: Request) -> SessionResponse:
|
||||
import secrets
|
||||
entry = _require_entry(request, workspace_id)
|
||||
sid = req.session_id or f"sess_{secrets.token_hex(6)}"
|
||||
if any(s.session_id == sid for s in entry.runner.ws.list_sessions()):
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"session id already exists: {sid!r}")
|
||||
allowed = (frozenset(req.allowed_mounts) if req.allowed_mounts else None)
|
||||
sess = entry.runner.ws.create_session(sid, allowed_mounts=allowed)
|
||||
return SessionResponse(session_id=sess.session_id, cwd=sess.cwd)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SessionResponse])
|
||||
async def list_sessions(workspace_id: str,
|
||||
request: Request) -> list[SessionResponse]:
|
||||
entry = _require_entry(request, workspace_id)
|
||||
return [
|
||||
SessionResponse(session_id=s.session_id, cwd=s.cwd)
|
||||
for s in entry.runner.ws.list_sessions()
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{session_id}", response_model=DeleteSessionResponse)
|
||||
async def delete_session(workspace_id: str, session_id: str,
|
||||
request: Request) -> DeleteSessionResponse:
|
||||
entry = _require_entry(request, workspace_id)
|
||||
if not any(s.session_id == session_id
|
||||
for s in entry.runner.ws.list_sessions()):
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
await entry.runner.call(entry.runner.ws.close_session(session_id))
|
||||
return DeleteSessionResponse(session_id=session_id)
|
||||
@@ -0,0 +1,170 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from mirage import Workspace
|
||||
from mirage.server.clone import clone_workspace_with_override
|
||||
from mirage.server.summary import make_detail
|
||||
from mirage.server.version.api import (branch, checkout, commit_state,
|
||||
diff_live_vs_ref, read_version,
|
||||
resolve_ref, status_state, version_diff,
|
||||
version_log)
|
||||
from mirage.server.version.errors import HeadMovedError, NoSuchBranchError
|
||||
from mirage.server.version.state_tree import to_state
|
||||
from mirage.server.version.store import VersionStore
|
||||
from mirage.workspace.snapshot import to_state_dict
|
||||
|
||||
from mirage.server.schemas import ( # isort: skip
|
||||
BranchRequest, BranchResponse, CheckoutRequest, CloneRequest,
|
||||
CommitRequest, CommitResponse, DiffResponse, VersionLogItem,
|
||||
WorkspaceDetail)
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
|
||||
async def _state_of(ws: Workspace) -> dict:
|
||||
return await to_state_dict(ws)
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/commit",
|
||||
response_model=CommitResponse)
|
||||
async def commit_version(workspace_id: str, req: CommitRequest,
|
||||
request: Request) -> CommitResponse:
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
entry = registry.get(workspace_id)
|
||||
state = await entry.runner.call(_state_of(entry.runner.ws))
|
||||
store = await VersionStore.open(request.app.state.version_backend,
|
||||
workspace_id)
|
||||
try:
|
||||
version = await commit_state(store, state, req.branch, req.message)
|
||||
except HeadMovedError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except NoSuchBranchError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return CommitResponse(version=version.decode(), branch=req.branch)
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/versions",
|
||||
response_model=list[VersionLogItem])
|
||||
async def list_versions(
|
||||
workspace_id: str, request: Request, branch: str = Query("main")
|
||||
) -> list[VersionLogItem]: # noqa: E125
|
||||
store = await VersionStore.open(request.app.state.version_backend,
|
||||
workspace_id)
|
||||
if branch not in await store.branches():
|
||||
return []
|
||||
entries = await version_log(store, branch)
|
||||
return [VersionLogItem(id=e["id"], message=e["message"]) for e in entries]
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/branch",
|
||||
response_model=BranchResponse,
|
||||
status_code=201)
|
||||
async def create_branch(workspace_id: str, req: BranchRequest,
|
||||
request: Request) -> BranchResponse:
|
||||
store = await VersionStore.open(request.app.state.version_backend,
|
||||
workspace_id)
|
||||
if req.name in await store.branches():
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"branch already exists: {req.name!r}")
|
||||
try:
|
||||
await branch(store, req.name, req.from_branch)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"no such branch: {req.from_branch!r}")
|
||||
head = await store.head(req.name)
|
||||
return BranchResponse(branch=req.name, version=head.decode())
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/diff", response_model=DiffResponse)
|
||||
async def diff_versions(
|
||||
workspace_id: str,
|
||||
request: Request,
|
||||
a: str | None = Query(None),
|
||||
b: str | None = Query(None),
|
||||
branch: str = Query("main"),
|
||||
) -> DiffResponse: # noqa: E125
|
||||
store = await VersionStore.open(request.app.state.version_backend,
|
||||
workspace_id)
|
||||
state = None
|
||||
if a is None or b is None:
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
entry = registry.get(workspace_id)
|
||||
state = await entry.runner.call(_state_of(entry.runner.ws))
|
||||
try:
|
||||
if a is not None and b is not None:
|
||||
changes = await version_diff(store, await resolve_ref(store, a),
|
||||
await resolve_ref(store, b))
|
||||
elif a is not None:
|
||||
changes = await diff_live_vs_ref(store, state, a)
|
||||
else:
|
||||
changes = await status_state(store, state, branch)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="version not found")
|
||||
return DiffResponse(**changes)
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/checkout",
|
||||
response_model=WorkspaceDetail)
|
||||
async def checkout_version(workspace_id: str, req: CheckoutRequest,
|
||||
request: Request) -> WorkspaceDetail:
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
entry = registry.get(workspace_id)
|
||||
store = await VersionStore.open(request.app.state.version_backend,
|
||||
workspace_id)
|
||||
try:
|
||||
await entry.runner.call(checkout(store, entry.runner.ws, req.ref))
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"version not found: {req.ref}")
|
||||
return await make_detail(entry)
|
||||
|
||||
|
||||
@router.post("/workspaces/clone",
|
||||
response_model=WorkspaceDetail,
|
||||
status_code=201)
|
||||
async def clone_workspace_version(req: CloneRequest,
|
||||
request: Request) -> WorkspaceDetail:
|
||||
registry = request.app.state.registry
|
||||
if req.id is not None and req.id in registry:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"workspace id already exists: {req.id!r}")
|
||||
if req.at is not None:
|
||||
store = await VersionStore.open(request.app.state.version_backend,
|
||||
req.source_id)
|
||||
version = await resolve_ref(store, req.at)
|
||||
try:
|
||||
entries, meta = await read_version(store, version)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"version not found: {req.at}")
|
||||
ws = await Workspace.from_state(to_state(entries, meta))
|
||||
else:
|
||||
if req.source_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
src = registry.get(req.source_id)
|
||||
ws = await src.runner.call(
|
||||
clone_workspace_with_override(src.runner.ws, None))
|
||||
try:
|
||||
entry = registry.add(ws, workspace_id=req.id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
return await make_detail(entry)
|
||||
@@ -0,0 +1,166 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from mirage import Workspace
|
||||
from mirage.resource.registry import build_resource
|
||||
from mirage.server.clone import clone_workspace_with_override
|
||||
from mirage.server.paths import PathOutsideRootError, resolve_within_root
|
||||
from mirage.server.summary import make_brief, make_detail
|
||||
from mirage.workspace.snapshot.utils import norm_mount_prefix
|
||||
|
||||
from mirage.server.schemas import ( # isort: skip
|
||||
CloneWorkspaceRequest, CreateWorkspaceRequest, DeleteWorkspaceResponse,
|
||||
LoadWorkspaceRequest, SnapshotWorkspaceRequest, SnapshotWorkspaceResponse,
|
||||
WorkspaceBrief, WorkspaceDetail)
|
||||
|
||||
router = APIRouter(prefix="/v1/workspaces")
|
||||
|
||||
|
||||
@router.post("", response_model=WorkspaceDetail, status_code=201)
|
||||
async def create_workspace(req: CreateWorkspaceRequest,
|
||||
request: Request) -> WorkspaceDetail:
|
||||
registry = request.app.state.registry
|
||||
if req.id is not None and req.id in registry:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"workspace id already exists: {req.id!r}")
|
||||
kwargs = req.config.to_workspace_kwargs()
|
||||
ws = Workspace(**kwargs)
|
||||
try:
|
||||
for prefix, target in req.config.fuse_mounts().items():
|
||||
mountpoint = target if isinstance(target, str) else None
|
||||
ws.add_fuse_mount(prefix, mountpoint)
|
||||
entry = registry.add(ws, workspace_id=req.id)
|
||||
except ValueError as e:
|
||||
await ws.close()
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception:
|
||||
await ws.close()
|
||||
raise
|
||||
return await make_detail(entry)
|
||||
|
||||
|
||||
@router.get("", response_model=list[WorkspaceBrief])
|
||||
async def list_workspaces(request: Request) -> list[WorkspaceBrief]:
|
||||
return [make_brief(e) for e in request.app.state.registry.list()]
|
||||
|
||||
|
||||
@router.get("/{workspace_id}", response_model=WorkspaceDetail)
|
||||
async def get_workspace(
|
||||
workspace_id: str, request: Request, verbose: bool = Query(False)
|
||||
) -> WorkspaceDetail: # noqa: E125
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
return await make_detail(registry.get(workspace_id), verbose=verbose)
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}", response_model=DeleteWorkspaceResponse)
|
||||
async def delete_workspace(workspace_id: str,
|
||||
request: Request) -> DeleteWorkspaceResponse:
|
||||
import time
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
await registry.remove(workspace_id)
|
||||
return DeleteWorkspaceResponse(id=workspace_id, closed_at=time.time())
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/clone",
|
||||
response_model=WorkspaceDetail,
|
||||
status_code=201)
|
||||
async def clone_workspace(workspace_id: str, req: CloneWorkspaceRequest,
|
||||
request: Request) -> WorkspaceDetail:
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
if req.id is not None and req.id in registry:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"workspace id already exists: {req.id!r}")
|
||||
src_entry = registry.get(workspace_id)
|
||||
new_ws = await src_entry.runner.call(
|
||||
clone_workspace_with_override(src_entry.runner.ws, req.override))
|
||||
try:
|
||||
entry = registry.add(new_ws, workspace_id=req.id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
return await make_detail(entry)
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/snapshot",
|
||||
response_model=SnapshotWorkspaceResponse)
|
||||
async def snapshot_workspace(workspace_id: str, req: SnapshotWorkspaceRequest,
|
||||
request: Request) -> SnapshotWorkspaceResponse:
|
||||
registry = request.app.state.registry
|
||||
if workspace_id not in registry:
|
||||
raise HTTPException(status_code=404, detail="workspace not found")
|
||||
entry = registry.get(workspace_id)
|
||||
try:
|
||||
target = resolve_within_root(request.app.state.snapshot_root, req.path)
|
||||
except PathOutsideRootError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
await entry.runner.call(_run_snapshot(entry.runner.ws, str(target)))
|
||||
return SnapshotWorkspaceResponse(id=workspace_id,
|
||||
path=str(target),
|
||||
size=target.stat().st_size)
|
||||
|
||||
|
||||
async def _run_snapshot(ws: Workspace, target: str) -> None:
|
||||
await ws.snapshot(target)
|
||||
|
||||
|
||||
@router.post("/load", response_model=WorkspaceDetail, status_code=201)
|
||||
async def load_workspace(req: LoadWorkspaceRequest,
|
||||
request: Request) -> WorkspaceDetail:
|
||||
registry = request.app.state.registry
|
||||
try:
|
||||
safe_path = resolve_within_root(request.app.state.snapshot_root,
|
||||
req.path)
|
||||
except PathOutsideRootError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if req.id is not None and req.id in registry:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"workspace id already exists: {req.id!r}")
|
||||
resources = _build_load_resources(req.override)
|
||||
try:
|
||||
ws = await Workspace.load(str(safe_path), resources=resources)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"snapshot not found: {req.path}")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
try:
|
||||
entry = registry.add(ws, workspace_id=req.id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
return await make_detail(entry)
|
||||
|
||||
|
||||
def _build_load_resources(override: dict[str, Any] | None) -> dict | None:
|
||||
if not override or "mounts" not in override:
|
||||
return None
|
||||
out: dict = {}
|
||||
for prefix, block in override["mounts"].items():
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
resource_name = block.get("resource")
|
||||
config = block.get("config") or {}
|
||||
if resource_name is None:
|
||||
continue
|
||||
out[norm_mount_prefix(prefix)] = build_resource(resource_name, config)
|
||||
return out or None
|
||||
@@ -0,0 +1,48 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.server.schemas.common import MountSummary, SessionSummary
|
||||
from mirage.server.schemas.health import HealthResponse
|
||||
from mirage.server.schemas.versions import (BranchRequest, BranchResponse,
|
||||
CheckoutRequest, CloneRequest,
|
||||
CommitRequest, CommitResponse,
|
||||
DiffResponse, VersionLogItem)
|
||||
|
||||
from mirage.server.schemas.workspaces import ( # isort: skip
|
||||
CloneWorkspaceRequest, CreateWorkspaceRequest, DeleteWorkspaceResponse,
|
||||
LoadWorkspaceRequest, SnapshotWorkspaceRequest, SnapshotWorkspaceResponse,
|
||||
WorkspaceBrief, WorkspaceDetail, WorkspaceInternals)
|
||||
|
||||
__all__ = [
|
||||
"MountSummary",
|
||||
"SessionSummary",
|
||||
"HealthResponse",
|
||||
"WorkspaceInternals",
|
||||
"WorkspaceBrief",
|
||||
"WorkspaceDetail",
|
||||
"CreateWorkspaceRequest",
|
||||
"CloneWorkspaceRequest",
|
||||
"SnapshotWorkspaceRequest",
|
||||
"SnapshotWorkspaceResponse",
|
||||
"LoadWorkspaceRequest",
|
||||
"DeleteWorkspaceResponse",
|
||||
"CommitRequest",
|
||||
"CommitResponse",
|
||||
"VersionLogItem",
|
||||
"CheckoutRequest",
|
||||
"CloneRequest",
|
||||
"DiffResponse",
|
||||
"BranchRequest",
|
||||
"BranchResponse",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MountSummary(BaseModel):
|
||||
prefix: str
|
||||
resource: str
|
||||
mode: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class SessionSummary(BaseModel):
|
||||
session_id: str
|
||||
cwd: str
|
||||
@@ -0,0 +1,21 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
workspaces: int
|
||||
uptime_s: float
|
||||
@@ -0,0 +1,64 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CommitRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
branch: str = "main"
|
||||
message: str = ""
|
||||
|
||||
|
||||
class CommitResponse(BaseModel):
|
||||
version: str
|
||||
branch: str
|
||||
|
||||
|
||||
class VersionLogItem(BaseModel):
|
||||
id: str
|
||||
message: str
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
ref: str
|
||||
|
||||
|
||||
class CloneRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
source_id: str
|
||||
at: str | None = None
|
||||
id: str | None = None
|
||||
|
||||
|
||||
class DiffResponse(BaseModel):
|
||||
added: list[str]
|
||||
modified: list[str]
|
||||
deleted: list[str]
|
||||
|
||||
|
||||
class BranchRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
from_branch: str = "main"
|
||||
|
||||
|
||||
class BranchResponse(BaseModel):
|
||||
branch: str
|
||||
version: str
|
||||
@@ -0,0 +1,84 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from mirage.config import WorkspaceConfig
|
||||
from mirage.server.schemas.common import MountSummary, SessionSummary
|
||||
|
||||
|
||||
class WorkspaceInternals(BaseModel):
|
||||
cache_bytes: int | None
|
||||
cache_entries: int | None
|
||||
history_length: int
|
||||
in_flight_jobs: int
|
||||
|
||||
|
||||
class WorkspaceBrief(BaseModel):
|
||||
id: str
|
||||
mode: str
|
||||
mount_count: int
|
||||
session_count: int
|
||||
created_at: float
|
||||
|
||||
|
||||
class WorkspaceDetail(BaseModel):
|
||||
id: str
|
||||
mode: str
|
||||
created_at: float
|
||||
fuse_mountpoints: dict[str, str] = Field(default_factory=dict)
|
||||
sessions: list[SessionSummary] = Field(default_factory=list)
|
||||
mounts: list[MountSummary] = Field(default_factory=list)
|
||||
internals: WorkspaceInternals | None = None
|
||||
|
||||
|
||||
class CreateWorkspaceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
config: WorkspaceConfig
|
||||
id: str | None = None
|
||||
|
||||
|
||||
class CloneWorkspaceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str | None = None
|
||||
override: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SnapshotWorkspaceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: str
|
||||
|
||||
|
||||
class SnapshotWorkspaceResponse(BaseModel):
|
||||
id: str
|
||||
path: str
|
||||
size: int
|
||||
|
||||
|
||||
class LoadWorkspaceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: str
|
||||
id: str | None = None
|
||||
override: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DeleteWorkspaceResponse(BaseModel):
|
||||
id: str
|
||||
closed_at: float
|
||||
@@ -0,0 +1,91 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage import Workspace
|
||||
from mirage.resource.history import HISTORY_PREFIX
|
||||
from mirage.server.registry import WorkspaceEntry
|
||||
from mirage.server.schemas import (MountSummary, SessionSummary,
|
||||
WorkspaceBrief, WorkspaceDetail,
|
||||
WorkspaceInternals)
|
||||
from mirage.workspace.snapshot.utils import norm_mount_prefix
|
||||
|
||||
_AUTO_PREFIXES = {"/dev/", norm_mount_prefix(HISTORY_PREFIX)}
|
||||
_DESCRIPTION_MAX = 120
|
||||
|
||||
|
||||
def _is_auto_prefix(prefix: str) -> bool:
|
||||
return prefix in _AUTO_PREFIXES
|
||||
|
||||
|
||||
def _mount_description(resource) -> str:
|
||||
raw = getattr(resource, "PROMPT", "") or ""
|
||||
if len(raw) <= _DESCRIPTION_MAX:
|
||||
return raw
|
||||
return raw[:_DESCRIPTION_MAX - 1].rstrip() + "\u2026"
|
||||
|
||||
|
||||
def _user_mounts(ws: Workspace):
|
||||
return [m for m in ws._registry.mounts() if not _is_auto_prefix(m.prefix)]
|
||||
|
||||
|
||||
async def _build_internals(ws: Workspace) -> WorkspaceInternals:
|
||||
cache = ws.cache
|
||||
history_len = len(await ws.history())
|
||||
return WorkspaceInternals(
|
||||
cache_bytes=cache.cache_size,
|
||||
cache_entries=cache.cache_entries,
|
||||
history_length=history_len,
|
||||
in_flight_jobs=len(ws.job_table.list_jobs()),
|
||||
)
|
||||
|
||||
|
||||
def make_brief(entry: WorkspaceEntry) -> WorkspaceBrief:
|
||||
ws = entry.runner.ws
|
||||
user_mounts = _user_mounts(ws)
|
||||
workspace_mode = (user_mounts[0].mode.value if user_mounts else "read")
|
||||
return WorkspaceBrief(
|
||||
id=entry.id,
|
||||
mode=workspace_mode,
|
||||
mount_count=len(user_mounts),
|
||||
session_count=len(ws.list_sessions()),
|
||||
created_at=entry.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def make_detail(entry: WorkspaceEntry,
|
||||
verbose: bool = False) -> WorkspaceDetail:
|
||||
ws = entry.runner.ws
|
||||
user_mounts = _user_mounts(ws)
|
||||
workspace_mode = (user_mounts[0].mode.value if user_mounts else "read")
|
||||
mounts = [
|
||||
MountSummary(
|
||||
prefix=m.prefix,
|
||||
resource=m.resource.name,
|
||||
mode=m.mode.value,
|
||||
description=_mount_description(m.resource),
|
||||
) for m in user_mounts
|
||||
]
|
||||
sessions = [
|
||||
SessionSummary(session_id=s.session_id, cwd=s.cwd)
|
||||
for s in ws.list_sessions()
|
||||
]
|
||||
return WorkspaceDetail(
|
||||
id=entry.id,
|
||||
mode=workspace_mode,
|
||||
created_at=entry.created_at,
|
||||
fuse_mountpoints=ws.fuse_mountpoints,
|
||||
mounts=mounts,
|
||||
sessions=sessions,
|
||||
internals=await _build_internals(ws) if verbose else None,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,152 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.server.version.errors import NoSuchBranchError
|
||||
from mirage.server.version.state_tree import (CACHE_PREFIX, META_PATH,
|
||||
blob_to_meta, meta_to_blob,
|
||||
to_state, tree_inputs_from_state)
|
||||
from mirage.server.version.store import VersionStore
|
||||
from mirage.types import DriftPolicy, StateKey
|
||||
from mirage.workspace.snapshot import (apply_state_dict, install_fingerprints,
|
||||
to_state_dict)
|
||||
|
||||
|
||||
async def snapshot_tree(store: VersionStore, ws) -> bytes:
|
||||
return await snapshot_tree_from_state(store, await to_state_dict(ws))
|
||||
|
||||
|
||||
async def snapshot_tree_from_state(store: VersionStore, state: dict) -> bytes:
|
||||
entries, meta = tree_inputs_from_state(state)
|
||||
tree_entries: dict[str, bytes] = {}
|
||||
for path, data in entries.items():
|
||||
tree_entries[path] = await store.write_blob(data)
|
||||
tree_entries[META_PATH] = await store.write_blob(meta_to_blob(meta))
|
||||
return await store.write_tree(tree_entries)
|
||||
|
||||
|
||||
async def commit(store: VersionStore,
|
||||
ws,
|
||||
branch: str = "main",
|
||||
message: str = "") -> bytes:
|
||||
return await commit_state(store, await to_state_dict(ws), branch, message)
|
||||
|
||||
|
||||
async def commit_state(store: VersionStore,
|
||||
state: dict,
|
||||
branch: str = "main",
|
||||
message: str = "") -> bytes:
|
||||
tree = await snapshot_tree_from_state(store, state)
|
||||
branches = await store.branches()
|
||||
parents: list[bytes] = []
|
||||
if branch in branches:
|
||||
parents = [await store.head(branch)]
|
||||
elif branches:
|
||||
raise NoSuchBranchError(branch)
|
||||
return await store.commit(tree, parents, branch, message)
|
||||
|
||||
|
||||
async def branch(store: VersionStore,
|
||||
name: str,
|
||||
from_branch: str = "main") -> None:
|
||||
head = await store.head(from_branch)
|
||||
await store.set_branch(name, head)
|
||||
|
||||
|
||||
async def read_version(store: VersionStore,
|
||||
version: bytes) -> tuple[dict[str, bytes], dict]:
|
||||
tree = (await store.read_commit(version)).tree
|
||||
contents = await store.read_tree(tree)
|
||||
meta_oid = contents.pop(META_PATH, None)
|
||||
meta = (blob_to_meta(await store.read_blob(meta_oid))
|
||||
if meta_oid is not None else {
|
||||
"mounts": []
|
||||
})
|
||||
entries: dict[str, bytes] = {}
|
||||
for path, oid in contents.items():
|
||||
entries[path] = await store.read_blob(oid)
|
||||
return entries, meta
|
||||
|
||||
|
||||
async def resolve_ref(store: VersionStore, ref) -> bytes:
|
||||
if isinstance(ref, str):
|
||||
if ref in await store.branches():
|
||||
return await store.head(ref)
|
||||
return ref.encode()
|
||||
return ref
|
||||
|
||||
|
||||
async def checkout(store: VersionStore,
|
||||
ws,
|
||||
ref,
|
||||
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> None:
|
||||
version = await resolve_ref(store, ref)
|
||||
entries, meta = await read_version(store, version)
|
||||
state = to_state(entries, meta)
|
||||
await ws._cache.clear()
|
||||
await apply_state_dict(ws, state)
|
||||
install_fingerprints(ws,
|
||||
state.get(StateKey.FINGERPRINTS) or [], drift_policy)
|
||||
|
||||
|
||||
def _strip_meta(changes: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||
return {
|
||||
kind: [
|
||||
p for p in paths
|
||||
if p != META_PATH and not p.startswith(CACHE_PREFIX)
|
||||
]
|
||||
for kind, paths in changes.items()
|
||||
}
|
||||
|
||||
|
||||
async def version_log(store: VersionStore, branch: str) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for oid in await store.log(branch):
|
||||
commit_obj = await store.read_commit(oid)
|
||||
out.append({
|
||||
"id": oid.decode(),
|
||||
"message": commit_obj.message.decode(),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def version_diff(store: VersionStore, version_a: bytes,
|
||||
version_b: bytes) -> dict[str, list[str]]:
|
||||
tree_a = (await store.read_commit(version_a)).tree
|
||||
tree_b = (await store.read_commit(version_b)).tree
|
||||
return _strip_meta(await store.diff(tree_a, tree_b))
|
||||
|
||||
|
||||
async def diff_live_vs_ref(store: VersionStore, state: dict,
|
||||
ref) -> dict[str, list[str]]:
|
||||
live_tree = await snapshot_tree_from_state(store, state)
|
||||
version = await resolve_ref(store, ref)
|
||||
ref_tree = (await store.read_commit(version)).tree
|
||||
return _strip_meta(await store.diff(ref_tree, live_tree))
|
||||
|
||||
|
||||
async def status(store: VersionStore,
|
||||
ws,
|
||||
branch: str = "main") -> dict[str, list[str]]:
|
||||
return await status_state(store, await to_state_dict(ws), branch)
|
||||
|
||||
|
||||
async def status_state(store: VersionStore,
|
||||
state: dict,
|
||||
branch: str = "main") -> dict[str, list[str]]:
|
||||
live_tree = await snapshot_tree_from_state(store, state)
|
||||
if branch in await store.branches():
|
||||
head_tree = (await store.read_commit(await store.head(branch))).tree
|
||||
else:
|
||||
head_tree = await store.write_tree({})
|
||||
return _strip_meta(await store.diff(head_tree, live_tree))
|
||||
@@ -0,0 +1,40 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from dulwich.repo import Repo
|
||||
|
||||
from mirage.server.paths import resolve_within_root, validate_path_segment
|
||||
|
||||
|
||||
class VersionBackend(Protocol):
|
||||
|
||||
def open_repo(self, workspace_id: str) -> Repo:
|
||||
...
|
||||
|
||||
|
||||
class LocalBackend:
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self._root = Path(root)
|
||||
|
||||
def open_repo(self, workspace_id: str) -> Repo:
|
||||
path = resolve_within_root(self._root,
|
||||
validate_path_segment(workspace_id))
|
||||
if (path / "objects").is_dir():
|
||||
return Repo(str(path))
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return Repo.init_bare(str(path))
|
||||
@@ -0,0 +1,30 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
|
||||
class HeadMovedError(Exception):
|
||||
|
||||
def __init__(self, branch: str) -> None:
|
||||
self.branch = branch
|
||||
super().__init__(
|
||||
f"branch {branch!r} moved since this commit was prepared; "
|
||||
"refusing to overwrite (re-read the head and retry)")
|
||||
|
||||
|
||||
class NoSuchBranchError(Exception):
|
||||
|
||||
def __init__(self, branch: str) -> None:
|
||||
self.branch = branch
|
||||
super().__init__(f"no branch {branch!r}; create it first with "
|
||||
"`mirage workspace branch`")
|
||||
@@ -0,0 +1,172 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
|
||||
from mirage.types import CacheKey, MountKey, ResourceStateKey, StateKey
|
||||
from mirage.workspace.snapshot.state import to_state_dict
|
||||
from mirage.workspace.snapshot.tar_io import _json_default
|
||||
from mirage.workspace.snapshot.utils import FORMAT_VERSION
|
||||
|
||||
META_PATH = ".mirage-meta.json"
|
||||
CACHE_PREFIX = ".mirage-cache/"
|
||||
|
||||
|
||||
def _is_reserved(tree_path: str) -> bool:
|
||||
return tree_path == META_PATH or tree_path.startswith(CACHE_PREFIX)
|
||||
|
||||
|
||||
def _tree_path(prefix: str, rel: str) -> str:
|
||||
p = prefix.strip("/")
|
||||
r = rel.lstrip("/")
|
||||
return f"{p}/{r}" if p else r
|
||||
|
||||
|
||||
def _rel_path(prefix: str, tree_path: str) -> str:
|
||||
p = prefix.strip("/")
|
||||
rest = tree_path[len(p) + 1:] if p else tree_path
|
||||
return "/" + rest
|
||||
|
||||
|
||||
def _belongs(tree_prefix: str, tree_path: str) -> bool:
|
||||
if not tree_prefix:
|
||||
return True
|
||||
return tree_path == tree_prefix or tree_path.startswith(tree_prefix + "/")
|
||||
|
||||
|
||||
def meta_to_blob(meta: dict) -> bytes:
|
||||
return json.dumps(meta, default=_json_default).encode("utf-8")
|
||||
|
||||
|
||||
def blob_to_meta(data: bytes) -> dict:
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
async def to_tree_inputs(ws) -> tuple[dict[str, bytes], dict]:
|
||||
return tree_inputs_from_state(await to_state_dict(ws))
|
||||
|
||||
|
||||
def tree_inputs_from_state(state: dict) -> tuple[dict[str, bytes], dict]:
|
||||
entries: dict[str, bytes] = {}
|
||||
mounts_meta: list[dict] = []
|
||||
for mount in state[StateKey.MOUNTS]:
|
||||
prefix = mount[MountKey.PREFIX]
|
||||
resource_state = dict(mount[MountKey.RESOURCE_STATE])
|
||||
files = resource_state.pop(ResourceStateKey.FILES, {})
|
||||
for rel, data in files.items():
|
||||
entries[_tree_path(prefix, rel)] = data
|
||||
mounts_meta.append({
|
||||
MountKey.INDEX:
|
||||
mount[MountKey.INDEX],
|
||||
MountKey.PREFIX:
|
||||
prefix,
|
||||
MountKey.MODE:
|
||||
mount[MountKey.MODE],
|
||||
MountKey.CONSISTENCY:
|
||||
mount[MountKey.CONSISTENCY],
|
||||
MountKey.RESOURCE_CLASS:
|
||||
mount[MountKey.RESOURCE_CLASS],
|
||||
MountKey.RESOURCE_STATE:
|
||||
resource_state,
|
||||
})
|
||||
cache = state[StateKey.CACHE]
|
||||
config = {
|
||||
StateKey.MIRAGE_VERSION: state[StateKey.MIRAGE_VERSION],
|
||||
StateKey.DEFAULT_SESSION_ID: state[StateKey.DEFAULT_SESSION_ID],
|
||||
StateKey.DEFAULT_AGENT_ID: state[StateKey.DEFAULT_AGENT_ID],
|
||||
StateKey.CURRENT_AGENT_ID: state[StateKey.CURRENT_AGENT_ID],
|
||||
CacheKey.LIMIT: cache[CacheKey.LIMIT],
|
||||
CacheKey.MAX_DRAIN_BYTES: cache[CacheKey.MAX_DRAIN_BYTES],
|
||||
}
|
||||
cache_meta: list[dict] = []
|
||||
for i, entry in enumerate(cache[CacheKey.ENTRIES]):
|
||||
ref = f"{CACHE_PREFIX}{i}"
|
||||
entries[ref] = entry[CacheKey.DATA]
|
||||
cache_meta.append({
|
||||
CacheKey.KEY: entry[CacheKey.KEY],
|
||||
CacheKey.FINGERPRINT: entry.get(CacheKey.FINGERPRINT),
|
||||
CacheKey.TTL: entry.get(CacheKey.TTL),
|
||||
CacheKey.CACHED_AT: entry.get(CacheKey.CACHED_AT),
|
||||
CacheKey.SIZE: entry.get(CacheKey.SIZE),
|
||||
"ref": ref,
|
||||
})
|
||||
meta = {
|
||||
"mounts": mounts_meta,
|
||||
"config": config,
|
||||
"cache": cache_meta,
|
||||
"fingerprints": state.get(StateKey.FINGERPRINTS) or [],
|
||||
"sessions": state.get(StateKey.SESSIONS) or [],
|
||||
}
|
||||
return entries, meta
|
||||
|
||||
|
||||
def to_state(entries: dict[str, bytes], meta: dict) -> dict:
|
||||
mounts: list[dict] = []
|
||||
for mount in meta["mounts"]:
|
||||
prefix = mount[MountKey.PREFIX]
|
||||
tree_prefix = prefix.strip("/")
|
||||
resource_state = dict(mount[MountKey.RESOURCE_STATE])
|
||||
files: dict[str, bytes] = {}
|
||||
for tree_path, data in entries.items():
|
||||
if _is_reserved(tree_path):
|
||||
continue
|
||||
if _belongs(tree_prefix, tree_path):
|
||||
files[_rel_path(prefix, tree_path)] = data
|
||||
resource_state[ResourceStateKey.FILES] = files
|
||||
mounts.append({
|
||||
MountKey.INDEX: mount[MountKey.INDEX],
|
||||
MountKey.PREFIX: prefix,
|
||||
MountKey.MODE: mount[MountKey.MODE],
|
||||
MountKey.CONSISTENCY: mount[MountKey.CONSISTENCY],
|
||||
MountKey.RESOURCE_CLASS: mount[MountKey.RESOURCE_CLASS],
|
||||
MountKey.RESOURCE_STATE: resource_state,
|
||||
})
|
||||
config = meta.get("config", {})
|
||||
cache_entries: list[dict] = []
|
||||
for c in meta.get("cache", []):
|
||||
cache_entries.append({
|
||||
CacheKey.KEY: c[CacheKey.KEY],
|
||||
CacheKey.DATA: entries[c["ref"]],
|
||||
CacheKey.FINGERPRINT: c.get(CacheKey.FINGERPRINT),
|
||||
CacheKey.TTL: c.get(CacheKey.TTL),
|
||||
CacheKey.CACHED_AT: c.get(CacheKey.CACHED_AT),
|
||||
CacheKey.SIZE: c.get(CacheKey.SIZE),
|
||||
})
|
||||
return {
|
||||
StateKey.VERSION:
|
||||
FORMAT_VERSION,
|
||||
StateKey.MIRAGE_VERSION:
|
||||
config.get(StateKey.MIRAGE_VERSION, "unknown"),
|
||||
StateKey.MOUNTS:
|
||||
mounts,
|
||||
StateKey.SESSIONS:
|
||||
meta.get("sessions", []),
|
||||
StateKey.DEFAULT_SESSION_ID:
|
||||
config.get(StateKey.DEFAULT_SESSION_ID, "default"),
|
||||
StateKey.DEFAULT_AGENT_ID:
|
||||
config.get(StateKey.DEFAULT_AGENT_ID, "default"),
|
||||
StateKey.CURRENT_AGENT_ID:
|
||||
config.get(StateKey.CURRENT_AGENT_ID, "default"),
|
||||
StateKey.CACHE: {
|
||||
CacheKey.LIMIT: config.get(CacheKey.LIMIT, "512MB"),
|
||||
CacheKey.MAX_DRAIN_BYTES: config.get(CacheKey.MAX_DRAIN_BYTES),
|
||||
CacheKey.ENTRIES: cache_entries,
|
||||
},
|
||||
StateKey.HISTORY:
|
||||
None,
|
||||
StateKey.JOBS: [],
|
||||
StateKey.FINGERPRINTS:
|
||||
meta.get("fingerprints", []),
|
||||
StateKey.LIVE_ONLY_MOUNTS: [],
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import stat
|
||||
import time
|
||||
|
||||
from dulwich.diff_tree import (CHANGE_ADD, CHANGE_DELETE, CHANGE_MODIFY,
|
||||
tree_changes)
|
||||
from dulwich.objects import Blob, Commit, Tree
|
||||
from dulwich.repo import Repo
|
||||
|
||||
from mirage.server.version.backend import VersionBackend
|
||||
from mirage.server.version.errors import HeadMovedError
|
||||
|
||||
FILE_MODE = 0o100644
|
||||
DIR_MODE = 0o40000
|
||||
AUTHOR = b"mirage <mirage@local>"
|
||||
|
||||
|
||||
def _add_blob(repo: Repo, data: bytes) -> bytes:
|
||||
blob = Blob.from_string(data)
|
||||
repo.object_store.add_object(blob)
|
||||
return blob.id
|
||||
|
||||
|
||||
def _read_blob(repo: Repo, oid: bytes) -> bytes:
|
||||
return repo.object_store[oid].as_raw_string()
|
||||
|
||||
|
||||
def _build_tree(repo: Repo, entries: dict[str, bytes]) -> bytes:
|
||||
tree = Tree()
|
||||
subdirs: dict[str, dict[str, bytes]] = {}
|
||||
for path, oid in entries.items():
|
||||
if "/" in path:
|
||||
head, rest = path.split("/", 1)
|
||||
subdirs.setdefault(head, {})[rest] = oid
|
||||
else:
|
||||
tree.add(path.encode(), FILE_MODE, oid)
|
||||
for name, sub in subdirs.items():
|
||||
tree.add(name.encode(), DIR_MODE, _build_tree(repo, sub))
|
||||
repo.object_store.add_object(tree)
|
||||
return tree.id
|
||||
|
||||
|
||||
def _read_tree(repo: Repo, oid: bytes, prefix: str = "") -> dict[str, bytes]:
|
||||
out: dict[str, bytes] = {}
|
||||
for name, mode, sha in repo.object_store[oid].items():
|
||||
rel = name.decode()
|
||||
full = f"{prefix}/{rel}" if prefix else rel
|
||||
if stat.S_ISDIR(mode):
|
||||
out.update(_read_tree(repo, sha, full))
|
||||
else:
|
||||
out[full] = sha
|
||||
return out
|
||||
|
||||
|
||||
def _commit(repo: Repo, tree_oid: bytes, parents: list[bytes], branch: str,
|
||||
message: str) -> bytes:
|
||||
commit = Commit()
|
||||
commit.tree = tree_oid
|
||||
commit.parents = list(parents)
|
||||
commit.author = commit.committer = AUTHOR
|
||||
now = int(time.time())
|
||||
commit.author_time = commit.commit_time = now
|
||||
commit.author_timezone = commit.commit_timezone = 0
|
||||
commit.encoding = b"UTF-8"
|
||||
commit.message = message.encode()
|
||||
repo.object_store.add_object(commit)
|
||||
ref = b"refs/heads/" + branch.encode()
|
||||
expected_old = parents[0] if parents else None
|
||||
if expected_old is None:
|
||||
ok = repo.refs.add_if_new(ref, commit.id)
|
||||
else:
|
||||
ok = repo.refs.set_if_equals(ref, expected_old, commit.id)
|
||||
if not ok:
|
||||
raise HeadMovedError(branch)
|
||||
repo.refs.set_symbolic_ref(b"HEAD", ref)
|
||||
return commit.id
|
||||
|
||||
|
||||
def _head(repo: Repo, branch: str) -> bytes:
|
||||
return repo.refs[b"refs/heads/" + branch.encode()]
|
||||
|
||||
|
||||
def _set_branch(repo: Repo, name: str, oid: bytes) -> None:
|
||||
repo.refs[b"refs/heads/" + name.encode()] = oid
|
||||
|
||||
|
||||
def _read_commit(repo: Repo, oid: bytes) -> Commit:
|
||||
return repo.object_store[oid]
|
||||
|
||||
|
||||
def _branches(repo: Repo) -> list[str]:
|
||||
prefix = b"refs/heads/"
|
||||
names = [
|
||||
name[len(prefix):].decode() for name in repo.get_refs()
|
||||
if name.startswith(prefix)
|
||||
]
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def _log(repo: Repo, branch: str) -> list[bytes]:
|
||||
head = repo.refs[b"refs/heads/" + branch.encode()]
|
||||
return [entry.commit.id for entry in repo.get_walker(include=[head])]
|
||||
|
||||
|
||||
def _diff(repo: Repo, tree_a: bytes, tree_b: bytes) -> dict[str, list[str]]:
|
||||
added: list[str] = []
|
||||
modified: list[str] = []
|
||||
deleted: list[str] = []
|
||||
for change in tree_changes(repo.object_store, tree_a, tree_b):
|
||||
if change.type == CHANGE_ADD:
|
||||
added.append(change.new.path.decode())
|
||||
elif change.type == CHANGE_DELETE:
|
||||
deleted.append(change.old.path.decode())
|
||||
elif change.type == CHANGE_MODIFY:
|
||||
modified.append(change.new.path.decode())
|
||||
return {
|
||||
"added": sorted(added),
|
||||
"modified": sorted(modified),
|
||||
"deleted": sorted(deleted),
|
||||
}
|
||||
|
||||
|
||||
class VersionStore:
|
||||
|
||||
def __init__(self, repo: Repo) -> None:
|
||||
self._repo = repo
|
||||
|
||||
@classmethod
|
||||
async def open(cls, backend: VersionBackend,
|
||||
workspace_id: str) -> "VersionStore":
|
||||
repo = await asyncio.to_thread(backend.open_repo, workspace_id)
|
||||
return cls(repo)
|
||||
|
||||
async def write_blob(self, data: bytes) -> bytes:
|
||||
return await asyncio.to_thread(_add_blob, self._repo, data)
|
||||
|
||||
async def read_blob(self, oid: bytes) -> bytes:
|
||||
return await asyncio.to_thread(_read_blob, self._repo, oid)
|
||||
|
||||
async def write_tree(self, entries: dict[str, bytes]) -> bytes:
|
||||
return await asyncio.to_thread(_build_tree, self._repo, entries)
|
||||
|
||||
async def read_tree(self, oid: bytes) -> dict[str, bytes]:
|
||||
return await asyncio.to_thread(_read_tree, self._repo, oid)
|
||||
|
||||
async def commit(self, tree_oid: bytes, parents: list[bytes], branch: str,
|
||||
message: str) -> bytes:
|
||||
return await asyncio.to_thread(_commit, self._repo, tree_oid, parents,
|
||||
branch, message)
|
||||
|
||||
async def head(self, branch: str) -> bytes:
|
||||
return await asyncio.to_thread(_head, self._repo, branch)
|
||||
|
||||
async def set_branch(self, name: str, oid: bytes) -> None:
|
||||
await asyncio.to_thread(_set_branch, self._repo, name, oid)
|
||||
|
||||
async def read_commit(self, oid: bytes) -> Commit:
|
||||
return await asyncio.to_thread(_read_commit, self._repo, oid)
|
||||
|
||||
async def branches(self) -> list[str]:
|
||||
return await asyncio.to_thread(_branches, self._repo)
|
||||
|
||||
async def log(self, branch: str) -> list[bytes]:
|
||||
return await asyncio.to_thread(_log, self._repo, branch)
|
||||
|
||||
async def diff(self, tree_a: bytes, tree_b: bytes) -> dict[str, list[str]]:
|
||||
return await asyncio.to_thread(_diff, self._repo, tree_a, tree_b)
|
||||
Reference in New Issue
Block a user