Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions controlplane/admin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (

var errWarehousePayloadNotAllowed = errors.New("warehouse payload must be updated via /orgs/:id/warehouse")

var errWarehouseStillExists = errors.New("managed warehouse still exists for org")

// maxWarehousePutBodyBytes caps the admin PUT body. Warehouse payloads are
// under 10 KB in practice; 1 MiB leaves room for future fields while keeping
// the handler from loading unbounded input into memory.
Expand Down Expand Up @@ -225,6 +227,16 @@ func (s *gormAPIStore) UpdateOrg(name string, updates configstore.Org) (*configs
func (s *gormAPIStore) DeleteOrg(name string) (bool, error) {
returnRows := int64(0)
err := s.db().Transaction(func(tx *gorm.DB) error {
// Deleting an org while its managed warehouse row still exists would
// cascade the row away and leak the Duckling CR + infra behind it. The
// warehouse must be deprovisioned (which removes the row) first.
var warehouses int64
if err := tx.Model(&configstore.ManagedWarehouse{}).Where("org_id = ?", name).Count(&warehouses).Error; err != nil {
return err
}
if warehouses > 0 {
return errWarehouseStillExists
}
if err := tx.Where("org_id = ?", name).Delete(&configstore.OrgUser{}).Error; err != nil {
return err
}
Expand Down Expand Up @@ -638,6 +650,10 @@ func (h *apiHandler) deleteOrg(c *gin.Context) {
name := c.Param("id")
ok, err := h.store.DeleteOrg(name)
if err != nil {
if errors.Is(err, errWarehouseStillExists) {
c.JSON(http.StatusConflict, gin.H{"error": "warehouse still exists — deprovision it and wait for teardown to complete before deleting the org"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
Expand Down
45 changes: 44 additions & 1 deletion controlplane/admin/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ func (s *fakeAPIStore) DeleteOrg(name string) (bool, error) {
if _, ok := s.orgs[name]; !ok {
return false, nil
}
if _, ok := s.warehouses[name]; ok {
return false, errWarehouseStillExists
}
delete(s.orgs, name)
delete(s.warehouses, name)
return true, nil
}

Expand Down Expand Up @@ -1110,6 +1112,47 @@ func TestUpdateOrgRejectsNestedWarehousePayload(t *testing.T) {
}
}

func TestDeleteOrgRejectsWhenWarehouseStillExists(t *testing.T) {
store := newFakeAPIStore()
seedOrgWithWarehouse(store, "analytics")
router := newTestAPIRouter(store)

req := httptest.NewRequest(http.MethodDelete, "/api/v1/orgs/analytics", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusConflict, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "deprovision") {
t.Fatalf("expected error to mention deprovision, got: %s", rec.Body.String())
}
if _, ok := store.orgs["analytics"]; !ok {
t.Fatal("expected org to survive a rejected delete")
}
if _, ok := store.warehouses["analytics"]; !ok {
t.Fatal("expected warehouse to survive a rejected delete")
}
}

func TestDeleteOrgSucceedsAfterWarehouseRemoved(t *testing.T) {
store := newFakeAPIStore()
seedOrgWithWarehouse(store, "analytics")
delete(store.warehouses, "analytics")
router := newTestAPIRouter(store)

req := httptest.NewRequest(http.MethodDelete, "/api/v1/orgs/analytics", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if _, ok := store.orgs["analytics"]; ok {
t.Fatal("expected org to be deleted once the warehouse is gone")
}
}

func TestGetOrgOmitsMinWorkers(t *testing.T) {
store := newFakeAPIStore()
store.orgs["analytics"] = &configstore.Org{
Expand Down
14 changes: 14 additions & 0 deletions controlplane/admin/ui/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ export function useUpdateWarehouse(id: string) {
});
}

// POST /orgs/:id/deprovision — asynchronous duckling teardown. Invalidate the
// warehouse (state flips to deleting) and the org queries (warehouse presence
// gates org deletion).
export function useDeprovisionWarehouse(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.deprovisionWarehouse(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["orgs", id, "warehouse"] });
qc.invalidateQueries({ queryKey: ["orgs"] });
},
});
}

// ---- ducklings (admin-only) ----

const EMPTY_DRIFT: DucklingDriftResponse = { available: false, checked: 0, entries: [] };
Expand Down
4 changes: 4 additions & 0 deletions controlplane/admin/ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export const api = {
getWarehouse: (id: string) => get<ManagedWarehouse>(`/orgs/${enc(id)}/warehouse`),
updateWarehouse: (id: string, body: Partial<ManagedWarehouse>) =>
put<ManagedWarehouse>(`/orgs/${enc(id)}/warehouse`, body),
// Kicks off asynchronous teardown of the org's duckling (202). 409s when the
// warehouse state isn't ready/failed/provisioning.
deprovisionWarehouse: (id: string) =>
post<{ status: string; org: string }>(`/orgs/${enc(id)}/deprovision`, {}),

// ducklings (admin-only)
getDucklingDrift: () => get<DucklingDriftResponse>("/ducklings/drift"),
Expand Down
141 changes: 134 additions & 7 deletions controlplane/admin/ui/src/pages/OrgDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ducklingBroken, ducklingEntryFor, fmtTime } from "@/lib/format";
import { ShardBadge } from "@/components/ShardBadge";
import {
useDeleteOrg,
useDeprovisionWarehouse,
useDucklingsMetadata,
useOrg,
useUpdateOrg,
Expand Down Expand Up @@ -77,6 +78,7 @@ export function OrgDetail() {
const [form, setForm] = useState<FormState | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");

useEffect(() => {
if (org.data) setForm(orgToForm(org.data));
Expand Down Expand Up @@ -123,13 +125,23 @@ export function OrgDetail() {
}
};

// Org deletion is blocked while a managed warehouse still exists: the correct
// flow is deprovision → provisioner tears down the duckling and removes the
// warehouse row → then delete. The backend 409s too; this is belt-and-suspenders.
const orgHasWarehouse = Boolean(org.data?.warehouse) || Boolean(warehouse.data);

const closeDelete = () => {
setConfirmDelete(false);
setDeleteConfirmText("");
};

const doDelete = async () => {
try {
await del.mutateAsync(id);
navigate("/orgs");
} catch (e) {
setMsg({ kind: "err", text: e instanceof Error ? e.message : "Delete failed" });
setConfirmDelete(false);
closeDelete();
}
};

Expand All @@ -139,9 +151,26 @@ export function OrgDetail() {
id={id}
actions={
<AdminGate>
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
<Trash2 className="h-4 w-4" /> Delete org
</Button>
{orgHasWarehouse ? (
// Disabled buttons swallow pointer events, so the tooltip
// triggers on a wrapping span. delayDuration 0 = immediate.
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span tabIndex={0}>
<Button variant="destructive" size="sm" disabled className="pointer-events-none">
<Trash2 className="h-4 w-4" /> Delete org
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
Deprovision the warehouse first — org delete is blocked while it exists.
</TooltipContent>
</Tooltip>
) : (
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
<Trash2 className="h-4 w-4" /> Delete org
</Button>
)}
</AdminGate>
}
/>
Expand Down Expand Up @@ -230,19 +259,43 @@ export function OrgDetail() {
</div>
</PageBody>

<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<Dialog open={confirmDelete} onOpenChange={(open) => (open ? setConfirmDelete(true) : closeDelete())}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete org "{id}"?</DialogTitle>
<DialogDescription>
This removes the org and all of its users from the config store. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{orgHasWarehouse && (
<p className="flex items-start gap-2 text-xs text-warning">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
This org still has a managed warehouse. Deletion is blocked until the warehouse is
deprovisioned and fully gone — deprovision it from the warehouse panel first.
</span>
</p>
)}
<Field label="Type the org id to confirm">
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder={id}
className="font-mono text-xs"
/>
</Field>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setConfirmDelete(false)}>
<Button variant="outline" size="sm" onClick={closeDelete}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={doDelete} disabled={del.isPending}>
<Button
variant="destructive"
size="sm"
onClick={doDelete}
disabled={del.isPending || orgHasWarehouse || deleteConfirmText.trim() !== id}
>
{del.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
Expand Down Expand Up @@ -298,10 +351,13 @@ function WarehousePanel({
error: unknown;
}) {
const update = useUpdateWarehouse(orgId);
const deprovision = useDeprovisionWarehouse(orgId);
const metadata = useDucklingsMetadata();
const [image, setImage] = useState("");
const [version, setVersion] = useState("");
const [ducklingNameInput, setDucklingNameInput] = useState("");
const [confirmDeprovision, setConfirmDeprovision] = useState(false);
const [deprovisionConfirmText, setDeprovisionConfirmText] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);

useEffect(() => {
Expand Down Expand Up @@ -352,6 +408,26 @@ function WarehousePanel({
}
};

// Already deleting/deleted (or no warehouse at all) → nothing to deprovision.
const canDeprovision = !missing && data != null && data.state !== "deleting" && data.state !== "deleted";

const closeDeprovision = () => {
setConfirmDeprovision(false);
setDeprovisionConfirmText("");
};

const doDeprovision = async () => {
setMsg(null);
try {
await deprovision.mutateAsync();
setMsg({ kind: "ok", text: "Deprovisioning started." });
} catch (e) {
// A 409 (wrong warehouse state) surfaces its backend message as-is.
setMsg({ kind: "err", text: e instanceof Error ? e.message : "Deprovision failed" });
}
closeDeprovision();
};

return (
<Card>
<CardHeader className="flex-row items-center justify-between">
Expand Down Expand Up @@ -462,9 +538,60 @@ function WarehousePanel({
</Badge>
</div>
</div>

{/* Teardown */}
{canDeprovision && (
<div className="space-y-2 border-t border-border pt-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Danger zone</p>
<div className="flex items-center gap-3">
<AdminGate>
<Button variant="destructive" size="sm" onClick={() => setConfirmDeprovision(true)}>
<Trash2 className="h-4 w-4" /> Deprovision warehouse
</Button>
</AdminGate>
<span className="text-xs text-muted-foreground">
Required before the org can be deleted.
</span>
</div>
</div>
)}
</>
)}
</CardContent>

<Dialog open={confirmDeprovision} onOpenChange={(open) => (open ? setConfirmDeprovision(true) : closeDeprovision())}>
<DialogContent>
<DialogHeader>
<DialogTitle>Deprovision warehouse for "{orgId}"?</DialogTitle>
<DialogDescription>
This permanently tears down the org's duckling — the Duckling CR, the S3 data bucket, the
metadata database, and the IAM role. Teardown runs asynchronously; the org itself is not
deleted. This cannot be undone.
</DialogDescription>
</DialogHeader>
<Field label="Type the org id to confirm">
<Input
value={deprovisionConfirmText}
onChange={(e) => setDeprovisionConfirmText(e.target.value)}
placeholder={orgId}
className="font-mono text-xs"
/>
</Field>
<DialogFooter>
<Button variant="outline" size="sm" onClick={closeDeprovision}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={doDeprovision}
disabled={deprovision.isPending || deprovisionConfirmText.trim() !== orgId}
>
{deprovision.isPending ? "Deprovisioning…" : "Deprovision"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
Expand Down
Loading