This is an automated email from the ASF dual-hosted git repository.
gangwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new b1901e72 test: add rest catalog integration test (#361)
b1901e72 is described below
commit b1901e727515703dc2795b026969e0566a52cf97
Author: Feiyang Li <[email protected]>
AuthorDate: Wed Dec 3 15:01:06 2025 +0800
test: add rest catalog integration test (#361)
Add integration testing infrastructure for the REST Catalog:
- Docker Compose setup (`docker-compose.yml`) for running a real REST
catalog server
- Test utilities for process management (`cmd_util.cc/h`) - run cli commands
- Docker Compose control utilities (`docker_compose_util.cc/h`) managing
containerized services
- Modified GitHub CI to support integration tests.
- These test cases (cmd / docker compose related) only work on Linux, so
added conditional compilation flags for Linux-specific test utilities.
---
.github/workflows/test.yml | 3 +-
CMakeLists.txt | 6 +
ci/scripts/build_iceberg.sh | 2 +
meson.options | 8 +
src/iceberg/test/CMakeLists.txt | 33 ++--
src/iceberg/test/meson.build | 25 ++-
.../iceberg-rest-fixture/docker-compose.yml | 19 +-
src/iceberg/test/rest_catalog_test.cc | 213 ++++++++++-----------
src/iceberg/test/table_test.cc | 6 -
src/iceberg/test/util/cmd_util.cc | 133 +++++++++++++
src/iceberg/test/util/cmd_util.h | 63 ++++++
src/iceberg/test/util/docker_compose_util.cc | 54 ++++++
src/iceberg/test/util/docker_compose_util.h | 67 +++++++
13 files changed, 471 insertions(+), 161 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3fafaa6f..b65b5287 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -54,7 +54,7 @@ jobs:
env:
CC: gcc-14
CXX: g++-14
- run: ci/scripts/build_iceberg.sh $(pwd)
+ run: ci/scripts/build_iceberg.sh $(pwd) ON
- name: Build Example
shell: bash
env:
@@ -110,6 +110,7 @@ jobs:
runs-on: ubuntu-24.04
CC: gcc-14
CXX: g++-14
+ meson-setup-args: -Drest_integration_test=enabled
- title: AMD64 Windows 2025
runs-on: windows-2025
meson-setup-args: --vsenv
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0d6f3806..5c956b1b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -42,6 +42,7 @@ option(ICEBERG_BUILD_SHARED "Build shared library" OFF)
option(ICEBERG_BUILD_TESTS "Build tests" ON)
option(ICEBERG_BUILD_BUNDLE "Build the battery included library" ON)
option(ICEBERG_BUILD_REST "Build rest catalog client" ON)
+option(ICEBERG_BUILD_REST_INTEGRATION_TESTS "Build rest catalog integration
tests" OFF)
option(ICEBERG_ENABLE_ASAN "Enable Address Sanitizer" OFF)
option(ICEBERG_ENABLE_UBSAN "Enable Undefined Behavior Sanitizer" OFF)
@@ -60,6 +61,11 @@ else()
set(MSVC_TOOLCHAIN FALSE)
endif()
+if(ICEBERG_BUILD_REST_INTEGRATION_TESTS AND WIN32)
+ set(ICEBERG_BUILD_REST_INTEGRATION_TESTS OFF)
+ message(WARNING "Cannot build rest integration test on Windows, turning it
off.")
+endif()
+
include(CMakeParseArguments)
include(IcebergBuildUtils)
include(IcebergSanitizer)
diff --git a/ci/scripts/build_iceberg.sh b/ci/scripts/build_iceberg.sh
index 40bfd167..cb23a53c 100755
--- a/ci/scripts/build_iceberg.sh
+++ b/ci/scripts/build_iceberg.sh
@@ -21,6 +21,7 @@ set -eux
source_dir=${1}
build_dir=${1}/build
+build_rest_integration_test=${2:-OFF}
mkdir ${build_dir}
pushd ${build_dir}
@@ -34,6 +35,7 @@ CMAKE_ARGS=(
"-DCMAKE_INSTALL_PREFIX=${CMAKE_INSTALL_PREFIX:-${ICEBERG_HOME}}"
"-DICEBERG_BUILD_STATIC=ON"
"-DICEBERG_BUILD_SHARED=ON"
+ "-DICEBERG_BUILD_REST_INTEGRATION_TESTS=${build_rest_integration_test}"
)
if is_windows; then
diff --git a/meson.options b/meson.options
index 943f1e46..9152af34 100644
--- a/meson.options
+++ b/meson.options
@@ -36,4 +36,12 @@ option(
description: 'Build rest catalog client',
value: 'enabled',
)
+
+option(
+ 'rest_integration_test',
+ type: 'feature',
+ description: 'Build integration test for rest catalog',
+ value: 'disabled',
+)
+
option('tests', type: 'feature', description: 'Build tests', value: 'enabled')
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 40e4b0c7..a13d1f82 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -15,11 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-fetchcontent_declare(cpp-httplib
- GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
- GIT_TAG 89c932f313c6437c38f2982869beacc89c2f2246
#release-0.26.0
-)
-
fetchcontent_declare(googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG b514bdc898e2951020cbdca1304b75f5950d1f59 #
release-1.15.2
@@ -27,11 +22,7 @@ fetchcontent_declare(googletest
NAMES
GTest)
-if(ICEBERG_BUILD_REST)
- fetchcontent_makeavailable(cpp-httplib googletest)
-else()
- fetchcontent_makeavailable(googletest)
-endif()
+fetchcontent_makeavailable(googletest)
set(ICEBERG_TEST_RESOURCES "${CMAKE_SOURCE_DIR}/src/iceberg/test/resources")
@@ -53,11 +44,9 @@ function(add_iceberg_test test_name)
target_sources(${test_name} PRIVATE ${ARG_SOURCES})
if(ARG_USE_BUNDLE)
- target_link_libraries(${test_name} PRIVATE iceberg_bundle_static
GTest::gtest_main
- GTest::gmock)
+ target_link_libraries(${test_name} PRIVATE iceberg_bundle_static
GTest::gmock_main)
else()
- target_link_libraries(${test_name} PRIVATE iceberg_static GTest::gtest_main
- GTest::gmock)
+ target_link_libraries(${test_name} PRIVATE iceberg_static
GTest::gmock_main)
endif()
add_test(NAME ${test_name} COMMAND ${test_name})
@@ -173,16 +162,18 @@ if(ICEBERG_BUILD_REST)
add_executable(${test_name})
target_include_directories(${test_name} PRIVATE
"${CMAKE_BINARY_DIR}/iceberg/test/")
target_sources(${test_name} PRIVATE ${ARG_SOURCES})
- target_link_libraries(${test_name} PRIVATE GTest::gtest_main GTest::gmock
- iceberg_rest_static)
+ target_link_libraries(${test_name} PRIVATE GTest::gmock_main
iceberg_rest_static)
add_test(NAME ${test_name} COMMAND ${test_name})
endfunction()
- add_rest_iceberg_test(rest_catalog_test
- SOURCES
- rest_catalog_test.cc
- rest_json_internal_test.cc
+ add_rest_iceberg_test(rest_catalog_test SOURCES rest_json_internal_test.cc
rest_util_test.cc)
- target_include_directories(rest_catalog_test PRIVATE
${cpp-httplib_SOURCE_DIR})
+ if(ICEBERG_BUILD_REST_INTEGRATION_TESTS)
+ add_rest_iceberg_test(rest_catalog_integration_test
+ SOURCES
+ rest_catalog_test.cc
+ util/cmd_util.cc
+ util/docker_compose_util.cc)
+ endif()
endif()
diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build
index 0ca58a2d..4cb153ba 100644
--- a/src/iceberg/test/meson.build
+++ b/src/iceberg/test/meson.build
@@ -91,17 +91,28 @@ iceberg_tests = {
}
if get_option('rest').enabled()
- cpp_httplib_dep = dependency('cpp-httplib')
iceberg_tests += {
'rest_catalog_test': {
- 'sources': files(
- 'rest_catalog_test.cc',
- 'rest_json_internal_test.cc',
- 'rest_util_test.cc',
- ),
- 'dependencies': [iceberg_rest_dep, cpp_httplib_dep],
+ 'sources': files('rest_json_internal_test.cc',
'rest_util_test.cc'),
+ 'dependencies': [iceberg_rest_dep],
},
}
+ if get_option('rest_integration_test').enabled()
+ if host_machine.system() == 'windows'
+ warning('Cannot build rest integration test on Windows, skipping.')
+ else
+ iceberg_tests += {
+ 'rest_integration_test': {
+ 'sources': files(
+ 'rest_catalog_test.cc',
+ 'util/cmd_util.cc',
+ 'util/docker_compose_util.cc',
+ ),
+ 'dependencies': [iceberg_rest_dep],
+ },
+ }
+ endif
+ endif
endif
foreach test_name, values : iceberg_tests
diff --git a/subprojects/cpp-httplib.wrap
b/src/iceberg/test/resources/iceberg-rest-fixture/docker-compose.yml
similarity index 63%
rename from subprojects/cpp-httplib.wrap
rename to src/iceberg/test/resources/iceberg-rest-fixture/docker-compose.yml
index 02abaa09..0a5c37ec 100644
--- a/subprojects/cpp-httplib.wrap
+++ b/src/iceberg/test/resources/iceberg-rest-fixture/docker-compose.yml
@@ -15,13 +15,12 @@
# specific language governing permissions and limitations
# under the License.
-[wrap-file]
-directory = cpp-httplib-0.26.0
-source_url =
https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.26.0.tar.gz
-source_filename = cpp-httplib-0.26.0.tar.gz
-source_hash = a66f908f50ccb119769adce44fe1eac75f81b6ffab7c4ac0211bb663ffeb2688
-source_fallback_url =
https://github.com/mesonbuild/wrapdb/releases/download/cpp-httplib_0.26.0-1/cpp-httplib-0.26.0.tar.gz
-wrapdb_version = 0.26.0-1
-
-[provide]
-dependency_names = cpp-httplib
+services:
+ rest:
+ image: apache/iceberg-rest-fixture:latest
+ environment:
+ - CATALOG_CATALOG__IMPL=org.apache.iceberg.jdbc.JdbcCatalog
+ - CATALOG_URI=jdbc:sqlite:file:/tmp/iceberg_rest_mode=memory
+ - CATALOG_WAREHOUSE=file:///tmp/iceberg_warehouse
+ ports:
+ - "8181:8181"
diff --git a/src/iceberg/test/rest_catalog_test.cc
b/src/iceberg/test/rest_catalog_test.cc
index 40befeed..f91782a0 100644
--- a/src/iceberg/test/rest_catalog_test.cc
+++ b/src/iceberg/test/rest_catalog_test.cc
@@ -19,152 +19,133 @@
#include "iceberg/catalog/rest/rest_catalog.h"
+#include <unistd.h>
+
+#include <chrono>
+#include <memory>
+#include <print>
#include <string>
+#include <thread>
#include <unordered_map>
+#include <arpa/inet.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
+#include <netinet/in.h>
+#include <sys/socket.h>
#include "iceberg/catalog/rest/catalog_properties.h"
+#include "iceberg/result.h"
#include "iceberg/table_identifier.h"
#include "iceberg/test/matchers.h"
+#include "iceberg/test/test_resource.h"
+#include "iceberg/test/util/docker_compose_util.h"
namespace iceberg::rest {
-// Test fixture for REST catalog tests, This assumes you have a local REST
catalog service
-// running Default configuration: http://localhost:8181.
-class RestCatalogTest : public ::testing::Test {
- protected:
- void SetUp() override {
- // Default configuration for local testing
- // You can override this with environment variables if needed
- const char* uri_env = std::getenv("ICEBERG_REST_URI");
- const char* warehouse_env = std::getenv("ICEBERG_REST_WAREHOUSE");
-
- std::string uri = uri_env ? uri_env : "http://localhost:8181";
- std::string warehouse = warehouse_env ? warehouse_env : "default";
-
- config_ = RestCatalogProperties::default_properties();
- config_->Set(RestCatalogProperties::kUri, uri)
- .Set(RestCatalogProperties::kName, std::string("test_catalog"))
- .Set(RestCatalogProperties::kWarehouse, warehouse);
- }
-
- void TearDown() override {}
+namespace {
- std::unique_ptr<RestCatalogProperties> config_;
-};
+constexpr uint16_t kRestCatalogPort = 8181;
+constexpr int kMaxRetries = 60; // Wait up to 60 seconds
+constexpr int kRetryDelayMs = 1000;
-TEST_F(RestCatalogTest, DISABLED_MakeCatalogSuccess) {
- auto catalog_result = RestCatalog::Make(*config_);
- EXPECT_THAT(catalog_result, IsOk());
+constexpr std::string_view kDockerProjectName = "iceberg-rest-catalog-service";
+constexpr std::string_view kCatalogName = "test_catalog";
+constexpr std::string_view kWarehouseName = "default";
+constexpr std::string_view kLocalhostUri = "http://localhost";
- if (catalog_result.has_value()) {
- auto& catalog = catalog_result.value();
- EXPECT_EQ(catalog->name(), "test_catalog");
+/// \brief Check if a localhost port is ready to accept connections
+/// \param port Port number to check
+/// \return true if the port is accessible on localhost, false otherwise
+bool CheckServiceReady(uint16_t port) {
+ int sock = socket(AF_INET, SOCK_STREAM, 0);
+ if (sock < 0) {
+ return false;
}
+
+ struct timeval timeout{
+ .tv_sec = 1,
+ .tv_usec = 0,
+ };
+ setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+
+ sockaddr_in addr{
+ .sin_family = AF_INET,
+ .sin_port = htons(port),
+ .sin_addr = {.s_addr = htonl(INADDR_LOOPBACK)} // 127.0.0.1
+ };
+ bool result =
+ (connect(sock, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr))
== 0);
+ close(sock);
+ return result;
}
-TEST_F(RestCatalogTest, DISABLED_MakeCatalogEmptyUri) {
- auto invalid_config = RestCatalogProperties::default_properties();
- invalid_config->Set(RestCatalogProperties::kUri, std::string(""));
+} // namespace
- auto catalog_result = RestCatalog::Make(*invalid_config);
- EXPECT_THAT(catalog_result, IsError(ErrorKind::kInvalidArgument));
- EXPECT_THAT(catalog_result, HasErrorMessage("uri"));
-}
+/// \brief Integration test fixture for REST catalog with automatic Docker
Compose setup。
+class RestCatalogIntegrationTest : public ::testing::Test {
+ protected:
+ static void SetUpTestSuite() {
+ std::string project_name{kDockerProjectName};
+ std::filesystem::path resources_dir =
GetResourcePath("iceberg-rest-fixture");
+
+ // Create and start DockerCompose
+ docker_compose_ = std::make_unique<DockerCompose>(project_name,
resources_dir);
+ docker_compose_->Up();
+
+ // Wait for REST catalog to be ready on localhost
+ std::println("[INFO] Waiting for REST catalog to be ready at
localhost:{}...",
+ kRestCatalogPort);
+ for (int i = 0; i < kMaxRetries; ++i) {
+ if (CheckServiceReady(kRestCatalogPort)) {
+ std::println("[INFO] REST catalog is ready!");
+ return;
+ }
+ std::println(
+ "[INFO] Waiting for 1s for REST catalog to be ready... (attempt
{}/{})", i + 1,
+ kMaxRetries);
+ std::this_thread::sleep_for(std::chrono::milliseconds(kRetryDelayMs));
+ }
+ throw std::runtime_error("REST catalog failed to start within {} seconds");
+ }
-TEST_F(RestCatalogTest, DISABLED_MakeCatalogWithCustomProperties) {
- auto custom_config = RestCatalogProperties::default_properties();
- custom_config
- ->Set(RestCatalogProperties::kUri,
config_->Get(RestCatalogProperties::kUri))
- .Set(RestCatalogProperties::kName,
config_->Get(RestCatalogProperties::kName))
- .Set(RestCatalogProperties::kWarehouse,
- config_->Get(RestCatalogProperties::kWarehouse))
- .Set(RestCatalogProperties::Entry<std::string>{"custom_prop", ""},
- std::string("custom_value"))
- .Set(RestCatalogProperties::Entry<std::string>{"timeout", ""},
- std::string("30000"));
-
- auto catalog_result = RestCatalog::Make(*custom_config);
- EXPECT_THAT(catalog_result, IsOk());
-}
+ static void TearDownTestSuite() { docker_compose_.reset(); }
-TEST_F(RestCatalogTest, DISABLED_ListNamespaces) {
- auto catalog_result = RestCatalog::Make(*config_);
- ASSERT_THAT(catalog_result, IsOk());
- auto& catalog = catalog_result.value();
+ void SetUp() override {}
- Namespace ns{.levels = {}};
- auto result = catalog->ListNamespaces(ns);
- EXPECT_THAT(result, IsOk());
- EXPECT_FALSE(result->empty());
- EXPECT_EQ(result->front().levels,
(std::vector<std::string>{"my_namespace_test2"}));
-}
+ void TearDown() override {}
-TEST_F(RestCatalogTest, DISABLED_CreateNamespaceNotImplemented) {
- auto catalog_result = RestCatalog::Make(*config_);
- ASSERT_THAT(catalog_result, IsOk());
- auto catalog = std::move(catalog_result.value());
+ // Helper function to create a REST catalog instance
+ Result<std::unique_ptr<RestCatalog>> CreateCatalog() {
+ auto config = RestCatalogProperties::default_properties();
+ config
+ ->Set(RestCatalogProperties::kUri,
+ std::format("{}:{}", kLocalhostUri, kRestCatalogPort))
+ .Set(RestCatalogProperties::kName, std::string(kCatalogName))
+ .Set(RestCatalogProperties::kWarehouse, std::string(kWarehouseName));
+ return RestCatalog::Make(*config);
+ }
- Namespace ns{.levels = {"test_namespace"}};
- std::unordered_map<std::string, std::string> props = {{"owner", "test"}};
+ static inline std::unique_ptr<DockerCompose> docker_compose_;
+};
+
+TEST_F(RestCatalogIntegrationTest, MakeCatalogSuccess) {
+ auto catalog_result = CreateCatalog();
+ ASSERT_THAT(catalog_result, IsOk());
- auto result = catalog->CreateNamespace(ns, props);
- EXPECT_THAT(result, IsError(ErrorKind::kNotImplemented));
+ auto& catalog = catalog_result.value();
+ EXPECT_EQ(catalog->name(), kCatalogName);
}
-TEST_F(RestCatalogTest, DISABLED_IntegrationTestFullNamespaceWorkflow) {
- auto catalog_result = RestCatalog::Make(*config_);
+TEST_F(RestCatalogIntegrationTest, ListNamespaces) {
+ auto catalog_result = CreateCatalog();
ASSERT_THAT(catalog_result, IsOk());
- auto catalog = std::move(catalog_result.value());
+ auto& catalog = catalog_result.value();
- // 1. List initial namespaces
Namespace root{.levels = {}};
- auto list_result1 = catalog->ListNamespaces(root);
- ASSERT_THAT(list_result1, IsOk());
- size_t initial_count = list_result1->size();
-
- // 2. Create a new namespace
- Namespace test_ns{.levels = {"integration_test_ns"}};
- std::unordered_map<std::string, std::string> props = {
- {"owner", "test"}, {"created_by", "rest_catalog_test"}};
- auto create_result = catalog->CreateNamespace(test_ns, props);
- EXPECT_THAT(create_result, IsOk());
-
- // 3. Verify namespace exists
- auto exists_result = catalog->NamespaceExists(test_ns);
- EXPECT_THAT(exists_result, HasValue(::testing::Eq(true)));
-
- // 4. List namespaces again (should have one more)
- auto list_result2 = catalog->ListNamespaces(root);
- ASSERT_THAT(list_result2, IsOk());
- EXPECT_EQ(list_result2->size(), initial_count + 1);
-
- // 5. Get namespace properties
- auto props_result = catalog->GetNamespaceProperties(test_ns);
- ASSERT_THAT(props_result, IsOk());
- EXPECT_EQ((*props_result)["owner"], "test");
-
- // 6. Update properties
- std::unordered_map<std::string, std::string> updates = {
- {"description", "test namespace"}};
- std::unordered_set<std::string> removals = {};
- auto update_result = catalog->UpdateNamespaceProperties(test_ns, updates,
removals);
- EXPECT_THAT(update_result, IsOk());
-
- // 7. Verify updated properties
- auto props_result2 = catalog->GetNamespaceProperties(test_ns);
- ASSERT_THAT(props_result2, IsOk());
- EXPECT_EQ((*props_result2)["description"], "test namespace");
-
- // 8. Drop the namespace (cleanup)
- auto drop_result = catalog->DropNamespace(test_ns);
- EXPECT_THAT(drop_result, IsOk());
-
- // 9. Verify namespace no longer exists
- auto exists_result2 = catalog->NamespaceExists(test_ns);
- EXPECT_THAT(exists_result2, HasValue(::testing::Eq(false)));
+ auto result = catalog->ListNamespaces(root);
+ EXPECT_THAT(result, IsOk());
+ EXPECT_TRUE(result->empty());
}
} // namespace iceberg::rest
diff --git a/src/iceberg/test/table_test.cc b/src/iceberg/test/table_test.cc
index 59b89f99..59710e0f 100644
--- a/src/iceberg/test/table_test.cc
+++ b/src/iceberg/test/table_test.cc
@@ -19,12 +19,6 @@
#include "iceberg/table.h"
-#include <filesystem>
-#include <fstream>
-#include <optional>
-#include <sstream>
-#include <string>
-
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
diff --git a/src/iceberg/test/util/cmd_util.cc
b/src/iceberg/test/util/cmd_util.cc
new file mode 100644
index 00000000..da1940e1
--- /dev/null
+++ b/src/iceberg/test/util/cmd_util.cc
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/test/util/cmd_util.h"
+
+#include <unistd.h>
+
+#include <array>
+#include <cstring>
+#include <format>
+#include <iostream>
+#include <print>
+#include <stdexcept>
+
+#include <sys/wait.h>
+
+namespace iceberg {
+
+Command::Command(std::string program) : program_(std::move(program)) {}
+
+Command& Command::Arg(std::string arg) {
+ args_.push_back(std::move(arg));
+ return *this;
+}
+
+Command& Command::Args(const std::vector<std::string>& args) {
+ args_.insert(args_.end(), args.begin(), args.end());
+ return *this;
+}
+
+Command& Command::CurrentDir(const std::filesystem::path& path) {
+ cwd_ = path;
+ return *this;
+}
+
+Command& Command::Env(const std::string& key, const std::string& val) {
+ env_vars_[key] = val;
+ return *this;
+}
+
+void Command::RunCommand(const std::string& desc) const {
+ std::println("[INFO] Starting to {}, command: {} {}", desc, program_,
FormatArgs());
+
+ std::cout.flush();
+ std::cerr.flush();
+
+ pid_t pid = fork();
+
+ if (pid == -1) {
+ std::println(stderr, "[ERROR] Fork failed: {}", std::strerror(errno));
+ throw std::runtime_error(std::format("Fork failed: {}",
std::strerror(errno)));
+ }
+
+ // --- Child Process ---
+ if (pid == 0) {
+ if (!cwd_.empty()) {
+ std::error_code ec;
+ std::filesystem::current_path(cwd_, ec);
+ if (ec) {
+ std::println(stderr, "Failed to change directory to '{}': {}",
cwd_.string(),
+ ec.message());
+ _exit(126); // Command invoked cannot execute
+ }
+ }
+
+ for (const auto& [k, v] : env_vars_) {
+ setenv(k.c_str(), v.c_str(), 1);
+ }
+
+ std::vector<char*> argv;
+ argv.reserve(args_.size() + 2);
+ argv.push_back(const_cast<char*>(program_.c_str()));
+
+ for (const auto& arg : args_) {
+ argv.push_back(const_cast<char*>(arg.c_str()));
+ }
+ argv.push_back(nullptr);
+
+ execvp(program_.c_str(), argv.data());
+
+ std::println(stderr, "execvp failed: {}", std::strerror(errno));
+ _exit(127);
+ }
+
+ // --- Parent Process ---
+ int status = 0;
+ if (waitpid(pid, &status, 0) == -1) {
+ std::println(stderr, "[ERROR] waitpid failed: {}", std::strerror(errno));
+ throw std::runtime_error(std::format("waitpid failed: {}",
std::strerror(errno)));
+ }
+
+ int exit_code = -1;
+ if (WIFEXITED(status)) {
+ exit_code = WEXITSTATUS(status);
+ } else if (WIFSIGNALED(status)) {
+ exit_code = 128 + WTERMSIG(status);
+ }
+
+ if (exit_code == 0) {
+ std::println("[INFO] {} succeed!", desc);
+ return;
+ } else {
+ std::println(stderr, "[ERROR] {} failed. Exit code: {}", desc, exit_code);
+ throw std::runtime_error(
+ std::format("{} failed with exit code: {}", desc, exit_code));
+ }
+}
+
+std::string Command::FormatArgs() const {
+ std::string s;
+ for (const auto& a : args_) {
+ s += a + " ";
+ }
+ return s;
+}
+
+} // namespace iceberg
diff --git a/src/iceberg/test/util/cmd_util.h b/src/iceberg/test/util/cmd_util.h
new file mode 100644
index 00000000..0ce059bc
--- /dev/null
+++ b/src/iceberg/test/util/cmd_util.h
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+#include <filesystem>
+#include <map>
+#include <string>
+#include <vector>
+
+/// \file iceberg/test/util/cmd_util.h
+/// Utilities for building and executing shell commands in tests.
+
+namespace iceberg {
+
+/// \brief A shell command builder and executor for tests.
+class Command {
+ public:
+ explicit Command(std::string program);
+
+ /// \brief Add a single argument
+ Command& Arg(std::string arg);
+
+ /// \brief Add multiple arguments at once
+ Command& Args(const std::vector<std::string>& args);
+
+ /// \brief Set the current working directory for the command
+ Command& CurrentDir(const std::filesystem::path& path);
+
+ /// \brief Set an environment variable for the command
+ Command& Env(const std::string& key, const std::string& val);
+
+ /// \brief Execute the command and print logs
+ /// \return A Status indicating success or failure
+ void RunCommand(const std::string& desc) const;
+
+ private:
+ std::string program_;
+ std::vector<std::string> args_;
+ std::filesystem::path cwd_;
+ std::map<std::string, std::string> env_vars_;
+
+ /// \brief Format arguments for logging
+ std::string FormatArgs() const;
+};
+
+} // namespace iceberg
diff --git a/src/iceberg/test/util/docker_compose_util.cc
b/src/iceberg/test/util/docker_compose_util.cc
new file mode 100644
index 00000000..da26da76
--- /dev/null
+++ b/src/iceberg/test/util/docker_compose_util.cc
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/test/util/docker_compose_util.h"
+
+#include <cctype>
+
+#include "iceberg/test/util/cmd_util.h"
+
+namespace iceberg {
+
+DockerCompose::DockerCompose(std::string project_name,
+ std::filesystem::path docker_compose_dir)
+ : project_name_(std::move(project_name)),
+ docker_compose_dir_(std::move(docker_compose_dir)) {}
+
+DockerCompose::~DockerCompose() { Down(); }
+
+void DockerCompose::Up() {
+ auto cmd = BuildDockerCommand({"up", "-d", "--wait", "--timeout", "60"});
+ return cmd.RunCommand("docker compose up");
+}
+
+void DockerCompose::Down() {
+ auto cmd = BuildDockerCommand({"down", "-v", "--remove-orphans"});
+ return cmd.RunCommand("docker compose down");
+}
+
+Command DockerCompose::BuildDockerCommand(const std::vector<std::string>&
args) const {
+ Command cmd("docker");
+ // Set working directory
+ cmd.CurrentDir(docker_compose_dir_);
+ // Use 'docker compose' subcommand with project name
+ cmd.Arg("compose").Arg("-p").Arg(project_name_).Args(args);
+ return cmd;
+}
+
+} // namespace iceberg
diff --git a/src/iceberg/test/util/docker_compose_util.h
b/src/iceberg/test/util/docker_compose_util.h
new file mode 100644
index 00000000..63928eb8
--- /dev/null
+++ b/src/iceberg/test/util/docker_compose_util.h
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+#include <filesystem>
+#include <string>
+#include <vector>
+
+#include "iceberg/test/util/cmd_util.h"
+
+namespace iceberg {
+
+/// \brief Docker Compose orchestration utilities for integration testing
+class DockerCompose {
+ public:
+ /// \brief Initializes the Docker Compose manager context.
+ /// \param project_name A unique identifier for this project to ensure test
isolation.
+ /// \param docker_compose_dir The directory path containing the target
+ /// docker-compose.yml.
+ DockerCompose(std::string project_name, std::filesystem::path
docker_compose_dir);
+
+ ~DockerCompose();
+
+ DockerCompose(const DockerCompose&) = delete;
+ DockerCompose& operator=(const DockerCompose&) = delete;
+ DockerCompose(DockerCompose&&) = default;
+ DockerCompose& operator=(DockerCompose&&) = default;
+
+ /// \brief Get the docker project name.
+ const std::string& project_name() const { return project_name_; }
+
+ /// \brief Executes 'docker-compose up' to start services.
+ /// \note May throw an exception if the services fail to start.
+ void Up();
+
+ /// \brief Executes 'docker-compose down' to stop and remove services.
+ /// \note May throw an exception if the services fail to stop.
+ void Down();
+
+ private:
+ std::string project_name_;
+ std::filesystem::path docker_compose_dir_;
+
+ /// \brief Build a docker compose Command with proper environment.
+ /// \param args Additional command line arguments.
+ /// \return Command object ready to execute.
+ Command BuildDockerCommand(const std::vector<std::string>& args) const;
+};
+
+} // namespace iceberg