Repository: juanfont/headscale

Issue:
[Bug] SQLite schema failed to validate
### Is this a support request?

- [x] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

v0.26.1 to v0.27.0

### Expected Behavior

everything went smoothly and the upgrade was successful.

### Steps To Reproduce

v0.26.1 to v0.27.0

### Environment

```markdown
- OS:Debian 12
- Headscale version: 
Docker for Headscale
version:headscale/headscale:latest
- Tailscale version: 1.90.4
```

### Runtime environment

- [ ] Headscale is behind a (reverse) proxy
- [x] Headscale runs in a container

### Debug information

SQLite schema failed to validate:

\>> Remove table "kvs"

\>> Remove table "machines"

2025-10-30T06:31:17Z FTL home/runner/work/headscale/headscale/cmd/headscale/cli/serve.go:32 > Error initializing error="creating new headscale: init state: init database: validating schema: invalid schema (-got, +want):\n\n>> Remove table "kvs"\n\n>> Remove table "machines"\n"

Hints from issue comments:
@angelbarrera92: Same here

Solution patch:
diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go
index e18f2e5d..581ca6d5 100644
--- a/hscontrol/db/db.go
+++ b/hscontrol/db/db.go
@@ -932,6 +932,26 @@ AND auth_key_id NOT IN (
 				},
 				Rollback: func(db *gorm.DB) error { return nil },
 			},
+			{
+				// Drop all tables that are no longer in use and has existed.
+				// They potentially still present from broken migrations in the past.
+				ID: "202510311551",
+				Migrate: func(tx *gorm.DB) error {
+					for _, oldTable := range []string{"namespaces", "machines", "shared_machines", "kvs", "pre_auth_key_acl_tags", "routes"} {
+						err := tx.Migrator().DropTable(oldTable)
+						if err != nil {
+							log.Trace().Str("table", oldTable).
+								Err(err).
+								Msg("Error dropping old table, continuing...")
+						}
+					}
+
+					return nil
+				},
+				Rollback: func(tx *gorm.DB) error {
+					return nil
+				},
+			},
 			// From this point, the following rules must be followed:
 			// - NEVER use gorm.AutoMigrate, write the exact migration steps needed
 			// - AutoMigrate depends on the struct staying exactly the same, which it won't over time.



Test patch:
diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
new file mode 100644
index 00000000..388fefbc
--- /dev/null
+++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
@@ -0,0 +1,40 @@
+PRAGMA foreign_keys=OFF;
+BEGIN TRANSACTION;
+CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`));
+INSERT INTO migrations VALUES('202312101416');
+INSERT INTO migrations VALUES('202312101430');
+INSERT INTO migrations VALUES('202402151347');
+INSERT INTO migrations VALUES('2024041121742');
+INSERT INTO migrations VALUES('202406021630');
+INSERT INTO migrations VALUES('202409271400');
+INSERT INTO migrations VALUES('202407191627');
+INSERT INTO migrations VALUES('202408181235');
+INSERT INTO migrations VALUES('202501221827');
+INSERT INTO migrations VALUES('202501311657');
+INSERT INTO migrations VALUES('202502070949');
+INSERT INTO migrations VALUES('202502131714');
+INSERT INTO migrations VALUES('202502171819');
+INSERT INTO migrations VALUES('202505091439');
+INSERT INTO migrations VALUES('202505141324');
+CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text);
+CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL);
+CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime);
+CREATE TABLE IF NOT EXISTS "nodes"  (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`forced_tags` text,`auth_key_id` integer,`expiry` datetime,`last_seen` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`));
+CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text);
+DELETE FROM sqlite_sequence;
+INSERT INTO sqlite_sequence VALUES('nodes',0);
+CREATE INDEX `idx_users_deleted_at` ON `users`(`deleted_at`);
+CREATE UNIQUE INDEX `idx_api_keys_prefix` ON `api_keys`(`prefix`);
+CREATE INDEX `idx_policies_deleted_at` ON `policies`(`deleted_at`);
+CREATE UNIQUE INDEX idx_provider_identifier ON users (provider_identifier) WHERE provider_identifier IS NOT NULL;
+CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identifier);
+CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL;
+
+-- Create all the old tables we have had and ensure they are clean up.
+CREATE TABLE `namespaces` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `machines` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `kvs` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `shared_machines` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `pre_auth_key_acl_tags` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `routes` (`id` text,PRIMARY KEY (`id`));
+COMMIT;