feat(services): initial config on ADD SERVICE — schema modal in DeckyCard, MazeNET drag, and Inspector
- DeckyServiceAddRequest gains an optional `config: dict` field, validated
against the service's config_schema before any state mutation (400 on
bad type, no half-written rows).
- Engine: add_service threads `config` into _add_topology_service /
_add_fleet_service, persisting validated cfg to decky_config.service_config
BEFORE compose regen so the first `up -d --build` materialises the env on
the new container. No follow-up apply needed.
- Frontend: shared AddServiceConfigModal — same wizard accordion shape, used by:
* DeckyCard's ADD SERVICE picker (Fleet & MazeNET inspectors via shared component)
* MazeNET Inspector's ADD SERVICE picker
* MazeNET palette drag-drop onto a deployed decky
Empty-schema services short-circuit to a one-click add (no modal flash).
Operator can cancel; errors surface in the modal.
- Tests: add_service config plumbing — persist, drop unknown keys, 400-equivalent
on bad types, back-compat empty-config.
- Drive-by: fix stale repo-method names in test_services_live.py
(create_topology_decky → add_topology_decky, get_topology_decky → list+pick helper,
service.added → service_added topic).
This commit is contained in:
@@ -166,6 +166,7 @@ async def _add_topology_service(
|
|||||||
topology_id: str,
|
topology_id: str,
|
||||||
decky_name: str,
|
decky_name: str,
|
||||||
service_name: str,
|
service_name: str,
|
||||||
|
initial_config: dict | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
decky = await _topology_decky(repo, topology_id, decky_name)
|
decky = await _topology_decky(repo, topology_id, decky_name)
|
||||||
services: list[str] = list(decky.get("services") or [])
|
services: list[str] = list(decky.get("services") or [])
|
||||||
@@ -174,7 +175,17 @@ async def _add_topology_service(
|
|||||||
f"service {service_name!r} already on decky {decky_name!r}"
|
f"service {service_name!r} already on decky {decky_name!r}"
|
||||||
)
|
)
|
||||||
services.append(service_name)
|
services.append(service_name)
|
||||||
await repo.update_topology_decky(decky["uuid"], {"services": services})
|
update: dict[str, Any] = {"services": services}
|
||||||
|
# If the caller supplied initial config, fold it into decky_config
|
||||||
|
# BEFORE compose regen so the first ``up`` materialises the env on
|
||||||
|
# the new container — no follow-up apply needed.
|
||||||
|
if initial_config:
|
||||||
|
cfg_blob = dict(decky.get("decky_config") or {})
|
||||||
|
sc = dict(cfg_blob.get("service_config") or {})
|
||||||
|
sc[service_name] = initial_config
|
||||||
|
cfg_blob["service_config"] = sc
|
||||||
|
update["decky_config"] = cfg_blob
|
||||||
|
await repo.update_topology_decky(decky["uuid"], update)
|
||||||
|
|
||||||
compose_path = await _rerender_topology_compose(repo, topology_id)
|
compose_path = await _rerender_topology_compose(repo, topology_id)
|
||||||
target = f"{decky_name}-{service_name}"
|
target = f"{decky_name}-{service_name}"
|
||||||
@@ -260,7 +271,10 @@ async def _persist_fleet_change(
|
|||||||
|
|
||||||
|
|
||||||
async def _add_fleet_service(
|
async def _add_fleet_service(
|
||||||
repo: BaseRepository, decky_name: str, service_name: str,
|
repo: BaseRepository,
|
||||||
|
decky_name: str,
|
||||||
|
service_name: str,
|
||||||
|
initial_config: dict | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
config, compose_path = _fleet_state_or_raise()
|
config, compose_path = _fleet_state_or_raise()
|
||||||
decky = _fleet_find_decky(config, decky_name)
|
decky = _fleet_find_decky(config, decky_name)
|
||||||
@@ -270,6 +284,12 @@ async def _add_fleet_service(
|
|||||||
f"service {service_name!r} already on decky {decky_name!r}"
|
f"service {service_name!r} already on decky {decky_name!r}"
|
||||||
)
|
)
|
||||||
services.append(service_name)
|
services.append(service_name)
|
||||||
|
if initial_config:
|
||||||
|
# Same path as _update_fleet_service_config: stash the validated
|
||||||
|
# cfg on the decky model so the compose write picks it up.
|
||||||
|
sc = dict(getattr(decky, "service_config", None) or {})
|
||||||
|
sc[service_name] = initial_config
|
||||||
|
decky.service_config = sc
|
||||||
await _persist_fleet_change(repo, decky, services, compose_path)
|
await _persist_fleet_change(repo, decky, services, compose_path)
|
||||||
target = f"{decky_name}-{service_name}"
|
target = f"{decky_name}-{service_name}"
|
||||||
await anyio.to_thread.run_sync(
|
await anyio.to_thread.run_sync(
|
||||||
@@ -313,17 +333,24 @@ async def add_service(
|
|||||||
decky_name: str,
|
decky_name: str,
|
||||||
service_name: str,
|
service_name: str,
|
||||||
topology_id: Optional[str] = None,
|
topology_id: Optional[str] = None,
|
||||||
|
config: dict | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Add *service_name* to a deployed decky.
|
"""Add *service_name* to a deployed decky.
|
||||||
|
|
||||||
Validates the service registry (rejects unknown / fleet_singleton
|
Validates the service registry (rejects unknown / fleet_singleton
|
||||||
names), persists the change, regenerates the compose file, runs
|
names) and the optional ``config`` against the service's schema,
|
||||||
|
persists the change, regenerates the compose file, runs
|
||||||
``up -d --no-deps --build <decky>-<service>`` in a worker thread,
|
``up -d --no-deps --build <decky>-<service>`` in a worker thread,
|
||||||
and publishes ``decky.<name>.service.added`` on the bus.
|
and publishes ``decky.<name>.service.added`` on the bus.
|
||||||
|
|
||||||
|
``config`` is the same dict shape PUT/POST .../config accepts; it's
|
||||||
|
coerced via ``BaseService.validate_cfg`` before any state write so
|
||||||
|
a 400-class failure leaves zero side-effects.
|
||||||
|
|
||||||
Returns the post-mutation services list.
|
Returns the post-mutation services list.
|
||||||
"""
|
"""
|
||||||
_validate_service_for_per_decky(service_name)
|
svc = _validate_service_for_per_decky(service_name)
|
||||||
|
initial_config = svc.validate_cfg(config) if config else {}
|
||||||
if decky_kind == "topology":
|
if decky_kind == "topology":
|
||||||
if not topology_id:
|
if not topology_id:
|
||||||
raise ServiceMutationError(
|
raise ServiceMutationError(
|
||||||
@@ -331,9 +358,13 @@ async def add_service(
|
|||||||
)
|
)
|
||||||
services = await _add_topology_service(
|
services = await _add_topology_service(
|
||||||
repo, topology_id, decky_name, service_name,
|
repo, topology_id, decky_name, service_name,
|
||||||
|
initial_config=initial_config,
|
||||||
)
|
)
|
||||||
elif decky_kind == "fleet":
|
elif decky_kind == "fleet":
|
||||||
services = await _add_fleet_service(repo, decky_name, service_name)
|
services = await _add_fleet_service(
|
||||||
|
repo, decky_name, service_name,
|
||||||
|
initial_config=initial_config,
|
||||||
|
)
|
||||||
else: # pragma: no cover — Literal narrows
|
else: # pragma: no cover — Literal narrows
|
||||||
raise ServiceMutationError(f"unknown decky_kind {decky_kind!r}")
|
raise ServiceMutationError(f"unknown decky_kind {decky_kind!r}")
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,14 @@ class DeckyServiceAddRequest(BaseModel):
|
|||||||
and must NOT be ``fleet_singleton`` — those run once fleet-wide,
|
and must NOT be ``fleet_singleton`` — those run once fleet-wide,
|
||||||
not per-decky. Validation happens server-side in the engine layer
|
not per-decky. Validation happens server-side in the engine layer
|
||||||
and surfaces as 422.
|
and surfaces as 422.
|
||||||
|
|
||||||
|
``config`` carries optional initial per-service config (same shape as
|
||||||
|
DeckyServiceConfigRequest.config) so the freshly-added container
|
||||||
|
comes up with the operator's env from the start, no follow-up Apply
|
||||||
|
needed. Empty dict = build with defaults.
|
||||||
"""
|
"""
|
||||||
name: str = PydanticField(..., min_length=1)
|
name: str = PydanticField(..., min_length=1)
|
||||||
|
config: dict[str, Any] = PydanticField(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class DeckyServicesResponse(BaseModel):
|
class DeckyServicesResponse(BaseModel):
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def _map_mutation_error(exc: ServiceMutationError) -> HTTPException:
|
|||||||
"/deckies/{decky_name}/services",
|
"/deckies/{decky_name}/services",
|
||||||
response_model=DeckyServicesResponse,
|
response_model=DeckyServicesResponse,
|
||||||
responses={
|
responses={
|
||||||
400: {"description": "Malformed request body"},
|
400: {"description": "Malformed request body or initial config rejected by service schema"},
|
||||||
401: {"description": "Could not validate credentials"},
|
401: {"description": "Could not validate credentials"},
|
||||||
403: {"description": "Insufficient permissions"},
|
403: {"description": "Insufficient permissions"},
|
||||||
404: {"description": "Decky not found"},
|
404: {"description": "Decky not found"},
|
||||||
@@ -78,7 +78,10 @@ async def api_fleet_add_service(
|
|||||||
services = await add_service(
|
services = await add_service(
|
||||||
repo, decky_kind="fleet",
|
repo, decky_kind="fleet",
|
||||||
decky_name=decky_name, service_name=req.name,
|
decky_name=decky_name, service_name=req.name,
|
||||||
|
config=req.config,
|
||||||
)
|
)
|
||||||
|
except ConfigValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except ServiceMutationError as exc:
|
except ServiceMutationError as exc:
|
||||||
raise _map_mutation_error(exc) from exc
|
raise _map_mutation_error(exc) from exc
|
||||||
return DeckyServicesResponse(decky_name=decky_name, services=services)
|
return DeckyServicesResponse(decky_name=decky_name, services=services)
|
||||||
@@ -197,7 +200,7 @@ async def api_fleet_remove_service(
|
|||||||
"/{topology_id}/deckies/{decky_name}/services",
|
"/{topology_id}/deckies/{decky_name}/services",
|
||||||
response_model=DeckyServicesResponse,
|
response_model=DeckyServicesResponse,
|
||||||
responses={
|
responses={
|
||||||
400: {"description": "Malformed request body"},
|
400: {"description": "Malformed request body or initial config rejected by service schema"},
|
||||||
401: {"description": "Could not validate credentials"},
|
401: {"description": "Could not validate credentials"},
|
||||||
403: {"description": "Insufficient permissions"},
|
403: {"description": "Insufficient permissions"},
|
||||||
404: {"description": "Topology or decky not found"},
|
404: {"description": "Topology or decky not found"},
|
||||||
@@ -215,7 +218,10 @@ async def api_topology_add_service(
|
|||||||
services = await add_service(
|
services = await add_service(
|
||||||
repo, decky_kind="topology", topology_id=topology_id,
|
repo, decky_kind="topology", topology_id=topology_id,
|
||||||
decky_name=decky_name, service_name=req.name,
|
decky_name=decky_name, service_name=req.name,
|
||||||
|
config=req.config,
|
||||||
)
|
)
|
||||||
|
except ConfigValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except ServiceMutationError as exc:
|
except ServiceMutationError as exc:
|
||||||
raise _map_mutation_error(exc) from exc
|
raise _map_mutation_error(exc) from exc
|
||||||
return DeckyServicesResponse(
|
return DeckyServicesResponse(
|
||||||
|
|||||||
120
decnet_web/src/components/AddServiceConfigModal.tsx
Normal file
120
decnet_web/src/components/AddServiceConfigModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import Modal from './Modal/Modal';
|
||||||
|
import ServiceConfigFields, {
|
||||||
|
type FormState as SvcFormState,
|
||||||
|
type ServiceConfigFieldDTO as SvcFieldDTO,
|
||||||
|
type SchemaResponse,
|
||||||
|
buildInitial as svcBuildInitial,
|
||||||
|
compactPayload as svcCompactPayload,
|
||||||
|
fmtSchemaError,
|
||||||
|
} from './ServiceConfigFields';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** When non-null, modal is open for this {decky, slug}. */
|
||||||
|
pending: { deckyName: string; slug: string } | null;
|
||||||
|
/** Operator dismissed the modal without adding. */
|
||||||
|
onCancel: () => void;
|
||||||
|
/** User confirmed (or schema is empty — auto-confirm path). */
|
||||||
|
onConfirm: (deckyName: string, slug: string, config: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddServiceConfigModal: React.FC<Props> = ({ pending, onCancel, onConfirm }) => {
|
||||||
|
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
||||||
|
const [state, setState] = useState<SvcFormState>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const slug = pending?.slug ?? null;
|
||||||
|
const deckyName = pending?.deckyName ?? null;
|
||||||
|
|
||||||
|
// Reset on slug change so leftover state from a previous open doesn't
|
||||||
|
// bleed through into a different service's form.
|
||||||
|
useEffect(() => {
|
||||||
|
setSchema(null);
|
||||||
|
setState({});
|
||||||
|
setBusy(false);
|
||||||
|
setErr(null);
|
||||||
|
if (!slug || !deckyName) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api.get<SchemaResponse>(`/topologies/services/${encodeURIComponent(slug)}/schema`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSchema(data);
|
||||||
|
// Empty schema → no operator decision to make; fire immediately
|
||||||
|
// and close. The caller's onConfirm handles the POST.
|
||||||
|
if (data.fields.length === 0) {
|
||||||
|
onConfirm(deckyName, slug, {}).catch(() => { /* caller surfaces */ });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(svcBuildInitial(data.fields, {}));
|
||||||
|
})
|
||||||
|
.catch((loadErr) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setErr(fmtSchemaError(loadErr, 'Schema load failed.'));
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [slug, deckyName, onConfirm]);
|
||||||
|
|
||||||
|
// Don't render anything while we're auto-confirming an empty-schema add —
|
||||||
|
// saves the brief flash of an empty modal.
|
||||||
|
if (!pending) return null;
|
||||||
|
if (schema && schema.fields.length === 0) return null;
|
||||||
|
|
||||||
|
const fields: SvcFieldDTO[] = schema?.fields ?? [];
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!schema || !slug || !deckyName) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const compact = svcCompactPayload(fields, state);
|
||||||
|
await onConfirm(deckyName, slug, compact);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||||
|
?? 'Add failed.';
|
||||||
|
setErr(msg);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(pending)}
|
||||||
|
onClose={busy ? () => { /* ignore close-during-busy */ } : onCancel}
|
||||||
|
title={slug ? `ADD ${slug.toUpperCase()}` : 'ADD SERVICE'}
|
||||||
|
accent="violet"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn small" onClick={onCancel} disabled={busy}>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn violet small"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !schema}
|
||||||
|
>
|
||||||
|
{busy ? 'ADDING…' : 'ADD'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="modal-body">
|
||||||
|
{!schema && !err && <div className="svc-cfg-status">Loading schema…</div>}
|
||||||
|
{err && <div className="svc-cfg-status alert-text">{err}</div>}
|
||||||
|
{schema && slug && fields.length > 0 && (
|
||||||
|
<ServiceConfigFields
|
||||||
|
serviceSlug={slug}
|
||||||
|
value={state}
|
||||||
|
onChange={setState}
|
||||||
|
idScope={`add-${slug}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddServiceConfigModal;
|
||||||
@@ -9,6 +9,7 @@ import { useToast } from './Toasts/useToast';
|
|||||||
import Modal from './Modal/Modal';
|
import Modal from './Modal/Modal';
|
||||||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||||||
import ServiceConfigForm from './ServiceConfigForm';
|
import ServiceConfigForm from './ServiceConfigForm';
|
||||||
|
import AddServiceConfigModal from './AddServiceConfigModal';
|
||||||
import ServiceConfigFields, {
|
import ServiceConfigFields, {
|
||||||
type FormState as SvcFormState,
|
type FormState as SvcFormState,
|
||||||
type ServiceConfigFieldDTO as SvcFieldDTO,
|
type ServiceConfigFieldDTO as SvcFieldDTO,
|
||||||
@@ -164,6 +165,9 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [opError, setOpError] = useState<string | null>(null);
|
const [opError, setOpError] = useState<string | null>(null);
|
||||||
const [openCfgSvc, setOpenCfgSvc] = useState<string | null>(null);
|
const [openCfgSvc, setOpenCfgSvc] = useState<string | null>(null);
|
||||||
|
// Pending add — when non-null, AddServiceConfigModal is mounted and
|
||||||
|
// will either auto-fire onConfirm (no schema fields) or show the form.
|
||||||
|
const [pendingAdd, setPendingAdd] = useState<{ deckyName: string; slug: string } | null>(null);
|
||||||
|
|
||||||
const removeService = async (slug: string) => {
|
const removeService = async (slug: string) => {
|
||||||
setOpError(null);
|
setOpError(null);
|
||||||
@@ -182,22 +186,30 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addService = async () => {
|
const beginAdd = () => {
|
||||||
if (!addSlug) return;
|
if (!addSlug) return;
|
||||||
setOpError(null);
|
setOpError(null);
|
||||||
setBusy(addSlug);
|
setPendingAdd({ deckyName: decky.name, slug: addSlug });
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAdd = async (deckyName: string, slug: string, cfg: Record<string, unknown>) => {
|
||||||
|
setBusy(slug);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post<{ services: string[] }>(
|
const { data } = await api.post<{ services: string[] }>(
|
||||||
`/deckies/${encodeURIComponent(decky.name)}/services`,
|
`/deckies/${encodeURIComponent(deckyName)}/services`,
|
||||||
{ name: addSlug },
|
{ name: slug, config: cfg },
|
||||||
);
|
);
|
||||||
onServicesChanged(decky.name, data.services);
|
onServicesChanged(deckyName, data.services);
|
||||||
|
setPendingAdd(null);
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
setAddSlug('');
|
setAddSlug('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Re-raise so the modal can surface the error in its own status row.
|
||||||
|
// Also mirror onto opError for the inline picker case.
|
||||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||||
?? 'Add failed.';
|
?? 'Add failed.';
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
@@ -343,7 +355,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!addSlug || busy === addSlug}
|
disabled={!addSlug || busy === addSlug}
|
||||||
onClick={addService}
|
onClick={beginAdd}
|
||||||
className="btn violet small"
|
className="btn violet small"
|
||||||
>
|
>
|
||||||
{busy === addSlug ? 'ADDING' : 'ADD'}
|
{busy === addSlug ? 'ADDING' : 'ADD'}
|
||||||
@@ -409,6 +421,11 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AddServiceConfigModal
|
||||||
|
pending={pendingAdd}
|
||||||
|
onCancel={() => setPendingAdd(null)}
|
||||||
|
onConfirm={confirmAdd}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ interface Props {
|
|||||||
// topologyStatus (active/degraded → live, pending/anything else →
|
// topologyStatus (active/degraded → live, pending/anything else →
|
||||||
// design-time only). Wiring these props from MazeNET.tsx is the
|
// design-time only). Wiring these props from MazeNET.tsx is the
|
||||||
// single switch that turns chips into live controls.
|
// single switch that turns chips into live controls.
|
||||||
onLiveAddService?: (nodeName: string, slug: string) => Promise<void>;
|
/** Trigger the schema-driven add-service flow. Synchronous: opens
|
||||||
|
* the AddServiceConfigModal at the page level (or auto-confirms if
|
||||||
|
* the service has no schema fields). Errors surface inside the modal. */
|
||||||
|
onLiveAddService?: (nodeName: string, slug: string) => void;
|
||||||
onLiveRemoveService?: (nodeName: string, slug: string) => Promise<void>;
|
onLiveRemoveService?: (nodeName: string, slug: string) => Promise<void>;
|
||||||
/** Per-decky-eligible service slugs, fetched via useServiceRegistry. */
|
/** Per-decky-eligible service slugs, fetched via useServiceRegistry. */
|
||||||
availableServices?: string[];
|
availableServices?: string[];
|
||||||
@@ -202,21 +205,15 @@ const Inspector: React.FC<Props> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!addSlug || busy === addSlug}
|
disabled={!addSlug || busy === addSlug}
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
if (!addSlug) return;
|
if (!addSlug) return;
|
||||||
setOpError(null);
|
setOpError(null);
|
||||||
setBusy(addSlug);
|
// Fire-and-forget: opens the schema-driven config
|
||||||
try {
|
// modal at the page level (or auto-confirms for
|
||||||
await onLiveAddService!(node.name, addSlug);
|
// schema-less services). Errors surface in the modal.
|
||||||
setAddOpen(false);
|
onLiveAddService!(node.name, addSlug);
|
||||||
setAddSlug('');
|
setAddOpen(false);
|
||||||
} catch (err) {
|
setAddSlug('');
|
||||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
|
||||||
?? 'Add failed.';
|
|
||||||
setOpError(msg);
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 10px', fontSize: '0.7rem',
|
padding: '4px 10px', fontSize: '0.7rem',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream
|
|||||||
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
||||||
import { useToast } from '../Toasts/useToast';
|
import { useToast } from '../Toasts/useToast';
|
||||||
import { useServiceRegistry } from '../../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../../hooks/useServiceRegistry';
|
||||||
|
import AddServiceConfigModal from '../AddServiceConfigModal';
|
||||||
|
|
||||||
/* Short unique suffix for default names — avoids the DB uniqueness
|
/* Short unique suffix for default names — avoids the DB uniqueness
|
||||||
* constraint regardless of delete/re-add sequencing on the client. */
|
* constraint regardless of delete/re-add sequencing on the client. */
|
||||||
@@ -116,15 +117,40 @@ const MazeNET: React.FC = () => {
|
|||||||
cross-tab. */
|
cross-tab. */
|
||||||
const serviceRegistry = useServiceRegistry();
|
const serviceRegistry = useServiceRegistry();
|
||||||
|
|
||||||
const liveAddService = useCallback(async (nodeName: string, slug: string) => {
|
const liveAddService = useCallback(async (
|
||||||
|
nodeName: string,
|
||||||
|
slug: string,
|
||||||
|
config: Record<string, unknown> = {},
|
||||||
|
) => {
|
||||||
const { data } = await axios.post<{ services: string[] }>(
|
const { data } = await axios.post<{ services: string[] }>(
|
||||||
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services`,
|
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services`,
|
||||||
{ name: slug },
|
{ name: slug, config },
|
||||||
);
|
);
|
||||||
setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName
|
setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName
|
||||||
? { ...x, services: data.services } : x));
|
? { ...x, services: data.services } : x));
|
||||||
}, [topologyId]);
|
}, [topologyId]);
|
||||||
|
|
||||||
|
// Pending add for the schema-driven config modal — both the palette
|
||||||
|
// drag-drop and the Inspector ADD SERVICE picker funnel through here so
|
||||||
|
// operators get the same "configure on first up" flow either way.
|
||||||
|
const [pendingAddSvc, setPendingAddSvc] = useState<{ deckyName: string; slug: string } | null>(null);
|
||||||
|
|
||||||
|
const requestAddService = useCallback((nodeName: string, slug: string) => {
|
||||||
|
setPendingAddSvc({ deckyName: nodeName, slug });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmAddService = useCallback(async (
|
||||||
|
nodeName: string, slug: string, cfg: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await liveAddService(nodeName, slug, cfg);
|
||||||
|
setPendingAddSvc(null);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'add service failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [liveAddService, flashErr]);
|
||||||
|
|
||||||
const liveRemoveService = useCallback(async (nodeName: string, slug: string) => {
|
const liveRemoveService = useCallback(async (nodeName: string, slug: string) => {
|
||||||
const { data } = await axios.delete<{ services: string[] }>(
|
const { data } = await axios.delete<{ services: string[] }>(
|
||||||
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services/${encodeURIComponent(slug)}`,
|
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services/${encodeURIComponent(slug)}`,
|
||||||
@@ -273,16 +299,13 @@ const MazeNET: React.FC = () => {
|
|||||||
// For active/degraded topologies, route through the live W3
|
// For active/degraded topologies, route through the live W3
|
||||||
// endpoint — the design-time mutator queue would silently
|
// endpoint — the design-time mutator queue would silently
|
||||||
// enqueue and the dropped chip would never visibly land
|
// enqueue and the dropped chip would never visibly land
|
||||||
// (resulting in the "no way to APPLY" feedback). liveAddService
|
// (resulting in the "no way to APPLY" feedback). Funnel through
|
||||||
// returns the post-mutation services list and patches local
|
// requestAddService so the schema-driven config modal pops if
|
||||||
// state so the chip appears immediately.
|
// the service has any user-tunable fields; empty-schema services
|
||||||
|
// auto-confirm and short-circuit, keeping drag fluency.
|
||||||
const live = topoStatus === 'active' || topoStatus === 'degraded';
|
const live = topoStatus === 'active' || topoStatus === 'degraded';
|
||||||
if (live) {
|
if (live) {
|
||||||
try {
|
requestAddService(target.name, drag.slug);
|
||||||
await liveAddService(target.name, drag.slug);
|
|
||||||
} catch (err) {
|
|
||||||
flashErr(err, 'add service failed');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextServices = [...target.services, drag.slug];
|
const nextServices = [...target.services, drag.slug];
|
||||||
@@ -297,7 +320,7 @@ const MazeNET: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, liveAddService],
|
[api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, requestAddService],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
||||||
@@ -900,7 +923,7 @@ const MazeNET: React.FC = () => {
|
|||||||
onDeleteEdge={removeEdge}
|
onDeleteEdge={removeEdge}
|
||||||
onRemoveService={removeServiceFromNode}
|
onRemoveService={removeServiceFromNode}
|
||||||
availableServices={serviceRegistry.perDecky}
|
availableServices={serviceRegistry.perDecky}
|
||||||
onLiveAddService={liveAddService}
|
onLiveAddService={requestAddService}
|
||||||
onLiveRemoveService={liveRemoveService}
|
onLiveRemoveService={liveRemoveService}
|
||||||
onToggleGateway={toggleGateway}
|
onToggleGateway={toggleGateway}
|
||||||
onAddDecky={(netId) => {
|
onAddDecky={(netId) => {
|
||||||
@@ -917,6 +940,11 @@ const MazeNET: React.FC = () => {
|
|||||||
className={inspectorOpen ? '' : 'collapsed'}
|
className={inspectorOpen ? '' : 'collapsed'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<AddServiceConfigModal
|
||||||
|
pending={pendingAddSvc}
|
||||||
|
onCancel={() => setPendingAddSvc(null)}
|
||||||
|
onConfirm={confirmAddService}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,11 +7,22 @@ hydrator run for real so the persistence path is exercised end-to-end.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_topology_decky(repo, decky_uuid: str) -> dict[str, Any]:
|
||||||
|
"""Helper: list and pick the one matching uuid (no per-uuid getter on the repo)."""
|
||||||
|
# Iterate all topologies' deckies — fine for tests with one row.
|
||||||
|
topologies = await repo.list_topologies()
|
||||||
|
for t in topologies:
|
||||||
|
for d in await repo.list_topology_deckies(t["id"]):
|
||||||
|
if d.get("uuid") == decky_uuid:
|
||||||
|
return d
|
||||||
|
raise AssertionError(f"decky {decky_uuid!r} not found in any topology")
|
||||||
|
|
||||||
from decnet.bus.fake import FakeBus
|
from decnet.bus.fake import FakeBus
|
||||||
from decnet.engine import services_live
|
from decnet.engine import services_live
|
||||||
from decnet.engine.services_live import ServiceMutationError
|
from decnet.engine.services_live import ServiceMutationError
|
||||||
@@ -43,7 +54,7 @@ async def topology_with_decky(repo: SQLiteRepository) -> dict:
|
|||||||
topo_id = await repo.create_topology({
|
topo_id = await repo.create_topology({
|
||||||
"name": "test-topo", "description": "",
|
"name": "test-topo", "description": "",
|
||||||
})
|
})
|
||||||
decky_uuid = await repo.create_topology_decky({
|
decky_uuid = await repo.add_topology_decky({
|
||||||
"topology_id": topo_id,
|
"topology_id": topo_id,
|
||||||
"name": "web1",
|
"name": "web1",
|
||||||
"ip": "10.0.0.5",
|
"ip": "10.0.0.5",
|
||||||
@@ -85,13 +96,13 @@ async def test_topology_add_service_persists_and_runs_compose_up(
|
|||||||
"up", "-d", "--no-deps", "--build", "web1-ssh",
|
"up", "-d", "--no-deps", "--build", "web1-ssh",
|
||||||
)
|
)
|
||||||
# Persisted to the DB.
|
# Persisted to the DB.
|
||||||
row = await repo.get_topology_decky(topology_with_decky["decky_uuid"])
|
row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"])
|
||||||
persisted_services = json.loads(row["services"]) if isinstance(row["services"], str) else row["services"]
|
persisted_services = json.loads(row["services"]) if isinstance(row["services"], str) else row["services"]
|
||||||
assert "ssh" in persisted_services
|
assert "ssh" in persisted_services
|
||||||
# Bus event published.
|
# Bus event published.
|
||||||
import asyncio
|
import asyncio
|
||||||
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
|
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
|
||||||
assert event.topic == "decky.web1.service.added"
|
assert event.topic == "decky.web1.service_added"
|
||||||
assert event.payload["service_name"] == "ssh"
|
assert event.payload["service_name"] == "ssh"
|
||||||
assert event.payload["topology_id"] == topology_with_decky["topology_id"]
|
assert event.payload["topology_id"] == topology_with_decky["topology_id"]
|
||||||
|
|
||||||
@@ -178,6 +189,105 @@ async def test_topology_remove_service_rejects_when_absent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- topology add with initial config ------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topology_add_service_with_initial_config_persists_to_decky_config(
|
||||||
|
repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus,
|
||||||
|
monkeypatch, tmp_path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
services_live, "_topology_compose_path",
|
||||||
|
lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml",
|
||||||
|
)
|
||||||
|
await services_live.add_service(
|
||||||
|
repo, decky_kind="topology",
|
||||||
|
topology_id=topology_with_decky["topology_id"],
|
||||||
|
decky_name="web1", service_name="ssh",
|
||||||
|
config={"password": "hunter2", "hostname": "mail-01"},
|
||||||
|
)
|
||||||
|
row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"])
|
||||||
|
cfg_blob = json.loads(row["decky_config"]) if isinstance(row["decky_config"], str) else row["decky_config"]
|
||||||
|
assert cfg_blob.get("service_config", {}).get("ssh") == {
|
||||||
|
"password": "hunter2", "hostname": "mail-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topology_add_service_with_invalid_config_aborts_before_persist(
|
||||||
|
repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus,
|
||||||
|
monkeypatch, tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""Bad cfg → ConfigValidationError, no DB write, no compose call."""
|
||||||
|
from decnet.services.base import ConfigValidationError
|
||||||
|
|
||||||
|
captured: list = []
|
||||||
|
monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: captured.append(a))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
services_live, "_topology_compose_path",
|
||||||
|
lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml",
|
||||||
|
)
|
||||||
|
with pytest.raises(ConfigValidationError):
|
||||||
|
await services_live.add_service(
|
||||||
|
repo, decky_kind="topology",
|
||||||
|
topology_id=topology_with_decky["topology_id"],
|
||||||
|
decky_name="web1", service_name="rdp",
|
||||||
|
config={"nla": "not-a-bool"},
|
||||||
|
)
|
||||||
|
# Ensure no compose ran and the services list wasn't appended to.
|
||||||
|
assert captured == []
|
||||||
|
row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"])
|
||||||
|
persisted = json.loads(row["services"]) if isinstance(row["services"], str) else row["services"]
|
||||||
|
assert "rdp" not in persisted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topology_add_service_empty_config_is_back_compat(
|
||||||
|
repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus,
|
||||||
|
monkeypatch, tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""No `config` arg / empty dict still adds the service — old callers safe."""
|
||||||
|
monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
services_live, "_topology_compose_path",
|
||||||
|
lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml",
|
||||||
|
)
|
||||||
|
services = await services_live.add_service(
|
||||||
|
repo, decky_kind="topology",
|
||||||
|
topology_id=topology_with_decky["topology_id"],
|
||||||
|
decky_name="web1", service_name="ssh",
|
||||||
|
)
|
||||||
|
assert services == ["http", "ssh"]
|
||||||
|
row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"])
|
||||||
|
cfg_blob = json.loads(row["decky_config"]) if isinstance(row["decky_config"], str) else row["decky_config"]
|
||||||
|
# No service_config key written when config is empty.
|
||||||
|
assert "ssh" not in (cfg_blob.get("service_config") or {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topology_add_service_drops_unknown_config_keys(
|
||||||
|
repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus,
|
||||||
|
monkeypatch, tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""validate_cfg drops unknown keys — they must not leak into decky_config."""
|
||||||
|
monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
services_live, "_topology_compose_path",
|
||||||
|
lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml",
|
||||||
|
)
|
||||||
|
await services_live.add_service(
|
||||||
|
repo, decky_kind="topology",
|
||||||
|
topology_id=topology_with_decky["topology_id"],
|
||||||
|
decky_name="web1", service_name="ssh",
|
||||||
|
config={"password": "hunter2", "wat": "nope"},
|
||||||
|
)
|
||||||
|
row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"])
|
||||||
|
cfg_blob = json.loads(row["decky_config"]) if isinstance(row["decky_config"], str) else row["decky_config"]
|
||||||
|
assert cfg_blob["service_config"]["ssh"] == {"password": "hunter2"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------- service registry validation -----------------------------
|
# ---------------- service registry validation -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user