diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 2057c4edb9..6f0d96424e 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -432,7 +432,7 @@ jobs: manifest linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json integration: - name: Docker tests (${{ matrix.package }}) + name: Docker tests (${{ matrix.package }}${{ matrix.release != 'default' && format(', {0}', matrix.release) || '' }}) needs: [changes, fast] if: ${{ !cancelled() && needs.changes.outputs.run != 'false' && needs.fast.result == 'success' }} runs-on: ubuntu-24.04 @@ -447,11 +447,41 @@ jobs: - tablepro-driver-clickhouse - tablepro-ssh - tablepro-test-fixtures + # Each fixture starts its engine's default release. An entry with + # another release runs the driver's whole suite again on the + # versions it names (docs/testing.md, Server releases). + release: [default] include: - package: tablepro-ssh apt: openssh-client - package: tablepro-test-fixtures apt: openssh-client + - package: tablepro-driver-postgres + release: postgres-14 + postgres: "14" + - package: tablepro-driver-postgres + release: postgres-18 + postgres: "18" + - package: tablepro-driver-mysql + release: mysql-8.0-mariadb-10.11 + mysql: "8.0" + mariadb: "10.11" + - package: tablepro-driver-mysql + release: mysql-9.7-mariadb-12.3 + mysql: "9.7" + mariadb: "12.3" + - package: tablepro-driver-mssql + release: mssql-2017 + mssql: "2017" + - package: tablepro-driver-mssql + release: mssql-2019 + mssql: "2019" + - package: tablepro-driver-mssql + release: mssql-2025 + mssql: "2025" + - package: tablepro-driver-clickhouse + release: clickhouse-26.3 + clickhouse: "26.3" defaults: run: working-directory: linux @@ -481,12 +511,17 @@ jobs: - name: Docker tests env: PACKAGE: ${{ matrix.package }} + TABLEPRO_TEST_POSTGRES: ${{ matrix.postgres }} + TABLEPRO_TEST_MYSQL: ${{ matrix.mysql }} + TABLEPRO_TEST_MARIADB: ${{ matrix.mariadb }} + TABLEPRO_TEST_MSSQL: ${{ matrix.mssql }} + TABLEPRO_TEST_CLICKHOUSE: ${{ matrix.clickhouse }} run: cargo nextest run --locked --profile ci -p "$PACKAGE" --run-ignored only - name: Upload JUnit report if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: junit-${{ matrix.package }} + name: junit-${{ matrix.package }}${{ matrix.release != 'default' && format('-{0}', matrix.release) || '' }} path: linux/target/nextest/ci/junit.xml integration-session: diff --git a/linux/README.md b/linux/README.md index c4fdf5e5ad..74c50ed27c 100644 --- a/linux/README.md +++ b/linux/README.md @@ -6,6 +6,23 @@ Native Linux database client, the Linux sibling of the macOS TablePro app. The t It connects to five engines: PostgreSQL, MySQL and MariaDB, SQL Server, ClickHouse and SQLite. Each connection session gets a window of its own. Nothing is released yet: no `linux-v*` tag exists. [ROADMAP.md](ROADMAP.md) lists what is built, what is not, and the release criteria. +## Supported servers + +TablePro supports the server releases their vendors still support. CI runs each driver's whole Docker suite on every release in the second column ([docs/testing.md](docs/testing.md#server-releases)). + +| Engine | Tested releases | Not supported | +|---|---|---| +| PostgreSQL | 14, 17 and 18 | 13 and older, past end of life (13 on 2025-11-13). 19 until it is released | +| MySQL | 8.0, 8.4 and 9.7 | 8.1 to 9.6, innovation releases past end of life. 5.7 and older | +| MariaDB | 10.11, 11.4 and 12.3 | 10.6 and older, past end of life (10.6 on 2026-07-06). Rolling releases such as 13.0 | +| SQL Server | 2017, 2019, 2022 and 2025 | 2016 and older, past extended support (2016 on 2026-07-14) | +| ClickHouse | 26.3 and 26.8 | Monthly releases between long-term ones. 25.8 and older long-term releases, past end of life (25.8 on 2026-08-29) | +| SQLite | the system library, 3.37.0 or newer | older libraries, which the driver refuses when it opens a file | + +- PostgreSQL 14 reaches end of life on 2026-11-12. +- MySQL 8.0 reached end of life on 2026-04-30, but Amazon RDS keeps 8.0 instances under paid Extended Support until 2029-07-31, so it stays tested. +- MariaDB 11.8 and PostgreSQL 15 and 16 are supported between the tested releases, which bound them. + ## Install These commands work from the first release, 0.1.0, on amd64 and arm64. diff --git a/linux/crates/drivers/clickhouse/src/ddl.rs b/linux/crates/drivers/clickhouse/src/ddl.rs index 70d56f8555..9e1ef7c5a4 100644 --- a/linux/crates/drivers/clickhouse/src/ddl.rs +++ b/linux/crates/drivers/clickhouse/src/ddl.rs @@ -132,14 +132,32 @@ impl DdlDialect for ClickHouseDialect { let draft = &alteration.draft; let name = self.quote_identifier(&draft.name); let changed = alteration.changed; + let mut default_written = false; if changed.column_type || changed.nullability { let resolved = draft.column_type.resolve(self, &draft.name)?; - statements.push(alter(format!( - "MODIFY COLUMN {name} {}", - with_nullability(&resolved.sql, draft.nullable) - ))); + let declaration = with_nullability(&resolved.sql, draft.nullable); + // From 25.12 the server refuses to make a column non-nullable + // without a DEFAULT to put in its NULLs. With none in the + // draft, the type's own default fills them, as older servers + // did, and is removed again. + let fills_nulls = alteration.original.info.nullable && !draft.nullable; + match default_sql(self, &draft.name, &draft.default, &resolved.column_type)? { + Some(default) if fills_nulls => { + statements.push(alter(format!("MODIFY COLUMN {name} {declaration} DEFAULT {default}"))); + default_written = true; + } + None if fills_nulls => { + statements.push(alter(format!( + "MODIFY COLUMN {name} {declaration} DEFAULT defaultValueOfTypeName({})", + self.string_literal(&declaration) + ))); + statements.push(alter(format!("MODIFY COLUMN {name} REMOVE DEFAULT"))); + default_written = true; + } + _ => statements.push(alter(format!("MODIFY COLUMN {name} {declaration}"))), + } } - if changed.default { + if changed.default && !default_written { let column_type = draft.column_type.column_type(self, &draft.name)?; statements.push(alter( match default_sql(self, &draft.name, &draft.default, &column_type)? { @@ -189,7 +207,11 @@ impl ClickHouseDialect { } fn comment(&self, text: Option<&str>) -> String { - let value = Value::Text(text.unwrap_or_default().to_owned()); + self.string_literal(text.unwrap_or_default()) + } + + fn string_literal(&self, text: &str) -> String { + let value = Value::Text(text.to_owned()); SqlDialect::literal(self, &value, &ColumnType::untyped_text("String")).unwrap_or_else(|_| "''".to_owned()) } } @@ -417,12 +439,37 @@ mod tests { [ "ALTER TABLE `analytics`.`events` RENAME COLUMN `note` TO `remark`", "ALTER TABLE `analytics`.`events` MODIFY COLUMN `label` REMOVE DEFAULT", - "ALTER TABLE `analytics`.`events` MODIFY COLUMN `remark` String", + "ALTER TABLE `analytics`.`events` MODIFY COLUMN `remark` String DEFAULT defaultValueOfTypeName('String')", + "ALTER TABLE `analytics`.`events` MODIFY COLUMN `remark` REMOVE DEFAULT", "ALTER TABLE `analytics`.`events` ADD COLUMN `tag` Nullable(String)", ] ); } + #[test] + fn a_column_made_non_nullable_fills_its_nulls_with_the_drafts_default() { + let plan = alter(&events(), |draft| { + let note = &mut draft.columns[2]; + note.nullable = false; + note.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + }); + + assert_eq!( + sql(plan), + ["ALTER TABLE `analytics`.`events` MODIFY COLUMN `note` String DEFAULT 'none'"] + ); + } + + #[test] + fn a_column_made_nullable_needs_no_default() { + let plan = alter(&events(), |draft| draft.columns[1].nullable = true); + + assert_eq!( + sql(plan), + ["ALTER TABLE `analytics`.`events` MODIFY COLUMN `label` Nullable(String)"] + ); + } + #[test] fn a_swap_steps_aside_through_a_temporary_name() { let plan = alter(&events(), |draft| { diff --git a/linux/crates/drivers/clickhouse/tests/integration/tls.rs b/linux/crates/drivers/clickhouse/tests/integration/tls.rs index 8519462a4f..1bde715d0f 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/tls.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/tls.rs @@ -144,7 +144,13 @@ async fn https_verify_full_ok() { .probe(within(CALL)) .await .expect("the server answers over TLS"); - assert!(connection.server_info().version.starts_with("25.")); + let (major, minor) = fixture.version().reported(); + assert_eq!( + (connection.server_info().major, connection.server_info().minor), + (major, minor), + "{}", + connection.server_info().version + ); connection.close().await.expect("a close"); } diff --git a/linux/crates/drivers/mssql/tests/integration/decode.rs b/linux/crates/drivers/mssql/tests/integration/decode.rs index 28753336be..87a8ac1f0f 100644 --- a/linux/crates/drivers/mssql/tests/integration/decode.rs +++ b/linux/crates/drivers/mssql/tests/integration/decode.rs @@ -26,7 +26,12 @@ async fn serverproperty_product_version_no_panic() { let version = scalar(connection.as_ref(), "SELECT SERVERPROPERTY('ProductVersion')").await; - assert!(connection.server_info().major >= 16, "{:?}", connection.server_info()); + assert_eq!( + connection.server_info().major, + fixture.version().major(), + "{:?}", + connection.server_info() + ); assert!(text(&version).starts_with(&connection.server_info().major.to_string())); } diff --git a/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs b/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs index 03195f3450..4cfb79143b 100644 --- a/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs @@ -1,4 +1,5 @@ -//! The connection conformance suite against MySQL 8.4 and MariaDB 11.4. +//! The connection conformance suite against MySQL and MariaDB, on the +//! releases `TABLEPRO_TEST_MYSQL` and `TABLEPRO_TEST_MARIADB` pick. use std::marker::PhantomData; use std::sync::Arc; @@ -23,15 +24,15 @@ pub trait Server: Send + Sync + 'static { const FLAVOUR: MysqlFlavour; } -pub struct Mysql84; +pub struct MysqlServer; -impl Server for Mysql84 { +impl Server for MysqlServer { const FLAVOUR: MysqlFlavour = MysqlFlavour::Mysql; } -pub struct Mariadb114; +pub struct MariadbServer; -impl Server for Mariadb114 { +impl Server for MariadbServer { const FLAVOUR: MysqlFlavour = MysqlFlavour::Mariadb; } @@ -200,7 +201,7 @@ impl ConformanceDdlEngine for MySqlEngine { /// MariaDB's `max_statement_time` stops a CREATE ... SELECT as it stops a /// SELECT. MySQL's `max_execution_time` bounds only read-only SELECTs, so /// it has no bounded DDL to lift and runs without this case. -impl BoundedDdlEngine for MySqlEngine { +impl BoundedDdlEngine for MySqlEngine { fn schema_change_exceeding_sql(&self, table: &TableRef, duration: Duration) -> String { format!( "CREATE TABLE {} AS SELECT SLEEP({}) AS id", @@ -236,10 +237,10 @@ impl LargeTableEngine for MySqlEngine { } mod mysql_suite { - use super::{MySqlEngine, Mysql84}; + use super::{MySqlEngine, MysqlServer}; tablepro_test_support::connection_conformance_suite! { - engine: MySqlEngine, + engine: MySqlEngine, docker: true, endpoint: network, pool: pooled, @@ -249,20 +250,20 @@ mod mysql_suite { } tablepro_test_support::ddl_conformance_suite! { - engine: MySqlEngine, + engine: MySqlEngine, docker: true, } tablepro_test_support::large_table_conformance_suite! { - engine: MySqlEngine, + engine: MySqlEngine, } } mod mariadb_suite { - use super::{Mariadb114, MySqlEngine}; + use super::{MariadbServer, MySqlEngine}; tablepro_test_support::connection_conformance_suite! { - engine: MySqlEngine, + engine: MySqlEngine, docker: true, endpoint: network, pool: pooled, @@ -272,11 +273,11 @@ mod mariadb_suite { } tablepro_test_support::ddl_conformance_suite! { - engine: MySqlEngine, + engine: MySqlEngine, docker: true, } tablepro_test_support::large_table_conformance_suite! { - engine: MySqlEngine, + engine: MySqlEngine, } } diff --git a/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs b/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs index 377e846481..df49f7d3ae 100644 --- a/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs +++ b/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs @@ -1,5 +1,5 @@ -//! What the driver promises on both MySQL 8.4 and MariaDB 11.4: the -//! session it sets up, how it stops statements, and how saves fail. +//! What the driver promises on both MySQL and MariaDB: the session it +//! sets up, how it stops statements, and how saves fail. use std::time::Duration; @@ -23,11 +23,7 @@ async fn server_info_product_flavour() { let info = connection.server_info(); assert_eq!(info.product, product); - let expected = match flavour { - MysqlFlavour::Mysql => (8, 4), - MysqlFlavour::Mariadb => (11, 4), - }; - assert_eq!((info.major, info.minor), expected, "{}", info.version); + assert_eq!((info.major, info.minor), fixture.server_version(), "{}", info.version); connection.close().await.expect("a close"); } } diff --git a/linux/crates/drivers/mysql/tests/integration/non_transactional.rs b/linux/crates/drivers/mysql/tests/integration/non_transactional.rs index 8d0adb8302..6e4b7c227a 100644 --- a/linux/crates/drivers/mysql/tests/integration/non_transactional.rs +++ b/linux/crates/drivers/mysql/tests/integration/non_transactional.rs @@ -5,7 +5,7 @@ use tablepro_core::statement::GuardedStatement; use tablepro_core::{DriverError, ForeignKeyMode, StatementOutcome, WriteBatch, WriteKind}; use tablepro_test_fixtures::MysqlFlavour; -use crate::support::{CALL, FLAVOURS, connect, count, setup, start, statement, table, within}; +use crate::support::{CALL, FLAVOURS, connect, count, scalar, setup, start, statement, table, text, within}; fn guarded(sql: &str, rows: u64) -> GuardedStatement { GuardedStatement::affecting_exactly(statement(sql), rows) @@ -190,7 +190,9 @@ async fn aria_table_uses_statement_by_statement() { } /// An InnoDB save rolls back, but what its trigger wrote into a MyISAM -/// table stays, and the server's warning 1196 says so. +/// table stays, and the server's warning 1196 says so. MySQL refuses +/// the save with 1785 before anything lands where it enforces GTID +/// consistency, as 9.7 does by default. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn innodb_trigger_into_myisam_rollback_warning_1196_partially_applied() { @@ -207,6 +209,9 @@ async fn innodb_trigger_into_myisam_rollback_warning_1196_partially_applied() { ) .await; + let enforces_gtid_consistency = flavour == MysqlFlavour::Mysql + && text(&scalar(connection.as_ref(), "SELECT @@GLOBAL.enforce_gtid_consistency").await) == "ON"; + let outcome = connection .write_batch( &batch( @@ -220,19 +225,26 @@ async fn innodb_trigger_into_myisam_rollback_warning_1196_partially_applied() { ) .await; - assert!( - matches!( - outcome, - Err(DriverError::PartiallyApplied { - applied: 1, - statement_index: 1, - .. - }) + let (expected, audited) = match enforces_gtid_consistency { + true => ( + matches!(outcome, Err(DriverError::RolledBack { statement_index: 0, .. })), + 0, ), - "{flavour:?}: {outcome:?}" - ); + false => ( + matches!( + outcome, + Err(DriverError::PartiallyApplied { + applied: 1, + statement_index: 1, + .. + }) + ), + 1, + ), + }; + assert!(expected, "{flavour:?}: {outcome:?}"); assert_eq!(count(connection.as_ref(), "SELECT COUNT(*) FROM source").await, 0); - assert_eq!(count(connection.as_ref(), "SELECT COUNT(*) FROM audit").await, 1); + assert_eq!(count(connection.as_ref(), "SELECT COUNT(*) FROM audit").await, audited); connection.close().await.expect("a close"); } } diff --git a/linux/crates/drivers/mysql/tests/integration/tls.rs b/linux/crates/drivers/mysql/tests/integration/tls.rs index 879ed3fee9..e886f3dc8c 100644 --- a/linux/crates/drivers/mysql/tests/integration/tls.rs +++ b/linux/crates/drivers/mysql/tests/integration/tls.rs @@ -18,7 +18,7 @@ use tablepro_test_support::conformance::{ }; use tokio_util::sync::CancellationToken; -use crate::conformance_engine::{Mariadb114, Mysql84, Server}; +use crate::conformance_engine::{MariadbServer, MysqlServer, Server}; use crate::support::{FLAVOURS, direct, options_over, password, run_ok, session, start_with, text}; /// The name to dial so the certificate's `localhost` matches: the @@ -89,12 +89,12 @@ impl TlsConformanceEngine for MySqlTlsEngine { } /// The password user is created REQUIRE SSL. MySQL checks that with - /// the credentials and answers 1045; MariaDB checks + /// the credentials and answers 1045; MariaDB from 11.2 checks /// `require_secure_transport` first and answers 3159. fn plaintext_rejection_category(&self) -> ErrorCategory { match S::FLAVOUR { - MysqlFlavour::Mysql => ErrorCategory::Authentication, - MysqlFlavour::Mariadb => ErrorCategory::Tls, + MysqlFlavour::Mariadb if !answers_as_a_password(&self.fixture) => ErrorCategory::Tls, + _ => ErrorCategory::Authentication, } } @@ -120,19 +120,19 @@ impl ClientIdentityEngine for MySqlTlsEngine { } mod mysql_suite { - use super::{MySqlTlsEngine, Mysql84}; + use super::{MySqlTlsEngine, MysqlServer}; tablepro_test_support::tls_conformance_suite! { - engine: MySqlTlsEngine, + engine: MySqlTlsEngine, client_identity: true, } } mod mariadb_suite { - use super::{Mariadb114, MySqlTlsEngine}; + use super::{MariadbServer, MySqlTlsEngine}; tablepro_test_support::tls_conformance_suite! { - engine: MySqlTlsEngine, + engine: MySqlTlsEngine, client_identity: true, } } @@ -299,8 +299,16 @@ async fn disabled_mode_uses_rsa_not_cleartext() { connection.close().await.expect("a close"); } +/// MariaDB before 11.2 refuses a plaintext login under +/// `require_secure_transport` with 1045, Access denied, as it refuses a +/// wrong password, rather than 3159 (MDEV-30178). +fn answers_as_a_password(fixture: &MysqlFixture) -> bool { + fixture.flavour() == MysqlFlavour::Mariadb && fixture.server_version() < (11, 2) +} + /// A login with no TLS requirement of its own meets the server's -/// `require_secure_transport`, which both servers report as 3159. +/// `require_secure_transport`, which both servers report as 3159, but +/// MariaDB before 11.2 as a refused password. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn disabled_against_require_secure_transport() { @@ -318,17 +326,19 @@ async fn disabled_against_require_secure_transport() { let outcome = MysqlDriver.connect(options, CancellationToken::new()).await; - assert!( - matches!( - &outcome, - Err(DriverError::Tls { - failure: TlsFailure::ServerRequiresTls, - .. - }) - ), - "{flavour:?}: {:?}", - outcome.err() + let refused_for_tls = matches!( + &outcome, + Err(DriverError::Tls { + failure: TlsFailure::ServerRequiresTls, + .. + }) ); + let refused_as_a_password = matches!(&outcome, Err(DriverError::Auth { .. })); + let expected = match answers_as_a_password(&fixture) { + true => refused_as_a_password, + false => refused_for_tls, + }; + assert!(expected, "{flavour:?}: {:?}", outcome.err()); } } diff --git a/linux/crates/drivers/postgres/tests/integration/metadata.rs b/linux/crates/drivers/postgres/tests/integration/metadata.rs index cf98b8604c..693c269e88 100644 --- a/linux/crates/drivers/postgres/tests/integration/metadata.rs +++ b/linux/crates/drivers/postgres/tests/integration/metadata.rs @@ -353,11 +353,15 @@ const CATALOG_TABLE: &[&str] = &[ )", ]; -async fn catalog_matches_information_schema(version: PostgresVersion) { - let fixture = PostgresFixture::start_version(version, HbaMode::Password) - .await - .expect("a PostgreSQL container"); +async fn catalog_matches_information_schema(fixture: PostgresFixture) { + let version = fixture.version(); let connection = connect(&fixture, false).await; + assert_eq!( + connection.server_info().major, + version.major(), + "{}", + connection.server_info().version + ); setup(connection.as_ref(), CATALOG_TABLE).await; let columns = connection @@ -485,13 +489,16 @@ fn typed_kinds() -> Vec<(&'static str, ColumnKind, ReadForm)> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn catalog_matches_information_schema_on_13() { - catalog_matches_information_schema(PostgresVersion::V13).await; + let fixture = PostgresFixture::start_version(PostgresVersion::V13, HbaMode::Password) + .await + .expect("a PostgreSQL container"); + catalog_matches_information_schema(fixture).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] -async fn catalog_matches_information_schema_on_18() { - catalog_matches_information_schema(PostgresVersion::V18).await; +async fn catalog_matches_information_schema_on_the_selected_release() { + catalog_matches_information_schema(start().await).await; } /// No statement is cached under a name, so a column added after the pool diff --git a/linux/crates/test-fixtures/src/clickhouse_fixture.rs b/linux/crates/test-fixtures/src/clickhouse_fixture.rs index 60e86eb9d1..42b94b9c02 100644 --- a/linux/crates/test-fixtures/src/clickhouse_fixture.rs +++ b/linux/crates/test-fixtures/src/clickhouse_fixture.rs @@ -10,7 +10,10 @@ use tokio::net::TcpStream; use tokio::time::Instant; use crate::container_file::with_files; -use crate::{ContainerFile, ExecOutput, FixtureCredentials, FixtureError, FixtureImage, PkiFile, TestNetwork, TestPki}; +use crate::{ + ClickHouseVersion, ContainerFile, ExecOutput, FixtureCredentials, FixtureError, PkiFile, ServerVersion, + TestNetwork, TestPki, +}; const HTTP_PORT: u16 = ClickHouseFixture::HTTP_PORT; const HTTPS_PORT: u16 = ClickHouseFixture::HTTPS_PORT; @@ -32,6 +35,7 @@ pub struct ClickHouseFixture { http_port: u16, https_port: u16, network_alias: Option, + version: ClickHouseVersion, } impl ClickHouseFixture { @@ -57,7 +61,9 @@ impl ClickHouseFixture { ) -> Result { let pki = TestPki::generate()?; let password = FixtureCredentials::generate_password(); - let mut request: ContainerRequest = FixtureImage::CLICKHOUSE + let version = ClickHouseVersion::selected()?; + let mut request: ContainerRequest = version + .image() .generic() .with_exposed_port(HTTP_PORT.tcp()) .with_exposed_port(HTTPS_PORT.tcp()) @@ -84,6 +90,7 @@ impl ClickHouseFixture { http_port, https_port, network_alias, + version, }; fixture.create_database(ready_timeout).await?; Ok(fixture) @@ -159,6 +166,10 @@ impl ClickHouseFixture { self.network_alias.as_deref() } + pub fn version(&self) -> ClickHouseVersion { + self.version + } + pub fn pki(&self) -> &TestPki { &self.pki } diff --git a/linux/crates/test-fixtures/src/clickhouse_version.rs b/linux/crates/test-fixtures/src/clickhouse_version.rs new file mode 100644 index 0000000000..d1227ac0c5 --- /dev/null +++ b/linux/crates/test-fixtures/src/clickhouse_version.rs @@ -0,0 +1,55 @@ +use crate::{FixtureImage, ServerVersion}; + +/// The ClickHouse release a fixture runs: the long-term releases +/// ClickHouse still supports. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ClickHouseVersion { + V26_3, + #[default] + V26_8, +} + +impl ClickHouseVersion { + /// The major and minor version `version()` reports. + pub fn reported(self) -> (u32, u32) { + match self { + Self::V26_3 => (26, 3), + Self::V26_8 => (26, 8), + } + } +} + +impl ServerVersion for ClickHouseVersion { + const VARIABLE: &'static str = "TABLEPRO_TEST_CLICKHOUSE"; + const ALL: &'static [Self] = &[Self::V26_3, Self::V26_8]; + + fn name(self) -> &'static str { + match self { + Self::V26_3 => "26.3", + Self::V26_8 => "26.8", + } + } + + fn image(self) -> FixtureImage { + match self { + Self::V26_3 => FixtureImage::CLICKHOUSE_26_3, + Self::V26_8 => FixtureImage::CLICKHOUSE, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_version_pins_the_image_of_its_release() { + crate::server_version::tests::check_releases::("clickhouse/clickhouse-server"); + for &version in ClickHouseVersion::ALL { + let (major, minor) = version.reported(); + assert_eq!(version.image().tag, format!("{major}.{minor}")); + assert_eq!(version.name(), version.image().tag); + } + assert_eq!(ClickHouseVersion::default().image(), FixtureImage::CLICKHOUSE); + } +} diff --git a/linux/crates/test-fixtures/src/fixture_error.rs b/linux/crates/test-fixtures/src/fixture_error.rs index 3e025139aa..9d224765c7 100644 --- a/linux/crates/test-fixtures/src/fixture_error.rs +++ b/linux/crates/test-fixtures/src/fixture_error.rs @@ -14,6 +14,12 @@ pub enum FixtureError { status: ExitStatus, stderr: String, }, + #[error("{variable}={value} names no release a fixture pins; it pins {known}")] + UnknownRelease { + variable: &'static str, + value: String, + known: String, + }, #[error("{service} did not accept connections within {seconds} seconds")] NotReady { service: &'static str, seconds: u64 }, #[error("{service} came back on host port {after}, not {before}")] diff --git a/linux/crates/test-fixtures/src/fixture_image.rs b/linux/crates/test-fixtures/src/fixture_image.rs index e30120c1ff..0e6b0cdc0d 100644 --- a/linux/crates/test-fixtures/src/fixture_image.rs +++ b/linux/crates/test-fixtures/src/fixture_image.rs @@ -15,6 +15,10 @@ impl FixtureImage { name: "postgres", tag: "13-alpine", }; + pub const POSTGRES_14: Self = Self { + name: "postgres", + tag: "14-alpine", + }; pub const POSTGRES_18: Self = Self { name: "postgres", tag: "18-alpine", @@ -23,17 +27,49 @@ impl FixtureImage { name: "mysql", tag: "8.4", }; + pub const MYSQL_8_0: Self = Self { + name: "mysql", + tag: "8.0", + }; + pub const MYSQL_9_7: Self = Self { + name: "mysql", + tag: "9.7", + }; pub const MARIADB: Self = Self { name: "mariadb", tag: "11.4", }; + pub const MARIADB_10_11: Self = Self { + name: "mariadb", + tag: "10.11", + }; + pub const MARIADB_12_3: Self = Self { + name: "mariadb", + tag: "12.3", + }; pub const MSSQL: Self = Self { name: "mcr.microsoft.com/mssql/server", tag: "2022-CU14-ubuntu-22.04", }; + pub const MSSQL_2017: Self = Self { + name: "mcr.microsoft.com/mssql/server", + tag: "2017-CU31-GDR19-ubuntu-18.04", + }; + pub const MSSQL_2019: Self = Self { + name: "mcr.microsoft.com/mssql/server", + tag: "2019-CU32-GDR12-ubuntu-20.04", + }; + pub const MSSQL_2025: Self = Self { + name: "mcr.microsoft.com/mssql/server", + tag: "2025-CU9-GDR1-ubuntu-22.04", + }; pub const CLICKHOUSE: Self = Self { name: "clickhouse/clickhouse-server", - tag: "25.8", + tag: "26.8", + }; + pub const CLICKHOUSE_26_3: Self = Self { + name: "clickhouse/clickhouse-server", + tag: "26.3", }; pub const PGBOUNCER: Self = Self { name: "edoburu/pgbouncer", diff --git a/linux/crates/test-fixtures/src/lib.rs b/linux/crates/test-fixtures/src/lib.rs index a63acafb97..f151519581 100644 --- a/linux/crates/test-fixtures/src/lib.rs +++ b/linux/crates/test-fixtures/src/lib.rs @@ -1,4 +1,5 @@ mod clickhouse_fixture; +mod clickhouse_version; mod container_file; mod exec_output; mod fixture_credentials; @@ -8,10 +9,13 @@ mod hba_mode; mod host_certificate; mod host_command; mod host_key_revocation; +mod mariadb_version; mod mssql_fixture; +mod mssql_version; mod mysql_fixture; mod mysql_flavour; mod mysql_options; +mod mysql_version; mod openssh_fixture; mod pgbouncer_fixture; mod pki_algorithm; @@ -20,6 +24,7 @@ mod pool_mode; mod postgres_fixture; mod postgres_version; mod scripted_askpass; +mod server_version; mod ssh_agent; mod ssh_auth_variant; mod ssh_key_pair; @@ -27,6 +32,7 @@ mod test_network; mod test_pki; pub use clickhouse_fixture::ClickHouseFixture; +pub use clickhouse_version::ClickHouseVersion; pub use container_file::ContainerFile; pub use exec_output::ExecOutput; pub use fixture_credentials::FixtureCredentials; @@ -35,10 +41,13 @@ pub use fixture_image::FixtureImage; pub use hba_mode::HbaMode; pub use host_certificate::HostCertificate; pub use host_key_revocation::HostKeyRevocation; +pub use mariadb_version::MariadbVersion; pub use mssql_fixture::MssqlFixture; +pub use mssql_version::MssqlVersion; pub use mysql_fixture::MysqlFixture; pub use mysql_flavour::MysqlFlavour; pub use mysql_options::MysqlOptions; +pub use mysql_version::MysqlVersion; pub use openssh_fixture::OpenSshFixture; pub use pgbouncer_fixture::PgBouncerFixture; pub use pki_algorithm::PkiAlgorithm; @@ -47,6 +56,7 @@ pub use pool_mode::PoolMode; pub use postgres_fixture::PostgresFixture; pub use postgres_version::PostgresVersion; pub use scripted_askpass::ScriptedAskpass; +pub use server_version::ServerVersion; pub use ssh_agent::SshAgent; pub use ssh_auth_variant::SshAuthVariant; pub use ssh_key_pair::SshKeyPair; diff --git a/linux/crates/test-fixtures/src/mariadb_version.rs b/linux/crates/test-fixtures/src/mariadb_version.rs new file mode 100644 index 0000000000..04a75a10bd --- /dev/null +++ b/linux/crates/test-fixtures/src/mariadb_version.rs @@ -0,0 +1,58 @@ +use crate::{FixtureImage, ServerVersion}; + +/// The MariaDB release a fixture runs: long-term releases only. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MariadbVersion { + V10_11, + #[default] + V11_4, + V12_3, +} + +impl MariadbVersion { + /// The major and minor version `VERSION()` reports. + pub fn reported(self) -> (u32, u32) { + match self { + Self::V10_11 => (10, 11), + Self::V11_4 => (11, 4), + Self::V12_3 => (12, 3), + } + } +} + +impl ServerVersion for MariadbVersion { + const VARIABLE: &'static str = "TABLEPRO_TEST_MARIADB"; + const ALL: &'static [Self] = &[Self::V10_11, Self::V11_4, Self::V12_3]; + + fn name(self) -> &'static str { + match self { + Self::V10_11 => "10.11", + Self::V11_4 => "11.4", + Self::V12_3 => "12.3", + } + } + + fn image(self) -> FixtureImage { + match self { + Self::V10_11 => FixtureImage::MARIADB_10_11, + Self::V11_4 => FixtureImage::MARIADB, + Self::V12_3 => FixtureImage::MARIADB_12_3, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_version_pins_the_image_of_its_release() { + crate::server_version::tests::check_releases::("mariadb"); + for &version in MariadbVersion::ALL { + let (major, minor) = version.reported(); + assert_eq!(version.image().tag, format!("{major}.{minor}")); + assert_eq!(version.name(), version.image().tag); + } + assert_eq!(MariadbVersion::default().image(), FixtureImage::MARIADB); + } +} diff --git a/linux/crates/test-fixtures/src/mssql_fixture.rs b/linux/crates/test-fixtures/src/mssql_fixture.rs index 8ecd424163..8ab89a5f82 100644 --- a/linux/crates/test-fixtures/src/mssql_fixture.rs +++ b/linux/crates/test-fixtures/src/mssql_fixture.rs @@ -6,7 +6,9 @@ use testcontainers_modules::mssql_server::MssqlServer; use uuid::Uuid; use crate::container_file::with_files; -use crate::{ContainerFile, ExecOutput, FixtureCredentials, FixtureError, FixtureImage, PkiFile, TestPki}; +use crate::{ + ContainerFile, ExecOutput, FixtureCredentials, FixtureError, MssqlVersion, PkiFile, ServerVersion, TestPki, +}; const CONTAINER_PORT: u16 = 1433; const ADMIN_USER: &str = "sa"; @@ -19,6 +21,7 @@ pub struct MssqlFixture { password: String, host: String, port: u16, + version: MssqlVersion, } impl MssqlFixture { @@ -27,11 +30,13 @@ impl MssqlFixture { pub async fn start() -> Result { let pki = TestPki::generate()?; let password = format!("Tp1!{}", Uuid::new_v4().simple()); + let version = MssqlVersion::selected()?; + let image = version.image(); let request = MssqlServer::default() .with_accept_eula() .with_sa_password(&password) - .with_name(FixtureImage::MSSQL.name) - .with_tag(FixtureImage::MSSQL.tag) + .with_name(image.name) + .with_tag(image.tag) .with_startup_timeout(STARTUP_TIMEOUT) // SQL Server otherwise takes 80% of the host's memory, and a // container left behind by a killed test keeps all of it. @@ -45,6 +50,7 @@ impl MssqlFixture { password, host, port, + version, }) } @@ -72,6 +78,10 @@ impl MssqlFixture { self.port } + pub fn version(&self) -> MssqlVersion { + self.version + } + pub fn pki(&self) -> &TestPki { &self.pki } diff --git a/linux/crates/test-fixtures/src/mssql_version.rs b/linux/crates/test-fixtures/src/mssql_version.rs new file mode 100644 index 0000000000..4f2db22f49 --- /dev/null +++ b/linux/crates/test-fixtures/src/mssql_version.rs @@ -0,0 +1,61 @@ +use crate::{FixtureImage, ServerVersion}; + +/// The SQL Server release a fixture runs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MssqlVersion { + V2017, + V2019, + #[default] + V2022, + V2025, +} + +impl MssqlVersion { + /// The major version `SERVERPROPERTY('ProductVersion')` reports, + /// which is not the year in the release's name. + pub fn major(self) -> u32 { + match self { + Self::V2017 => 14, + Self::V2019 => 15, + Self::V2022 => 16, + Self::V2025 => 17, + } + } +} + +impl ServerVersion for MssqlVersion { + const VARIABLE: &'static str = "TABLEPRO_TEST_MSSQL"; + const ALL: &'static [Self] = &[Self::V2017, Self::V2019, Self::V2022, Self::V2025]; + + fn name(self) -> &'static str { + match self { + Self::V2017 => "2017", + Self::V2019 => "2019", + Self::V2022 => "2022", + Self::V2025 => "2025", + } + } + + fn image(self) -> FixtureImage { + match self { + Self::V2017 => FixtureImage::MSSQL_2017, + Self::V2019 => FixtureImage::MSSQL_2019, + Self::V2022 => FixtureImage::MSSQL, + Self::V2025 => FixtureImage::MSSQL_2025, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_version_pins_the_image_of_its_year() { + crate::server_version::tests::check_releases::("mcr.microsoft.com/mssql/server"); + for &version in MssqlVersion::ALL { + assert!(version.image().tag.starts_with(&format!("{}-", version.name()))); + } + assert_eq!(MssqlVersion::default().image(), FixtureImage::MSSQL); + } +} diff --git a/linux/crates/test-fixtures/src/mysql_fixture.rs b/linux/crates/test-fixtures/src/mysql_fixture.rs index be907539af..9c42023e1a 100644 --- a/linux/crates/test-fixtures/src/mysql_fixture.rs +++ b/linux/crates/test-fixtures/src/mysql_fixture.rs @@ -22,6 +22,7 @@ pub struct MysqlFixture { host: String, port: u16, network_alias: Option, + server_version: (u32, u32), } impl MysqlFixture { @@ -44,10 +45,11 @@ impl MysqlFixture { let certificate_password = FixtureCredentials::generate_password(); let flavour = options.flavour; let prefix = flavour.environment_prefix(); + let (release, server_version) = flavour.selected()?; let image = flavour .ready_conditions() .into_iter() - .fold(flavour.image().generic(), GenericImage::with_wait_for) + .fold(release.generic(), GenericImage::with_wait_for) .with_exposed_port(Self::CONTAINER_PORT.tcp()); let mut request = image .with_env_var(format!("{prefix}_ROOT_PASSWORD"), &root_password) @@ -74,6 +76,7 @@ impl MysqlFixture { host, port, network_alias, + server_version, }) } @@ -127,6 +130,11 @@ impl MysqlFixture { &self.options } + /// The major and minor version of the release the fixture started. + pub fn server_version(&self) -> (u32, u32) { + self.server_version + } + pub fn host(&self) -> &str { &self.host } diff --git a/linux/crates/test-fixtures/src/mysql_flavour.rs b/linux/crates/test-fixtures/src/mysql_flavour.rs index 7b8a8a5510..08fd61b321 100644 --- a/linux/crates/test-fixtures/src/mysql_flavour.rs +++ b/linux/crates/test-fixtures/src/mysql_flavour.rs @@ -1,6 +1,6 @@ use testcontainers::core::WaitFor; -use crate::FixtureImage; +use crate::{FixtureError, FixtureImage, MariadbVersion, MysqlVersion, ServerVersion}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MysqlFlavour { @@ -9,11 +9,19 @@ pub enum MysqlFlavour { } impl MysqlFlavour { - pub fn image(self) -> FixtureImage { - match self { - Self::Mysql => FixtureImage::MYSQL, - Self::Mariadb => FixtureImage::MARIADB, - } + /// The image of the release the flavour's variable picks, and the + /// major and minor version that release reports. + pub fn selected(self) -> Result<(FixtureImage, (u32, u32)), FixtureError> { + Ok(match self { + Self::Mysql => { + let version = MysqlVersion::selected()?; + (version.image(), version.reported()) + } + Self::Mariadb => { + let version = MariadbVersion::selected()?; + (version.image(), version.reported()) + } + }) } pub fn environment_prefix(self) -> &'static str { diff --git a/linux/crates/test-fixtures/src/mysql_version.rs b/linux/crates/test-fixtures/src/mysql_version.rs new file mode 100644 index 0000000000..a4f57ce204 --- /dev/null +++ b/linux/crates/test-fixtures/src/mysql_version.rs @@ -0,0 +1,61 @@ +use crate::{FixtureImage, ServerVersion}; + +/// The MySQL release a fixture runs. +/// +/// 8.0 is past Oracle's end of life, but Amazon RDS keeps it under +/// Extended Support until 2029-07-31 and enrols every 8.0 instance. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MysqlVersion { + V8_0, + #[default] + V8_4, + V9_7, +} + +impl MysqlVersion { + /// The major and minor version `VERSION()` reports. + pub fn reported(self) -> (u32, u32) { + match self { + Self::V8_0 => (8, 0), + Self::V8_4 => (8, 4), + Self::V9_7 => (9, 7), + } + } +} + +impl ServerVersion for MysqlVersion { + const VARIABLE: &'static str = "TABLEPRO_TEST_MYSQL"; + const ALL: &'static [Self] = &[Self::V8_0, Self::V8_4, Self::V9_7]; + + fn name(self) -> &'static str { + match self { + Self::V8_0 => "8.0", + Self::V8_4 => "8.4", + Self::V9_7 => "9.7", + } + } + + fn image(self) -> FixtureImage { + match self { + Self::V8_0 => FixtureImage::MYSQL_8_0, + Self::V8_4 => FixtureImage::MYSQL, + Self::V9_7 => FixtureImage::MYSQL_9_7, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_version_pins_the_image_of_its_release() { + crate::server_version::tests::check_releases::("mysql"); + for &version in MysqlVersion::ALL { + let (major, minor) = version.reported(); + assert_eq!(version.image().tag, format!("{major}.{minor}")); + assert_eq!(version.name(), version.image().tag); + } + assert_eq!(MysqlVersion::default().image(), FixtureImage::MYSQL); + } +} diff --git a/linux/crates/test-fixtures/src/postgres_fixture.rs b/linux/crates/test-fixtures/src/postgres_fixture.rs index e9c47e3d1f..add9120ea2 100644 --- a/linux/crates/test-fixtures/src/postgres_fixture.rs +++ b/linux/crates/test-fixtures/src/postgres_fixture.rs @@ -10,7 +10,7 @@ use tokio::time::Instant; use crate::container_file::with_files; use crate::{ ContainerFile, ExecOutput, FixtureCredentials, FixtureError, HbaMode, PkiAlgorithm, PkiFile, PostgresVersion, - TestNetwork, TestPki, + ServerVersion, TestNetwork, TestPki, }; const DATABASE: &str = "tablepro"; @@ -39,13 +39,13 @@ impl PostgresFixture { pub const NETWORK_ROLE: &'static str = "postgres"; pub async fn start(hba: HbaMode) -> Result { - Self::launch(hba, PostgresVersion::default(), None, PkiAlgorithm::default(), None).await + Self::launch(hba, PostgresVersion::selected()?, None, PkiAlgorithm::default(), None).await } pub async fn start_in(hba: HbaMode, network: &TestNetwork) -> Result { Self::launch( hba, - PostgresVersion::default(), + PostgresVersion::selected()?, Some(network), PkiAlgorithm::default(), None, @@ -65,7 +65,7 @@ impl PostgresFixture { let port = TcpListener::bind(("0.0.0.0", 0))?.local_addr()?.port(); Self::launch( hba, - PostgresVersion::default(), + PostgresVersion::selected()?, None, PkiAlgorithm::default(), Some(port), @@ -77,7 +77,7 @@ impl PostgresFixture { /// binding cases: the server hashes its certificate by the /// algorithm that signed it. pub async fn start_with_pki(hba: HbaMode, algorithm: PkiAlgorithm) -> Result { - Self::launch(hba, PostgresVersion::default(), None, algorithm, None).await + Self::launch(hba, PostgresVersion::selected()?, None, algorithm, None).await } async fn launch( diff --git a/linux/crates/test-fixtures/src/postgres_version.rs b/linux/crates/test-fixtures/src/postgres_version.rs index 79cc35fe3e..d8edf30af7 100644 --- a/linux/crates/test-fixtures/src/postgres_version.rs +++ b/linux/crates/test-fixtures/src/postgres_version.rs @@ -1,47 +1,62 @@ -use crate::FixtureImage; +use crate::{FixtureImage, ServerVersion}; /// The PostgreSQL major a fixture runs. /// -/// The catalog and the decoders have to hold on the oldest and the -/// newest server the app supports, so those two ends are the choices -/// beside the default. +/// 13 is past its end of life, and stays for the catalog query the +/// driver keeps for servers before 14. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum PostgresVersion { V13, + V14, #[default] V17, V18, } impl PostgresVersion { - pub fn image(self) -> FixtureImage { - match self { - Self::V13 => FixtureImage::POSTGRES_13, - Self::V17 => FixtureImage::POSTGRES, - Self::V18 => FixtureImage::POSTGRES_18, - } - } - pub fn major(self) -> u32 { match self { Self::V13 => 13, + Self::V14 => 14, Self::V17 => 17, Self::V18 => 18, } } } +impl ServerVersion for PostgresVersion { + const VARIABLE: &'static str = "TABLEPRO_TEST_POSTGRES"; + const ALL: &'static [Self] = &[Self::V13, Self::V14, Self::V17, Self::V18]; + + fn name(self) -> &'static str { + match self { + Self::V13 => "13", + Self::V14 => "14", + Self::V17 => "17", + Self::V18 => "18", + } + } + + fn image(self) -> FixtureImage { + match self { + Self::V13 => FixtureImage::POSTGRES_13, + Self::V14 => FixtureImage::POSTGRES_14, + Self::V17 => FixtureImage::POSTGRES, + Self::V18 => FixtureImage::POSTGRES_18, + } + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn each_version_pins_the_image_of_its_major() { - for version in [PostgresVersion::V13, PostgresVersion::V17, PostgresVersion::V18] { - let image = version.image(); - - assert_eq!(image.name, "postgres"); - assert_eq!(image.tag, format!("{}-alpine", version.major())); + crate::server_version::tests::check_releases::("postgres"); + for &version in PostgresVersion::ALL { + assert_eq!(version.image().tag, format!("{}-alpine", version.major())); + assert_eq!(version.name(), version.major().to_string()); } assert_eq!(PostgresVersion::default().image(), FixtureImage::POSTGRES); } diff --git a/linux/crates/test-fixtures/src/server_version.rs b/linux/crates/test-fixtures/src/server_version.rs new file mode 100644 index 0000000000..7e4d7bdddd --- /dev/null +++ b/linux/crates/test-fixtures/src/server_version.rs @@ -0,0 +1,70 @@ +use std::env::VarError; +use std::fmt::Debug; + +use crate::{FixtureError, FixtureImage}; + +/// A release of one engine that a fixture can start. +/// +/// A fixture starts the default release unless the engine's variable +/// names another, so CI runs a driver's whole suite again on the oldest +/// and the newest release the vendor supports. +pub trait ServerVersion: Copy + Debug + Default + PartialEq + 'static { + /// The environment variable that picks the release. + const VARIABLE: &'static str; + const ALL: &'static [Self]; + + /// What the variable says to pick this release: `14`, `8.0`, `2019`. + fn name(self) -> &'static str; + + fn image(self) -> FixtureImage; + + /// The release the variable names, or the default when it is unset + /// or empty. + fn selected() -> Result { + match std::env::var(Self::VARIABLE) { + Ok(value) => Self::named(&value), + Err(VarError::NotPresent) => Ok(Self::default()), + Err(VarError::NotUnicode(value)) => Self::named(&value.to_string_lossy()), + } + } + + fn named(value: &str) -> Result { + if value.is_empty() { + return Ok(Self::default()); + } + Self::ALL + .iter() + .copied() + .find(|release| release.name() == value) + .ok_or_else(|| FixtureError::UnknownRelease { + variable: Self::VARIABLE, + value: value.to_owned(), + known: Self::ALL + .iter() + .map(|release| release.name()) + .collect::>() + .join(", "), + }) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::collections::HashSet; + + use super::*; + + pub(crate) fn check_releases(image_name: &str) { + let names: HashSet<_> = V::ALL.iter().map(|release| release.name()).collect(); + assert_eq!(names.len(), V::ALL.len(), "{} names a release twice", V::VARIABLE); + assert!(V::ALL.contains(&V::default())); + for &release in V::ALL { + assert_eq!(release.image().name, image_name, "{release:?}"); + assert_eq!(V::named(release.name()).unwrap(), release); + } + assert_eq!(V::named("").unwrap(), V::default()); + let unknown = V::named("1.0").unwrap_err().to_string(); + assert!(unknown.contains(V::VARIABLE), "{unknown}"); + assert!(unknown.contains(V::default().name()), "{unknown}"); + } +} diff --git a/linux/crates/test-fixtures/tests/fixtures.rs b/linux/crates/test-fixtures/tests/fixtures.rs index f9eb029387..72b3bd2db9 100644 --- a/linux/crates/test-fixtures/tests/fixtures.rs +++ b/linux/crates/test-fixtures/tests/fixtures.rs @@ -10,7 +10,7 @@ use std::process::Stdio; use tablepro_test_fixtures::{ ClickHouseFixture, ExecOutput, FixtureCredentials, HbaMode, HostKeyRevocation, MssqlFixture, MysqlFixture, MysqlFlavour, MysqlOptions, OpenSshFixture, PgBouncerFixture, PkiFile, PoolMode, PostgresFixture, PostgresVersion, - ScriptedAskpass, SshAuthVariant, SshKeyPair, TestNetwork, TestPki, + ScriptedAskpass, ServerVersion, SshAuthVariant, SshKeyPair, TestNetwork, TestPki, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; @@ -480,7 +480,7 @@ async fn through_ssh( #[tokio::test] #[ignore = "requires docker"] async fn postgres_fixture_runs_the_requested_major() -> TestResult { - for version in [PostgresVersion::V13, PostgresVersion::V18] { + for &version in PostgresVersion::ALL { let fixture = PostgresFixture::start_version(version, HbaMode::Password).await?; let output = fixture .exec(&[ diff --git a/linux/docs/drivers/clickhouse.md b/linux/docs/drivers/clickhouse.md index cd5bc683a9..73d577fc08 100644 --- a/linux/docs/drivers/clickhouse.md +++ b/linux/docs/drivers/clickhouse.md @@ -1,6 +1,6 @@ # ClickHouse -Driver id `clickhouse`, crate `crates/drivers/clickhouse`. It speaks ClickHouse's HTTP interface through the official `clickhouse` crate 0.15 with its default features off, on a hyper-util client whose connector dials through the transport and whose TLS is hyper-rustls with the driver's own rustls config. The integration suite runs against ClickHouse 25.8. The contract it implements is in [adding-drivers.md](../adding-drivers.md). +Driver id `clickhouse`, crate `crates/drivers/clickhouse`. It speaks ClickHouse's HTTP interface through the official `clickhouse` crate 0.15 with its default features off, on a hyper-util client whose connector dials through the transport and whose TLS is hyper-rustls with the driver's own rustls config. The integration suite runs against ClickHouse 26.8, and CI runs it again on 26.3 ([supported servers](../../README.md#supported-servers)). The contract it implements is in [adding-drivers.md](../adding-drivers.md). The default port is 8123, the plain HTTP port. With TLS on, use the server's HTTPS port (8443 by default); a TLS connect to the HTTP port fails. @@ -75,6 +75,8 @@ The grid names a ClickHouse row by its sorting key (`RowIdentity::SortingKey`), A failed statement is sorted by whether it can have landed. A server error that refused it (a syntax error, an unknown column) did not apply. A cancel, a timeout, an unfinished mutation wait (341), a network error, or a body with no error code may have applied, so the outcome comes from the `WriteLedger`, with a `CommitToken::ClickHouseQuery` of every statement's query id. +A column made non-nullable in the structure tab is written as `MODIFY COLUMN name T DEFAULT `: from 25.12 the server refuses the change without a DEFAULT to put in the column's NULLs. The draft's default fills them; with none, `defaultValueOfTypeName` does, as older servers did on their own, and a `REMOVE DEFAULT` after it leaves the column with no default. + ### Resolving an unknown outcome `CommitResolutionSupport::QueryLog`. `resolve_commit` asks about the first statement the save could not confirm: diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 3b84577ceb..a9cb5016a8 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -1,6 +1,6 @@ # MySQL and MariaDB -Driver id `mysql`, crate `crates/drivers/mysql`. One driver serves both servers through sqlx 0.9 (`mysql`, `mysql-rsa`, `tls-rustls-aws-lc-rs`) with a deadpool 0.13 pool. `MySqlFlavour` tells them apart from `VERSION()` (MariaDB says so in it), because they name their timeouts, error numbers and catalogue columns differently. The integration suite runs against MySQL 8.4 and MariaDB 11.4. The contract it implements is in [adding-drivers.md](../adding-drivers.md). +Driver id `mysql`, crate `crates/drivers/mysql`. One driver serves both servers through sqlx 0.9 (`mysql`, `mysql-rsa`, `tls-rustls-aws-lc-rs`) with a deadpool 0.13 pool. `MySqlFlavour` tells them apart from `VERSION()` (MariaDB says so in it), because they name their timeouts, error numbers and catalogue columns differently. The integration suite runs against MySQL 8.4 and MariaDB 11.4, and CI runs it again on MySQL 8.0 with MariaDB 10.11 and on MySQL 9.7 with MariaDB 12.3 ([supported servers](../../README.md#supported-servers)). The contract it implements is in [adding-drivers.md](../adding-drivers.md). A connection needs a database name (`database_required`). @@ -64,7 +64,7 @@ A save or a schema change runs in the server's own zone, `@@GLOBAL.time_zone`, a ## TLS -- `Disable` connects without TLS. A server with `require_secure_transport` answers with error 3159, reported as `ServerRequiresTls`. +- `Disable` connects without TLS. A server with `require_secure_transport` answers with error 3159, reported as `ServerRequiresTls`. MariaDB before 11.2 answers 1045, Access denied, as it does for a wrong password ([MDEV-30178](https://jira.mariadb.org/browse/MDEV-30178)), so on 10.11 the refusal reads as an authentication failure. - `Require` encrypts without checking the certificate. A server that does not offer TLS is reported as `ServerRefusedTls`. - `VerifyFull` is sqlx's `VerifyIdentity`. It trusts the system's CA store and the connection's CA file, which sqlx takes only as PEM, so the driver hands it both as one bundle; sqlx adds the Mozilla roots compiled into it ([Trust](../../ARCHITECTURE.md#trust)). `Require` checks no certificate, so it loads neither. - The name checked is the host sqlx dials, so a server name override works only through an SSH forward, where the dial goes to a socket path (`ServerNameOverride::SshForwardOnly`). @@ -110,7 +110,7 @@ Whether a write can be undone depends on the table's storage engine, not on the - An exact row guard relies on `CLIENT_FOUND_ROWS`, which sqlx turns on: an `UPDATE` reports the rows it matched, also the ones it left unchanged. - On a non-transactional table, a statement whose connection drops before its answer may have run, so it is reported as unknown, not as refused. -- A trigger on an InnoDB table can write into a non-transactional one. A `ROLLBACK` then leaves those rows, and the server says so with warning 1196. The driver reads `SHOW WARNINGS` after every rollback, and a 1196 makes the outcome `PartiallyApplied` instead of `RolledBack`. +- A trigger on an InnoDB table can write into a non-transactional one. A `ROLLBACK` then leaves those rows, and the server says so with warning 1196. The driver reads `SHOW WARNINGS` after every rollback, and a 1196 makes the outcome `PartiallyApplied` instead of `RolledBack`. A MySQL server that enforces GTID consistency, as 9.7 does by default, refuses such a save with 1785 before anything lands, and it reads as `RolledBack`. - MariaDB's `max_statement_time` would stop a long `ALTER` the way it stops a `SELECT`, so a schema change on MariaDB lifts it for the batch and puts it back after. A connection where it cannot be put back is closed. MySQL's bound covers only `SELECT`. - Every step is prepared, also one with no parameters, so a stray `;` in generated SQL fails instead of running a second statement. The prepared statement is closed after it runs. A step with no parameters that the server cannot prepare (error 1295, such as `CREATE TRIGGER`) is sent again over the text protocol. - MySQL cannot suspend foreign keys for one batch, so a batch that asks for it is refused before anything is sent. diff --git a/linux/docs/drivers/postgresql.md b/linux/docs/drivers/postgresql.md index 2c41388cee..47c3038abd 100644 --- a/linux/docs/drivers/postgresql.md +++ b/linux/docs/drivers/postgresql.md @@ -1,6 +1,6 @@ # PostgreSQL -Driver id `postgres`, crate `crates/drivers/postgres`. It runs on tokio-postgres 0.7.18 with postgres-types and postgres-protocol, deadpool-postgres 0.14.2 for the pool and tokio-postgres-rustls 0.14 for TLS, all from crates.io ([ADR 0016](../decisions/0016-driver-contract-v2.md)). The integration suite runs against PostgreSQL 17, with the catalogue also checked on 13 and 18. The contract it implements is in [adding-drivers.md](../adding-drivers.md). +Driver id `postgres`, crate `crates/drivers/postgres`. It runs on tokio-postgres 0.7.18 with postgres-types and postgres-protocol, deadpool-postgres 0.14.2 for the pool and tokio-postgres-rustls 0.14 for TLS, all from crates.io ([ADR 0016](../decisions/0016-driver-contract-v2.md)). The integration suite runs against PostgreSQL 17, and CI runs it again on 14 and 18; the catalogue is also checked on 13 ([supported servers](../../README.md#supported-servers)). The contract it implements is in [adding-drivers.md](../adding-drivers.md). ## Connections diff --git a/linux/docs/drivers/sql-server.md b/linux/docs/drivers/sql-server.md index b42c81e50f..e42bc319f8 100644 --- a/linux/docs/drivers/sql-server.md +++ b/linux/docs/drivers/sql-server.md @@ -1,6 +1,6 @@ # SQL Server -Driver id `mssql`, crate `crates/drivers/mssql`. It runs on tiberius 0.13.0 from crates.io (`tds73`, `rustls`, `chrono`, and `integrated-auth-gssapi` behind the crate's `kerberos` feature) with a deadpool 0.13 pool. The integration suite runs against SQL Server 2022. The contract it implements is in [adding-drivers.md](../adding-drivers.md). +Driver id `mssql`, crate `crates/drivers/mssql`. It runs on tiberius 0.13.0 from crates.io (`tds73`, `rustls`, `chrono`, and `integrated-auth-gssapi` behind the crate's `kerberos` feature) with a deadpool 0.13 pool. The integration suite runs against SQL Server 2022, and CI runs it again on 2017, 2019 and 2025 ([supported servers](../../README.md#supported-servers)). The contract it implements is in [adding-drivers.md](../adding-drivers.md). The driver needs SQL Server 2016 or later: every column read joins `sys.masked_columns`, and dropping a table uses `DROP TABLE IF EXISTS`. diff --git a/linux/docs/testing.md b/linux/docs/testing.md index fefb5ff030..23befcc177 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -224,6 +224,30 @@ meson and distro builds use cargo test, which works too: cargo test -p tablepro-driver-postgres -- --ignored --test-threads=1 ``` +### Server releases + +A fixture starts its engine's default release unless the engine's variable names another: + +| Variable | Releases | Default | +|---|---|---| +| `TABLEPRO_TEST_POSTGRES` | 13, 14, 17, 18 | 17 | +| `TABLEPRO_TEST_MYSQL` | 8.0, 8.4, 9.7 | 8.4 | +| `TABLEPRO_TEST_MARIADB` | 10.11, 11.4, 12.3 | 11.4 | +| `TABLEPRO_TEST_MSSQL` | 2017, 2019, 2022, 2025 | 2022 | +| `TABLEPRO_TEST_CLICKHOUSE` | 26.3, 26.8 | 26.8 | + +`crates/test-fixtures/src/fixture_image.rs` pins each release's image, and each engine's version type (`PostgresVersion`, `MysqlVersion`, `MariadbVersion`, `MssqlVersion`, `ClickHouseVersion`) reads its variable through `ServerVersion::selected`. An empty value picks the default; a release the fixtures do not pin fails the fixture with the ones they do. PostgreSQL 13 is past end of life and stays only for the catalogue test of the driver's query for servers before 14. + +The `integration` job runs each driver's suite on the defaults, then once more for each entry with a `release`: PostgreSQL 14 and 18, MySQL 8.0 with MariaDB 10.11, MySQL 9.7 with MariaDB 12.3, SQL Server 2017, 2019 and 2025, and ClickHouse 26.3. The README's [supported servers](../README.md#supported-servers) table says why those releases and not others. A test that checks the server's version reads it from the fixture (`PostgresFixture::version`, `MysqlFixture::server_version`, `MssqlFixture::version`, `ClickHouseFixture::version`), never from a literal, so it holds on every release. + +Locally, set the variables for the run: + +```bash +TABLEPRO_TEST_MYSQL=8.0 TABLEPRO_TEST_MARIADB=10.11 cargo nextest run -p tablepro-driver-mysql --run-ignored only +``` + +A new release gets its image in `fixture_image.rs`, a variant in its version type, a matrix entry in `.github/workflows/build-linux.yml` and a row in the README table. A release past its vendor's end of life loses all four. + ### Docker or Podman Upstream CI uses Docker. Fedora ships Podman instead, and on Debian it is the easier install; either way, point testcontainers at Podman's rootless socket: @@ -370,7 +394,7 @@ The tests in `crates/app/src/ui/editor/script_runner.rs` drive `run_batches` und GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runners. It runs on a pull request, and on a push to `main`, `linux`, `linux/**` or `feat/linux-**`, that touches `linux/` or one of the three Linux workflows: `build-linux.yml`, `linux-packages.yml` and `release-linux.yml`. It also runs every Monday and by hand. Every action is pinned by commit SHA and every tool by version. 1. **Fast checks**: `meson setup`, `cargo fmt --all -- --check`, the source greps (no `thread_local!` in services, no blocking file I/O under `ui`, no action group inserted as `win`, no `set_qdata`, no em dash in user-facing text), `scripts/check-doc-symbols.sh` over the docs that name code, clippy with and without default features, `meson compile`, `xvfb-run -a dbus-run-session -- meson test --print-errorlogs`, a Secret Service step under `gnome-keyring-daemon`, then `meson dist`. Runs in an `ubuntu:26.04` container, which ships GLib 2.88 plus the meson, appstream and desktop-file-utils packages. A failed run keeps `_build/meson-logs/testlog.txt` as the `meson-testlog` artifact. [CONTRIBUTING.md](../CONTRIBUTING.md#fast-job-commands) lists the same commands. -2. **Docker tests** (`integration`, `integration-session`, `integration-app`): runs after fast checks pass, and keeps each package's JUnit report as a `junit-` artifact. One matrix entry per package with docker tests (the PostgreSQL driver with PgBouncer, the MySQL driver with MariaDB, the SQL Server and ClickHouse drivers, all four with their TLS suites, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker, one test at a time per package. `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. The app's docker tests (`crates/app/tests/tracker_save_postgres.rs`) run the same way, since the app links GTK; that job names its test targets, because the app's `startup` test needs Meson. The SQLite driver needs no container: its suites run in the fast job. +2. **Docker tests** (`integration`, `integration-session`, `integration-app`): runs after fast checks pass, and keeps each package's JUnit report as a `junit-` artifact, `junit--` for a release entry. One matrix entry per package with docker tests (the PostgreSQL driver with PgBouncer, the MySQL driver with MariaDB, the SQL Server and ClickHouse drivers, all four with their TLS suites, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker, one test at a time per package. The driver packages have an entry more for each older or newer server release ([Server releases](#server-releases)). `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. The app's docker tests (`crates/app/tests/tracker_save_postgres.rs`) run the same way, since the app links GTK; that job names its test targets, because the app's `startup` test needs Meson. The SQLite driver needs no container: its suites run in the fast job. 3. **Supply chain**: `cargo deny check` against `deny.toml` (advisories, bans, licenses and sources, with every feature, both Linux targets and the dev-dependencies), `cargo shear` for unused or misplaced dependencies, and `build-aux/update-third-party-licenses.sh`, failing when `data/licenses`, `build-aux/debian/copyright` or the spec's `License` tag differ from what it writes. Then `zizmor` over the three Linux workflows. 4. **Translations**: in `ubuntu:26.10`, whose gettext 1.0 reads path-qualified Rust calls such as `crate::i18n::gettext(...)`. `po/POTFILES.in` must match `git ls-files`, `meson setup --fatal-meson-warnings` must pass, and `po/tablepro.pot` must match a fresh `meson compile -C _build tablepro-pot` in everything but its creation date. Regenerating it locally needs gettext 1.0 or newer (`po/README.md`). A failed run keeps the template it wrote as the `tablepro-pot` artifact. 5. **Documentation links**: lychee, offline, over every Markdown file under `linux/`, with heading anchors checked.