diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 80c39bd..3dbfe61 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -51,10 +51,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Install psql and mysql clients - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq postgresql-client default-mysql-client - name: Run integration tests env: INTEGRATION: "1" diff --git a/CHANGELOG.md b/CHANGELOG.md index f2eb6f9..b8ad71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Clarified that seed phases with only `create_if_missing` can omit the `seed_sets` field entirely (`seed_sets` defaults to empty via `#[serde(default)]`); updated integration test YAML specs accordingly + ### Added - Integration tests with docker-compose for end-to-end testing against real Postgres 16, MySQL 8.0, and nginx services (`tests/integration_test.rs`): wait-for TCP/HTTP/timeout/multiple targets, render template, fetch HTTP, exec command, seed PostgreSQL and MySQL with cross-table reference verification, create database/schema, idempotency, and reset mode - Additional create-if-missing integration tests: 2 PostgreSQL and 2 MySQL tests using known non-existing database names (`initium_noexist_alpha`, `initium_noexist_beta`) to verify database creation, existence checks, and idempotent re-runs - `tests/docker-compose.yml` with Postgres, MySQL, and HTTP health-check server definitions -- `tests/fixtures/` with seed spec files and template for integration tests +- `tests/input/` with seed spec files and template for integration tests - Separate GitHub Actions workflow (`.github/workflows/integration.yml`) for integration tests with service containers - Helm chart unit tests using helm-unittest plugin (`charts/initium/tests/deployment_test.yaml`) covering deployment rendering, securityContext enforcement, disabled sampleDeployment, multiple initContainers, extraVolumes/extraVolumeMounts, image configuration, workdir mount, and labels - `helm unittest` step added to CI helm-lint job with automatic plugin installation diff --git a/src/seed/db.rs b/src/seed/db.rs index e8ba35d..ff5e219 100644 --- a/src/seed/db.rs +++ b/src/seed/db.rs @@ -307,6 +307,11 @@ impl Database for PostgresDb { Ok(()) } + // Seed values are untyped strings that may target columns of any type + // (INTEGER, TEXT, etc.). Parameterized queries with $N send values as + // TEXT, which postgres cannot implicitly cast to other types. String + // literals have type `unknown` and auto-cast to the target column type, + // so we use escaped literals here. Identifiers are still sanitized. fn insert_row( &mut self, table: &str, @@ -319,20 +324,34 @@ impl Database for PostgresDb { .map(|c| format!("\"{}\"", sanitize_identifier(c))) .collect(); let value_list: Vec = values.iter().map(|v| escape_sql_value(v)).collect(); - let returning_col = sanitize_identifier(auto_id_column.unwrap_or("id")); - let sql = format!( - "INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING COALESCE(CAST(\"{}\" AS BIGINT), 0)", - sanitize_identifier(table), - col_list.join(", "), - value_list.join(", "), - returning_col - ); - let row = self - .client - .query_one(&sql, &[]) - .map_err(|e| format!("inserting row into '{}': {}", table, e))?; - let id: i64 = row.get(0); - Ok(Some(id)) + + if let Some(auto_col) = auto_id_column { + let returning_col = sanitize_identifier(auto_col); + let sql = format!( + "INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING COALESCE(CAST(\"{}\" AS BIGINT), 0)", + sanitize_identifier(table), + col_list.join(", "), + value_list.join(", "), + returning_col + ); + let row = self + .client + .query_one(&sql, &[]) + .map_err(|e| format!("inserting row into '{}': {}", table, e))?; + let id: i64 = row.get(0); + Ok(Some(id)) + } else { + let sql = format!( + "INSERT INTO \"{}\" ({}) VALUES ({})", + sanitize_identifier(table), + col_list.join(", "), + value_list.join(", "), + ); + self.client + .execute(&sql, &[]) + .map_err(|e| format!("inserting row into '{}': {}", table, e))?; + Ok(None) + } } fn row_exists( diff --git a/tests/README.md b/tests/README.md index 2a11133..e94d32a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,7 +30,7 @@ docker compose -f tests/docker-compose.yml down | `test_waitfor_tcp_postgres` | wait-for TCP against Postgres succeeds | | `test_waitfor_tcp_mysql` | wait-for TCP against MySQL succeeds | | `test_waitfor_http_server` | wait-for HTTP against nginx returns 200 | -| `test_waitfor_nonexistent_service` | wait-for against closed port fails with exit code 1 | +| `test_waitfor_nonexistent_service_timeout` | wait-for against closed port fails with exit code 1 | | `test_waitfor_multiple_targets` | wait-for with Postgres + MySQL + HTTP all reachable | | `test_render_template` | render envsubst template produces correct output | | `test_fetch_from_http_server` | fetch from nginx writes HTML to file | diff --git a/tests/input/create-db-mysql.yaml b/tests/input/create-db-mysql.yaml new file mode 100644 index 0000000..5b49c8d --- /dev/null +++ b/tests/input/create-db-mysql.yaml @@ -0,0 +1,15 @@ +database: + driver: mysql + url_env: MYSQL_URL + tracking_table: initium_seed + +phases: + - name: create_db + order: 1 + database: initium_created_db + create_if_missing: true + seed_sets: + - name: placeholder + tables: + - table: products + rows: [] diff --git a/tests/input/create-db-postgres.yaml b/tests/input/create-db-postgres.yaml new file mode 100644 index 0000000..d41efb0 --- /dev/null +++ b/tests/input/create-db-postgres.yaml @@ -0,0 +1,15 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_db + order: 1 + database: initium_created_db + create_if_missing: true + seed_sets: + - name: placeholder + tables: + - table: departments + rows: [] diff --git a/tests/input/create-nonexistent-db-alpha-mysql.yaml b/tests/input/create-nonexistent-db-alpha-mysql.yaml new file mode 100644 index 0000000..1bcae1b --- /dev/null +++ b/tests/input/create-nonexistent-db-alpha-mysql.yaml @@ -0,0 +1,10 @@ +database: + driver: mysql + url_env: MYSQL_URL + tracking_table: initium_seed + +phases: + - name: create_alpha + order: 1 + database: initium_noexist_alpha + create_if_missing: true diff --git a/tests/input/create-nonexistent-db-alpha-postgres.yaml b/tests/input/create-nonexistent-db-alpha-postgres.yaml new file mode 100644 index 0000000..fc1fa37 --- /dev/null +++ b/tests/input/create-nonexistent-db-alpha-postgres.yaml @@ -0,0 +1,10 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_alpha + order: 1 + database: initium_noexist_alpha + create_if_missing: true diff --git a/tests/input/create-nonexistent-db-beta-mysql.yaml b/tests/input/create-nonexistent-db-beta-mysql.yaml new file mode 100644 index 0000000..462ade6 --- /dev/null +++ b/tests/input/create-nonexistent-db-beta-mysql.yaml @@ -0,0 +1,10 @@ +database: + driver: mysql + url_env: MYSQL_URL + tracking_table: initium_seed + +phases: + - name: create_beta + order: 1 + database: initium_noexist_beta + create_if_missing: true diff --git a/tests/input/create-nonexistent-db-beta-postgres.yaml b/tests/input/create-nonexistent-db-beta-postgres.yaml new file mode 100644 index 0000000..e823f8f --- /dev/null +++ b/tests/input/create-nonexistent-db-beta-postgres.yaml @@ -0,0 +1,10 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_beta + order: 1 + database: initium_noexist_beta + create_if_missing: true diff --git a/tests/input/create-schema-postgres.yaml b/tests/input/create-schema-postgres.yaml new file mode 100644 index 0000000..81f2d5b --- /dev/null +++ b/tests/input/create-schema-postgres.yaml @@ -0,0 +1,15 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_schema + order: 1 + schema: test_analytics + create_if_missing: true + seed_sets: + - name: placeholder + tables: + - table: departments + rows: [] diff --git a/tests/fixtures/seed-mysql.yaml b/tests/input/seed-mysql.yaml similarity index 100% rename from tests/fixtures/seed-mysql.yaml rename to tests/input/seed-mysql.yaml diff --git a/tests/fixtures/seed-postgres.yaml b/tests/input/seed-postgres.yaml similarity index 100% rename from tests/fixtures/seed-postgres.yaml rename to tests/input/seed-postgres.yaml diff --git a/tests/fixtures/template.conf.tmpl b/tests/input/template.conf.tmpl similarity index 100% rename from tests/fixtures/template.conf.tmpl rename to tests/input/template.conf.tmpl diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2d16faa..05204ba 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -18,24 +18,30 @@ fn integration_enabled() -> bool { std::env::var("INTEGRATION").map_or(false, |v| v == "1") } -fn fixtures_dir() -> String { +fn input_dir() -> String { let manifest = env!("CARGO_MANIFEST_DIR"); - format!("{}/tests/fixtures", manifest) + format!("{}/tests/input", manifest) } +#[cfg(feature = "postgres")] const PG_URL: &str = "postgres://initium:initium@localhost:15432/initium_test"; +#[cfg(feature = "mysql")] const MYSQL_URL_STR: &str = "mysql://initium:initium@localhost:13306/initium_test"; +#[cfg(feature = "mysql")] const MYSQL_ROOT_URL_STR: &str = "mysql://root:rootpass@localhost:13306/initium_test"; +#[cfg(feature = "postgres")] fn pg_client() -> postgres::Client { postgres::Client::connect(PG_URL, postgres::NoTls).expect("failed to connect to postgres") } +#[cfg(feature = "mysql")] fn mysql_conn() -> mysql::PooledConn { let pool = mysql::Pool::new(MYSQL_URL_STR).expect("failed to connect to mysql"); pool.get_conn().expect("failed to get mysql connection") } +#[cfg(feature = "mysql")] fn mysql_root_conn() -> mysql::PooledConn { let pool = mysql::Pool::new(MYSQL_ROOT_URL_STR).expect("failed to connect to mysql as root"); pool.get_conn() @@ -215,7 +221,7 @@ fn test_render_template() { return; } let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let template = format!("{}/template.conf.tmpl", fixtures_dir()); + let template = format!("{}/template.conf.tmpl", input_dir()); let out = Command::new(initium_bin()) .args([ @@ -335,6 +341,7 @@ fn test_exec_failing_command() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create tables, seed, verify // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres() { if !integration_enabled() { @@ -352,7 +359,7 @@ fn test_seed_postgres() { ) .expect("failed to create postgres tables"); - let spec = format!("{}/seed-postgres.yaml", fixtures_dir()); + let spec = format!("{}/seed-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) @@ -448,6 +455,7 @@ fn test_seed_postgres() { // --------------------------------------------------------------------------- // seed: MySQL — create tables, seed, verify // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql() { if !integration_enabled() { @@ -469,7 +477,7 @@ fn test_seed_mysql() { ) .unwrap(); - let spec = format!("{}/seed-mysql.yaml", fixtures_dir()); + let spec = format!("{}/seed-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_URL_STR) @@ -540,6 +548,7 @@ fn test_seed_mysql() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create database via seed phase // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_database() { if !integration_enabled() { @@ -549,16 +558,9 @@ fn test_seed_postgres_create_database() { let mut client = pg_client(); let _ = client.batch_execute("DROP DATABASE IF EXISTS initium_created_db"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-seed.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_db\n order: 1\n database: initium_created_db\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-db-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -585,7 +587,7 @@ fn test_seed_postgres_create_database() { // Idempotent re-run let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to re-run seed"); @@ -600,6 +602,7 @@ fn test_seed_postgres_create_database() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create schema via seed phase // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_schema() { if !integration_enabled() { @@ -609,16 +612,9 @@ fn test_seed_postgres_create_schema() { let mut client = pg_client(); let _ = client.batch_execute("DROP SCHEMA IF EXISTS test_analytics CASCADE"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-schema-seed.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_schema\n order: 1\n schema: test_analytics\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-schema-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -649,6 +645,7 @@ fn test_seed_postgres_create_schema() { // --------------------------------------------------------------------------- // seed: MySQL — create database via seed phase // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql_create_database() { if !integration_enabled() { @@ -659,16 +656,9 @@ fn test_seed_mysql_create_database() { let mut conn = mysql_root_conn(); let _ = conn.query_drop("DROP DATABASE IF EXISTS initium_created_db"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-seed.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: mysql\n url_env: MYSQL_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_db\n order: 1\n database: initium_created_db\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: products\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-db-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to run seed"); @@ -689,7 +679,7 @@ fn test_seed_mysql_create_database() { // Idempotent re-run let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to re-run seed"); @@ -704,6 +694,7 @@ fn test_seed_mysql_create_database() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create non-existing database and seed data into it // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_nonexistent_db_alpha() { if !integration_enabled() { @@ -723,16 +714,9 @@ fn test_seed_postgres_create_nonexistent_db_alpha() { .get(0); assert_eq!(count, 0, "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-alpha.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_alpha\n order: 1\n database: initium_noexist_alpha\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-alpha-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -764,6 +748,7 @@ fn test_seed_postgres_create_nonexistent_db_alpha() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create a second non-existing database with different name // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_nonexistent_db_beta() { if !integration_enabled() { @@ -783,16 +768,9 @@ fn test_seed_postgres_create_nonexistent_db_beta() { .get(0); assert_eq!(count, 0, "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-beta.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_beta\n order: 1\n database: initium_noexist_beta\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-beta-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -820,7 +798,7 @@ fn test_seed_postgres_create_nonexistent_db_beta() { // Re-run to verify idempotency — should not fail let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to re-run seed"); @@ -835,6 +813,7 @@ fn test_seed_postgres_create_nonexistent_db_beta() { // --------------------------------------------------------------------------- // seed: MySQL — create non-existing database and verify // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql_create_nonexistent_db_alpha() { if !integration_enabled() { @@ -854,16 +833,9 @@ fn test_seed_mysql_create_nonexistent_db_alpha() { .unwrap(); assert_eq!(count, Some(0), "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-alpha.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: mysql\n url_env: MYSQL_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_alpha\n order: 1\n database: initium_noexist_alpha\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: products\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-alpha-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to run seed"); @@ -889,6 +861,7 @@ fn test_seed_mysql_create_nonexistent_db_alpha() { // --------------------------------------------------------------------------- // seed: MySQL — create a second non-existing database with different name // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql_create_nonexistent_db_beta() { if !integration_enabled() { @@ -908,16 +881,9 @@ fn test_seed_mysql_create_nonexistent_db_beta() { .unwrap(); assert_eq!(count, Some(0), "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-beta.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: mysql\n url_env: MYSQL_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_beta\n order: 1\n database: initium_noexist_beta\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: products\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-beta-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to run seed"); @@ -939,7 +905,7 @@ fn test_seed_mysql_create_nonexistent_db_beta() { // Re-run to verify idempotency — should not fail let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to re-run seed");