Refactor component terminology to programs in config and manifest
- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications. - Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks. - Adjusted tests to reflect the new program structure and ensure proper functionality. - Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
This commit is contained in:
@@ -10,7 +10,8 @@ from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core.config import GENERATED_DIR
|
||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
from castle_core.stacks import available_actions
|
||||
|
||||
from castle_api.config import get_castle_root, get_registry
|
||||
from castle_api.mesh import mesh_state
|
||||
@@ -20,6 +21,12 @@ from castle_api.models import (
|
||||
ComponentSummary,
|
||||
GatewayInfo,
|
||||
GatewayRoute,
|
||||
JobDetail,
|
||||
JobSummary,
|
||||
ProgramDetail,
|
||||
ProgramSummary,
|
||||
ServiceDetail,
|
||||
ServiceSummary,
|
||||
StatusResponse,
|
||||
SystemdInfo,
|
||||
)
|
||||
@@ -88,8 +95,8 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
|
||||
description = svc.description
|
||||
source = None
|
||||
stack = None
|
||||
if svc.component and svc.component in config.components:
|
||||
comp = config.components[svc.component]
|
||||
if svc.component and svc.component in config.programs:
|
||||
comp = config.programs[svc.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
@@ -126,8 +133,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
||||
description = job.description
|
||||
source = None
|
||||
stack = None
|
||||
if job.component and job.component in config.components:
|
||||
comp = config.components[job.component]
|
||||
if job.component and job.component in config.programs:
|
||||
comp = config.programs[job.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
@@ -147,8 +154,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
|
||||
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
|
||||
# Determine behavior
|
||||
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
||||
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
||||
@@ -178,7 +185,7 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
category="component",
|
||||
category="program",
|
||||
description=comp.description,
|
||||
behavior=behavior,
|
||||
stack=comp.stack,
|
||||
@@ -190,6 +197,454 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typed builder functions — one per concept
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_systemd_info(name: str, timer: bool = False) -> SystemdInfo:
|
||||
unit_name = f"castle-{name}.service"
|
||||
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||
return SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=timer)
|
||||
|
||||
|
||||
def _backfill_source(name: str, config: object) -> str | None:
|
||||
"""Resolve source path from program ref in config."""
|
||||
if name in config.programs:
|
||||
return config.programs[name].source
|
||||
ref = None
|
||||
if name in config.services:
|
||||
ref = config.services[name].component
|
||||
elif name in config.jobs:
|
||||
ref = config.jobs[name].component
|
||||
if ref and ref in config.programs:
|
||||
return config.programs[ref].source
|
||||
return None
|
||||
|
||||
|
||||
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||
"""Build a ServiceSummary from a DeployedComponent."""
|
||||
systemd_info = _make_systemd_info(name) if deployed.managed else None
|
||||
return ServiceSummary(
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
stack=deployed.stack,
|
||||
runner=deployed.runner,
|
||||
port=deployed.port,
|
||||
health_path=deployed.health_path,
|
||||
proxy_path=deployed.proxy_path,
|
||||
managed=deployed.managed,
|
||||
systemd=systemd_info,
|
||||
)
|
||||
|
||||
|
||||
def _service_from_spec(
|
||||
name: str, svc: ServiceSpec, config: object
|
||||
) -> ServiceSummary:
|
||||
"""Build a ServiceSummary from a ServiceSpec."""
|
||||
port = None
|
||||
health_path = None
|
||||
proxy_path = None
|
||||
if svc.expose and svc.expose.http:
|
||||
port = svc.expose.http.internal.port
|
||||
health_path = svc.expose.http.health_path
|
||||
if svc.proxy and svc.proxy.caddy:
|
||||
proxy_path = svc.proxy.caddy.path_prefix
|
||||
|
||||
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
|
||||
systemd_info = _make_systemd_info(name) if managed else None
|
||||
|
||||
description = svc.description
|
||||
source = None
|
||||
stack = None
|
||||
if svc.component and svc.component in config.programs:
|
||||
comp = config.programs[svc.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
stack = comp.stack
|
||||
|
||||
return ServiceSummary(
|
||||
id=name,
|
||||
description=description,
|
||||
stack=stack,
|
||||
runner=svc.run.runner,
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
proxy_path=proxy_path,
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||
"""Build a JobSummary from a DeployedComponent."""
|
||||
systemd_info = (
|
||||
_make_systemd_info(name, timer=True) if deployed.managed else None
|
||||
)
|
||||
return JobSummary(
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
stack=deployed.stack,
|
||||
runner=deployed.runner,
|
||||
schedule=deployed.schedule,
|
||||
managed=deployed.managed,
|
||||
systemd=systemd_info,
|
||||
)
|
||||
|
||||
|
||||
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
|
||||
"""Build a JobSummary from a JobSpec."""
|
||||
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
|
||||
systemd_info = _make_systemd_info(name, timer=True) if managed else None
|
||||
|
||||
description = job.description
|
||||
source = None
|
||||
stack = None
|
||||
if job.component and job.component in config.programs:
|
||||
comp = config.programs[job.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
stack = comp.stack
|
||||
|
||||
return JobSummary(
|
||||
id=name,
|
||||
description=description,
|
||||
stack=stack,
|
||||
runner=job.run.runner,
|
||||
schedule=job.schedule,
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _program_from_spec(
|
||||
name: str, comp: ProgramSpec, root: Path, config: object | None = None
|
||||
) -> ProgramSummary:
|
||||
"""Build a ProgramSummary from a ProgramSpec."""
|
||||
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
||||
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
||||
|
||||
if is_tool:
|
||||
behavior = "tool"
|
||||
elif is_frontend:
|
||||
behavior = "frontend"
|
||||
else:
|
||||
behavior = None
|
||||
|
||||
# Derive behavior from backing service/job
|
||||
if behavior is None and config is not None:
|
||||
svc_components = {
|
||||
s.component for s in config.services.values() if s.component
|
||||
}
|
||||
job_components = {
|
||||
j.component for j in config.jobs.values() if j.component
|
||||
}
|
||||
if name in svc_components or name in config.services:
|
||||
behavior = "daemon"
|
||||
elif name in job_components or name in config.jobs:
|
||||
behavior = "tool"
|
||||
|
||||
source = comp.source
|
||||
runner = None
|
||||
if source:
|
||||
source_dir = root / source
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
installed: bool | None = None
|
||||
if comp.install and comp.install.path:
|
||||
alias = comp.install.path.alias or name
|
||||
installed = shutil.which(alias) is not None
|
||||
elif config is not None:
|
||||
# Daemons: check if the service's run.tool binary is on PATH
|
||||
svc = config.services.get(name)
|
||||
if svc and hasattr(svc.run, "tool"):
|
||||
installed = shutil.which(svc.run.tool) is not None
|
||||
|
||||
return ProgramSummary(
|
||||
id=name,
|
||||
description=comp.description,
|
||||
behavior=behavior,
|
||||
stack=comp.stack,
|
||||
runner=runner,
|
||||
version=comp.tool.version if comp.tool else None,
|
||||
source=source,
|
||||
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
|
||||
installed=installed,
|
||||
actions=available_actions(comp),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typed endpoints — /services, /jobs, /programs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/services", response_model=list[ServiceSummary], tags=["services-data"])
|
||||
def list_services(include_remote: bool = False) -> list[ServiceSummary]:
|
||||
"""List all services — deployed from registry, non-deployed from castle.yaml."""
|
||||
registry = get_registry()
|
||||
hostname = registry.node.hostname
|
||||
summaries: list[ServiceSummary] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# Deployed services (non-scheduled)
|
||||
for name, deployed in registry.deployed.items():
|
||||
if deployed.schedule:
|
||||
continue
|
||||
s = _service_from_deployed(name, deployed)
|
||||
s.node = hostname
|
||||
# Backfill source
|
||||
root = get_castle_root()
|
||||
if root and s.source is None:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
s.source = _backfill_source(name, config)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
summaries.append(s)
|
||||
seen.add(name)
|
||||
|
||||
# Non-deployed from castle.yaml
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
for name, svc in config.services.items():
|
||||
if name not in seen:
|
||||
s = _service_from_spec(name, svc, config)
|
||||
s.node = hostname
|
||||
summaries.append(s)
|
||||
seen.add(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Remote
|
||||
if include_remote:
|
||||
for remote_host, remote in mesh_state.all_nodes().items():
|
||||
for name, d in remote.registry.deployed.items():
|
||||
if not d.schedule and name not in seen:
|
||||
s = _service_from_deployed(name, d)
|
||||
s.node = remote_host
|
||||
summaries.append(s)
|
||||
seen.add(name)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
@router.get(
|
||||
"/services/{name}",
|
||||
response_model=ServiceDetail,
|
||||
tags=["services-data"],
|
||||
)
|
||||
def get_service(name: str) -> ServiceDetail:
|
||||
"""Get detailed info for a single service."""
|
||||
registry = get_registry()
|
||||
|
||||
if name in registry.deployed and not registry.deployed[name].schedule:
|
||||
deployed = registry.deployed[name]
|
||||
summary = _service_from_deployed(name, deployed)
|
||||
root = get_castle_root()
|
||||
if root and summary.source is None:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
summary.source = _backfill_source(name, config)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raw = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"port": deployed.port,
|
||||
"health_path": deployed.health_path,
|
||||
"proxy_path": deployed.proxy_path,
|
||||
"managed": deployed.managed,
|
||||
"behavior": deployed.behavior,
|
||||
"stack": deployed.stack,
|
||||
}
|
||||
return ServiceDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
summary = _service_from_spec(name, svc, config)
|
||||
raw = svc.model_dump(mode="json", exclude_none=True)
|
||||
return ServiceDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service '{name}' not found",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=list[JobSummary], tags=["jobs-data"])
|
||||
def list_jobs(include_remote: bool = False) -> list[JobSummary]:
|
||||
"""List all jobs — deployed from registry, non-deployed from castle.yaml."""
|
||||
registry = get_registry()
|
||||
hostname = registry.node.hostname
|
||||
summaries: list[JobSummary] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# Deployed jobs (scheduled)
|
||||
for name, deployed in registry.deployed.items():
|
||||
if not deployed.schedule:
|
||||
continue
|
||||
s = _job_from_deployed(name, deployed)
|
||||
s.node = hostname
|
||||
root = get_castle_root()
|
||||
if root and s.source is None:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
s.source = _backfill_source(name, config)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
summaries.append(s)
|
||||
seen.add(name)
|
||||
|
||||
# Non-deployed from castle.yaml
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
for name, job in config.jobs.items():
|
||||
if name not in seen:
|
||||
s = _job_from_spec(name, job, config)
|
||||
s.node = hostname
|
||||
summaries.append(s)
|
||||
seen.add(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Remote
|
||||
if include_remote:
|
||||
for remote_host, remote in mesh_state.all_nodes().items():
|
||||
for name, d in remote.registry.deployed.items():
|
||||
if d.schedule and name not in seen:
|
||||
s = _job_from_deployed(name, d)
|
||||
s.node = remote_host
|
||||
summaries.append(s)
|
||||
seen.add(name)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
@router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"])
|
||||
def get_job(name: str) -> JobDetail:
|
||||
"""Get detailed info for a single job."""
|
||||
registry = get_registry()
|
||||
|
||||
if name in registry.deployed and registry.deployed[name].schedule:
|
||||
deployed = registry.deployed[name]
|
||||
summary = _job_from_deployed(name, deployed)
|
||||
root = get_castle_root()
|
||||
if root and summary.source is None:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
summary.source = _backfill_source(name, config)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raw = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"managed": deployed.managed,
|
||||
"schedule": deployed.schedule,
|
||||
"behavior": deployed.behavior,
|
||||
"stack": deployed.stack,
|
||||
}
|
||||
return JobDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
summary = _job_from_spec(name, job, config)
|
||||
raw = job.model_dump(mode="json", exclude_none=True)
|
||||
return JobDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Job '{name}' not found",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
|
||||
def list_programs() -> list[ProgramSummary]:
|
||||
"""List all programs from the software catalog (castle.yaml programs section)."""
|
||||
root = get_castle_root()
|
||||
if not root:
|
||||
return []
|
||||
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
hostname = get_registry().node.hostname
|
||||
summaries: list[ProgramSummary] = []
|
||||
|
||||
for name, comp in config.programs.items():
|
||||
summary = _program_from_spec(name, comp, root, config)
|
||||
if summary.behavior is None:
|
||||
continue
|
||||
summary.node = hostname
|
||||
summaries.append(summary)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
@router.get("/programs/{name}", response_model=ProgramDetail, tags=["programs"])
|
||||
def get_program(name: str) -> ProgramDetail:
|
||||
"""Get detailed info for a single program."""
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.programs:
|
||||
comp = config.programs[name]
|
||||
summary = _program_from_spec(name, comp, root, config)
|
||||
raw = comp.model_dump(mode="json", exclude_none=True)
|
||||
return ProgramDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Program '{name}' not found",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy /components endpoint (compat shim)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/components", response_model=list[ComponentSummary])
|
||||
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
||||
"""List all components — deployed from registry, non-deployed from castle.yaml.
|
||||
@@ -232,33 +687,37 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
||||
summaries.append(s)
|
||||
seen.add(name)
|
||||
|
||||
# Backfill source from component refs for deployed items
|
||||
# Backfill source from program refs for deployed items
|
||||
for s in summaries:
|
||||
if s.source is None and s.id in config.components:
|
||||
s.source = config.components[s.id].source
|
||||
if s.source is None and s.id in config.programs:
|
||||
s.source = config.programs[s.id].source
|
||||
elif s.source is None:
|
||||
# Check if a service/job references a component
|
||||
# Check if a service/job references a program
|
||||
ref = None
|
||||
if s.id in config.services:
|
||||
ref = config.services[s.id].component
|
||||
elif s.id in config.jobs:
|
||||
ref = config.jobs[s.id].component
|
||||
if ref and ref in config.components:
|
||||
s.source = config.components[ref].source
|
||||
if ref and ref in config.programs:
|
||||
s.source = config.programs[ref].source
|
||||
|
||||
# Components (tools/frontends) not already represented by a
|
||||
# service or job entry. Skip if the component has no
|
||||
# distinct behavior (e.g. it's just the software identity
|
||||
# behind a service).
|
||||
for name, comp in config.components.items():
|
||||
if name in seen:
|
||||
continue
|
||||
summary = _summary_from_component(name, comp, root)
|
||||
# Programs — always include entries from the programs
|
||||
# section as catalog items. Derive behavior from backing
|
||||
# service/job when the program has no install/build spec.
|
||||
svc_components = {s.component for s in config.services.values() if s.component}
|
||||
job_components = {j.component for j in config.jobs.values() if j.component}
|
||||
|
||||
for name, comp in config.programs.items():
|
||||
summary = _summary_from_program(name, comp, root)
|
||||
if summary.behavior is None:
|
||||
continue
|
||||
if name in svc_components or name in config.services:
|
||||
summary.behavior = "daemon"
|
||||
elif name in job_components or name in config.jobs:
|
||||
summary.behavior = "tool"
|
||||
else:
|
||||
continue
|
||||
summary.node = local_hostname
|
||||
summaries.append(summary)
|
||||
seen.add(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -297,23 +756,23 @@ def get_component(name: str) -> ComponentDetail:
|
||||
deployed = registry.deployed[name]
|
||||
summary = _summary_from_deployed(name, deployed)
|
||||
|
||||
# Backfill source from castle.yaml component ref
|
||||
# Backfill source from castle.yaml program ref
|
||||
root = get_castle_root()
|
||||
if root and summary.source is None:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.components:
|
||||
summary.source = config.components[name].source
|
||||
if name in config.programs:
|
||||
summary.source = config.programs[name].source
|
||||
else:
|
||||
ref = None
|
||||
if name in config.services:
|
||||
ref = config.services[name].component
|
||||
elif name in config.jobs:
|
||||
ref = config.jobs[name].component
|
||||
if ref and ref in config.components:
|
||||
summary.source = config.components[ref].source
|
||||
if ref and ref in config.programs:
|
||||
summary.source = config.programs[ref].source
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -349,9 +808,9 @@ def get_component(name: str) -> ComponentDetail:
|
||||
raw = job.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
if name in config.components:
|
||||
comp = config.components[name]
|
||||
summary = _summary_from_component(name, comp, root)
|
||||
if name in config.programs:
|
||||
comp = config.programs[name]
|
||||
summary = _summary_from_program(name, comp, root)
|
||||
raw = comp.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user