Remove buttons: confirm + block program removal with active deployments
The Stage 2 editor split dropped the old confirm() — Remove program/service/job
deleted immediately on click. Restored confirmation on all three (FormFooter
window.confirm with a per-type message).
Removing a program that still has services/jobs referencing it would orphan
their program: ref. Now blocked:
- API: DELETE /config/programs/{name} → 409 'Programs with active jobs or
services cannot be removed. Delete these first: …' (authoritative).
- App: ProgramFields shows the blocked reason instead of a Remove button when
the program has deployments.
- CLI: castle delete refuses a program with referencing deployments not named
the same (which would survive the delete dangling).
Verified live: DELETE program lakehouse → 409. api 52 / cli 24 green; build clean.
This commit is contained in:
@@ -74,6 +74,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={onDelete ? () => onDelete(job.id) : undefined}
|
onDelete={onDelete ? () => onDelete(job.id) : undefined}
|
||||||
deleteLabel="Remove job"
|
deleteLabel="Remove job"
|
||||||
|
confirmMessage={`Remove job "${job.id}" from castle.yaml? Run a deploy afterward to tear down its timer.`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={onDelete ? () => onDelete(program.id) : undefined}
|
onDelete={onDelete ? () => onDelete(program.id) : undefined}
|
||||||
deleteLabel="Remove program"
|
deleteLabel="Remove program"
|
||||||
|
confirmMessage={`Remove program "${program.id}" from castle.yaml? (Source on disk is untouched.)`}
|
||||||
|
deleteBlocked={
|
||||||
|
program.services.length + program.jobs.length > 0
|
||||||
|
? "Programs with active jobs or services cannot be removed — delete those first."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={onDelete ? () => onDelete(service.id) : undefined}
|
onDelete={onDelete ? () => onDelete(service.id) : undefined}
|
||||||
deleteLabel="Remove service"
|
deleteLabel="Remove service"
|
||||||
|
confirmMessage={`Remove service "${service.id}" from castle.yaml? Run a deploy afterward to tear down its unit.`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -122,18 +122,27 @@ export function FormFooter({
|
|||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
deleteLabel,
|
deleteLabel,
|
||||||
|
confirmMessage,
|
||||||
|
deleteBlocked,
|
||||||
}: {
|
}: {
|
||||||
saving: boolean
|
saving: boolean
|
||||||
saved: boolean
|
saved: boolean
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
deleteLabel: string
|
deleteLabel: string
|
||||||
|
confirmMessage?: string
|
||||||
|
/** When set, removal is disallowed and this reason is shown instead of the button. */
|
||||||
|
deleteBlocked?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
||||||
{onDelete ? (
|
{deleteBlocked ? (
|
||||||
|
<span className="text-xs text-amber-400">{deleteBlocked}</span>
|
||||||
|
) : onDelete ? (
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={() => {
|
||||||
|
if (window.confirm(confirmMessage ?? `${deleteLabel}?`)) onDelete()
|
||||||
|
}}
|
||||||
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
|
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} /> {deleteLabel}
|
<Trash2 size={12} /> {deleteLabel}
|
||||||
|
|||||||
@@ -169,13 +169,27 @@ def save_program(name: str, request: ProgramConfigRequest) -> dict:
|
|||||||
|
|
||||||
@router.delete("/programs/{name}")
|
@router.delete("/programs/{name}")
|
||||||
def delete_program(name: str) -> dict:
|
def delete_program(name: str) -> dict:
|
||||||
"""Remove a program from castle.yaml."""
|
"""Remove a program from castle.yaml.
|
||||||
|
|
||||||
|
Refuses if any service or job still references the program — those
|
||||||
|
deployments must be removed first so no dangling `program:` ref is left.
|
||||||
|
"""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
if name not in config.programs:
|
if name not in config.programs:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Program '{name}' not found",
|
detail=f"Program '{name}' not found",
|
||||||
)
|
)
|
||||||
|
refs = [s for s, spec in config.services.items() if spec.program == name]
|
||||||
|
refs += [j for j, spec in config.jobs.items() if spec.program == name]
|
||||||
|
if refs:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"Programs with active jobs or services cannot be removed. "
|
||||||
|
f"Delete these first: {', '.join(refs)}"
|
||||||
|
),
|
||||||
|
)
|
||||||
del config.programs[name]
|
del config.programs[name]
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"ok": True, "program": name, "action": "deleted"}
|
return {"ok": True, "program": name, "action": "deleted"}
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ def run_delete(args: argparse.Namespace) -> int:
|
|||||||
where = [s for s, present in
|
where = [s for s, present in
|
||||||
(("program", in_programs), ("service", in_services), ("job", in_jobs)) if present]
|
(("program", in_programs), ("service", in_services), ("job", in_jobs)) if present]
|
||||||
|
|
||||||
|
# A program can't be removed while a deployment still references it. Refs
|
||||||
|
# named the same are removed in this call; any other referencing deployment
|
||||||
|
# would be left dangling, so refuse.
|
||||||
|
if in_programs:
|
||||||
|
dangling = [s for s, spec in config.services.items() if spec.program == name and s != name]
|
||||||
|
dangling += [j for j, spec in config.jobs.items() if spec.program == name and j != name]
|
||||||
|
if dangling:
|
||||||
|
print(
|
||||||
|
"Error: programs with active jobs or services cannot be removed.\n"
|
||||||
|
f" Delete these first: {', '.join(dangling)}"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
# Resolve source dir (from the program entry) for the optional --source removal.
|
# Resolve source dir (from the program entry) for the optional --source removal.
|
||||||
source_dir: Path | None = None
|
source_dir: Path | None = None
|
||||||
if in_programs and config.programs[name].source:
|
if in_programs and config.programs[name].source:
|
||||||
|
|||||||
Reference in New Issue
Block a user