Files
rustfs/scripts/table-catalog/test_duckdb_smoke.py
T
GatewayJ 2eb4ddf4af test(table-catalog): automate DuckDB REST conformance (#6750)
* test(table-catalog): automate DuckDB REST conformance

* fix(table-catalog): protect DuckDB smoke tables
2026-08-27 22:34:10 +08:00

221 lines
8.5 KiB
Python

#!/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()