Stage 1: API — correct editable shape + program/deployment links
The config editor read service/job detail 'manifest' as the castle.yaml spec but the endpoint returned the flat *runtime registry* shape for deployed entries — so service fields (port/health/proxy) never prefilled and a save would 422/corrupt. Detail 'manifest' is now always the editable castle.yaml ServiceSpec/JobSpec when the entry is declared there; the flat registry shape is a display-only fallback for entries that exist only at runtime. Also: - ServiceSummary/JobSummary gain run_target (what it runs) + program ref; ServiceSummary gains proxy_host. - ProgramSummary gains services[]/jobs[] — the deployments referencing a program (a program → 0-N services/jobs), for linking + create prefill. - App types updated to match; deleted orphaned DeploymentEditor/AddDeployment (leftovers from the removed ConfigEditor). Verified live: service manifest nested (expose.http.internal.port, proxy.caddy .host prefill), run_target=lakehoused, program lakehouse → services:[lakehouse]. api 52 green; ruff + app build clean.
This commit is contained in:
@@ -1,189 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { Plus, X } from "lucide-react"
|
|
||||||
|
|
||||||
const TEMPLATES: Record<string, Record<string, unknown>> = {
|
|
||||||
service: {
|
|
||||||
run: {
|
|
||||||
runner: "python",
|
|
||||||
program: "",
|
|
||||||
cwd: "",
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
expose: {
|
|
||||||
http: {
|
|
||||||
internal: { port: 9001 },
|
|
||||||
health_path: "/health",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
caddy: { path_prefix: "/" },
|
|
||||||
},
|
|
||||||
manage: {
|
|
||||||
systemd: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tool: {
|
|
||||||
behavior: "tool",
|
|
||||||
},
|
|
||||||
job: {
|
|
||||||
run: {
|
|
||||||
runner: "command",
|
|
||||||
argv: [""],
|
|
||||||
cwd: "",
|
|
||||||
},
|
|
||||||
schedule: "0 2 * * *",
|
|
||||||
},
|
|
||||||
empty: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddDeploymentProps {
|
|
||||||
onAdd: (name: string, config: Record<string, unknown>) => Promise<void>
|
|
||||||
existingNames: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddDeployment({ onAdd, existingNames }: AddDeploymentProps) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [description, setDescription] = useState("")
|
|
||||||
const [template, setTemplate] = useState("service")
|
|
||||||
const [port, setPort] = useState("")
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
|
|
||||||
const nameError =
|
|
||||||
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
|
|
||||||
? "lowercase letters, numbers, and hyphens"
|
|
||||||
: existingNames.includes(name)
|
|
||||||
? "already exists"
|
|
||||||
: ""
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!name || nameError) return
|
|
||||||
setSaving(true)
|
|
||||||
setError("")
|
|
||||||
try {
|
|
||||||
const config: Record<string, unknown> = JSON.parse(
|
|
||||||
JSON.stringify(TEMPLATES[template] ?? {})
|
|
||||||
)
|
|
||||||
if (description) config.description = description
|
|
||||||
|
|
||||||
// Fill in template-specific fields
|
|
||||||
if (template === "service") {
|
|
||||||
const run = config.run as Record<string, unknown>
|
|
||||||
run.program = name
|
|
||||||
run.cwd = name
|
|
||||||
const proxy = (config.proxy as Record<string, Record<string, string>>).caddy
|
|
||||||
proxy.path_prefix = `/${name}`
|
|
||||||
if (port) {
|
|
||||||
const expose = (config.expose as Record<string, Record<string, Record<string, number>>>)
|
|
||||||
expose.http.internal.port = parseInt(port, 10)
|
|
||||||
}
|
|
||||||
} else if (template === "tool") {
|
|
||||||
// tool template has behavior preset, no extra config needed
|
|
||||||
}
|
|
||||||
|
|
||||||
await onAdd(name, config)
|
|
||||||
setName("")
|
|
||||||
setDescription("")
|
|
||||||
setPort("")
|
|
||||||
setOpen(false)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="w-full flex items-center justify-center gap-2 p-4 border border-dashed border-[var(--border)] rounded-lg text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={16} /> Add entry
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="font-semibold">New entry</h3>
|
|
||||||
<button onClick={() => setOpen(false)} className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field label="Name">
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value.toLowerCase())}
|
|
||||||
placeholder="my-service"
|
|
||||||
autoFocus
|
|
||||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
|
||||||
/>
|
|
||||||
{nameError && <p className="text-xs text-red-400 mt-1">{nameError}</p>}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Template">
|
|
||||||
<select
|
|
||||||
value={template}
|
|
||||||
onChange={(e) => setTemplate(e.target.value)}
|
|
||||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
|
||||||
>
|
|
||||||
<option value="service">Service (FastAPI + systemd + Caddy)</option>
|
|
||||||
<option value="tool">Tool (PATH install)</option>
|
|
||||||
<option value="job">Job (scheduled task)</option>
|
|
||||||
<option value="empty">Empty</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="Description">
|
|
||||||
<input
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="What does this entry do?"
|
|
||||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{template === "service" && (
|
|
||||||
<Field label="Port">
|
|
||||||
<input
|
|
||||||
value={port}
|
|
||||||
onChange={(e) => setPort(e.target.value)}
|
|
||||||
placeholder="9001"
|
|
||||||
className="w-32 bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!name || !!nameError || saving}
|
|
||||||
className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Plus size={14} /> Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
|
||||||
import type { DeploymentDetail } from "@/types"
|
|
||||||
import { DeploymentFields } from "./DeploymentFields"
|
|
||||||
import { BehaviorBadge } from "./BehaviorBadge"
|
|
||||||
import { StackBadge } from "./StackBadge"
|
|
||||||
|
|
||||||
interface DeploymentEditorProps {
|
|
||||||
deployment: DeploymentDetail
|
|
||||||
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
|
||||||
onDelete: (name: string) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeploymentEditor({ deployment, onSave, onDelete }: DeploymentEditorProps) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="w-full flex items-center justify-between p-4 hover:bg-white/5 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
||||||
<span className="font-semibold">{deployment.id}</span>
|
|
||||||
<span className="text-sm text-[var(--muted)]">{deployment.description}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<BehaviorBadge behavior={deployment.behavior} />
|
|
||||||
<StackBadge stack={deployment.stack} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t border-[var(--border)] p-4">
|
|
||||||
<DeploymentFields
|
|
||||||
deployment={deployment}
|
|
||||||
onSave={onSave}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,11 +9,14 @@ export interface ServiceSummary {
|
|||||||
description: string | null
|
description: string | null
|
||||||
stack: string | null
|
stack: string | null
|
||||||
runner: string | null
|
runner: string | null
|
||||||
|
run_target: string | null
|
||||||
port: number | null
|
port: number | null
|
||||||
health_path: string | null
|
health_path: string | null
|
||||||
proxy_path: string | null
|
proxy_path: string | null
|
||||||
|
proxy_host: string | null
|
||||||
managed: boolean
|
managed: boolean
|
||||||
systemd: SystemdInfo | null
|
systemd: SystemdInfo | null
|
||||||
|
program: string | null
|
||||||
source: string | null
|
source: string | null
|
||||||
node: string | null
|
node: string | null
|
||||||
}
|
}
|
||||||
@@ -27,9 +30,11 @@ export interface JobSummary {
|
|||||||
description: string | null
|
description: string | null
|
||||||
stack: string | null
|
stack: string | null
|
||||||
runner: string | null
|
runner: string | null
|
||||||
|
run_target: string | null
|
||||||
schedule: string | null
|
schedule: string | null
|
||||||
managed: boolean
|
managed: boolean
|
||||||
systemd: SystemdInfo | null
|
systemd: SystemdInfo | null
|
||||||
|
program: string | null
|
||||||
source: string | null
|
source: string | null
|
||||||
node: string | null
|
node: string | null
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,8 @@ export interface ProgramSummary {
|
|||||||
installed: boolean | null
|
installed: boolean | null
|
||||||
active: boolean | null
|
active: boolean | null
|
||||||
actions: string[]
|
actions: string[]
|
||||||
|
services: string[]
|
||||||
|
jobs: string[]
|
||||||
node: string | null
|
node: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,14 @@ class ServiceSummary(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
stack: str | None = None
|
stack: str | None = None
|
||||||
runner: str | None = None
|
runner: str | None = None
|
||||||
|
run_target: str | None = None # what it runs: program name, argv, image, …
|
||||||
port: int | None = None
|
port: int | None = None
|
||||||
health_path: str | None = None
|
health_path: str | None = None
|
||||||
proxy_path: str | None = None
|
proxy_path: str | None = None
|
||||||
|
proxy_host: str | None = None
|
||||||
managed: bool = False
|
managed: bool = False
|
||||||
systemd: SystemdInfo | None = None
|
systemd: SystemdInfo | None = None
|
||||||
|
program: str | None = None # the program this deployment references, if any
|
||||||
source: str | None = None
|
source: str | None = None
|
||||||
node: str | None = None
|
node: str | None = None
|
||||||
|
|
||||||
@@ -72,9 +75,11 @@ class JobSummary(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
stack: str | None = None
|
stack: str | None = None
|
||||||
runner: str | None = None
|
runner: str | None = None
|
||||||
|
run_target: str | None = None # what it runs: program name, argv, …
|
||||||
schedule: str | None = None
|
schedule: str | None = None
|
||||||
managed: bool = False
|
managed: bool = False
|
||||||
systemd: SystemdInfo | None = None
|
systemd: SystemdInfo | None = None
|
||||||
|
program: str | None = None # the program this deployment references, if any
|
||||||
source: str | None = None
|
source: str | None = None
|
||||||
node: str | None = None
|
node: str | None = None
|
||||||
|
|
||||||
@@ -102,6 +107,8 @@ class ProgramSummary(BaseModel):
|
|||||||
installed: bool | None = None
|
installed: bool | None = None
|
||||||
active: bool | None = None # uniform lifecycle state (on PATH / running / served)
|
active: bool | None = None # uniform lifecycle state (on PATH / running / served)
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
services: list[str] = [] # services that deploy this program
|
||||||
|
jobs: list[str] = [] # jobs that deploy this program
|
||||||
node: str | None = None
|
node: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -230,17 +230,34 @@ def _backfill_source(name: str, config: object) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_target(run: object) -> str | None:
|
||||||
|
"""A human label for what a run spec executes (program / argv / image / …)."""
|
||||||
|
if run is None:
|
||||||
|
return None
|
||||||
|
for attr in ("program", "image", "script", "base_url"):
|
||||||
|
val = getattr(run, attr, None)
|
||||||
|
if val:
|
||||||
|
return str(val)
|
||||||
|
argv = getattr(run, "argv", None)
|
||||||
|
if argv:
|
||||||
|
return " ".join(argv)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||||
"""Build a ServiceSummary from a Deployment."""
|
"""Build a ServiceSummary from a Deployment."""
|
||||||
systemd_info = _make_systemd_info(name) if deployed.managed else None
|
systemd_info = _make_systemd_info(name) if deployed.managed else None
|
||||||
|
run_target = " ".join(deployed.run_cmd) if deployed.run_cmd else None
|
||||||
return ServiceSummary(
|
return ServiceSummary(
|
||||||
id=name,
|
id=name,
|
||||||
description=deployed.description,
|
description=deployed.description,
|
||||||
stack=deployed.stack,
|
stack=deployed.stack,
|
||||||
runner=deployed.runner,
|
runner=deployed.runner,
|
||||||
|
run_target=run_target,
|
||||||
port=deployed.port,
|
port=deployed.port,
|
||||||
health_path=deployed.health_path,
|
health_path=deployed.health_path,
|
||||||
proxy_path=deployed.proxy_path,
|
proxy_path=deployed.proxy_path,
|
||||||
|
proxy_host=deployed.proxy_host,
|
||||||
managed=deployed.managed,
|
managed=deployed.managed,
|
||||||
systemd=systemd_info,
|
systemd=systemd_info,
|
||||||
)
|
)
|
||||||
@@ -270,16 +287,20 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
|
|||||||
source = comp.source
|
source = comp.source
|
||||||
stack = comp.stack
|
stack = comp.stack
|
||||||
|
|
||||||
|
proxy_host = svc.proxy.caddy.host if (svc.proxy and svc.proxy.caddy) else None
|
||||||
return ServiceSummary(
|
return ServiceSummary(
|
||||||
id=name,
|
id=name,
|
||||||
description=description,
|
description=description,
|
||||||
stack=stack,
|
stack=stack,
|
||||||
runner=svc.run.runner,
|
runner=svc.run.runner,
|
||||||
|
run_target=_run_target(svc.run),
|
||||||
port=port,
|
port=port,
|
||||||
health_path=health_path,
|
health_path=health_path,
|
||||||
proxy_path=proxy_path,
|
proxy_path=proxy_path,
|
||||||
|
proxy_host=proxy_host,
|
||||||
managed=managed,
|
managed=managed,
|
||||||
systemd=systemd_info,
|
systemd=systemd_info,
|
||||||
|
program=svc.program,
|
||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -287,11 +308,13 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
|
|||||||
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||||
"""Build a JobSummary from a Deployment."""
|
"""Build a JobSummary from a Deployment."""
|
||||||
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
|
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
|
||||||
|
run_target = " ".join(deployed.run_cmd) if deployed.run_cmd else None
|
||||||
return JobSummary(
|
return JobSummary(
|
||||||
id=name,
|
id=name,
|
||||||
description=deployed.description,
|
description=deployed.description,
|
||||||
stack=deployed.stack,
|
stack=deployed.stack,
|
||||||
runner=deployed.runner,
|
runner=deployed.runner,
|
||||||
|
run_target=run_target,
|
||||||
schedule=deployed.schedule,
|
schedule=deployed.schedule,
|
||||||
managed=deployed.managed,
|
managed=deployed.managed,
|
||||||
systemd=systemd_info,
|
systemd=systemd_info,
|
||||||
@@ -318,9 +341,11 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
|
|||||||
description=description,
|
description=description,
|
||||||
stack=stack,
|
stack=stack,
|
||||||
runner=job.run.runner,
|
runner=job.run.runner,
|
||||||
|
run_target=_run_target(job.run),
|
||||||
schedule=job.schedule,
|
schedule=job.schedule,
|
||||||
managed=managed,
|
managed=managed,
|
||||||
systemd=systemd_info,
|
systemd=systemd_info,
|
||||||
|
program=job.program,
|
||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -344,10 +369,15 @@ def _program_from_spec(
|
|||||||
|
|
||||||
# Uniform lifecycle state (on PATH / running / served) — needs full config.
|
# Uniform lifecycle state (on PATH / running / served) — needs full config.
|
||||||
active: bool | None = None
|
active: bool | None = None
|
||||||
|
services: list[str] = []
|
||||||
|
jobs: list[str] = []
|
||||||
if config is not None:
|
if config is not None:
|
||||||
from castle_core.lifecycle import is_active
|
from castle_core.lifecycle import is_active
|
||||||
|
|
||||||
active = is_active(name, config)
|
active = is_active(name, config)
|
||||||
|
# Deployments that reference this program (a program → 0-N services/jobs).
|
||||||
|
services = [s for s, spec in config.services.items() if spec.program == name]
|
||||||
|
jobs = [j for j, spec in config.jobs.items() if spec.program == name]
|
||||||
|
|
||||||
return ProgramSummary(
|
return ProgramSummary(
|
||||||
id=name,
|
id=name,
|
||||||
@@ -364,6 +394,8 @@ def _program_from_spec(
|
|||||||
installed=installed,
|
installed=installed,
|
||||||
active=active,
|
active=active,
|
||||||
actions=available_actions(comp),
|
actions=available_actions(comp),
|
||||||
|
services=services,
|
||||||
|
jobs=jobs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -434,22 +466,36 @@ def list_services(include_remote: bool = False) -> list[ServiceSummary]:
|
|||||||
tags=["services-data"],
|
tags=["services-data"],
|
||||||
)
|
)
|
||||||
def get_service(name: str) -> ServiceDetail:
|
def get_service(name: str) -> ServiceDetail:
|
||||||
"""Get detailed info for a single service."""
|
"""Get detailed info for a single service.
|
||||||
registry = get_registry()
|
|
||||||
|
|
||||||
|
The `manifest` is the editable castle.yaml ServiceSpec whenever the service
|
||||||
|
is declared there — that's the source of truth the config editor reads and
|
||||||
|
writes. Only services that exist *only* in the runtime registry (e.g. infra
|
||||||
|
not in castle.yaml) fall back to the flat deployed shape (display-only).
|
||||||
|
"""
|
||||||
|
root = get_castle_root()
|
||||||
|
config = None
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if config is not None and name in config.services:
|
||||||
|
svc = config.services[name]
|
||||||
|
summary = _service_from_spec(name, svc, config)
|
||||||
|
manifest = svc.model_dump(mode="json", exclude_none=True)
|
||||||
|
return ServiceDetail(**summary.model_dump(), manifest=manifest)
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
if name in registry.deployed and not registry.deployed[name].schedule:
|
if name in registry.deployed and not registry.deployed[name].schedule:
|
||||||
deployed = registry.deployed[name]
|
deployed = registry.deployed[name]
|
||||||
summary = _service_from_deployed(name, deployed)
|
summary = _service_from_deployed(name, deployed)
|
||||||
root = get_castle_root()
|
if config is not None and summary.source is None:
|
||||||
if root and summary.source is None:
|
summary.source = _backfill_source(name, config)
|
||||||
try:
|
manifest = {
|
||||||
from castle_core.config import load_config
|
|
||||||
|
|
||||||
config = load_config(root)
|
|
||||||
summary.source = _backfill_source(name, config)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
raw = {
|
|
||||||
"runner": deployed.runner,
|
"runner": deployed.runner,
|
||||||
"run_cmd": deployed.run_cmd,
|
"run_cmd": deployed.run_cmd,
|
||||||
"env": deployed.env,
|
"env": deployed.env,
|
||||||
@@ -460,18 +506,7 @@ def get_service(name: str) -> ServiceDetail:
|
|||||||
"behavior": deployed.behavior,
|
"behavior": deployed.behavior,
|
||||||
"stack": deployed.stack,
|
"stack": deployed.stack,
|
||||||
}
|
}
|
||||||
return ServiceDetail(**summary.model_dump(), manifest=raw)
|
return ServiceDetail(**summary.model_dump(), manifest=manifest)
|
||||||
|
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -536,22 +571,31 @@ def list_jobs(include_remote: bool = False) -> list[JobSummary]:
|
|||||||
|
|
||||||
@router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"])
|
@router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"])
|
||||||
def get_job(name: str) -> JobDetail:
|
def get_job(name: str) -> JobDetail:
|
||||||
"""Get detailed info for a single job."""
|
"""Get detailed info for a single job. `manifest` is the editable castle.yaml
|
||||||
registry = get_registry()
|
JobSpec when declared there; falls back to the runtime registry otherwise."""
|
||||||
|
root = get_castle_root()
|
||||||
|
config = None
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if config is not None and name in config.jobs:
|
||||||
|
job = config.jobs[name]
|
||||||
|
summary = _job_from_spec(name, job, config)
|
||||||
|
manifest = job.model_dump(mode="json", exclude_none=True)
|
||||||
|
return JobDetail(**summary.model_dump(), manifest=manifest)
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
if name in registry.deployed and registry.deployed[name].schedule:
|
if name in registry.deployed and registry.deployed[name].schedule:
|
||||||
deployed = registry.deployed[name]
|
deployed = registry.deployed[name]
|
||||||
summary = _job_from_deployed(name, deployed)
|
summary = _job_from_deployed(name, deployed)
|
||||||
root = get_castle_root()
|
if config is not None and summary.source is None:
|
||||||
if root and summary.source is None:
|
summary.source = _backfill_source(name, config)
|
||||||
try:
|
manifest = {
|
||||||
from castle_core.config import load_config
|
|
||||||
|
|
||||||
config = load_config(root)
|
|
||||||
summary.source = _backfill_source(name, config)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
raw = {
|
|
||||||
"runner": deployed.runner,
|
"runner": deployed.runner,
|
||||||
"run_cmd": deployed.run_cmd,
|
"run_cmd": deployed.run_cmd,
|
||||||
"env": deployed.env,
|
"env": deployed.env,
|
||||||
@@ -560,18 +604,7 @@ def get_job(name: str) -> JobDetail:
|
|||||||
"behavior": deployed.behavior,
|
"behavior": deployed.behavior,
|
||||||
"stack": deployed.stack,
|
"stack": deployed.stack,
|
||||||
}
|
}
|
||||||
return JobDetail(**summary.model_dump(), manifest=raw)
|
return JobDetail(**summary.model_dump(), manifest=manifest)
|
||||||
|
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ class TestServiceDetail:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["id"] == "test-svc"
|
assert data["id"] == "test-svc"
|
||||||
assert "manifest" in data
|
assert "manifest" in data
|
||||||
assert data["manifest"]["runner"] == "python"
|
# manifest is the editable castle.yaml ServiceSpec (nested run spec)
|
||||||
|
assert data["manifest"]["run"]["runner"] == "python"
|
||||||
|
assert data["run_target"] == "test-svc"
|
||||||
|
|
||||||
def test_not_found(self, client: TestClient) -> None:
|
def test_not_found(self, client: TestClient) -> None:
|
||||||
"""Returns 404 for unknown service."""
|
"""Returns 404 for unknown service."""
|
||||||
|
|||||||
Reference in New Issue
Block a user