fix(api): editable-spec detail + PATCH-merge saves + shared gateway parser

Deployment/program edits could silently drop spec fields:

- GET /deployments/{name} served the runtime view (no reach/program/root/expose)
  for deployed deployments, so the dashboard round-tripped a lossy manifest and
  stripped fields on save (broke astro). Now serves the editable castle.yaml spec
  when the deployment is in config.
- _save_deployment and save_program replaced the whole object with client input.
  Now shallow-merge over the existing spec (omit = preserve, null = clear), so a
  partial/lossy save can't drop untouched fields.
- save_yaml rebuilt GatewayConfig from `port` only, wiping tls/domain/tunnel/
  cert_hook on a whole-file save. Now uses a shared parse_gateway() (also used by
  load_config) so gateway fields can't drift between the two.

Dashboard forms (ServiceFields/StaticFields) send explicit null to clear, per the
merge contract; adds exposure host-label helpers. Coverage: detail-serves-spec,
save round-trip, partial-patch-preserves, null-clears, program partial-patch,
parse_gateway, and config save/load round-trip.
This commit is contained in:
2026-07-05 23:23:59 -07:00
parent eeaa65f7ce
commit 07c02aa151
10 changed files with 309 additions and 72 deletions

View File

@@ -1,5 +1,7 @@
import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
@@ -38,6 +40,9 @@ function applyLauncher(run: Obj, launcher: string, target: string): Obj {
/** Edit a service's deployment config (run / expose / proxy / env). */
export function ServiceFields({ service, onSave, onDelete }: Props) {
const { data: gateway } = useGateway()
const domain = gateway?.domain
const publicDomain = gateway?.public_domain
const m = service.manifest
const run = obj(m.run)
const internal = obj(obj(obj(m.expose).http).internal)
@@ -80,7 +85,9 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
try {
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
delete config.id
config.description = description || undefined
// The API merges (PATCH): omit = preserve, null = clear. So to CLEAR a field
// we must send an explicit null, not drop it — otherwise the old value sticks.
config.description = description || null
// Rebuild the run block for the chosen launcher, preserving other fields.
config.run = applyLauncher(obj(config.run), launcher, runProgram)
@@ -96,7 +103,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
},
}
} else {
delete config.expose
config.expose = null // explicit clear (merge: omit would preserve the old port)
}
// reach needs a port to route through the gateway; without one it's off.
config.reach = port ? reach : "off"
@@ -146,7 +153,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
{isTcp ? (
<Field
label="Exposure"
hint="A raw-TCP service — reachable by name + port via DNS, not the HTTP gateway. Edit its port/TLS in deployments/<name>.yaml for now."
hint={`A raw-TCP service — reachable by name + port via DNS, not the HTTP gateway. Edit its port/TLS in deployments/${service.id}.yaml for now.`}
>
<div className="font-mono text-xs text-[var(--muted)] space-y-1">
<div>
@@ -162,7 +169,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
<div>
reach{" "}
<span className="text-[var(--fg)]">{String(m.reach ?? "internal")}</span> {" "}
{service.id}.&lt;gateway.domain&gt;:{String(tcp.port)}
{gatewayHost(service.id, domain)}:{String(tcp.port)}
</div>
</div>
</Field>
@@ -188,7 +195,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
/>
<Field
label="Reach"
hint="How far this service is exposed. off: host:port only. internal: <service-name>.<gateway.domain> via the gateway. public: also to the internet via the Cloudflare tunnel."
hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`}
>
<div className="flex items-center gap-2">
<select
@@ -207,8 +214,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
: reach === "off"
? "host:port only"
: reach === "public"
? `${service.id}.<gateway.public_domain>`
: `${service.id}.<gateway.domain>`}
? publicGatewayHost(service.id, publicDomain)
: gatewayHost(service.id, domain)}
</span>
</div>
</Field>