From 84058dce1c6983c0c2f7d42e2db82d699e6f4cbd Mon Sep 17 00:00:00 2001 From: Simon Carr Date: Sat, 16 May 2026 22:50:34 +0100 Subject: [PATCH] fix(hosts): clean docker rows on delete --- apps/ingest/internal/handlers/terminal_ws.go | 10 ++++++-- .../handlers/terminal_ws_security_test.go | 12 ++++++++++ apps/web/lib/actions/agents-core.ts | 23 +++++++++++++++++++ .../lib/actions/agents-delete-hard.test.mjs | 14 +++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/ingest/internal/handlers/terminal_ws.go b/apps/ingest/internal/handlers/terminal_ws.go index 1ab9479c..44e28e13 100644 --- a/apps/ingest/internal/handlers/terminal_ws.go +++ b/apps/ingest/internal/handlers/terminal_ws.go @@ -126,8 +126,7 @@ func (h *TerminalWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { authMsg.Password = "" if err != nil { slog.Warn("terminal ws: SSH connection failed", "session_id", sessionID, "host_id", info.HostID, "username", info.Username, "err", err) - reason := "ssh authentication failed" - message := "SSH authentication failed" + reason, message := terminalSSHFailureDetails(err) if errors.Is(err, queries.ErrSSHHostKeyNotTrusted) || errors.Is(err, queries.ErrSSHHostKeyMismatch) { reason = "ssh host key verification failed" message = "SSH host key verification failed" @@ -375,6 +374,13 @@ func isSSHAuthenticationFailure(err error) bool { return errors.As(err, &authErr) } +func terminalSSHFailureDetails(err error) (reason string, message string) { + if isSSHAuthenticationFailure(err) { + return "ssh authentication failed", "SSH authentication failed" + } + return "ssh connection failed", "SSH connection failed" +} + func writeWS(ctx context.Context, conn *websocket.Conn, msg wsMessage) error { raw, err := json.Marshal(msg) if err != nil { diff --git a/apps/ingest/internal/handlers/terminal_ws_security_test.go b/apps/ingest/internal/handlers/terminal_ws_security_test.go index 3c01f160..a38ae749 100644 --- a/apps/ingest/internal/handlers/terminal_ws_security_test.go +++ b/apps/ingest/internal/handlers/terminal_ws_security_test.go @@ -90,6 +90,18 @@ func TestIsSSHAuthenticationFailure(t *testing.T) { } } +func TestTerminalSSHFailureDetails(t *testing.T) { + reason, message := terminalSSHFailureDetails(&ssh.ServerAuthError{}) + if reason != "ssh authentication failed" || message != "SSH authentication failed" { + t.Fatalf("terminalSSHFailureDetails(auth) = %q, %q", reason, message) + } + + reason, message = terminalSSHFailureDetails(errors.New("dial tcp: lookup host.example.test: no such host")) + if reason != "ssh connection failed" || message != "SSH connection failed" { + t.Fatalf("terminalSSHFailureDetails(network) = %q, %q", reason, message) + } +} + func TestInsecureSkipVerifyTrueAllowlist(t *testing.T) { _, thisFile, _, ok := runtime.Caller(0) if !ok { diff --git a/apps/web/lib/actions/agents-core.ts b/apps/web/lib/actions/agents-core.ts index 96a65a67..744496f8 100644 --- a/apps/web/lib/actions/agents-core.ts +++ b/apps/web/lib/actions/agents-core.ts @@ -12,6 +12,10 @@ import { revokedCertificates, hosts, hostDockerStatus, + dockerContainers, + dockerContainerLifecycleEvents, + dockerContainerMetrics, + dockerTelemetryBatches, hostMetrics, hostGroupMembers, checks, @@ -1231,6 +1235,25 @@ export async function deleteHost( .delete(hostMetrics) .where(and(eq(hostMetrics.hostId, hostId), eq(hostMetrics.instanceId, instanceId))) + // 12a. Docker inventory/telemetry rows reference both the host and, for + // per-container data, docker_containers rows. Delete child tables + // first so hosts with Docker telemetry can still be removed. + await tx + .delete(dockerContainerMetrics) + .where(and(eq(dockerContainerMetrics.hostId, hostId), eq(dockerContainerMetrics.instanceId, instanceId))) + await tx + .delete(dockerContainerLifecycleEvents) + .where(and(eq(dockerContainerLifecycleEvents.hostId, hostId), eq(dockerContainerLifecycleEvents.instanceId, instanceId))) + await tx + .delete(dockerContainers) + .where(and(eq(dockerContainers.hostId, hostId), eq(dockerContainers.instanceId, instanceId))) + await tx + .delete(dockerTelemetryBatches) + .where(and(eq(dockerTelemetryBatches.hostId, hostId), eq(dockerTelemetryBatches.instanceId, instanceId))) + await tx + .delete(hostDockerStatus) + .where(and(eq(hostDockerStatus.hostId, hostId), eq(hostDockerStatus.instanceId, instanceId))) + // 13. Resource tags await tx .delete(resourceTags) diff --git a/apps/web/lib/actions/agents-delete-hard.test.mjs b/apps/web/lib/actions/agents-delete-hard.test.mjs index f46d859a..bc34fdbc 100644 --- a/apps/web/lib/actions/agents-delete-hard.test.mjs +++ b/apps/web/lib/actions/agents-delete-hard.test.mjs @@ -36,4 +36,18 @@ test('deleteHost performs a hard delete and clears agent dependants before delet pendingDelete < agentDelete, 'pending CSR rows must be deleted before the agent row to avoid FK rollback', ) + + for (const table of [ + 'dockerContainerMetrics', + 'dockerContainerLifecycleEvents', + 'dockerContainers', + 'dockerTelemetryBatches', + 'hostDockerStatus', + ]) { + assert.match( + segment, + new RegExp(`\\.delete\\(${table}\\)`), + `deleteHost must delete ${table} rows before deleting the host`, + ) + } })