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.
This commit is contained in:
Michael Lopez
2026-09-25 12:54:27 +00:00
committed by Kristoffer Dalby
parent d60bac5c79
commit 04d1e3c83f
3 changed files with 23 additions and 3 deletions
+1
View File
@@ -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)
+10 -1
View File
@@ -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)
+12 -2
View File
@@ -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))
})
}
}