From 04d1e3c83fb0ca8e97079ed9652133c6f76303ba Mon Sep 17 00:00:00 2001 From: Michael Lopez Date: Fri, 25 Sep 2026 12:54:27 +0000 Subject: [PATCH] db: name the nodes that block a user deletion The error only said the user still has nodes, which the CLI prompt did not mention at all. Wrap ErrUserStillHasNodes with the ID and hostname of every blocking node so the operator knows what to remove. Run the DestroyUser test table on Postgres as well as SQLite, since the two schemas define different foreign-key actions; the Postgres variant skips without a local server. --- CHANGELOG.md | 1 + hscontrol/db/users.go | 11 ++++++++++- hscontrol/db/users_test.go | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab0abe2..f7aa4488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ tags; any other tag is rejected, for new and re-registering nodes alike. See - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) - Fix `headscale users destroy`/`rename` reporting "multiple users match query" when no user matches; an ambiguous match now lists the matching users [#3476](https://github.com/juanfont/headscale/pull/3476) +- Deleting a user that still owns nodes now lists the nodes (ID and hostname) that must be deleted first [#3475](https://github.com/juanfont/headscale/pull/3475) - Headscale now requires Go 1.27 to build ## 0.29.4 (2026-09-23) diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go index 8e7b7223..84fd3649 100644 --- a/hscontrol/db/users.go +++ b/hscontrol/db/users.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strconv" + "strings" "testing" "github.com/juanfont/headscale/hscontrol/types" @@ -61,7 +62,15 @@ func DestroyUser(tx *gorm.DB, uid types.UserID) error { } if len(nodes) > 0 { - return ErrUserStillHasNodes + blocking := make([]string, len(nodes)) + for i, node := range nodes { + blocking[i] = fmt.Sprintf("%d (%s)", node.ID.Uint64(), node.Hostname) + } + + return fmt.Errorf( + "%w: %d node(s) must be deleted first: %s", + ErrUserStillHasNodes, len(nodes), strings.Join(blocking, ", "), + ) } keys, err := ListPreAuthKeysByUser(tx, uid) diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go index 75b2ac1e..bec969ab 100644 --- a/hscontrol/db/users_test.go +++ b/hscontrol/db/users_test.go @@ -1,6 +1,7 @@ package db import ( + "fmt" "testing" "github.com/juanfont/headscale/hscontrol/types" @@ -86,7 +87,9 @@ func TestDestroyUserErrors(t *testing.T) { require.NoError(t, trx.Error) err = db.DestroyUser(types.UserID(user.ID)) - assert.ErrorIs(t, err, ErrUserStillHasNodes) + require.ErrorIs(t, err, ErrUserStillHasNodes) + // The error names the blocking node so it can be found. + require.ErrorContains(t, err, fmt.Sprintf("%d (testnode)", node.ID)) }, }, { @@ -204,13 +207,20 @@ func TestDestroyUserErrors(t *testing.T) { }, } + // User deletion depends on foreign-key actions that differ between the + // hand-written SQLite schema and the GORM-generated Postgres schema, so + // run every case on both. The Postgres variant skips when no local + // server can be started. for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name+"-sqlite", func(t *testing.T) { db, err := newSQLiteTestDB() require.NoError(t, err) tt.test(t, db) }) + t.Run(tt.name+"-postgres", func(t *testing.T) { + tt.test(t, newPostgresTestDB(t)) + }) } }