mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 00:17:11 +00:00
test(table-catalog): automate DuckDB REST conformance (#6750)
* test(table-catalog): automate DuckDB REST conformance * fix(table-catalog): protect DuckDB smoke tables
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the RustFS DuckDB REST Catalog smoke helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import duckdb_smoke
|
||||
|
||||
|
||||
class DuckDBSmokeTest(unittest.TestCase):
|
||||
def args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
endpoint="http://127.0.0.1:9000",
|
||||
access_key="rustfsadmin",
|
||||
secret_key="rustfsadmin",
|
||||
region="us-east-1",
|
||||
bucket="rustfs-duckdb-smoke",
|
||||
namespace="duckdb_smoke",
|
||||
table="events",
|
||||
duckdb="duckdb",
|
||||
duckdb_version="1.5.5",
|
||||
timeout=60.0,
|
||||
cleanup=True,
|
||||
replace=False,
|
||||
insecure=False,
|
||||
live_evidence_output=None,
|
||||
rustfs_build="rustfs-test",
|
||||
git_sha="abc123",
|
||||
catalog_backing="object",
|
||||
operator="test-operator",
|
||||
run_timestamp_utc="2026-08-27T00:00:00Z",
|
||||
)
|
||||
|
||||
def test_parse_args_rejects_unsafe_identifiers(self) -> None:
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
duckdb_smoke.parse_args(["--namespace", "bad-name"])
|
||||
|
||||
def test_duckdb_client_version_removes_v_prefix(self) -> None:
|
||||
process = SimpleNamespace(returncode=0, stdout="v1.5.5\n", stderr="")
|
||||
with mock.patch.object(duckdb_smoke.subprocess, "run", return_value=process):
|
||||
self.assertEqual(duckdb_smoke.duckdb_client_version("duckdb", 10), "1.5.5")
|
||||
|
||||
def test_parse_duckdb_json_accepts_multiple_query_batches(self) -> None:
|
||||
batches = duckdb_smoke.parse_duckdb_json(
|
||||
'[{"row_count":2}]\n[{"id":20},\n{"id":40}]\n'
|
||||
)
|
||||
|
||||
self.assertEqual(batches[0][0]["row_count"], 2)
|
||||
self.assertEqual([row["id"] for row in batches[1]], [20, 40])
|
||||
|
||||
def test_run_duckdb_does_not_parse_json_for_failed_process(self) -> None:
|
||||
process = SimpleNamespace(returncode=1, stdout="not-json", stderr="expected failure")
|
||||
with mock.patch.object(duckdb_smoke.subprocess, "run", return_value=process):
|
||||
execution = duckdb_smoke.run_duckdb("duckdb", "SELECT 1", 10)
|
||||
|
||||
self.assertEqual(execution.returncode, 1)
|
||||
self.assertEqual(execution.batches, [])
|
||||
|
||||
def test_concurrent_retry_markers_do_not_accept_generic_commit_errors(self) -> None:
|
||||
args = self.args()
|
||||
generic_failure = duckdb_smoke.DuckDBExecution(1, [], "", "commit failed: permission denied")
|
||||
with mock.patch.object(duckdb_smoke, "run_duckdb", return_value=generic_failure):
|
||||
with self.assertRaisesRegex(RuntimeError, "permission denied"):
|
||||
duckdb_smoke.run_concurrent_inserts("duckdb", args, "events_write")
|
||||
|
||||
def test_canonical_sql_covers_single_table_lifecycle(self) -> None:
|
||||
sql = duckdb_smoke.canonical_positive_sql(
|
||||
self.args(),
|
||||
"events_seed",
|
||||
"events_write",
|
||||
"events_purge",
|
||||
"events_drop",
|
||||
)
|
||||
|
||||
self.assertIn("STAGE_CREATE_TABLES false", sql)
|
||||
self.assertIn("SKIP_CREATE_TABLE_METADATA_UPDATES true", sql)
|
||||
self.assertIn("CREATE TABLE", sql)
|
||||
self.assertIn("INSERT INTO", sql)
|
||||
self.assertIn("UPDATE", sql)
|
||||
self.assertIn("DELETE FROM", sql)
|
||||
self.assertIn("MERGE INTO", sql)
|
||||
self.assertIn("ALTER TABLE", sql)
|
||||
self.assertIn("iceberg_snapshots", sql)
|
||||
self.assertNotIn("DROP TABLE IF EXISTS", sql)
|
||||
self.assertIn('DROP TABLE "rustfs_duckdb"."duckdb_smoke"."events_drop"', sql)
|
||||
|
||||
def test_prepare_smoke_tables_refuses_existing_identifiers_without_replace(self) -> None:
|
||||
catalog = mock.Mock()
|
||||
catalog.table_exists.side_effect = lambda identifier: identifier[1] == "events_write"
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "duckdb_smoke.events_write"):
|
||||
duckdb_smoke.prepare_smoke_tables(
|
||||
catalog,
|
||||
"duckdb_smoke",
|
||||
["events_seed", "events_write"],
|
||||
replace=False,
|
||||
)
|
||||
|
||||
catalog.drop_table.assert_not_called()
|
||||
|
||||
def test_prepare_smoke_tables_replaces_only_existing_identifiers_when_requested(self) -> None:
|
||||
catalog = mock.Mock()
|
||||
catalog.table_exists.side_effect = lambda identifier: identifier[1] == "events_write"
|
||||
|
||||
duckdb_smoke.prepare_smoke_tables(
|
||||
catalog,
|
||||
"duckdb_smoke",
|
||||
["events_seed", "events_write"],
|
||||
replace=True,
|
||||
)
|
||||
|
||||
catalog.drop_table.assert_called_once_with(("duckdb_smoke", "events_write"))
|
||||
|
||||
def test_cleanup_preserves_a_preexisting_namespace(self) -> None:
|
||||
catalog = mock.Mock()
|
||||
catalog.table_exists.return_value = False
|
||||
|
||||
result = duckdb_smoke.cleanup_tables(
|
||||
catalog,
|
||||
"duckdb_smoke",
|
||||
["events_seed", "events_write"],
|
||||
drop_namespace=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result, "dropped-tables-preserved-existing-namespace")
|
||||
catalog.drop_namespace.assert_not_called()
|
||||
|
||||
def test_alias_sql_uses_s3tables_signing(self) -> None:
|
||||
sql = duckdb_smoke.alias_sql(self.args(), "events_write")
|
||||
|
||||
self.assertIn("ENDPOINT 'http://127.0.0.1:9000/_iceberg'", sql)
|
||||
self.assertIn("SIGV4_SERVICE 's3tables'", sql)
|
||||
self.assertIn("INSERT INTO", sql)
|
||||
self.assertIn("alias_final_row_count", sql)
|
||||
|
||||
def test_boundary_sql_records_required_compatibility_options(self) -> None:
|
||||
args = self.args()
|
||||
stage_sql = duckdb_smoke.negative_sql(
|
||||
args,
|
||||
kind="stage-create",
|
||||
seed_table="events_seed",
|
||||
write_table="events_write",
|
||||
purge_table="events_purge",
|
||||
)
|
||||
stage_attach = stage_sql.split('DETACH "bootstrap_stage-create";', 1)[1]
|
||||
self.assertNotIn("STAGE_CREATE_TABLES false", stage_attach)
|
||||
self.assertIn("CREATE TABLE", stage_attach)
|
||||
|
||||
purge_sql = duckdb_smoke.negative_sql(
|
||||
args,
|
||||
kind="purge",
|
||||
seed_table="events_seed",
|
||||
write_table="events_write",
|
||||
purge_table="events_purge",
|
||||
)
|
||||
self.assertIn("PURGE_REQUESTED true", purge_sql)
|
||||
self.assertIn('DROP TABLE "purge_requested"."duckdb_smoke"."events_purge"', purge_sql)
|
||||
|
||||
v3_sql = duckdb_smoke.negative_sql(
|
||||
args,
|
||||
kind="format-v3",
|
||||
seed_table="events_seed",
|
||||
write_table="events_write",
|
||||
purge_table="events_purge",
|
||||
)
|
||||
self.assertIn("'format-version' = '3'", v3_sql)
|
||||
|
||||
multi_sql = duckdb_smoke.multi_table_sql(
|
||||
args,
|
||||
seed_table="events_seed",
|
||||
write_table="events_write",
|
||||
purge_table="events_purge",
|
||||
)
|
||||
self.assertIn("DISABLE_MULTI_TABLE_COMMIT true", multi_sql)
|
||||
self.assertIn("BEGIN TRANSACTION", multi_sql)
|
||||
self.assertIn("'non-atomic'", multi_sql)
|
||||
|
||||
def test_pyiceberg_args_use_canonical_catalog(self) -> None:
|
||||
args = duckdb_smoke.pyiceberg_args(self.args())
|
||||
|
||||
self.assertEqual(args.rest_path, "/iceberg")
|
||||
self.assertEqual(args.rest_signing_name, "s3")
|
||||
self.assertEqual(args.bucket, "rustfs-duckdb-smoke")
|
||||
|
||||
def test_live_evidence_records_automated_duckdb_claim(self) -> None:
|
||||
args = self.args()
|
||||
result = duckdb_smoke.DuckDBSmokeResult(
|
||||
client_version="1.5.5",
|
||||
metadata_location="s3://rustfs-duckdb-smoke/metadata/00001.json",
|
||||
row_count=2,
|
||||
cleanup_result="dropped-tables-and-namespace",
|
||||
checks={"canonical_rest_catalog": "pass"},
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir) / "evidence.json"
|
||||
args.live_evidence_output = str(output)
|
||||
with mock.patch.object(duckdb_smoke.sys, "argv", ["duckdb_smoke.py", "--secret-key", "secret"]):
|
||||
duckdb_smoke.write_live_evidence(args, result)
|
||||
document = json.loads(output.read_text(encoding="utf-8"))
|
||||
|
||||
evidence = document["live_conformance_evidence"]
|
||||
self.assertEqual(evidence["client_name"], "DuckDB Iceberg")
|
||||
self.assertEqual(evidence["claim"], "automated-rest-catalog-smoke")
|
||||
self.assertIn("--secret-key '<redacted>'", evidence["command"])
|
||||
self.assertNotIn("--secret-key secret", evidence["command"])
|
||||
self.assertEqual(document["validation"]["status"], "accepted")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user