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

Reply via email to