Harden host deletion and test stability
This commit is contained in:
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -158,6 +159,49 @@ func (r *HostsRepo) ListAll(ctx context.Context) ([]Host, error) {
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
func (r *HostsRepo) RuntimeDependencyCountsByHostID(ctx context.Context, hostID string) (HostDeleteBlocker, error) {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
if hostID == "" {
|
||||
return HostDeleteBlocker{}, fmt.Errorf("host_id is required")
|
||||
}
|
||||
|
||||
host, err := r.GetByHostID(ctx, hostID)
|
||||
if err != nil {
|
||||
return HostDeleteBlocker{}, err
|
||||
}
|
||||
|
||||
blocker := HostDeleteBlocker{HostID: host.HostID}
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM import_batches WHERE host_id = ?`, host.ID).Scan(&blocker.ImportBatchCount); err != nil {
|
||||
return HostDeleteBlocker{}, fmt.Errorf("count import batches for host %q: %w", hostID, err)
|
||||
}
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM managed_resources WHERE host_id = ?`, host.ID).Scan(&blocker.ManagedResourceCount); err != nil {
|
||||
return HostDeleteBlocker{}, fmt.Errorf("count managed resources for host %q: %w", hostID, err)
|
||||
}
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM reconcile_runs WHERE host_id = ?`, host.ID).Scan(&blocker.ReconcileRunCount); err != nil {
|
||||
return HostDeleteBlocker{}, fmt.Errorf("count reconcile runs for host %q: %w", hostID, err)
|
||||
}
|
||||
return blocker, nil
|
||||
}
|
||||
|
||||
type HostDeleteBlocker struct {
|
||||
HostID string
|
||||
ImportBatchCount int
|
||||
ManagedResourceCount int
|
||||
ReconcileRunCount int
|
||||
}
|
||||
|
||||
func (e *HostDeleteBlocker) Error() string {
|
||||
if e == nil {
|
||||
return "host delete is blocked"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"host %q cannot be deleted while runtime state exists (import_batches=%d managed_resources=%d reconcile_runs=%d)",
|
||||
e.HostID,
|
||||
e.ImportBatchCount,
|
||||
e.ManagedResourceCount,
|
||||
e.ReconcileRunCount,
|
||||
)
|
||||
}
|
||||
|
||||
func (r *HostsRepo) DeleteByHostID(ctx context.Context, hostID string) error {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
@@ -165,6 +209,17 @@ func (r *HostsRepo) DeleteByHostID(ctx context.Context, hostID string) error {
|
||||
return fmt.Errorf("host_id is required")
|
||||
}
|
||||
|
||||
blocker, err := r.RuntimeDependencyCountsByHostID(ctx, hostID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("host %q not found", hostID)
|
||||
}
|
||||
return fmt.Errorf("resolve host %q runtime dependencies: %w", hostID, err)
|
||||
}
|
||||
if blocker.ImportBatchCount > 0 || blocker.ManagedResourceCount > 0 || blocker.ReconcileRunCount > 0 {
|
||||
return &blocker
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM hosts WHERE host_id = ?`, hostID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete host %q: %w", hostID, err)
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_pragma=foreign_keys(0)"
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000&_pragma=foreign_keys(0)"
|
||||
store, err := Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ func openTestDB(t *testing.T) *DB {
|
||||
func openTestDBWithFK(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test-fk.db")
|
||||
dsn := "file:" + filepath.ToSlash(dbPath)
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000"
|
||||
store, err := Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -279,6 +279,47 @@ func TestHostsRepoDeleteByHostID(t *testing.T) {
|
||||
t.Fatalf("ListAll() after delete len = %d, want 0", len(hosts))
|
||||
}
|
||||
}
|
||||
func TestHostsRepoDeleteByHostIDBlocksWhenRuntimeStateExists(t *testing.T) {
|
||||
store := openTestDBWithFK(t)
|
||||
batchID := createTestBatch(t, store)
|
||||
|
||||
hostRowID := mustHostRowIDForBatch(t, store, batchID)
|
||||
host, err := store.Hosts().GetByID(context.Background(), hostRowID)
|
||||
if err != nil {
|
||||
t.Fatalf("Hosts().GetByID() error = %v", err)
|
||||
}
|
||||
if _, err := store.ManagedResources().Create(context.Background(), ManagedResource{
|
||||
BatchID: batchID,
|
||||
HostID: host.ID,
|
||||
ResourceType: "group",
|
||||
HostResourceID: "group_1",
|
||||
ResourceName: "group",
|
||||
}); err != nil {
|
||||
t.Fatalf("ManagedResources().Create() error = %v", err)
|
||||
}
|
||||
providerID := mustProviderIDForBatch(t, store, batchID)
|
||||
if _, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{
|
||||
BatchID: batchID,
|
||||
HostID: host.ID,
|
||||
ProviderID: providerID,
|
||||
Status: "active",
|
||||
SummaryJSON: `{}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReconcileRuns().Create() error = %v", err)
|
||||
}
|
||||
|
||||
err = store.Hosts().DeleteByHostID(context.Background(), host.HostID)
|
||||
if err == nil {
|
||||
t.Fatal("DeleteByHostID() error = nil, want blocked error")
|
||||
}
|
||||
var blocker *HostDeleteBlocker
|
||||
if !errors.As(err, &blocker) {
|
||||
t.Fatalf("DeleteByHostID() error = %T %v, want HostDeleteBlocker", err, err)
|
||||
}
|
||||
if blocker.ImportBatchCount != 1 || blocker.ManagedResourceCount != 1 || blocker.ReconcileRunCount != 1 {
|
||||
t.Fatalf("blocker = %+v, want all dependency counts = 1", blocker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostsRepoUpdateProbeByHostID(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
@@ -361,3 +402,20 @@ func TestHostsRepoDeleteByHostIDEmptyError(t *testing.T) {
|
||||
t.Fatal("DeleteByHostID('') error = nil, want error")
|
||||
}
|
||||
}
|
||||
func mustHostRowIDForBatch(t *testing.T, store *DB, batchID int64) int64 {
|
||||
t.Helper()
|
||||
var hostID int64
|
||||
if err := store.SQLDB().QueryRow(`SELECT host_id FROM import_batches WHERE id = ?`, batchID).Scan(&hostID); err != nil {
|
||||
t.Fatalf("query host_id for batch %d error = %v", batchID, err)
|
||||
}
|
||||
return hostID
|
||||
}
|
||||
|
||||
func mustProviderIDForBatch(t *testing.T, store *DB, batchID int64) int64 {
|
||||
t.Helper()
|
||||
var providerID int64
|
||||
if err := store.SQLDB().QueryRow(`SELECT provider_id FROM import_batches WHERE id = ?`, batchID).Scan(&providerID); err != nil {
|
||||
t.Fatalf("query provider_id for batch %d error = %v", batchID, err)
|
||||
}
|
||||
return providerID
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user