diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab0abe26..f7aa44882 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 8e7b72236..84fd3649e 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 75b2ac1e5..bec969ab1 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)) + }) } }