From c6736dd6d68e805a584c99049e0ad018fef6decd Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 May 2025 11:08:33 +0200 Subject: [PATCH] db: add sqlite "source of truth" schema Signed-off-by: Kristoffer Dalby --- cmd/headscale/cli/serve.go | 8 + flake.nix | 2 +- go.mod | 4 +- go.sum | 4 +- hscontrol/db/db.go | 377 ++++++++- hscontrol/db/db_test.go | 767 +++++++++++++++--- .../db/ephemeral_garbage_collector_test.go | 42 +- hscontrol/db/ip.go | 4 +- hscontrol/db/node.go | 6 +- hscontrol/db/node_test.go | 26 +- hscontrol/db/preauth_keys_test.go | 3 +- hscontrol/db/schema.sql | 110 +++ hscontrol/db/sqliteconfig/config.go | 345 ++++++++ hscontrol/db/sqliteconfig/config_test.go | 211 +++++ hscontrol/db/sqliteconfig/integration_test.go | 269 ++++++ hscontrol/db/suite_test.go | 3 +- ...3-to-0-23-0-routes-are-dropped-2063.sqlite | Bin 98304 -> 0 bytes ...0-23-0-routes-fail-foreign-key-2076.sqlite | Bin 57344 -> 0 bytes ...3-0-to-0-24-0-no-more-special-types.sqlite | Bin 94208 -> 0 bytes ...3-0-to-0-24-0-preauthkey-tags-table.sqlite | Bin 69632 -> 0 bytes .../failing-node-preauth-constraint.sqlite | Bin 65536 -> 0 bytes .../pre-24-postgresdb.pssql.dump | Bin ...to-0-23-0-routes-are-dropped-2063_dump.sql | 59 ++ ...23-0-routes-fail-foreign-key-2076_dump.sql | 52 ++ ...0-to-0-24-0-no-more-special-types_dump.sql | 40 + ...0-to-0-24-0-preauthkey-tags-table_dump.sql | 40 + .../failing-node-preauth-constraint_dump.sql | 34 + .../from_nblock_db01__0.14.0__0.24.0.sql | 97 +++ .../from_nblock_db01__0.14.0__0.24.1.sql | 95 +++ .../from_nblock_db01__0.14.0__0.24.2.sql | 95 +++ .../from_nblock_db01__0.14.0__0.25.0.sql | 98 +++ .../from_nblock_db01__0.14.0__0.25.1.sql | 101 +++ .../from_nblock_db01__0.14.0__0.26.1.sql | 81 ++ .../from_nblock_db02__0.22.1__0.24.0.sql | 98 +++ .../from_nblock_db02__0.22.1__0.24.1.sql | 104 +++ .../from_nblock_db02__0.22.1__0.24.2.sql | 113 +++ .../from_nblock_db02__0.22.1__0.24.3.sql | 117 +++ .../from_nblock_db02__0.22.1__0.25.0.sql | 124 +++ .../from_nblock_db02__0.22.1__0.25.1.sql | 136 ++++ .../from_nblock_db02__0.22.1__0.26.1.sql | 146 ++++ .../testdata/sqlite/headscale_0.10.0_dump.sql | 11 + .../sqlite/headscale_0.10.0_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.1_dump.sql | 11 + .../sqlite/headscale_0.10.1_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.2_dump.sql | 11 + .../sqlite/headscale_0.10.2_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.3_dump.sql | 11 + .../sqlite/headscale_0.10.3_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.4_dump.sql | 11 + .../sqlite/headscale_0.10.4_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.5_dump.sql | 11 + .../sqlite/headscale_0.10.5_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.6_dump.sql | 11 + .../sqlite/headscale_0.10.6_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.7_dump.sql | 11 + .../sqlite/headscale_0.10.7_schema.sql | 7 + .../testdata/sqlite/headscale_0.10.8_dump.sql | 11 + .../sqlite/headscale_0.10.8_schema.sql | 7 + .../testdata/sqlite/headscale_0.11.0_dump.sql | 11 + .../sqlite/headscale_0.11.0_schema.sql | 7 + .../testdata/sqlite/headscale_0.12.1_dump.sql | 11 + .../sqlite/headscale_0.12.1_schema.sql | 7 + .../sqlite/headscale_0.12.2-beta1_dump.sql | 11 + .../sqlite/headscale_0.12.2-beta1_schema.sql | 7 + .../testdata/sqlite/headscale_0.12.2_dump.sql | 11 + .../sqlite/headscale_0.12.2_schema.sql | 7 + .../testdata/sqlite/headscale_0.12.3_dump.sql | 11 + .../sqlite/headscale_0.12.3_schema.sql | 7 + .../testdata/sqlite/headscale_0.12.4_dump.sql | 11 + .../sqlite/headscale_0.12.4_schema.sql | 7 + .../sqlite/headscale_0.13.0-beta1_dump.sql | 11 + .../sqlite/headscale_0.13.0-beta1_schema.sql | 7 + .../testdata/sqlite/headscale_0.13.0_dump.sql | 13 + .../sqlite/headscale_0.13.0_schema.sql | 9 + .../sqlite/headscale_0.14.0-beta1_dump.sql | 13 + .../sqlite/headscale_0.14.0-beta1_schema.sql | 9 + .../sqlite/headscale_0.14.0-beta2_dump.sql | 13 + .../sqlite/headscale_0.14.0-beta2_schema.sql | 9 + .../testdata/sqlite/headscale_0.14.0_dump.sql | 13 + .../sqlite/headscale_0.14.0_schema.sql | 9 + .../sqlite/headscale_0.15.0-beta1_dump.sql | 11 + .../sqlite/headscale_0.15.0-beta1_schema.sql | 7 + .../sqlite/headscale_0.15.0-beta2_dump.sql | 11 + .../sqlite/headscale_0.15.0-beta2_schema.sql | 7 + .../sqlite/headscale_0.15.0-beta3_dump.sql | 11 + .../sqlite/headscale_0.15.0-beta3_schema.sql | 7 + .../sqlite/headscale_0.15.0-beta4_dump.sql | 11 + .../sqlite/headscale_0.15.0-beta4_schema.sql | 7 + .../sqlite/headscale_0.15.0-beta5_dump.sql | 11 + .../sqlite/headscale_0.15.0-beta5_schema.sql | 7 + .../sqlite/headscale_0.15.0-beta6_dump.sql | 11 + .../sqlite/headscale_0.15.0-beta6_schema.sql | 7 + .../testdata/sqlite/headscale_0.15.0_dump.sql | 11 + .../sqlite/headscale_0.15.0_schema.sql | 7 + .../sqlite/headscale_0.16.0-beta1_dump.sql | 11 + .../sqlite/headscale_0.16.0-beta1_schema.sql | 7 + .../testdata/sqlite/headscale_0.16.0_dump.sql | 11 + .../sqlite/headscale_0.16.0_schema.sql | 7 + .../testdata/sqlite/headscale_0.16.1_dump.sql | 11 + .../sqlite/headscale_0.16.1_schema.sql | 7 + .../testdata/sqlite/headscale_0.16.2_dump.sql | 11 + .../sqlite/headscale_0.16.2_schema.sql | 7 + .../testdata/sqlite/headscale_0.16.3_dump.sql | 11 + .../sqlite/headscale_0.16.3_schema.sql | 7 + .../testdata/sqlite/headscale_0.16.4_dump.sql | 11 + .../sqlite/headscale_0.16.4_schema.sql | 7 + .../sqlite/headscale_0.17.0-alpha1_dump.sql | 11 + .../sqlite/headscale_0.17.0-alpha1_schema.sql | 7 + .../sqlite/headscale_0.17.0-alpha2_dump.sql | 11 + .../sqlite/headscale_0.17.0-alpha2_schema.sql | 7 + .../sqlite/headscale_0.17.0-alpha3_dump.sql | 11 + .../sqlite/headscale_0.17.0-alpha3_schema.sql | 7 + .../sqlite/headscale_0.17.0-alpha4_dump.sql | 12 + .../sqlite/headscale_0.17.0-alpha4_schema.sql | 8 + .../sqlite/headscale_0.17.0-beta1_dump.sql | 12 + .../sqlite/headscale_0.17.0-beta1_schema.sql | 8 + .../sqlite/headscale_0.17.0-beta5_dump.sql | 12 + .../sqlite/headscale_0.17.0-beta5_schema.sql | 8 + .../testdata/sqlite/headscale_0.17.0_dump.sql | 12 + .../sqlite/headscale_0.17.0_schema.sql | 8 + .../testdata/sqlite/headscale_0.17.1_dump.sql | 12 + .../sqlite/headscale_0.17.1_schema.sql | 8 + .../sqlite/headscale_0.18.0-beta1_dump.sql | 14 + .../sqlite/headscale_0.18.0-beta1_schema.sql | 10 + .../sqlite/headscale_0.18.0-beta2_dump.sql | 14 + .../sqlite/headscale_0.18.0-beta2_schema.sql | 10 + .../sqlite/headscale_0.18.0-beta3_dump.sql | 14 + .../sqlite/headscale_0.18.0-beta3_schema.sql | 10 + .../sqlite/headscale_0.18.0-beta4_dump.sql | 14 + .../sqlite/headscale_0.18.0-beta4_schema.sql | 10 + .../testdata/sqlite/headscale_0.18.0_dump.sql | 14 + .../sqlite/headscale_0.18.0_schema.sql | 10 + .../sqlite/headscale_0.19.0-beta1_dump.sql | 14 + .../sqlite/headscale_0.19.0-beta1_schema.sql | 10 + .../sqlite/headscale_0.19.0-beta2_dump.sql | 14 + .../sqlite/headscale_0.19.0-beta2_schema.sql | 10 + .../testdata/sqlite/headscale_0.19.0_dump.sql | 14 + .../sqlite/headscale_0.19.0_schema.sql | 10 + .../testdata/sqlite/headscale_0.2.0_dump.sql | 9 + .../sqlite/headscale_0.2.0_schema.sql | 5 + .../testdata/sqlite/headscale_0.20.0_dump.sql | 14 + .../sqlite/headscale_0.20.0_schema.sql | 10 + .../testdata/sqlite/headscale_0.21.0_dump.sql | 14 + .../sqlite/headscale_0.21.0_schema.sql | 10 + .../sqlite/headscale_0.22.0-alpha1_dump.sql | 14 + .../sqlite/headscale_0.22.0-alpha1_schema.sql | 10 + .../sqlite/headscale_0.22.0-alpha2_dump.sql | 14 + .../sqlite/headscale_0.22.0-alpha2_schema.sql | 10 + .../sqlite/headscale_0.22.0-alpha3_dump.sql | 14 + .../sqlite/headscale_0.22.0-alpha3_schema.sql | 10 + .../testdata/sqlite/headscale_0.22.0_dump.sql | 14 + .../sqlite/headscale_0.22.0_schema.sql | 10 + .../testdata/sqlite/headscale_0.22.1_dump.sql | 14 + .../sqlite/headscale_0.22.1_schema.sql | 10 + .../testdata/sqlite/headscale_0.22.2_dump.sql | 14 + .../sqlite/headscale_0.22.2_schema.sql | 10 + .../testdata/sqlite/headscale_0.22.3_dump.sql | 14 + .../sqlite/headscale_0.22.3_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha10_dump.sql | 19 + .../headscale_0.23.0-alpha10_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha11_dump.sql | 19 + .../headscale_0.23.0-alpha11_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha12_dump.sql | 19 + .../headscale_0.23.0-alpha12_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha1_dump.sql | 14 + .../sqlite/headscale_0.23.0-alpha1_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha2_dump.sql | 16 + .../sqlite/headscale_0.23.0-alpha2_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha3_dump.sql | 16 + .../sqlite/headscale_0.23.0-alpha3_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha4_dump.sql | 16 + .../sqlite/headscale_0.23.0-alpha4_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha5_dump.sql | 18 + .../sqlite/headscale_0.23.0-alpha5_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha7_dump.sql | 19 + .../sqlite/headscale_0.23.0-alpha7_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha8_dump.sql | 19 + .../sqlite/headscale_0.23.0-alpha8_schema.sql | 10 + .../sqlite/headscale_0.23.0-alpha9_dump.sql | 19 + .../sqlite/headscale_0.23.0-alpha9_schema.sql | 10 + .../sqlite/headscale_0.23.0-beta.4_dump.sql | 22 + .../sqlite/headscale_0.23.0-beta.4_schema.sql | 12 + .../sqlite/headscale_0.23.0-beta.5_dump.sql | 22 + .../sqlite/headscale_0.23.0-beta.5_schema.sql | 12 + .../sqlite/headscale_0.23.0-beta1_dump.sql | 22 + .../sqlite/headscale_0.23.0-beta1_schema.sql | 12 + .../sqlite/headscale_0.23.0-beta2_dump.sql | 22 + .../sqlite/headscale_0.23.0-beta2_schema.sql | 12 + .../sqlite/headscale_0.23.0-beta3_dump.sql | 22 + .../sqlite/headscale_0.23.0-beta3_schema.sql | 12 + .../sqlite/headscale_0.23.0-rc.1_dump.sql | 22 + .../sqlite/headscale_0.23.0-rc.1_schema.sql | 12 + .../testdata/sqlite/headscale_0.23.0_dump.sql | 22 + .../sqlite/headscale_0.23.0_schema.sql | 12 + .../sqlite/headscale_0.24.0-beta.1_dump.sql | 27 + .../sqlite/headscale_0.24.0-beta.1_schema.sql | 14 + .../sqlite/headscale_0.24.0-beta.2_dump.sql | 27 + .../sqlite/headscale_0.24.0-beta.2_schema.sql | 14 + .../testdata/sqlite/headscale_0.24.0_dump.sql | 27 + .../sqlite/headscale_0.24.0_schema.sql | 14 + .../testdata/sqlite/headscale_0.24.1_dump.sql | 28 + .../sqlite/headscale_0.24.1_schema.sql | 14 + .../testdata/sqlite/headscale_0.24.2_dump.sql | 28 + .../sqlite/headscale_0.24.2_schema.sql | 14 + .../testdata/sqlite/headscale_0.24.3_dump.sql | 30 + .../sqlite/headscale_0.24.3_schema.sql | 14 + .../sqlite/headscale_0.25.0-beta.1_dump.sql | 29 + .../sqlite/headscale_0.25.0-beta.1_schema.sql | 14 + .../sqlite/headscale_0.25.0-beta.2_dump.sql | 30 + .../sqlite/headscale_0.25.0-beta.2_schema.sql | 14 + .../testdata/sqlite/headscale_0.25.0_dump.sql | 30 + .../sqlite/headscale_0.25.0_schema.sql | 14 + .../testdata/sqlite/headscale_0.25.1_dump.sql | 30 + .../sqlite/headscale_0.25.1_schema.sql | 14 + .../sqlite/headscale_0.26.0-beta.1_dump.sql | 30 + .../sqlite/headscale_0.26.0-beta.1_schema.sql | 12 + .../sqlite/headscale_0.26.0-beta.2_dump.sql | 31 + .../sqlite/headscale_0.26.0-beta.2_schema.sql | 12 + .../testdata/sqlite/headscale_0.26.0_dump.sql | 32 + .../sqlite/headscale_0.26.0_schema.sql | 12 + .../testdata/sqlite/headscale_0.26.1_dump.sql | 32 + .../sqlite/headscale_0.26.1_schema.sql | 12 + .../testdata/sqlite/headscale_0.3.0_dump.sql | 9 + .../sqlite/headscale_0.3.0_schema.sql | 5 + .../testdata/sqlite/headscale_0.4.0_dump.sql | 9 + .../sqlite/headscale_0.4.0_schema.sql | 5 + .../testdata/sqlite/headscale_0.5.0_dump.sql | 9 + .../sqlite/headscale_0.5.0_schema.sql | 5 + .../testdata/sqlite/headscale_0.6.0_dump.sql | 9 + .../sqlite/headscale_0.6.0_schema.sql | 5 + .../testdata/sqlite/headscale_0.7.0_dump.sql | 9 + .../sqlite/headscale_0.7.0_schema.sql | 5 + .../testdata/sqlite/headscale_0.7.1_dump.sql | 9 + .../sqlite/headscale_0.7.1_schema.sql | 5 + .../testdata/sqlite/headscale_0.8.0_dump.sql | 11 + .../sqlite/headscale_0.8.0_schema.sql | 7 + .../testdata/sqlite/headscale_0.8.1_dump.sql | 11 + .../sqlite/headscale_0.8.1_schema.sql | 7 + .../testdata/sqlite/headscale_0.9.0_dump.sql | 11 + .../sqlite/headscale_0.9.0_schema.sql | 7 + .../testdata/sqlite/headscale_0.9.1_dump.sql | 11 + .../sqlite/headscale_0.9.1_schema.sql | 7 + .../testdata/sqlite/headscale_0.9.2_dump.sql | 11 + .../sqlite/headscale_0.9.2_schema.sql | 7 + .../testdata/sqlite/headscale_0.9.3_dump.sql | 11 + .../sqlite/headscale_0.9.3_schema.sql | 7 + .../wrongly-migrated-schema-0.25.1_dump.sql | 101 +++ hscontrol/db/text_serialiser.go | 4 +- 248 files changed, 6228 insertions(+), 207 deletions(-) create mode 100644 hscontrol/db/schema.sql create mode 100644 hscontrol/db/sqliteconfig/config.go create mode 100644 hscontrol/db/sqliteconfig/config_test.go create mode 100644 hscontrol/db/sqliteconfig/integration_test.go delete mode 100644 hscontrol/db/testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite delete mode 100644 hscontrol/db/testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite delete mode 100644 hscontrol/db/testdata/0-23-0-to-0-24-0-no-more-special-types.sqlite delete mode 100644 hscontrol/db/testdata/0-23-0-to-0-24-0-preauthkey-tags-table.sqlite delete mode 100644 hscontrol/db/testdata/failing-node-preauth-constraint.sqlite rename hscontrol/db/testdata/{ => postgres}/pre-24-postgresdb.pssql.dump (100%) create mode 100644 hscontrol/db/testdata/sqlite/0-22-3-to-0-23-0-routes-are-dropped-2063_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/0-22-3-to-0-23-0-routes-fail-foreign-key-2076_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/0-23-0-to-0-24-0-no-more-special-types_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/0-23-0-to-0-24-0-preauthkey-tags-table_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/failing-node-preauth-constraint_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db01__0.14.0__0.24.0.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db01__0.14.0__0.24.1.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db01__0.14.0__0.24.2.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db01__0.14.0__0.25.0.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db01__0.14.0__0.25.1.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db01__0.14.0__0.26.1.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db02__0.22.1__0.24.0.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db02__0.22.1__0.24.1.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db02__0.22.1__0.24.2.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db02__0.22.1__0.24.3.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db02__0.22.1__0.25.0.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db02__0.22.1__0.25.1.sql create mode 100644 hscontrol/db/testdata/sqlite/from_nblock_db02__0.22.1__0.26.1.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.5_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.5_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.6_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.6_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.7_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.7_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.8_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.10.8_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.11.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.11.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.2-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.2-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.12.4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.13.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.13.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.13.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.13.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.14.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.14.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.14.0-beta2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.14.0-beta2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.14.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.14.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta5_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta5_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta6_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0-beta6_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.15.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.16.4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-alpha4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-beta5_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0-beta5_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.17.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0-beta4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.18.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.19.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.19.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.19.0-beta2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.19.0-beta2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.19.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.19.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.2.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.2.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.20.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.20.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.21.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.21.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0-alpha1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0-alpha1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0-alpha2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0-alpha2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0-alpha3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0-alpha3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.22.3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha10_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha10_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha11_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha11_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha12_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha12_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha5_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha5_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha7_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha7_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha8_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha8_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha9_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-alpha9_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta.4_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta.4_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta.5_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta.5_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-beta3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-rc.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0-rc.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.23.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.0-beta.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.0-beta.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.0-beta.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.0-beta.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.24.3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.0-beta.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.0-beta.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.0-beta.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.0-beta.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.25.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.3.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.3.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.4.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.4.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.5.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.5.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.6.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.6.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.7.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.7.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.7.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.7.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.8.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.8.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.8.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.8.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.0_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.0_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.1_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.1_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.2_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.2_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.3_dump.sql create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.9.3_schema.sql create mode 100644 hscontrol/db/testdata/sqlite/wrongly-migrated-schema-0.25.1_dump.sql diff --git a/cmd/headscale/cli/serve.go b/cmd/headscale/cli/serve.go index 91597400..8f05f851 100644 --- a/cmd/headscale/cli/serve.go +++ b/cmd/headscale/cli/serve.go @@ -2,10 +2,12 @@ package cli import ( "errors" + "fmt" "net/http" "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "github.com/tailscale/squibble" ) func init() { @@ -21,6 +23,12 @@ var serveCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { app, err := newHeadscaleServerWithConfig() if err != nil { + var squibbleErr squibble.ValidationError + if errors.As(err, &squibbleErr) { + fmt.Printf("SQLite schema failed to validate:\n") + fmt.Println(squibbleErr.Diff) + } + log.Fatal().Caller().Err(err).Msg("Error initializing") } diff --git a/flake.nix b/flake.nix index b1a34d56..bc05e02d 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ overlay = _: prev: let pkgs = nixpkgs.legacyPackages.${prev.system}; buildGo = pkgs.buildGo124Module; - vendorHash = "sha256-ACab+UvKrh+7G5KXNS+Iu9y8ZExefQDhwEKgIv0iIvE="; + vendorHash = "sha256-S2GnCg2dyfjIyi5gXhVEuRs5Bop2JAhZcnhg1fu4/Gg="; in { headscale = buildGo { pname = "headscale"; diff --git a/go.mod b/go.mod index ccc69953..399cc807 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33 + github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -116,7 +117,7 @@ require ( github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect - github.com/creachadair/mds v0.24.1 // indirect + github.com/creachadair/mds v0.24.3 // indirect github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/distribution/reference v0.6.0 // indirect @@ -213,7 +214,6 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d // indirect - github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 // indirect github.com/vishvananda/netns v0.0.4 // indirect diff --git a/go.sum b/go.sum index 88561bd2..3696736b 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,8 @@ github.com/creachadair/command v0.1.22 h1:WmdrURwZdmPD1jm13SjKooaMoqo7mW1qI2BPCS github.com/creachadair/command v0.1.22/go.mod h1:YFc+OMGucqTpxwQg/iJnNg8BMNmRPDK60rYy8ckgKwE= github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE= github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8= -github.com/creachadair/mds v0.24.1 h1:bzL4ItCtAUxxO9KkotP0PVzlw4tnJicAcjPu82v2mGs= -github.com/creachadair/mds v0.24.1/go.mod h1:ArfS0vPHoLV/SzuIzoqTEZfoYmac7n9Cj8XPANHocvw= +github.com/creachadair/mds v0.24.3 h1:X7cM2ymZSyl4IVWnfyXLxRXMJ6awhbcWvtLPhfnTaqI= +github.com/creachadair/mds v0.24.3/go.mod h1:0oeHt9QWu8VfnmskOL4zi2CumjEvB29ScmtOmdrhFeU= github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index bab0061e..56d7860b 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + _ "embed" "encoding/json" "errors" "fmt" @@ -15,9 +16,11 @@ import ( "github.com/glebarez/sqlite" "github.com/go-gormigrate/gormigrate/v2" + "github.com/juanfont/headscale/hscontrol/db/sqliteconfig" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" + "github.com/tailscale/squibble" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -27,12 +30,23 @@ import ( "zgo.at/zcache/v2" ) +//go:embed schema.sql +var dbSchema string + func init() { schema.RegisterSerializer("text", TextSerialiser{}) } var errDatabaseNotSupported = errors.New("database type not supported") +var errForeignKeyConstraintsViolated = errors.New("foreign key constraints violated") + +const ( + maxIdleConns = 100 + maxOpenConns = 100 + contextTimeoutSecs = 10 +) + // KV is a key-value store in a psql table. For future use... // TODO(kradalby): Is this used for anything? type KV struct { @@ -471,6 +485,7 @@ func NewHeadscaleDatabase( // Drop the old table. _ = tx.Migrator().DropTable(&preAuthKeyACLTag{}) + return nil }, Rollback: func(db *gorm.DB) error { return nil }, @@ -602,7 +617,7 @@ COMMIT; }, Rollback: func(db *gorm.DB) error { return nil }, }, - // Ensure there are no nodes refering to a deleted preauthkey. + // Ensure there are no nodes referring to a deleted preauthkey. { ID: "202502070949", Migrate: func(tx *gorm.DB) error { @@ -718,6 +733,208 @@ AND auth_key_id NOT IN ( }, Rollback: func(db *gorm.DB) error { return nil }, }, + // Schema migration to ensure all tables match the expected schema. + // This migration recreates all tables to match the exact structure in schema.sql, + // preserving all data during the process. + // Only SQLite will be migrated for consistency. + { + ID: "202507021200", + Migrate: func(tx *gorm.DB) error { + // Only run on SQLite + if cfg.Type != types.DatabaseSqlite { + log.Info().Msg("Skipping schema migration on non-SQLite database") + return nil + } + + log.Info().Msg("Starting schema recreation with table renaming") + + // Rename existing tables to _old versions + tablesToRename := []string{"users", "pre_auth_keys", "api_keys", "nodes", "policies"} + + // Check if routes table exists and drop it (should have been migrated already) + var routesExists bool + err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesExists) + if err == nil && routesExists { + log.Info().Msg("Dropping leftover routes table") + if err := tx.Exec("DROP TABLE routes").Error; err != nil { + return fmt.Errorf("dropping routes table: %w", err) + } + } + + // Drop all indexes first to avoid conflicts + indexesToDrop := []string{ + "idx_users_deleted_at", + "idx_provider_identifier", + "idx_name_provider_identifier", + "idx_name_no_provider_identifier", + "idx_api_keys_prefix", + "idx_policies_deleted_at", + } + + for _, index := range indexesToDrop { + _ = tx.Exec("DROP INDEX IF EXISTS " + index).Error + } + + for _, table := range tablesToRename { + // Check if table exists before renaming + var exists bool + err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Row().Scan(&exists) + if err != nil { + return fmt.Errorf("checking if table %s exists: %w", table, err) + } + + if exists { + // Drop old table if it exists from previous failed migration + _ = tx.Exec("DROP TABLE IF EXISTS " + table + "_old").Error + + // Rename current table to _old + if err := tx.Exec("ALTER TABLE " + table + " RENAME TO " + table + "_old").Error; err != nil { + return fmt.Errorf("renaming table %s to %s_old: %w", table, table, err) + } + } + } + + // Create new tables with correct schema + tableCreationSQL := []string{ + `CREATE TABLE users( + id integer PRIMARY KEY AUTOINCREMENT, + name text, + display_name text, + email text, + provider_identifier text, + provider text, + profile_pic_url text, + created_at datetime, + updated_at datetime, + deleted_at datetime +)`, + `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, + expiration datetime, + created_at 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, + expiration datetime, + last_seen datetime, + created_at datetime +)`, + `CREATE TABLE 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, + last_seen datetime, + expiry 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, + data text, + created_at datetime, + updated_at datetime, + deleted_at datetime +)`, + } + + for _, createSQL := range tableCreationSQL { + if err := tx.Exec(createSQL).Error; err != nil { + return fmt.Errorf("creating new table: %w", err) + } + } + + // Copy data directly using SQL + dataCopySQL := []string{ + `INSERT INTO users (id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at) + SELECT id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at + FROM users_old`, + + `INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at) + SELECT id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at + FROM pre_auth_keys_old`, + + `INSERT INTO api_keys (id, prefix, hash, expiration, last_seen, created_at) + SELECT id, prefix, hash, expiration, last_seen, created_at + FROM api_keys_old`, + + `INSERT INTO nodes (id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, forced_tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at) + SELECT id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, forced_tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at + FROM nodes_old`, + + `INSERT INTO policies (id, data, created_at, updated_at, deleted_at) + SELECT id, data, created_at, updated_at, deleted_at + FROM policies_old`, + } + + for _, copySQL := range dataCopySQL { + if err := tx.Exec(copySQL).Error; err != nil { + return fmt.Errorf("copying data: %w", err) + } + } + + // Create indexes + indexes := []string{ + "CREATE INDEX idx_users_deleted_at ON users(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 UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix)", + "CREATE INDEX idx_policies_deleted_at ON policies(deleted_at)", + } + + for _, indexSQL := range indexes { + if err := tx.Exec(indexSQL).Error; err != nil { + return fmt.Errorf("creating index: %w", err) + } + } + + // Drop old tables only after everything succeeds + for _, table := range tablesToRename { + if err := tx.Exec("DROP TABLE IF EXISTS " + table + "_old").Error; err != nil { + log.Warn().Str("table", table+"_old").Err(err).Msg("Failed to drop old table, but migration succeeded") + } + } + + log.Info().Msg("Schema recreation completed successfully") + return nil + }, + Rollback: func(db *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 wont over time. + // - Never write migrations that requires foreign keys to be disabled. }, ) @@ -725,6 +942,30 @@ AND auth_key_id NOT IN ( log.Fatal().Err(err).Msgf("Migration failed: %v", err) } + // Validate that the schema ends up in the expected state. + // This is currently only done on sqlite as squibble does not + // support Postgres and we use our sqlite schema as our source of + // truth. + if cfg.Type == types.DatabaseSqlite { + sqlConn, err := dbConn.DB() + if err != nil { + return nil, fmt.Errorf("getting DB from gorm: %w", err) + } + + // or else it blocks... + sqlConn.SetMaxIdleConns(maxIdleConns) + sqlConn.SetMaxOpenConns(maxOpenConns) + defer sqlConn.SetMaxIdleConns(1) + defer sqlConn.SetMaxOpenConns(1) + + ctx, cancel := context.WithTimeout(context.Background(), contextTimeoutSecs*time.Second) + defer cancel() + + if err := squibble.Validate(ctx, sqlConn, dbSchema); err != nil { + return nil, fmt.Errorf("validating schema: %w", err) + } + } + db := HSDatabase{ DB: dbConn, cfg: &cfg, @@ -758,32 +999,26 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) { Str("path", cfg.Sqlite.Path). Msg("Opening database") + // Build SQLite configuration with pragmas set at connection time + sqliteConfig := sqliteconfig.Default(cfg.Sqlite.Path) + if cfg.Sqlite.WriteAheadLog { + sqliteConfig.JournalMode = sqliteconfig.JournalModeWAL + sqliteConfig.WALAutocheckpoint = cfg.Sqlite.WALAutoCheckPoint + } + + connectionURL, err := sqliteConfig.ToURL() + if err != nil { + return nil, fmt.Errorf("building sqlite connection URL: %w", err) + } + db, err := gorm.Open( - sqlite.Open(cfg.Sqlite.Path), + sqlite.Open(connectionURL), &gorm.Config{ PrepareStmt: cfg.Gorm.PrepareStmt, Logger: dbLogger, }, ) - if err := db.Exec(` - PRAGMA foreign_keys=ON; - PRAGMA busy_timeout=10000; - PRAGMA auto_vacuum=INCREMENTAL; - PRAGMA synchronous=NORMAL; - `).Error; err != nil { - return nil, fmt.Errorf("enabling foreign keys: %w", err) - } - - if cfg.Sqlite.WriteAheadLog { - if err := db.Exec(fmt.Sprintf(` - PRAGMA journal_mode=WAL; - PRAGMA wal_autocheckpoint=%d; - `, cfg.Sqlite.WALAutoCheckPoint)).Error; err != nil { - return nil, fmt.Errorf("setting WAL mode: %w", err) - } - } - // The pure Go SQLite library does not handle locking in // the same way as the C based one and we can't use the gorm // connection pool as of 2022/02/23. @@ -812,7 +1047,7 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) { dbString += " sslmode=disable" } } else { - dbString += fmt.Sprintf(" sslmode=%s", cfg.Postgres.Ssl) + dbString += " sslmode=" + cfg.Postgres.Ssl } if cfg.Postgres.Port != 0 { @@ -820,7 +1055,7 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) { } if cfg.Postgres.Pass != "" { - dbString += fmt.Sprintf(" password=%s", cfg.Postgres.Pass) + dbString += " password=" + cfg.Postgres.Pass } db, err := gorm.Open(postgres.Open(dbString), &gorm.Config{ @@ -848,29 +1083,84 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) { } func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormigrate.Gormigrate) error { - // Turn off foreign keys for the duration of the migration if using sqlite to - // prevent data loss due to the way the GORM migrator handles certain schema - // changes. if cfg.Type == types.DatabaseSqlite { - var fkEnabled int - if err := dbConn.Raw("PRAGMA foreign_keys").Scan(&fkEnabled).Error; err != nil { + // SQLite: Run migrations step-by-step, only disabling foreign keys when necessary + + // List of migration IDs that require foreign keys to be disabled + // These are migrations that perform complex schema changes that GORM cannot handle safely with FK enabled + // NO NEW MIGRATIONS SHOULD BE ADDED HERE. ALL NEW MIGRATIONS MUST RUN WITH FOREIGN KEYS ENABLED. + migrationsRequiringFKDisabled := map[string]bool{ + "202312101416": true, // Initial migration with complex table/column renames + "202402151347": true, // Migration that removes last_successful_update column + "2024041121742": true, // Migration that changes IP address storage format + "202407191627": true, // User table automigration with FK constraint issues + "202408181235": true, // User table automigration with FK constraint issues + "202501221827": true, // Route table automigration with FK constraint issues + "202501311657": true, // PreAuthKey table automigration with FK constraint issues + // Add other migration IDs here as they are identified to need FK disabled + } + + // Get the current foreign key status + var fkOriginallyEnabled int + if err := dbConn.Raw("PRAGMA foreign_keys").Scan(&fkOriginallyEnabled).Error; err != nil { return fmt.Errorf("checking foreign key status: %w", err) } - if fkEnabled == 1 { - if err := dbConn.Exec("PRAGMA foreign_keys = OFF").Error; err != nil { - return fmt.Errorf("disabling foreign keys: %w", err) - } - defer dbConn.Exec("PRAGMA foreign_keys = ON") + + // Get all migration IDs in order from the actual migration definitions + // Only IDs that are in the migrationsRequiringFKDisabled map will be processed with FK disabled + // any other new migrations are ran after. + migrationIDs := []string{ + "202312101416", + "202312101430", + "202402151347", + "2024041121742", + "202406021630", + "202407191627", + "202408181235", + "202409271400", + "202501221827", + "202501311657", + "202502070949", + "202502131714", + "202502171819", + "202505091439", + "202505141324", + // As of 2025-07-02, no new IDs should be added here. + // They will be ran by the migrations.Migrate() call below. } - } - if err := migrations.Migrate(); err != nil { - return err - } + for _, migrationID := range migrationIDs { + log.Trace().Str("migration_id", migrationID).Msg("Running migration") + needsFKDisabled := migrationsRequiringFKDisabled[migrationID] - // Since we disabled foreign keys for the migration, we need to check for - // constraint violations manually at the end of the migration. - if cfg.Type == types.DatabaseSqlite { + if needsFKDisabled { + // Disable foreign keys for this migration + if err := dbConn.Exec("PRAGMA foreign_keys = OFF").Error; err != nil { + return fmt.Errorf("disabling foreign keys for migration %s: %w", migrationID, err) + } + } else { + // Ensure foreign keys are enabled for this migration + if err := dbConn.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + return fmt.Errorf("enabling foreign keys for migration %s: %w", migrationID, err) + } + } + + // Run up to this specific migration (will only run the next pending migration) + if err := migrations.MigrateTo(migrationID); err != nil { + return fmt.Errorf("running migration %s: %w", migrationID, err) + } + } + + if err := dbConn.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + return fmt.Errorf("restoring foreign keys: %w", err) + } + + // Run the rest of the migrations + if err := migrations.Migrate(); err != nil { + return err + } + + // Check for constraint violations at the end type constraintViolation struct { Table string RowID int @@ -904,7 +1194,12 @@ func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormig Msg("Foreign key constraint violated") } - return fmt.Errorf("foreign key constraints violated") + return errForeignKeyConstraintsViolated + } + } else { + // PostgreSQL can run all migrations in one block - no foreign key issues + if err := migrations.Migrate(); err != nil { + return err } } @@ -949,6 +1244,7 @@ func Read[T any](db *gorm.DB, fn func(rx *gorm.DB) (T, error)) (T, error) { var no T return no, err } + return ret, nil } @@ -970,5 +1266,6 @@ func Write[T any](db *gorm.DB, fn func(tx *gorm.DB) (T, error)) (T, error) { var no T return no, err } + return ret, tx.Commit().Error } diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 10781c7b..86332a0d 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -2,8 +2,6 @@ package db import ( "database/sql" - "fmt" - "io" "net/netip" "os" "os/exec" @@ -24,10 +22,10 @@ import ( "zgo.at/zcache/v2" ) -// TestMigrationsSQLite is the main function for testing migrations, -// we focus on SQLite correctness as it is the main database used in headscale. -// All migrations that are worth testing should be added here. -func TestMigrationsSQLite(t *testing.T) { +// TestSQLiteMigrationAndDataValidation tests specific SQLite migration scenarios +// and validates data integrity after migration. All migrations that require data validation +// should be added here. +func TestSQLiteMigrationAndDataValidation(t *testing.T) { ipp := func(p string) netip.Prefix { return netip.MustParsePrefix(p) } @@ -43,12 +41,39 @@ func TestMigrationsSQLite(t *testing.T) { tests := []struct { dbPath string wantFunc func(*testing.T, *HSDatabase) - wantErr string }{ { - dbPath: "testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite", - wantFunc: func(t *testing.T, h *HSDatabase) { - nodes, err := Read(h.DB, func(rx *gorm.DB) (types.Nodes, error) { + dbPath: "testdata/sqlite/0-22-3-to-0-23-0-routes-are-dropped-2063_dump.sql", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + // Comprehensive data preservation validation for 0.22.3->0.23.0 migration + // Expected data from dump: 4 users, 17 pre_auth_keys, 14 machines/nodes, 12 routes + + // Verify users data preservation - should have 4 users + users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { + return ListUsers(rx) + }) + require.NoError(t, err) + assert.Len(t, users, 4, "should preserve all 4 users from original schema") + + // Verify pre_auth_keys data preservation - should have 17 keys + preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) { + var keys []types.PreAuthKey + err := rx.Find(&keys).Error + return keys, err + }) + require.NoError(t, err) + assert.Len(t, preAuthKeys, 17, "should preserve all 17 pre_auth_keys from original schema") + + // Verify all nodes data preservation - should have 14 nodes + allNodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { + return ListNodes(rx) + }) + require.NoError(t, err) + assert.Len(t, allNodes, 14, "should preserve all 14 machines/nodes from original schema") + + // Verify specific nodes and their route migration with detailed validation + nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { n1, err := GetNodeByID(rx, 1) n26, err := GetNodeByID(rx, 26) n31, err := GetNodeByID(rx, 31) @@ -60,24 +85,66 @@ func TestMigrationsSQLite(t *testing.T) { return types.Nodes{n1, n26, n31, n32}, nil }) require.NoError(t, err) + assert.Len(t, nodes, 4, "should have retrieved 4 specific nodes") - // want := types.Routes{ - // r(1, "0.0.0.0/0", true, false), - // r(1, "::/0", true, false), - // r(1, "10.9.110.0/24", true, true), - // r(26, "172.100.100.0/24", true, true), - // r(26, "172.100.100.0/24", true, false, false), - // r(31, "0.0.0.0/0", true, false), - // r(31, "0.0.0.0/0", true, false, false), - // r(31, "::/0", true, false), - // r(31, "::/0", true, false, false), - // r(32, "192.168.0.24/32", true, true), - // } + // Validate specific node data from dump file + nodesByID := make(map[uint64]*types.Node) + for i := range nodes { + nodesByID[nodes[i].ID.Uint64()] = nodes[i] + } + + node1 := nodesByID[1] + node26 := nodesByID[26] + node31 := nodesByID[31] + node32 := nodesByID[32] + + require.NotNil(t, node1, "node 1 should exist") + require.NotNil(t, node26, "node 26 should exist") + require.NotNil(t, node31, "node 31 should exist") + require.NotNil(t, node32, "node 32 should exist") + + // Validate node data using cmp.Diff + expectedNodes := map[uint64]struct { + Hostname string + GivenName string + IPv4 string + }{ + 1: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.1"}, + 26: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.19"}, + 31: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.7"}, + 32: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.11"}, + } + + for nodeID, expected := range expectedNodes { + node := nodesByID[nodeID] + require.NotNil(t, node, "node %d should exist", nodeID) + + actual := struct { + Hostname string + GivenName string + IPv4 string + }{ + Hostname: node.Hostname, + GivenName: node.GivenName, + IPv4: node.IPv4.String(), + } + + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() node %d mismatch (-want +got):\n%s", nodeID, diff) + } + } + + // Validate that routes were properly migrated from routes table to approved_routes + // Based on the dump file routes data: + // Node 1 (machine_id 1): routes 1,2,3 (0.0.0.0/0 enabled, ::/0 enabled, 10.9.110.0/24 enabled+primary) + // Node 26 (machine_id 26): route 6 (172.100.100.0/24 enabled+primary), route 7 (172.100.100.0/24 disabled) + // Node 31 (machine_id 31): routes 8,10 (0.0.0.0/0 enabled, ::/0 enabled), routes 9,11 (duplicates disabled) + // Node 32 (machine_id 32): route 12 (192.168.0.24/32 enabled+primary) want := [][]netip.Prefix{ - {ipp("0.0.0.0/0"), ipp("10.9.110.0/24"), ipp("::/0")}, - {ipp("172.100.100.0/24")}, - {ipp("0.0.0.0/0"), ipp("::/0")}, - {ipp("192.168.0.24/32")}, + {ipp("0.0.0.0/0"), ipp("10.9.110.0/24"), ipp("::/0")}, // node 1: 3 enabled routes + {ipp("172.100.100.0/24")}, // node 26: 1 enabled route + {ipp("0.0.0.0/0"), ipp("::/0")}, // node 31: 2 enabled routes + {ipp("192.168.0.24/32")}, // node 32: 1 enabled route } var got [][]netip.Prefix for _, node := range nodes { @@ -85,14 +152,48 @@ func TestMigrationsSQLite(t *testing.T) { } if diff := cmp.Diff(want, got, util.PrefixComparer); diff != "" { - t.Errorf("TestMigrations() mismatch (-want +got):\n%s", diff) + t.Errorf("TestSQLiteMigrationAndDataValidation() route migration mismatch (-want +got):\n%s", diff) } + + // Verify routes table was dropped after migration + var routesTableExists bool + err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesTableExists) + require.NoError(t, err) + assert.False(t, routesTableExists, "routes table should have been dropped after migration") }, }, { - dbPath: "testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite", - wantFunc: func(t *testing.T, h *HSDatabase) { - node, err := Read(h.DB, func(rx *gorm.DB) (*types.Node, error) { + dbPath: "testdata/sqlite/0-22-3-to-0-23-0-routes-fail-foreign-key-2076_dump.sql", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + // Comprehensive data preservation validation for foreign key constraint issue case + // Expected data from dump: 4 users, 2 pre_auth_keys, 8 nodes + + // Verify users data preservation + users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { + return ListUsers(rx) + }) + require.NoError(t, err) + assert.Len(t, users, 4, "should preserve all 4 users from original schema") + + // Verify pre_auth_keys data preservation + preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) { + var keys []types.PreAuthKey + err := rx.Find(&keys).Error + return keys, err + }) + require.NoError(t, err) + assert.Len(t, preAuthKeys, 2, "should preserve all 2 pre_auth_keys from original schema") + + // Verify all nodes data preservation + allNodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { + return ListNodes(rx) + }) + require.NoError(t, err) + assert.Len(t, allNodes, 8, "should preserve all 8 nodes from original schema") + + // Verify specific node route migration + node, err := Read(hsdb.DB, func(rx *gorm.DB) (*types.Node, error) { return GetNodeByID(rx, 13) }) require.NoError(t, err) @@ -101,26 +202,26 @@ func TestMigrationsSQLite(t *testing.T) { _ = types.Routes{ // These routes exists, but have no nodes associated with them // when the migration starts. - // r(1, "0.0.0.0/0", true, true, false), - // r(1, "::/0", true, true, false), - // r(3, "0.0.0.0/0", true, true, false), - // r(3, "::/0", true, true, false), - // r(5, "0.0.0.0/0", true, true, false), - // r(5, "::/0", true, true, false), - // r(6, "0.0.0.0/0", true, true, false), - // r(6, "::/0", true, true, false), + // r(1, "0.0.0.0/0", true, false), + // r(1, "::/0", true, false), + // r(3, "0.0.0.0/0", true, false), + // r(3, "::/0", true, false), + // r(5, "0.0.0.0/0", true, false), + // r(5, "::/0", true, false), + // r(6, "0.0.0.0/0", true, false), + // r(6, "::/0", true, false), // r(6, "10.0.0.0/8", true, false, false), - // r(7, "0.0.0.0/0", true, true, false), - // r(7, "::/0", true, true, false), + // r(7, "0.0.0.0/0", true, false), + // r(7, "::/0", true, false), // r(7, "10.0.0.0/8", true, false, false), - // r(9, "0.0.0.0/0", true, true, false), - // r(9, "::/0", true, true, false), - // r(9, "10.0.0.0/8", true, true, false), - // r(11, "0.0.0.0/0", true, true, false), - // r(11, "::/0", true, true, false), - // r(11, "10.0.0.0/8", true, true, true), - // r(12, "0.0.0.0/0", true, true, false), - // r(12, "::/0", true, true, false), + // r(9, "0.0.0.0/0", true, false), + // r(9, "::/0", true, false), + // r(9, "10.0.0.0/8", true, false), + // r(11, "0.0.0.0/0", true, false), + // r(11, "::/0", true, false), + // r(11, "10.0.0.0/8", true, true), + // r(12, "0.0.0.0/0", true, false), + // r(12, "::/0", true, false), // r(12, "10.0.0.0/8", true, false, false), // // These nodes exists, so routes should be kept. @@ -131,8 +232,14 @@ func TestMigrationsSQLite(t *testing.T) { } want := []netip.Prefix{ipp("0.0.0.0/0"), ipp("10.18.80.2/32"), ipp("::/0")} if diff := cmp.Diff(want, node.ApprovedRoutes, util.PrefixComparer); diff != "" { - t.Errorf("TestMigrations() mismatch (-want +got):\n%s", diff) + t.Errorf("TestSQLiteMigrationAndDataValidation() route migration mismatch (-want +got):\n%s", diff) } + + // Verify routes table was dropped after migration + var routesTableExists bool + err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesTableExists) + require.NoError(t, err) + assert.False(t, routesTableExists, "routes table should have been dropped after migration") }, }, // at 14:15:06 ❯ go run ./cmd/headscale preauthkeys list @@ -143,9 +250,49 @@ func TestMigrationsSQLite(t *testing.T) { // 4 | f20155.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:test // 5 | b212b9.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:test,tag:woop,tag:dedu { - dbPath: "testdata/0-23-0-to-0-24-0-preauthkey-tags-table.sqlite", - wantFunc: func(t *testing.T, h *HSDatabase) { - keys, err := Read(h.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) { + dbPath: "testdata/sqlite/0-23-0-to-0-24-0-preauthkey-tags-table_dump.sql", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + // Comprehensive data preservation validation for pre-auth key tags migration + // Expected data from dump: 2 users (kratest, testkra), 5 pre_auth_keys with specific tags + + // Verify users data preservation with specific user data + users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { + return ListUsers(rx) + }) + require.NoError(t, err) + assert.Len(t, users, 2, "should preserve all 2 users from original schema") + + // Validate specific user data from dump file using cmp.Diff + expectedUsers := []types.User{ + {Model: gorm.Model{ID: 1}, Name: "kratest"}, + {Model: gorm.Model{ID: 2}, Name: "testkra"}, + } + + if diff := cmp.Diff(expectedUsers, users, + cmpopts.IgnoreFields(types.User{}, "CreatedAt", "UpdatedAt", "DeletedAt", "DisplayName", "Email", "ProviderIdentifier", "Provider", "ProfilePicURL")); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() users mismatch (-want +got):\n%s", diff) + } + + // Create maps for easier access in later validations + usersByName := make(map[string]*types.User) + for i := range users { + usersByName[users[i].Name] = &users[i] + } + kratest := usersByName["kratest"] + testkra := usersByName["testkra"] + + // Verify all pre_auth_keys data preservation + allKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) { + var keys []types.PreAuthKey + err := rx.Find(&keys).Error + return keys, err + }) + require.NoError(t, err) + assert.Len(t, allKeys, 5, "should preserve all 5 pre_auth_keys from original schema") + + // Verify specific pre-auth keys and their tag migration with exact data validation + keys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) { kratest, err := ListPreAuthKeysByUser(rx, 1) // kratest if err != nil { return nil, err @@ -159,51 +306,104 @@ func TestMigrationsSQLite(t *testing.T) { return append(kratest, testkra...), nil }) require.NoError(t, err) - assert.Len(t, keys, 5) - want := []types.PreAuthKey{ + + // Create map for easier validation by ID + keysByID := make(map[uint64]*types.PreAuthKey) + for i := range keys { + keysByID[keys[i].ID] = &keys[i] + } + + // Validate specific pre-auth key data and tag migration from pre_auth_key_acl_tags table + key1 := keysByID[1] + key2 := keysByID[2] + key3 := keysByID[3] + key4 := keysByID[4] + key5 := keysByID[5] + + require.NotNil(t, key1, "pre_auth_key 1 should exist") + require.NotNil(t, key2, "pre_auth_key 2 should exist") + require.NotNil(t, key3, "pre_auth_key 3 should exist") + require.NotNil(t, key4, "pre_auth_key 4 should exist") + require.NotNil(t, key5, "pre_auth_key 5 should exist") + + // Validate specific pre-auth key data and tag migration using cmp.Diff + expectedKeys := []types.PreAuthKey{ { - ID: 1, - Tags: []string{"tag:derp"}, + ID: 1, + Key: "09b28f8c3351984874d46dace0a70177a8721933a950b663", + UserID: kratest.ID, + Tags: []string{"tag:derp"}, }, { - ID: 2, - Tags: []string{"tag:derp"}, + ID: 2, + Key: "3112b953cb344191b2d5aec1b891250125bf7b437eac5d26", + UserID: kratest.ID, + Tags: []string{"tag:derp"}, }, { - ID: 3, - Tags: []string{"tag:derp", "tag:merp"}, + ID: 3, + Key: "7c23b9f215961e7609527aef78bf82fb19064b002d78c36f", + UserID: kratest.ID, + Tags: []string{"tag:derp", "tag:merp"}, }, { - ID: 4, - Tags: []string{"tag:test"}, + ID: 4, + Key: "f2015583852b725220cc4b107fb288a4cf7ac259bd458a32", + UserID: testkra.ID, + Tags: []string{"tag:test"}, }, { - ID: 5, - Tags: []string{"tag:test", "tag:woop", "tag:dedu"}, + ID: 5, + Key: "b212b990165e897944dd3772786544402729fb349da50f57", + UserID: testkra.ID, + Tags: []string{"tag:test", "tag:woop", "tag:dedu"}, }, } - if diff := cmp.Diff(want, keys, cmp.Comparer(func(a, b []string) bool { + if diff := cmp.Diff(expectedKeys, keys, cmp.Comparer(func(a, b []string) bool { sort.Sort(sort.StringSlice(a)) sort.Sort(sort.StringSlice(b)) return slices.Equal(a, b) - }), cmpopts.IgnoreFields(types.PreAuthKey{}, "Key", "UserID", "User", "CreatedAt", "Expiration")); diff != "" { - t.Errorf("TestMigrations() mismatch (-want +got):\n%s", diff) + }), cmpopts.IgnoreFields(types.PreAuthKey{}, "User", "CreatedAt", "Reusable", "Ephemeral", "Used", "Expiration")); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() pre-auth key tags migration mismatch (-want +got):\n%s", diff) } - if h.DB.Migrator().HasTable("pre_auth_key_acl_tags") { - t.Errorf("TestMigrations() table pre_auth_key_acl_tags should not exist") + // Verify pre_auth_key_acl_tags table was dropped after migration + if hsdb.DB.Migrator().HasTable("pre_auth_key_acl_tags") { + t.Errorf("TestSQLiteMigrationAndDataValidation() table pre_auth_key_acl_tags should not exist after migration") } }, }, { - dbPath: "testdata/0-23-0-to-0-24-0-no-more-special-types.sqlite", - wantFunc: func(t *testing.T, h *HSDatabase) { - nodes, err := Read(h.DB, func(rx *gorm.DB) (types.Nodes, error) { + dbPath: "testdata/sqlite/0-23-0-to-0-24-0-no-more-special-types_dump.sql", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + // Comprehensive data preservation validation for special types removal migration + // Expected data from dump: 2 users, 2 pre_auth_keys, 12 nodes + + // Verify users data preservation + users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { + return ListUsers(rx) + }) + require.NoError(t, err) + assert.Len(t, users, 2, "should preserve all 2 users from original schema") + + // Verify pre_auth_keys data preservation + preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) { + var keys []types.PreAuthKey + err := rx.Find(&keys).Error + return keys, err + }) + require.NoError(t, err) + assert.Len(t, preAuthKeys, 2, "should preserve all 2 pre_auth_keys from original schema") + + // Verify nodes data preservation and field validation + nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { return ListNodes(rx) }) require.NoError(t, err) + assert.Len(t, nodes, 12, "should preserve all 12 nodes from original schema") for _, node := range nodes { assert.Falsef(t, node.MachineKey.IsZero(), "expected non zero machinekey") @@ -213,7 +413,7 @@ func TestMigrationsSQLite(t *testing.T) { assert.Falsef(t, node.DiscoKey.IsZero(), "expected non zero discokey") assert.Contains(t, node.DiscoKey.String(), "discokey:") assert.NotNil(t, node.IPv4) - assert.NotNil(t, node.IPv4) + assert.NotNil(t, node.IPv6) assert.Len(t, node.Endpoints, 1) assert.NotNil(t, node.Hostinfo) assert.NotNil(t, node.MachineKey) @@ -221,12 +421,31 @@ func TestMigrationsSQLite(t *testing.T) { }, }, { - dbPath: "testdata/failing-node-preauth-constraint.sqlite", - wantFunc: func(t *testing.T, h *HSDatabase) { - nodes, err := Read(h.DB, func(rx *gorm.DB) (types.Nodes, error) { + dbPath: "testdata/sqlite/failing-node-preauth-constraint_dump.sql", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + // Comprehensive data preservation validation for node-preauth constraint issue + // Expected data from dump: 1 user, 2 api_keys, 6 nodes + + // Verify users data preservation + users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { + return ListUsers(rx) + }) + require.NoError(t, err) + assert.Len(t, users, 1, "should preserve all 1 user from original schema") + + // Verify api_keys data preservation + var apiKeyCount int + err = hsdb.DB.Raw("SELECT COUNT(*) FROM api_keys").Scan(&apiKeyCount).Error + require.NoError(t, err) + assert.Equal(t, 2, apiKeyCount, "should preserve all 2 api_keys from original schema") + + // Verify nodes data preservation and field validation + nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { return ListNodes(rx) }) require.NoError(t, err) + assert.Len(t, nodes, 6, "should preserve all 6 nodes from original schema") for _, node := range nodes { assert.Falsef(t, node.MachineKey.IsZero(), "expected non zero machinekey") @@ -240,25 +459,262 @@ func TestMigrationsSQLite(t *testing.T) { } }, }, + { + dbPath: "testdata/sqlite/wrongly-migrated-schema-0.25.1_dump.sql", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + // Test migration of a database that was wrongly migrated in 0.25.1 + // This database has several issues: + // 1. Missing proper user unique constraints (idx_provider_identifier, idx_name_provider_identifier, idx_name_no_provider_identifier) + // 2. Still has routes table that should have been migrated to node.approved_routes + // 3. Wrong FOREIGN KEY constraint on pre_auth_keys (CASCADE instead of SET NULL) + // 4. Missing some required indexes + + // Verify users table data is preserved with specific user data + users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { + return ListUsers(rx) + }) + require.NoError(t, err) + assert.Len(t, users, 2, "should preserve existing users") + + // Validate specific user data from dump file using cmp.Diff + expectedUsers := []types.User{ + {Model: gorm.Model{ID: 1}, Name: "user2"}, + {Model: gorm.Model{ID: 2}, Name: "user1"}, + } + + if diff := cmp.Diff(expectedUsers, users, + cmpopts.IgnoreFields(types.User{}, "CreatedAt", "UpdatedAt", "DeletedAt", "DisplayName", "Email", "ProviderIdentifier", "Provider", "ProfilePicURL")); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() users mismatch (-want +got):\n%s", diff) + } + + // Create maps for easier access in later validations + usersByName := make(map[string]*types.User) + for i := range users { + usersByName[users[i].Name] = &users[i] + } + user1 := usersByName["user1"] + user2 := usersByName["user2"] + + // Verify nodes table data is preserved and routes migrated to approved_routes + nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { + return ListNodes(rx) + }) + require.NoError(t, err) + assert.Len(t, nodes, 3, "should preserve existing nodes") + + // Validate specific node data from dump file + nodesByID := make(map[uint64]*types.Node) + for i := range nodes { + nodesByID[nodes[i].ID.Uint64()] = nodes[i] + } + + node1 := nodesByID[1] + node2 := nodesByID[2] + node3 := nodesByID[3] + require.NotNil(t, node1, "node 1 should exist") + require.NotNil(t, node2, "node 2 should exist") + require.NotNil(t, node3, "node 3 should exist") + + // Validate specific node field data using cmp.Diff + expectedNodes := map[uint64]struct { + Hostname string + GivenName string + IPv4 string + IPv6 string + UserID uint + }{ + 1: {Hostname: "node1", GivenName: "node1", IPv4: "100.64.0.1", IPv6: "fd7a:115c:a1e0::1", UserID: user2.ID}, + 2: {Hostname: "node2", GivenName: "node2", IPv4: "100.64.0.2", IPv6: "fd7a:115c:a1e0::2", UserID: user2.ID}, + 3: {Hostname: "node3", GivenName: "node3", IPv4: "100.64.0.3", IPv6: "fd7a:115c:a1e0::3", UserID: user1.ID}, + } + + for nodeID, expected := range expectedNodes { + node := nodesByID[nodeID] + require.NotNil(t, node, "node %d should exist", nodeID) + + actual := struct { + Hostname string + GivenName string + IPv4 string + IPv6 string + UserID uint + }{ + Hostname: node.Hostname, + GivenName: node.GivenName, + IPv4: node.IPv4.String(), + IPv6: func() string { + if node.IPv6 != nil { + return node.IPv6.String() + } else { + return "" + } + }(), + UserID: node.UserID, + } + + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() node %d basic fields mismatch (-want +got):\n%s", nodeID, diff) + } + + // Special validation for MachineKey content for node 1 only + if nodeID == 1 { + assert.Contains(t, node.MachineKey.String(), "mkey:1efe4388236c1c83fe0a19d3ce7c321ab81e138a4da57917c231ce4c01944409") + } + } + + // Check that routes were migrated from routes table to node.approved_routes using cmp.Diff + // Original routes table had 4 routes for nodes 1, 2, 3: + // Node 1: 0.0.0.0/0 (enabled), ::/0 (enabled) -> should have 2 approved routes + // Node 2: 192.168.100.0/24 (enabled) -> should have 1 approved route + // Node 3: 10.0.0.0/8 (disabled) -> should have 0 approved routes + expectedRoutes := map[uint64][]netip.Prefix{ + 1: {netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}, + 2: {netip.MustParsePrefix("192.168.100.0/24")}, + 3: nil, + } + + actualRoutes := map[uint64][]netip.Prefix{ + 1: node1.ApprovedRoutes, + 2: node2.ApprovedRoutes, + 3: node3.ApprovedRoutes, + } + + if diff := cmp.Diff(expectedRoutes, actualRoutes, util.PrefixComparer); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() routes migration mismatch (-want +got):\n%s", diff) + } + + // Verify pre_auth_keys data is preserved with specific key data + preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) { + var keys []types.PreAuthKey + err := rx.Find(&keys).Error + return keys, err + }) + require.NoError(t, err) + assert.Len(t, preAuthKeys, 2, "should preserve existing pre_auth_keys") + + // Validate specific pre_auth_key data from dump file using cmp.Diff + expectedKeys := []types.PreAuthKey{ + { + ID: 1, + Key: "3d133ec953e31fd41edbd935371234f762b4bae300cea618", + UserID: user2.ID, + Reusable: true, + Used: true, + }, + { + ID: 2, + Key: "9813cc1df1832259fb6322dad788bb9bec89d8a01eef683a", + UserID: user1.ID, + Reusable: true, + Used: true, + }, + } + + if diff := cmp.Diff(expectedKeys, preAuthKeys, + cmpopts.IgnoreFields(types.PreAuthKey{}, "User", "CreatedAt", "Expiration", "Ephemeral", "Tags")); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() pre_auth_keys mismatch (-want +got):\n%s", diff) + } + + // Verify api_keys data is preserved with specific key data + var apiKeys []struct { + ID uint64 + Prefix string + Hash []byte + CreatedAt string + Expiration string + LastSeen string + } + err = hsdb.DB.Raw("SELECT id, prefix, hash, created_at, expiration, last_seen FROM api_keys").Scan(&apiKeys).Error + require.NoError(t, err) + assert.Len(t, apiKeys, 1, "should preserve existing api_keys") + + // Validate specific api_key data from dump file using cmp.Diff + expectedAPIKey := struct { + ID uint64 + Prefix string + Hash []byte + }{ + ID: 1, + Prefix: "ak_test", + Hash: []byte{0xde, 0xad, 0xbe, 0xef}, + } + + actualAPIKey := struct { + ID uint64 + Prefix string + Hash []byte + }{ + ID: apiKeys[0].ID, + Prefix: apiKeys[0].Prefix, + Hash: apiKeys[0].Hash, + } + + if diff := cmp.Diff(expectedAPIKey, actualAPIKey); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() api_key mismatch (-want +got):\n%s", diff) + } + + // Validate date fields separately since they need Contains check + assert.Contains(t, apiKeys[0].CreatedAt, "2025-12-31", "created_at should be preserved") + assert.Contains(t, apiKeys[0].Expiration, "2025-06-18", "expiration should be preserved") + + // Verify that routes table no longer exists (should have been dropped) + var routesTableExists bool + err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesTableExists) + require.NoError(t, err) + assert.False(t, routesTableExists, "routes table should have been dropped") + + // Verify all required indexes exist with correct structure using cmp.Diff + expectedIndexes := []string{ + "idx_users_deleted_at", + "idx_provider_identifier", + "idx_name_provider_identifier", + "idx_name_no_provider_identifier", + "idx_api_keys_prefix", + "idx_policies_deleted_at", + } + + expectedIndexMap := make(map[string]bool) + for _, index := range expectedIndexes { + expectedIndexMap[index] = true + } + + actualIndexMap := make(map[string]bool) + for _, indexName := range expectedIndexes { + var indexExists bool + err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?", indexName).Row().Scan(&indexExists) + require.NoError(t, err) + actualIndexMap[indexName] = indexExists + } + + if diff := cmp.Diff(expectedIndexMap, actualIndexMap); diff != "" { + t.Errorf("TestSQLiteMigrationAndDataValidation() indexes existence mismatch (-want +got):\n%s", diff) + } + + // Verify proper foreign key constraints are set + // Check that pre_auth_keys has correct FK constraint (SET NULL, not CASCADE) + var preAuthKeyConstraint string + err = hsdb.DB.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='pre_auth_keys'").Row().Scan(&preAuthKeyConstraint) + require.NoError(t, err) + assert.Contains(t, preAuthKeyConstraint, "ON DELETE SET NULL", "pre_auth_keys should have SET NULL constraint") + assert.NotContains(t, preAuthKeyConstraint, "ON DELETE CASCADE", "pre_auth_keys should not have CASCADE constraint") + + // Verify that user unique constraints work properly + // Try to create duplicate local user (should fail) + err = hsdb.DB.Create(&types.User{Name: users[0].Name}).Error + require.Error(t, err, "should not allow duplicate local usernames") + assert.Contains(t, err.Error(), "UNIQUE constraint", "should fail with unique constraint error") + }, + }, } for _, tt := range tests { t.Run(tt.dbPath, func(t *testing.T) { - dbPath, err := testCopyOfDatabase(t, tt.dbPath) - if err != nil { - t.Fatalf("copying db for test: %s", err) - } - - hsdb, err := NewHeadscaleDatabase(types.DatabaseConfig{ - Type: "sqlite3", - Sqlite: types.SqliteConfig{ - Path: dbPath, - }, - }, "", emptyCache()) - if err != nil && tt.wantErr != err.Error() { - t.Errorf("TestMigrations() unexpected error = %v, wantErr %v", err, tt.wantErr) + if !strings.HasSuffix(tt.dbPath, ".sql") { + t.Fatalf("TestSQLiteMigrationAndDataValidation only supports .sql files, got: %s", tt.dbPath) } + hsdb := dbForTestWithPath(t, tt.dbPath) if tt.wantFunc != nil { tt.wantFunc(t, hsdb) } @@ -266,39 +722,27 @@ func TestMigrationsSQLite(t *testing.T) { } } -func testCopyOfDatabase(t *testing.T, src string) (string, error) { - sourceFileStat, err := os.Stat(src) - if err != nil { - return "", err - } - - if !sourceFileStat.Mode().IsRegular() { - return "", fmt.Errorf("%s is not a regular file", src) - } - - source, err := os.Open(src) - if err != nil { - return "", err - } - defer source.Close() - - tmpDir := t.TempDir() - fn := filepath.Base(src) - dst := filepath.Join(tmpDir, fn) - - destination, err := os.Create(dst) - if err != nil { - return "", err - } - defer destination.Close() - _, err = io.Copy(destination, source) - return dst, err -} - func emptyCache() *zcache.Cache[types.RegistrationID, types.RegisterNode] { return zcache.New[types.RegistrationID, types.RegisterNode](time.Minute, time.Hour) } +func createSQLiteFromSQLFile(sqlFilePath, dbPath string) error { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return err + } + defer db.Close() + + schemaContent, err := os.ReadFile(sqlFilePath) + if err != nil { + return err + } + + _, err = db.Exec(string(schemaContent)) + + return err +} + // requireConstraintFailed checks if the error is a constraint failure with // either SQLite and PostgreSQL error messages. func requireConstraintFailed(t *testing.T, err error) { @@ -415,7 +859,13 @@ func TestConstraints(t *testing.T) { } } -func TestMigrationsPostgres(t *testing.T) { +// TestPostgresMigrationAndDataValidation tests specific PostgreSQL migration scenarios +// and validates data integrity after migration. All migrations that require data validation +// should be added here. +// +// TODO(kradalby): Convert to use plain text SQL dumps instead of binary .pssql dumps for consistency +// with SQLite tests and easier version control. +func TestPostgresMigrationAndDataValidation(t *testing.T) { tests := []struct { name string dbPath string @@ -423,9 +873,10 @@ func TestMigrationsPostgres(t *testing.T) { }{ { name: "user-idx-breaking", - dbPath: "testdata/pre-24-postgresdb.pssql.dump", - wantFunc: func(t *testing.T, h *HSDatabase) { - users, err := Read(h.DB, func(rx *gorm.DB) ([]types.User, error) { + dbPath: "testdata/postgres/pre-24-postgresdb.pssql.dump", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { return ListUsers(rx) }) require.NoError(t, err) @@ -472,9 +923,27 @@ func TestMigrationsPostgres(t *testing.T) { func dbForTest(t *testing.T) *HSDatabase { t.Helper() + return dbForTestWithPath(t, "") +} + +func dbForTestWithPath(t *testing.T, sqlFilePath string) *HSDatabase { + t.Helper() dbPath := t.TempDir() + "/headscale_test.db" + // If SQL file path provided, validate and create database from it + if sqlFilePath != "" { + // Validate that the file is a SQL text file + if !strings.HasSuffix(sqlFilePath, ".sql") { + t.Fatalf("dbForTestWithPath only accepts .sql files, got: %s", sqlFilePath) + } + + err := createSQLiteFromSQLFile(sqlFilePath, dbPath) + if err != nil { + t.Fatalf("setting up database from SQL file %s: %s", sqlFilePath, err) + } + } + db, err := NewHeadscaleDatabase( types.DatabaseConfig{ Type: "sqlite3", @@ -489,7 +958,59 @@ func dbForTest(t *testing.T) *HSDatabase { t.Fatalf("setting up database: %s", err) } - t.Logf("database set up at: %s", dbPath) + if sqlFilePath != "" { + t.Logf("database set up from %s at: %s", sqlFilePath, dbPath) + } else { + t.Logf("database set up at: %s", dbPath) + } return db } + +// TestSQLiteAllTestdataMigrations tests migration compatibility across all SQLite schemas +// in the testdata directory. It verifies they can be successfully migrated to the current +// schema version. This test only validates migration success, not data integrity. +// +// A lot of the schemas have been automatically generated with old Headscale binaries on empty databases +// (no user/node data): +// - `headscale__schema.sql` (created with `sqlite3 headscale.db .schema`) +// - `headscale__dump.sql` (created with `sqlite3 headscale.db .dump`) +// where `_dump.sql` contains the migration steps that have been applied to the database. +func TestSQLiteAllTestdataMigrations(t *testing.T) { + t.Parallel() + schemas, err := os.ReadDir("testdata/sqlite") + require.NoError(t, err) + + t.Logf("loaded %d schemas", len(schemas)) + + for _, schema := range schemas { + if schema.IsDir() { + continue + } + + t.Logf("validating: %s", schema.Name()) + + t.Run(schema.Name(), func(t *testing.T) { + t.Parallel() + + dbPath := t.TempDir() + "/headscale_test.db" + + // Setup a database with the old schema + schemaPath := filepath.Join("testdata/sqlite", schema.Name()) + err := createSQLiteFromSQLFile(schemaPath, dbPath) + require.NoError(t, err) + + _, err = NewHeadscaleDatabase( + types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: dbPath, + }, + }, + "", + emptyCache(), + ) + require.NoError(t, err) + }) + } +} diff --git a/hscontrol/db/ephemeral_garbage_collector_test.go b/hscontrol/db/ephemeral_garbage_collector_test.go index ae75c6d7..b9edad79 100644 --- a/hscontrol/db/ephemeral_garbage_collector_test.go +++ b/hscontrol/db/ephemeral_garbage_collector_test.go @@ -11,9 +11,11 @@ import ( "github.com/stretchr/testify/assert" ) -const fiveHundredMillis = 500 * time.Millisecond -const oneHundredMillis = 100 * time.Millisecond -const fiftyMillis = 50 * time.Millisecond +const ( + fiveHundred = 500 * time.Millisecond + oneHundred = 100 * time.Millisecond + fifty = 50 * time.Millisecond +) // TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in EphemeralGarbageCollector(). // It creates a new EphemeralGarbageCollector, schedules several nodes for deletion with a short expiry, @@ -41,7 +43,7 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) { go gc.Start() // Schedule several nodes for deletion with short expiry - const expiry = fiftyMillis + const expiry = fifty const numNodes = 100 // Set up wait group for expected deletions @@ -56,7 +58,7 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) { // Check nodes are deleted deleteMutex.Lock() - assert.Equal(t, numNodes, len(deletedIDs), "Not all nodes were deleted") + assert.Len(t, deletedIDs, numNodes, "Not all nodes were deleted") deleteMutex.Unlock() // Schedule and immediately cancel to test that part of the code @@ -76,7 +78,7 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) { // Give any potential leaked goroutines a chance to exit // Still need a small sleep here as we're checking for absence of goroutines - time.Sleep(oneHundredMillis) + time.Sleep(oneHundred) // Check for leaked goroutines finalGoroutines := runtime.NumGoroutine() @@ -112,7 +114,7 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) { go gc.Start() defer gc.Close() - const shortExpiry = fiftyMillis + const shortExpiry = fifty const longExpiry = 1 * time.Hour nodeID := types.NodeID(1) @@ -128,7 +130,7 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) { // Verify that the node was deleted once deleteMutex.Lock() - assert.Equal(t, 1, len(deletedIDs), "Node should be deleted exactly once") + assert.Len(t, deletedIDs, 1, "Node should be deleted exactly once") assert.Equal(t, nodeID, deletedIDs[0], "The correct node should be deleted") deleteMutex.Unlock() } @@ -155,7 +157,7 @@ func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) { defer gc.Close() nodeID := types.NodeID(1) - const expiry = fiftyMillis + const expiry = fifty // Schedule node for deletion gc.Schedule(nodeID, expiry) @@ -172,7 +174,7 @@ func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) { } deleteMutex.Lock() - assert.Equal(t, 0, len(deletedIDs), "Node should not be deleted after cancellation") + assert.Empty(t, deletedIDs, "Node should not be deleted after cancellation") deleteMutex.Unlock() // Reschedule the node @@ -189,7 +191,7 @@ func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) { // Verify final state deleteMutex.Lock() - assert.Equal(t, 1, len(deletedIDs), "Node should be deleted after rescheduling") + assert.Len(t, deletedIDs, 1, "Node should be deleted after rescheduling") assert.Equal(t, nodeID, deletedIDs[0], "The correct node should be deleted") deleteMutex.Unlock() } @@ -212,7 +214,7 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) { go gc.Start() const longExpiry = 1 * time.Hour - const shortExpiry = fiftyMillis + const shortExpiry = fifty // Schedule node deletion with a long expiry gc.Schedule(types.NodeID(1), longExpiry) @@ -225,7 +227,7 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) { // Verify that no deletion occurred deleteMutex.Lock() - assert.Equal(t, 0, len(deletedIDs), "No node should be deleted when GC is closed before timer fires") + assert.Empty(t, deletedIDs, "No node should be deleted when GC is closed before timer fires") deleteMutex.Unlock() } @@ -269,7 +271,7 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) { // Give the GC time to fully close and clean up resources // This is still time-based but only affects when we check the goroutine count, // not the actual test logic - time.Sleep(oneHundredMillis) + time.Sleep(oneHundred) close(gcClosedCheck) }() @@ -279,7 +281,7 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) { gc.Schedule(nodeID, 1*time.Millisecond) // Set up a timeout channel for our test - timeout := time.After(fiveHundredMillis) + timeout := time.After(fiveHundred) // Check if any node was deleted (which shouldn't happen) select { @@ -329,7 +331,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) { // Number of concurrent scheduling goroutines const numSchedulers = 10 const nodesPerScheduler = 50 - const schedulingDuration = fiveHundredMillis + const schedulingDuration = fiveHundred // Use WaitGroup to wait for all scheduling goroutines to finish var wg sync.WaitGroup @@ -339,14 +341,14 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) { stopScheduling := make(chan struct{}) // Launch goroutines that continuously schedule nodes - for i := 0; i < numSchedulers; i++ { + for schedulerIndex := range numSchedulers { go func(schedulerID int) { defer wg.Done() baseNodeID := schedulerID * nodesPerScheduler // Keep scheduling nodes until signaled to stop - for j := 0; j < nodesPerScheduler; j++ { + for j := range nodesPerScheduler { select { case <-stopScheduling: return @@ -358,7 +360,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) { time.Sleep(time.Duration(rand.Intn(5)) * time.Millisecond) } } - }(i) + }(schedulerIndex) } // After a short delay, close the garbage collector while schedulers are still running @@ -377,7 +379,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) { wg.Wait() // Wait a bit longer to allow any leaked goroutines to do their work - time.Sleep(oneHundredMillis) + time.Sleep(oneHundred) // Check for leaks finalGoroutines := runtime.NumGoroutine() diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 3525795a..63130c4c 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -17,6 +17,8 @@ import ( "tailscale.com/net/tsaddr" ) +var errGeneratedIPBytesInvalid = errors.New("generated ip bytes are invalid ip") + // IPAllocator is a singleton responsible for allocating // IP addresses for nodes and making sure the same // address is not handed out twice. There can only be one @@ -236,7 +238,7 @@ func randomNext(pfx netip.Prefix) (netip.Addr, error) { ip, ok := netip.AddrFromSlice(valInRange.Bytes()) if !ok { - return netip.Addr{}, fmt.Errorf("generated ip bytes are invalid ip") + return netip.Addr{}, errGeneratedIPBytesInvalid } if !pfx.Contains(ip) { diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index bb362d2c..2de29e69 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -64,7 +64,7 @@ func ListPeers(tx *gorm.DB, nodeID types.NodeID, peerIDs ...types.NodeID) (types } // ListNodes queries the database for either all nodes if no parameters are given -// or for the given nodes if at least one node ID is given as parameter +// or for the given nodes if at least one node ID is given as parameter. func (hsdb *HSDatabase) ListNodes(nodeIDs ...types.NodeID) (types.Nodes, error) { return Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { return ListNodes(rx, nodeIDs...) @@ -72,7 +72,7 @@ func (hsdb *HSDatabase) ListNodes(nodeIDs ...types.NodeID) (types.Nodes, error) } // ListNodes queries the database for either all nodes if no parameters are given -// or for the given nodes if at least one node ID is given as parameter +// or for the given nodes if at least one node ID is given as parameter. func ListNodes(tx *gorm.DB, nodeIDs ...types.NodeID) (types.Nodes, error) { nodes := types.Nodes{} if err := tx. @@ -406,6 +406,7 @@ func (hsdb *HSDatabase) HandleNodeFromAuthPath( close(reg.Registered) newNode = true + return node, err } else { // If the node is already registered, this is a refresh. @@ -413,6 +414,7 @@ func (hsdb *HSDatabase) HandleNodeFromAuthPath( if err != nil { return nil, err } + return node, nil } } diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go index 56c967f1..9e302541 100644 --- a/hscontrol/db/node_test.go +++ b/hscontrol/db/node_test.go @@ -118,7 +118,7 @@ func (s *Suite) TestListPeers(c *check.C) { _, err = db.GetNodeByID(0) c.Assert(err, check.NotNil) - for index := 0; index <= 10; index++ { + for index := range 11 { nodeKey := key.NewNode() machineKey := key.NewMachine() @@ -589,6 +589,7 @@ func generateRandomNumber(t *testing.T, max int64) int64 { if err != nil { t.Fatalf("getting random number: %s", err) } + return n.Int64() + 1 } @@ -692,6 +693,7 @@ func TestRenameNode(t *testing.T) { return err } _, err = RegisterNode(tx, node2, nil, nil) + return err }) require.NoError(t, err) @@ -792,6 +794,7 @@ func TestListPeers(t *testing.T) { return err } _, err = RegisterNode(tx, node2, nil, nil) + return err }) require.NoError(t, err) @@ -804,30 +807,30 @@ func TestListPeers(t *testing.T) { // No parameter means no filter, should return all peers nodes, err = db.ListPeers(1) require.NoError(t, err) - assert.Equal(t, len(nodes), 1) + assert.Len(t, nodes, 1) assert.Equal(t, "test2", nodes[0].Hostname) // Empty node list should return all peers nodes, err = db.ListPeers(1, types.NodeIDs{}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 1) + assert.Len(t, nodes, 1) assert.Equal(t, "test2", nodes[0].Hostname) // No match in IDs should return empty list and no error nodes, err = db.ListPeers(1, types.NodeIDs{3, 4, 5}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 0) + assert.Empty(t, nodes) // Partial match in IDs nodes, err = db.ListPeers(1, types.NodeIDs{2, 3}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 1) + assert.Len(t, nodes, 1) assert.Equal(t, "test2", nodes[0].Hostname) // Several matched IDs, but node ID is still filtered out nodes, err = db.ListPeers(1, types.NodeIDs{1, 2, 3}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 1) + assert.Len(t, nodes, 1) assert.Equal(t, "test2", nodes[0].Hostname) } @@ -876,6 +879,7 @@ func TestListNodes(t *testing.T) { return err } _, err = RegisterNode(tx, node2, nil, nil) + return err }) require.NoError(t, err) @@ -888,32 +892,32 @@ func TestListNodes(t *testing.T) { // No parameter means no filter, should return all nodes nodes, err = db.ListNodes() require.NoError(t, err) - assert.Equal(t, len(nodes), 2) + assert.Len(t, nodes, 2) assert.Equal(t, "test1", nodes[0].Hostname) assert.Equal(t, "test2", nodes[1].Hostname) // Empty node list should return all nodes nodes, err = db.ListNodes(types.NodeIDs{}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 2) + assert.Len(t, nodes, 2) assert.Equal(t, "test1", nodes[0].Hostname) assert.Equal(t, "test2", nodes[1].Hostname) // No match in IDs should return empty list and no error nodes, err = db.ListNodes(types.NodeIDs{3, 4, 5}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 0) + assert.Empty(t, nodes) // Partial match in IDs nodes, err = db.ListNodes(types.NodeIDs{2, 3}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 1) + assert.Len(t, nodes, 1) assert.Equal(t, "test2", nodes[0].Hostname) // Several matched IDs nodes, err = db.ListNodes(types.NodeIDs{1, 2, 3}...) require.NoError(t, err) - assert.Equal(t, len(nodes), 2) + assert.Len(t, nodes, 2) assert.Equal(t, "test1", nodes[0].Hostname) assert.Equal(t, "test2", nodes[1].Hostname) } diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index 5ace968a..7945f090 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -8,9 +8,8 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "tailscale.com/types/ptr" - "gopkg.in/check.v1" + "tailscale.com/types/ptr" ) func (*Suite) TestCreatePreAuthKey(c *check.C) { diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql new file mode 100644 index 00000000..175e2aff --- /dev/null +++ b/hscontrol/db/schema.sql @@ -0,0 +1,110 @@ +-- This file is the representation of the SQLite schema of Headscale. +-- It is the "source of truth" and is used to validate any migrations +-- that are run against the database to ensure it ends in the expected state. + +CREATE TABLE migrations(id text,PRIMARY KEY(id)); + +CREATE TABLE users( + id integer PRIMARY KEY AUTOINCREMENT, + name text, + display_name text, + email text, + provider_identifier text, + provider text, + profile_pic_url text, + + created_at datetime, + updated_at datetime, + deleted_at datetime +); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); + + +-- The following three UNIQUE indexes work together to enforce the user identity model: +-- +-- 1. Users can be either local (provider_identifier is NULL) or from external providers (provider_identifier set) +-- 2. Each external provider identifier must be unique across the system +-- 3. Local usernames must be unique among local users +-- 4. The same username can exist across different providers with different identifiers +-- +-- Examples: +-- - Can create local user "alice" (provider_identifier=NULL) +-- - Can create external user "alice" with GitHub (name="alice", provider_identifier="alice_github") +-- - Can create external user "alice" with Google (name="alice", provider_identifier="alice_google") +-- - Cannot create another local user "alice" (blocked by idx_name_no_provider_identifier) +-- - Cannot create another user with provider_identifier="alice_github" (blocked by idx_provider_identifier) +-- - Cannot create user "bob" with provider_identifier="alice_github" (blocked by idx_name_provider_identifier) +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 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, + expiration datetime, + + created_at 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, + expiration datetime, + last_seen datetime, + + created_at datetime +); +CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); + +CREATE TABLE 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, + last_seen datetime, + expiry 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, + data text, + + created_at datetime, + updated_at datetime, + deleted_at datetime +); +CREATE INDEX idx_policies_deleted_at ON policies(deleted_at); diff --git a/hscontrol/db/sqliteconfig/config.go b/hscontrol/db/sqliteconfig/config.go new file mode 100644 index 00000000..3c1608d7 --- /dev/null +++ b/hscontrol/db/sqliteconfig/config.go @@ -0,0 +1,345 @@ +// Package sqliteconfig provides type-safe configuration for SQLite databases +// with proper enum validation and URL generation for modernc.org/sqlite driver. +package sqliteconfig + +import ( + "errors" + "fmt" + "strings" +) + +// Errors returned by config validation. +var ( + ErrPathEmpty = errors.New("path cannot be empty") + ErrBusyTimeoutNegative = errors.New("busy_timeout must be >= 0") + ErrInvalidJournalMode = errors.New("invalid journal_mode") + ErrInvalidAutoVacuum = errors.New("invalid auto_vacuum") + ErrWALAutocheckpoint = errors.New("wal_autocheckpoint must be >= -1") + ErrInvalidSynchronous = errors.New("invalid synchronous") +) + +const ( + // DefaultBusyTimeout is the default busy timeout in milliseconds. + DefaultBusyTimeout = 10000 +) + +// JournalMode represents SQLite journal_mode pragma values. +// Journal modes control how SQLite handles write transactions and crash recovery. +// +// Performance vs Durability Tradeoffs: +// +// WAL (Write-Ahead Logging) - Recommended for production: +// - Best performance for concurrent reads/writes +// - Readers don't block writers, writers don't block readers +// - Excellent crash recovery with minimal data loss risk +// - Uses additional .wal and .shm files +// - Default choice for Headscale production deployments +// +// DELETE - Traditional rollback journal: +// - Good performance for single-threaded access +// - Readers block writers and vice versa +// - Reliable crash recovery but with exclusive locking +// - Creates temporary journal files during transactions +// - Suitable for low-concurrency scenarios +// +// TRUNCATE - Similar to DELETE but faster cleanup: +// - Slightly better performance than DELETE +// - Same concurrency limitations as DELETE +// - Faster transaction commit by truncating instead of deleting journal +// +// PERSIST - Journal file remains between transactions: +// - Avoids file creation/deletion overhead +// - Same concurrency limitations as DELETE +// - Good for frequent small transactions +// +// MEMORY - Journal kept in memory: +// - Fastest performance but NO crash recovery +// - Data loss risk on power failure or crash +// - Only suitable for temporary or non-critical data +// +// OFF - No journaling: +// - Maximum performance but NO transaction safety +// - High risk of database corruption on crash +// - Should only be used for read-only or disposable databases +type JournalMode string + +const ( + // JournalModeWAL enables Write-Ahead Logging (RECOMMENDED for production). + // Best concurrent performance + crash recovery. Uses additional .wal/.shm files. + JournalModeWAL JournalMode = "WAL" + + // JournalModeDelete uses traditional rollback journaling. + // Good single-threaded performance, readers block writers. Creates temp journal files. + JournalModeDelete JournalMode = "DELETE" + + // JournalModeTruncate is like DELETE but with faster cleanup. + // Slightly better performance than DELETE, same safety with exclusive locking. + JournalModeTruncate JournalMode = "TRUNCATE" + + // JournalModePersist keeps journal file between transactions. + // Good for frequent transactions, avoids file creation/deletion overhead. + JournalModePersist JournalMode = "PERSIST" + + // JournalModeMemory keeps journal in memory (DANGEROUS). + // Fastest performance but NO crash recovery - data loss on power failure. + JournalModeMemory JournalMode = "MEMORY" + + // JournalModeOff disables journaling entirely (EXTREMELY DANGEROUS). + // Maximum performance but high corruption risk. Only for disposable databases. + JournalModeOff JournalMode = "OFF" +) + +// IsValid returns true if the JournalMode is valid. +func (j JournalMode) IsValid() bool { + switch j { + case JournalModeWAL, JournalModeDelete, JournalModeTruncate, + JournalModePersist, JournalModeMemory, JournalModeOff: + return true + default: + return false + } +} + +// String returns the string representation. +func (j JournalMode) String() string { + return string(j) +} + +// AutoVacuum represents SQLite auto_vacuum pragma values. +// Auto-vacuum controls how SQLite reclaims space from deleted data. +// +// Performance vs Storage Tradeoffs: +// +// INCREMENTAL - Recommended for production: +// - Reclaims space gradually during normal operations +// - Minimal performance impact on writes +// - Database size shrinks automatically over time +// - Can manually trigger with PRAGMA incremental_vacuum +// - Good balance of space efficiency and performance +// +// FULL - Automatic space reclamation: +// - Immediately reclaims space on every DELETE/DROP +// - Higher write overhead due to page reorganization +// - Keeps database file size minimal +// - Can cause significant slowdowns on large deletions +// - Best for applications with frequent deletes and limited storage +// +// NONE - No automatic space reclamation: +// - Fastest write performance (no vacuum overhead) +// - Database file only grows, never shrinks +// - Deleted space is reused but file size remains large +// - Requires manual VACUUM to reclaim space +// - Best for write-heavy workloads where storage isn't constrained +type AutoVacuum string + +const ( + // AutoVacuumNone disables automatic space reclamation. + // Fastest writes, file only grows. Requires manual VACUUM to reclaim space. + AutoVacuumNone AutoVacuum = "NONE" + + // AutoVacuumFull immediately reclaims space on every DELETE/DROP. + // Minimal file size but slower writes. Can impact performance on large deletions. + AutoVacuumFull AutoVacuum = "FULL" + + // AutoVacuumIncremental reclaims space gradually (RECOMMENDED for production). + // Good balance: minimal write impact, automatic space management over time. + AutoVacuumIncremental AutoVacuum = "INCREMENTAL" +) + +// IsValid returns true if the AutoVacuum is valid. +func (a AutoVacuum) IsValid() bool { + switch a { + case AutoVacuumNone, AutoVacuumFull, AutoVacuumIncremental: + return true + default: + return false + } +} + +// String returns the string representation. +func (a AutoVacuum) String() string { + return string(a) +} + +// Synchronous represents SQLite synchronous pragma values. +// Synchronous mode controls how aggressively SQLite flushes data to disk. +// +// Performance vs Durability Tradeoffs: +// +// NORMAL - Recommended for production: +// - Good balance of performance and safety +// - Syncs at critical moments (transaction commits in WAL mode) +// - Very low risk of corruption, minimal performance impact +// - Safe with WAL mode even with power loss +// - Default choice for most production applications +// +// FULL - Maximum durability: +// - Syncs to disk after every write operation +// - Highest data safety, virtually no corruption risk +// - Significant performance penalty (up to 50% slower) +// - Recommended for critical data where corruption is unacceptable +// +// EXTRA - Paranoid mode: +// - Even more aggressive syncing than FULL +// - Maximum possible data safety +// - Severe performance impact +// - Only for extremely critical scenarios +// +// OFF - Maximum performance, minimum safety: +// - No syncing, relies on OS to flush data +// - Fastest possible performance +// - High risk of corruption on power failure or crash +// - Only suitable for non-critical or easily recreatable data +type Synchronous string + +const ( + // SynchronousOff disables syncing (DANGEROUS). + // Fastest performance but high corruption risk on power failure. Avoid in production. + SynchronousOff Synchronous = "OFF" + + // SynchronousNormal provides balanced performance and safety (RECOMMENDED). + // Good performance with low corruption risk. Safe with WAL mode on power loss. + SynchronousNormal Synchronous = "NORMAL" + + // SynchronousFull provides maximum durability with performance cost. + // Syncs after every write. Up to 50% slower but virtually no corruption risk. + SynchronousFull Synchronous = "FULL" + + // SynchronousExtra provides paranoid-level data safety (EXTREME). + // Maximum safety with severe performance impact. Rarely needed in practice. + SynchronousExtra Synchronous = "EXTRA" +) + +// IsValid returns true if the Synchronous is valid. +func (s Synchronous) IsValid() bool { + switch s { + case SynchronousOff, SynchronousNormal, SynchronousFull, SynchronousExtra: + return true + default: + return false + } +} + +// String returns the string representation. +func (s Synchronous) String() string { + return string(s) +} + +// Config holds SQLite database configuration with type-safe enums. +// This configuration balances performance, durability, and operational requirements +// for Headscale's SQLite database usage patterns. +type Config struct { + Path string // file path or ":memory:" + BusyTimeout int // milliseconds (0 = default/disabled) + JournalMode JournalMode // journal mode (affects concurrency and crash recovery) + AutoVacuum AutoVacuum // auto vacuum mode (affects storage efficiency) + WALAutocheckpoint int // pages (-1 = default/not set, 0 = disabled, >0 = enabled) + Synchronous Synchronous // synchronous mode (affects durability vs performance) + ForeignKeys bool // enable foreign key constraints (data integrity) +} + +// Default returns the production configuration optimized for Headscale's usage patterns. +// This configuration prioritizes: +// - Concurrent access (WAL mode for multiple readers/writers) +// - Data durability with good performance (NORMAL synchronous) +// - Automatic space management (INCREMENTAL auto-vacuum) +// - Data integrity (foreign key constraints enabled) +// - Reasonable timeout for busy database scenarios (10s) +func Default(path string) *Config { + return &Config{ + Path: path, + BusyTimeout: DefaultBusyTimeout, + JournalMode: JournalModeWAL, + AutoVacuum: AutoVacuumIncremental, + WALAutocheckpoint: 1000, + Synchronous: SynchronousNormal, + ForeignKeys: true, + } +} + +// Memory returns a configuration for in-memory databases. +func Memory() *Config { + return &Config{ + Path: ":memory:", + WALAutocheckpoint: -1, // not set, use driver default + ForeignKeys: true, + } +} + +// Validate checks if all configuration values are valid. +func (c *Config) Validate() error { + if c.Path == "" { + return ErrPathEmpty + } + + if c.BusyTimeout < 0 { + return fmt.Errorf("%w, got %d", ErrBusyTimeoutNegative, c.BusyTimeout) + } + + if c.JournalMode != "" && !c.JournalMode.IsValid() { + return fmt.Errorf("%w: %s", ErrInvalidJournalMode, c.JournalMode) + } + + if c.AutoVacuum != "" && !c.AutoVacuum.IsValid() { + return fmt.Errorf("%w: %s", ErrInvalidAutoVacuum, c.AutoVacuum) + } + + if c.WALAutocheckpoint < -1 { + return fmt.Errorf("%w, got %d", ErrWALAutocheckpoint, c.WALAutocheckpoint) + } + + if c.Synchronous != "" && !c.Synchronous.IsValid() { + return fmt.Errorf("%w: %s", ErrInvalidSynchronous, c.Synchronous) + } + + return nil +} + +// ToURL builds a properly encoded SQLite connection string using _pragma parameters +// compatible with modernc.org/sqlite driver. +func (c *Config) ToURL() (string, error) { + if err := c.Validate(); err != nil { + return "", fmt.Errorf("invalid config: %w", err) + } + + var pragmas []string + + // Add pragma parameters only if they're set (non-zero/non-empty) + if c.BusyTimeout > 0 { + pragmas = append(pragmas, fmt.Sprintf("busy_timeout=%d", c.BusyTimeout)) + } + if c.JournalMode != "" { + pragmas = append(pragmas, fmt.Sprintf("journal_mode=%s", c.JournalMode)) + } + if c.AutoVacuum != "" { + pragmas = append(pragmas, fmt.Sprintf("auto_vacuum=%s", c.AutoVacuum)) + } + if c.WALAutocheckpoint >= 0 { + pragmas = append(pragmas, fmt.Sprintf("wal_autocheckpoint=%d", c.WALAutocheckpoint)) + } + if c.Synchronous != "" { + pragmas = append(pragmas, fmt.Sprintf("synchronous=%s", c.Synchronous)) + } + if c.ForeignKeys { + pragmas = append(pragmas, "foreign_keys=ON") + } + + // Handle different database types + var baseURL string + if c.Path == ":memory:" { + baseURL = ":memory:" + } else { + baseURL = "file:" + c.Path + } + + // Add parameters without encoding = signs + if len(pragmas) > 0 { + var queryParts []string + for _, pragma := range pragmas { + queryParts = append(queryParts, "_pragma="+pragma) + } + baseURL += "?" + strings.Join(queryParts, "&") + } + + return baseURL, nil +} diff --git a/hscontrol/db/sqliteconfig/config_test.go b/hscontrol/db/sqliteconfig/config_test.go new file mode 100644 index 00000000..edc215ed --- /dev/null +++ b/hscontrol/db/sqliteconfig/config_test.go @@ -0,0 +1,211 @@ +package sqliteconfig + +import ( + "testing" +) + +func TestJournalMode(t *testing.T) { + tests := []struct { + mode JournalMode + valid bool + }{ + {JournalModeWAL, true}, + {JournalModeDelete, true}, + {JournalModeTruncate, true}, + {JournalModePersist, true}, + {JournalModeMemory, true}, + {JournalModeOff, true}, + {JournalMode("INVALID"), false}, + {JournalMode(""), false}, + } + + for _, tt := range tests { + t.Run(string(tt.mode), func(t *testing.T) { + if got := tt.mode.IsValid(); got != tt.valid { + t.Errorf("JournalMode(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid) + } + }) + } +} + +func TestAutoVacuum(t *testing.T) { + tests := []struct { + mode AutoVacuum + valid bool + }{ + {AutoVacuumNone, true}, + {AutoVacuumFull, true}, + {AutoVacuumIncremental, true}, + {AutoVacuum("INVALID"), false}, + {AutoVacuum(""), false}, + } + + for _, tt := range tests { + t.Run(string(tt.mode), func(t *testing.T) { + if got := tt.mode.IsValid(); got != tt.valid { + t.Errorf("AutoVacuum(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid) + } + }) + } +} + +func TestSynchronous(t *testing.T) { + tests := []struct { + mode Synchronous + valid bool + }{ + {SynchronousOff, true}, + {SynchronousNormal, true}, + {SynchronousFull, true}, + {SynchronousExtra, true}, + {Synchronous("INVALID"), false}, + {Synchronous(""), false}, + } + + for _, tt := range tests { + t.Run(string(tt.mode), func(t *testing.T) { + if got := tt.mode.IsValid(); got != tt.valid { + t.Errorf("Synchronous(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid) + } + }) + } +} + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + }{ + { + name: "valid default config", + config: Default("/path/to/db.sqlite"), + }, + { + name: "empty path", + config: &Config{ + Path: "", + }, + wantErr: true, + }, + { + name: "negative busy timeout", + config: &Config{ + Path: "/path/to/db.sqlite", + BusyTimeout: -1, + }, + wantErr: true, + }, + { + name: "invalid journal mode", + config: &Config{ + Path: "/path/to/db.sqlite", + JournalMode: JournalMode("INVALID"), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfigToURL(t *testing.T) { + tests := []struct { + name string + config *Config + want string + }{ + { + name: "default config", + config: Default("/path/to/db.sqlite"), + want: "file:/path/to/db.sqlite?_pragma=busy_timeout=10000&_pragma=journal_mode=WAL&_pragma=auto_vacuum=INCREMENTAL&_pragma=wal_autocheckpoint=1000&_pragma=synchronous=NORMAL&_pragma=foreign_keys=ON", + }, + { + name: "memory config", + config: Memory(), + want: ":memory:?_pragma=foreign_keys=ON", + }, + { + name: "minimal config", + config: &Config{ + Path: "/simple/db.sqlite", + WALAutocheckpoint: -1, // not set + }, + want: "file:/simple/db.sqlite", + }, + { + name: "custom config", + config: &Config{ + Path: "/custom/db.sqlite", + BusyTimeout: 5000, + JournalMode: JournalModeDelete, + WALAutocheckpoint: -1, // not set + Synchronous: SynchronousFull, + ForeignKeys: true, + }, + want: "file:/custom/db.sqlite?_pragma=busy_timeout=5000&_pragma=journal_mode=DELETE&_pragma=synchronous=FULL&_pragma=foreign_keys=ON", + }, + { + name: "memory with custom timeout", + config: &Config{ + Path: ":memory:", + BusyTimeout: 2000, + WALAutocheckpoint: -1, // not set + ForeignKeys: true, + }, + want: ":memory:?_pragma=busy_timeout=2000&_pragma=foreign_keys=ON", + }, + { + name: "wal autocheckpoint zero", + config: &Config{ + Path: "/test.db", + WALAutocheckpoint: 0, + }, + want: "file:/test.db?_pragma=wal_autocheckpoint=0", + }, + { + name: "all options", + config: &Config{ + Path: "/full.db", + BusyTimeout: 15000, + JournalMode: JournalModeWAL, + AutoVacuum: AutoVacuumFull, + WALAutocheckpoint: 1000, + Synchronous: SynchronousExtra, + ForeignKeys: true, + }, + want: "file:/full.db?_pragma=busy_timeout=15000&_pragma=journal_mode=WAL&_pragma=auto_vacuum=FULL&_pragma=wal_autocheckpoint=1000&_pragma=synchronous=EXTRA&_pragma=foreign_keys=ON", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.config.ToURL() + if err != nil { + t.Errorf("Config.ToURL() error = %v", err) + return + } + if got != tt.want { + t.Errorf("Config.ToURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestConfigToURLInvalid(t *testing.T) { + config := &Config{ + Path: "", + BusyTimeout: -1, + } + _, err := config.ToURL() + if err == nil { + t.Error("Config.ToURL() with invalid config should return error") + } +} diff --git a/hscontrol/db/sqliteconfig/integration_test.go b/hscontrol/db/sqliteconfig/integration_test.go new file mode 100644 index 00000000..bb54ea1e --- /dev/null +++ b/hscontrol/db/sqliteconfig/integration_test.go @@ -0,0 +1,269 @@ +package sqliteconfig + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" +) + +const memoryDBPath = ":memory:" + +// TestSQLiteDriverPragmaIntegration verifies that the modernc.org/sqlite driver +// correctly applies all pragma settings from URL parameters, ensuring they work +// the same as the old SQL PRAGMA statements approach. +func TestSQLiteDriverPragmaIntegration(t *testing.T) { + tests := []struct { + name string + config *Config + expected map[string]any + }{ + { + name: "default configuration", + config: Default("/tmp/test.db"), + expected: map[string]any{ + "busy_timeout": 10000, + "journal_mode": "wal", + "auto_vacuum": 2, // INCREMENTAL = 2 + "wal_autocheckpoint": 1000, + "synchronous": 1, // NORMAL = 1 + "foreign_keys": 1, // ON = 1 + }, + }, + { + name: "memory database with foreign keys", + config: Memory(), + expected: map[string]any{ + "foreign_keys": 1, // ON = 1 + }, + }, + { + name: "custom configuration", + config: &Config{ + Path: "/tmp/custom.db", + BusyTimeout: 5000, + JournalMode: JournalModeDelete, + AutoVacuum: AutoVacuumFull, + WALAutocheckpoint: 1000, + Synchronous: SynchronousFull, + ForeignKeys: true, + }, + expected: map[string]any{ + "busy_timeout": 5000, + "journal_mode": "delete", + "auto_vacuum": 1, // FULL = 1 + "wal_autocheckpoint": 1000, + "synchronous": 2, // FULL = 2 + "foreign_keys": 1, // ON = 1 + }, + }, + { + name: "foreign keys disabled", + config: &Config{ + Path: "/tmp/no_fk.db", + ForeignKeys: false, + }, + expected: map[string]any{ + // foreign_keys should not be set (defaults to 0/OFF) + "foreign_keys": 0, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary database file if not memory + if tt.config.Path == memoryDBPath { + // For memory databases, no changes needed + } else { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + // Update config with actual temp path + configCopy := *tt.config + configCopy.Path = dbPath + tt.config = &configCopy + } + + // Generate URL and open database + url, err := tt.config.ToURL() + if err != nil { + t.Fatalf("Failed to generate URL: %v", err) + } + + t.Logf("Opening database with URL: %s", url) + + db, err := sql.Open("sqlite", url) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Test connection + if err := db.Ping(); err != nil { + t.Fatalf("Failed to ping database: %v", err) + } + + // Verify each expected pragma setting + for pragma, expectedValue := range tt.expected { + t.Run("pragma_"+pragma, func(t *testing.T) { + var actualValue any + query := "PRAGMA " + pragma + err := db.QueryRow(query).Scan(&actualValue) + if err != nil { + t.Fatalf("Failed to query %s: %v", query, err) + } + + t.Logf("%s: expected=%v, actual=%v", pragma, expectedValue, actualValue) + + // Handle type conversion for comparison + switch expected := expectedValue.(type) { + case int: + if actual, ok := actualValue.(int64); ok { + if int64(expected) != actual { + t.Errorf("%s: expected %d, got %d", pragma, expected, actual) + } + } else { + t.Errorf("%s: expected int %d, got %T %v", pragma, expected, actualValue, actualValue) + } + case string: + if actual, ok := actualValue.(string); ok { + if expected != actual { + t.Errorf("%s: expected %q, got %q", pragma, expected, actual) + } + } else { + t.Errorf("%s: expected string %q, got %T %v", pragma, expected, actualValue, actualValue) + } + default: + t.Errorf("Unsupported expected type for %s: %T", pragma, expectedValue) + } + }) + } + }) + } +} + +// TestForeignKeyConstraintEnforcement verifies that foreign key constraints +// are actually enforced when enabled via URL parameters. +func TestForeignKeyConstraintEnforcement(t *testing.T) { + tempDir := t.TempDir() + + dbPath := filepath.Join(tempDir, "fk_test.db") + config := Default(dbPath) + + url, err := config.ToURL() + if err != nil { + t.Fatalf("Failed to generate URL: %v", err) + } + + db, err := sql.Open("sqlite", url) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create test tables with foreign key relationship + schema := ` + CREATE TABLE parent ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + + CREATE TABLE child ( + id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES parent(id) + ); + ` + + if _, err := db.Exec(schema); err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + // Insert parent record + if _, err := db.Exec("INSERT INTO parent (id, name) VALUES (1, 'Parent 1')"); err != nil { + t.Fatalf("Failed to insert parent: %v", err) + } + + // Test 1: Valid foreign key should work + _, err = db.Exec("INSERT INTO child (id, parent_id, name) VALUES (1, 1, 'Child 1')") + if err != nil { + t.Fatalf("Valid foreign key insert failed: %v", err) + } + + // Test 2: Invalid foreign key should fail + _, err = db.Exec("INSERT INTO child (id, parent_id, name) VALUES (2, 999, 'Child 2')") + if err == nil { + t.Error("Expected foreign key constraint violation, but insert succeeded") + } else if !contains(err.Error(), "FOREIGN KEY constraint failed") { + t.Errorf("Expected foreign key constraint error, got: %v", err) + } else { + t.Logf("✓ Foreign key constraint correctly enforced: %v", err) + } + + // Test 3: Deleting referenced parent should fail + _, err = db.Exec("DELETE FROM parent WHERE id = 1") + if err == nil { + t.Error("Expected foreign key constraint violation when deleting referenced parent") + } else if !contains(err.Error(), "FOREIGN KEY constraint failed") { + t.Errorf("Expected foreign key constraint error on delete, got: %v", err) + } else { + t.Logf("✓ Foreign key constraint correctly prevented parent deletion: %v", err) + } +} + +// TestJournalModeValidation verifies that the journal_mode setting is applied correctly. +func TestJournalModeValidation(t *testing.T) { + modes := []struct { + mode JournalMode + expected string + }{ + {JournalModeWAL, "wal"}, + {JournalModeDelete, "delete"}, + {JournalModeTruncate, "truncate"}, + {JournalModeMemory, "memory"}, + } + + for _, tt := range modes { + t.Run(string(tt.mode), func(t *testing.T) { + tempDir := t.TempDir() + + dbPath := filepath.Join(tempDir, "journal_test.db") + config := &Config{ + Path: dbPath, + JournalMode: tt.mode, + ForeignKeys: true, + } + + url, err := config.ToURL() + if err != nil { + t.Fatalf("Failed to generate URL: %v", err) + } + + db, err := sql.Open("sqlite", url) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + var actualMode string + err = db.QueryRow("PRAGMA journal_mode").Scan(&actualMode) + if err != nil { + t.Fatalf("Failed to query journal_mode: %v", err) + } + + if actualMode != tt.expected { + t.Errorf("journal_mode: expected %q, got %q", tt.expected, actualMode) + } else { + t.Logf("✓ journal_mode correctly set to: %s", actualMode) + } + }) + } +} + +// contains checks if a string contains a substring (helper function). +func contains(str, substr string) bool { + return strings.Contains(str, substr) +} diff --git a/hscontrol/db/suite_test.go b/hscontrol/db/suite_test.go index e9c71823..0589ff81 100644 --- a/hscontrol/db/suite_test.go +++ b/hscontrol/db/suite_test.go @@ -1,7 +1,6 @@ package db import ( - "context" "log" "net/url" "os" @@ -84,7 +83,7 @@ func newPostgresTestDB(t *testing.T) *HSDatabase { func newPostgresDBForTest(t *testing.T) *url.URL { t.Helper() - ctx := context.Background() + ctx := t.Context() srv, err := postgrestest.Start(ctx) if err != nil { t.Fatal(err) diff --git a/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite b/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite deleted file mode 100644 index 10e1aaec5ed56ab30e47570788d37fa634fa0d82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98304 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 G_5TC3m>mEB diff --git a/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite b/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite deleted file mode 100644 index dbe969623060bbe12f2daa1cd00008788f80380f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57344 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 diff --git a/hscontrol/db/testdata/0-23-0-to-0-24-0-preauthkey-tags-table.sqlite b/hscontrol/db/testdata/0-23-0-to-0-24-0-preauthkey-tags-table.sqlite deleted file mode 100644 index 512c487996b18582e26bb214a74258f545a3a379..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69632 zcmeI*J#5?90S9nWAC}~c#JD&>Faj6M1wkytXYiY%ivo2WB`{(scm4n_lSqnBHWBM1 z5}i6%bV%9^ncA`4I<-R&S9IxAAXB%ZxS@LsbSk)OaGSWU;KEVZsekXAJ0bH@oN-J zy=0m7Plsh1#pOO{xL@m`}ek6bl+y9wZCTetj>|!C+!F1vB`Vn)LRQmN`@VG+!-VzNZ%O_ zXxZa9@m}N7krwWQ|rexwdAWjLE2- z=)#&gB8TqSqp>~W9Svyb!p72NrbC>zXB|#jW1VdFfHv=$uNI9L37Iek#$X!GkI(9Vd^?)j}wcJyTEcX~E9(Xa#x z&1r?awI6wOAXcmF^E=al7!0)Z;QJtm`vAl4YY)4jJEHS#IuQToqqcRgwYR^!x!v05 z&F+JCxElDg$mDO|+ih&$X$7-?buzPR{BGlRW4F=TYV7er?3fN!I^}Bq@V(X8Y0$N0 zYjbaF^HyVRZt3wno2?;^_vc@_mf?uB2lE8{`jE5C+20GBibRHlW`>NXjl%NkM(jmAGd-L9_NT(x%7ODqu*dY zMioWN)sZzmGI{%WV6U0;k2yJ|W5e>?L299zvwGE@Ub|GN>N@*F5-ueP+_u#@4%Wn^ z_$0ED?$q2oZ`Q-fQBAM8bXAVl>6rnPta3bQ*X%$_=6d776b;>4cab73vrkHsu{0W- zc&X(*jJOo3BnHtdIm2NTM$atlPY+6k>hd!CEFT<=Fi-gWUfMg_{XCa+e>q4W8fr`D zQT#4yIgiN5bH{|ful7%RWaM_}n`7TU`AEjN{`_-$mcMqz*=CMxxbVbXOfNlgE7FTX zu*Ykcp4!u1p-{bXgMGIA$|xTv2gtaTR!*h8!DvpL2+cbQIT`yX)9#yOctj|ebv%iq zi|6glgPnc8YaNg2Nd_%uia4A228+OqbgF~xvs&tF_ClemR@o={$$asPPM!?~;o_^a zWj0?pS%TP^x$;qdp?dQsdp|g$#hXv)HqRyH`NdHVgJ`XDlR%BQM|-V)DsmQK%%lgIj` z??yccPn4rKyHzs?)@pKQp?c@)>-yev55u<;3NYQyeZnqOuU=(;5ExA3r(4;y#xxBujMlpN%3Wm;V_qd%Z*pi6TVJ|NKA0 z*$np^`h*7rAOHafKmY;|fB*y_009U<00RG|Kr^$zG@Hy7|I+Hu@)o%wqORDZ2hIb1#@2_Zh?ellwdO8TZA- z?j%|d0SG_<0uX=z1Rwwb2tWV=5V-gPOF8-<{!>3Bk*A$nDkYN$P_c#YBBgJkMQ&VmkjqW?&ZZ7LJuGS0SG_<0uX=z1Rwwb2tWV=5J(8*vJ9Ka zW|>m({r}~kG2An5vGSA3`*afz2tWV=5P$##AOHafKmY;|I8TAc`4ZD(i^XE|t}Tg@ zZ5V>6sibb`hN3u*tm~3q*HlGO1WA{St}QEuW2r({)icRk$)ycZ-4OhH!wo^uZVJN8 zt>*G1T@#Hk`O{onb+b$FCs)L^{;>+~*oziFpgT6c+d`)*V3NV08o=~vkqnn-j_FjPsmNLR1h z-MZAZMMKaOo8CC@==F}Q(WXvsu&2G2H)LtuFbrvar=w)OuGGVtA7`SPn@yR1nZ9kP za)+WSq9NLnqgtdR+I2&eRDu54UENk>omd^!k+hkbr43bGmo-&3=2so1%W91Bn4MHD z7!;00bZa0SG_<0uX=z1Rwx`^A^DM|9Oj}J_tYn0uX=z1Rwwb z2tWV=5P-n>3;6f{+Q00Izz z00bZa0SG_<0uX?}+Y#{p|9_Et&TwCHf93wf{hs?R_e<`lGy)F@KmY;|fB*y_009U< z00Izz00honpj^x`>`LCV4mTX)oRkZb;P(cDVR>N^^vKvN=lx*L&m07EelY6?dt@{$ zXD30&4`%#eCM}xKmY;|fB*y_009U<00Izzz}XAn{{OQ#MTroA00bZa0SG_<0uX=z1Rwx`cTM2m DQpukF diff --git a/hscontrol/db/testdata/failing-node-preauth-constraint.sqlite b/hscontrol/db/testdata/failing-node-preauth-constraint.sqlite deleted file mode 100644 index 911c243461e93af35070ac5fb8c0208891a175ee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65536 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