diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3fafaa6f1..b65b5287f 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 0d6f3806b..5c956b1b1 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 40bfd1676..cb23a53c8 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 943f1e46a..9152af34d 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 0b4793ac0..693cb9342 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}) @@ -171,16 +160,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 1caeea2a7..8971968c7 100644 --- a/src/iceberg/test/meson.build +++ b/src/iceberg/test/meson.build @@ -89,17 +89,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 02abaa095..0a5c37ecb 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 40befeedf..f91782a03 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 + +#include +#include +#include #include +#include #include +#include #include #include +#include +#include #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 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(&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(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{"custom_prop", ""}, - std::string("custom_value")) - .Set(RestCatalogProperties::Entry{"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{"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> 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 props = {{"owner", "test"}}; + static inline std::unique_ptr 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 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 updates = { - {"description", "test namespace"}}; - std::unordered_set 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 59b89f997..59710e0f6 100644 --- a/src/iceberg/test/table_test.cc +++ b/src/iceberg/test/table_test.cc @@ -19,12 +19,6 @@ #include "iceberg/table.h" -#include -#include -#include -#include -#include - #include #include diff --git a/src/iceberg/test/util/cmd_util.cc b/src/iceberg/test/util/cmd_util.cc new file mode 100644 index 000000000..da1940e16 --- /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 + +#include +#include +#include +#include +#include +#include + +#include + +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& 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 argv; + argv.reserve(args_.size() + 2); + argv.push_back(const_cast(program_.c_str())); + + for (const auto& arg : args_) { + argv.push_back(const_cast(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 000000000..0ce059bc9 --- /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 +#include +#include +#include + +/// \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& 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 args_; + std::filesystem::path cwd_; + std::map 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 000000000..da26da760 --- /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 + +#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& 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 000000000..63928eb8c --- /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 +#include +#include + +#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& args) const; +}; + +} // namespace iceberg