fix(api): serve editable spec on /services/{name} + public_url for statics

- /services/{name} served the runtime view for a STATIC (caddy) deployment
  (it's not in config.services), so its manifest lacked reach/root/program and
  the dashboard's reach dropdown defaulted to "internal". Now serves the editable
  spec whenever the deployment is in config.deployments — the twin of the earlier
  /deployments/{name} fix.
- gateway route public_url scanned only config.services, so public statics got
  public_url=None. Now scans all deployments.

Coverage: parametrized invariant sweep (every kind × every detail endpoint serves
the editable spec) + a /deployments-vs-/services consistency test that fails the
instant the two paths diverge; public/internal/static public_url derivation and
raw-TCP route exclusion.
This commit is contained in:
2026-07-06 00:04:24 -07:00
parent 07c02aa151
commit b4b3db5327
2 changed files with 219 additions and 16 deletions

View File

@@ -506,19 +506,28 @@ def get_service(name: str) -> ServiceDetail:
summary = _service_from_deployed(name, deployed)
if config is not None and summary.source is None:
summary.source = _backfill_source(name, config)
manifest = {
"manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
"port": deployed.port,
"health_path": deployed.health_path,
"subdomain": deployed.subdomain,
"managed": deployed.managed,
"kind": deployed.kind,
"stack": deployed.stack,
}
# Serve the editable spec whenever the deployment is in castle.yaml — a
# STATIC (caddy) is in config.deployments but NOT config.services, so it
# lands here; without this its manifest lacks reach/root/program and the
# edit form defaults them wrong (calculator showed reach as internal).
if config is not None and name in config.deployments:
manifest = config.deployments[name].model_dump(
mode="json", exclude_none=True
)
else:
manifest = {
"manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
"port": deployed.port,
"health_path": deployed.health_path,
"subdomain": deployed.subdomain,
"managed": deployed.managed,
"kind": deployed.kind,
"stack": deployed.stack,
}
return ServiceDetail(**summary.model_dump(), manifest=manifest)
raise HTTPException(
@@ -892,11 +901,13 @@ def get_gateway() -> GatewayInfo:
except FileNotFoundError:
pass
# Which local services are public → their public URL (<name>.<public_domain>).
# Which local deployments are public → their public URL (<name>.<public_domain>).
# Scan ALL deployments, not just `config.services` — a public *static* (caddy)
# is not a service, so filtering to services dropped its public_url (calculator).
public_domain = registry.node.public_domain
public_names = {
name for name, svc in (config.services.items() if config else [])
if getattr(svc, "public", False)
name for name, dep in (config.deployments.items() if config else [])
if getattr(dep, "public", False)
}
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}