diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 69ea827..234436f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -5,7 +5,8 @@ RUN apt-get install -y \ build-essential git checkinstall libssl-dev openssl software-properties-common \ golang-go \ python3.11 python3-pip python3.11-venv libsass-dev libcairo2 libpango-1.0-0 libpangoft2-1.0-0 pangocairo-1.0 pngquant \ - sudo + sudo \ + libpq-dev ## vscode user RUN useradd -ms /bin/bash vscode \ diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt index 3b46c2c..944f464 100644 --- a/demo/CMakeLists.txt +++ b/demo/CMakeLists.txt @@ -2,3 +2,4 @@ add_subdirectory(generic-container) add_subdirectory(wiremock) add_subdirectory(google-test) +add_subdirectory(postgresql) diff --git a/demo/postgresql/CMakeLists.txt b/demo/postgresql/CMakeLists.txt new file mode 100644 index 0000000..00a8c8f --- /dev/null +++ b/demo/postgresql/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required (VERSION 3.26) +project (postgresql-demo + VERSION 0.1.0 + DESCRIPTION "Demonstrates usage of the PostgreSQL module for Testcontainers C in a simple main app" + LANGUAGES C +) + +set(TARGET_OUT demo_postgresql_module.out) + +include(FindPostgreSQL) + +# v17: use a lower version of the client library as not many distros ship libpq v18.x. +# Client-server communication is fully backwards compatible unless we want to use new client features. +find_package(PostgreSQL 17) + +if (${PostgreSQL_FOUND}) + add_executable(${TARGET_OUT} postgresql_module_demo.c) + target_link_libraries(${TARGET_OUT} PRIVATE testcontainers-c) + target_link_libraries(${TARGET_OUT} PRIVATE testcontainers-c-postgresql) + target_link_libraries(${TARGET_OUT} PRIVATE ${PostgreSQL_LIBRARIES}) +else() + message(WARNING "Could not find PostgreSQL client libraries. Skipping build of PostgreSQL demo. To fix this, try installing libpq and/or libpq-devel.") +endif() \ No newline at end of file diff --git a/demo/postgresql/README.md b/demo/postgresql/README.md new file mode 100644 index 0000000..3e25c3d --- /dev/null +++ b/demo/postgresql/README.md @@ -0,0 +1,18 @@ +# Demo - WireMocPostgreSQLk on Testcontainers C + +Demonstrates usage of the [PostgreSQL Module for Testcontainers C](../../modules/postgresql/README.md) in a simple main function. +No test framework is used here. + +## Running demo + +First ensure `libpq` and/or `libpq-devel` is installed on your system - this is required to build the example. + +From the root of the repository: + +```bash +cmake -B build/ +cmake --build build/ +./build/demo/postgresql/demo_postgresql_module.out +``` + +The demo program will start a PostgreSQL container, use `libpq` to connect to it, and then terminate the container. diff --git a/demo/postgresql/postgresql_module_demo.c b/demo/postgresql/postgresql_module_demo.c new file mode 100644 index 0000000..2d8ae20 --- /dev/null +++ b/demo/postgresql/postgresql_module_demo.c @@ -0,0 +1,53 @@ +#include +#include +#include + +#include +#include + +#include + +static const char* postgresqlImage = "postgres:18.1"; + +int main() { + printf("Creating new PostgreSQL container. Using image: %s\n", postgresqlImage); + int requestId = tc_psql_new_container(postgresqlImage); + + char* error; + int containerId = tc_container_run(requestId, error); + + if (containerId == -1) { + fprintf(stderr, "Failed to run the container: %s\n", error); + return EXIT_FAILURE; + } + + char connectionString[512]; + int bytesWritten = tc_psql_get_connection_string(containerId, connectionString, sizeof(connectionString)); + if (bytesWritten == -1) { + fprintf(stderr, "Failed to get connection string\n"); + return EXIT_FAILURE; + } + + printf("Connecting to PostgreSQL using connection string: %s\n", connectionString); + + PGconn* conn = PQconnectdb(connectionString); + if (PQstatus(conn) != CONNECTION_OK) { + fprintf(stderr, "Failed to connect to PostgreSQL: %s", PQerrorMessage(conn)); + PQfinish(conn); + return EXIT_FAILURE; + } + + printf("Successfully connected to PostgreSQL!\n"); + PQfinish(conn); + + error = tc_container_terminate(containerId); + if (error != NULL) { + fprintf(stderr, "Failed to terminate PostgreSQL container: %s", error); + return EXIT_FAILURE; + } + + printf("Terminated PostgreSQL container\n"); + + return EXIT_SUCCESS; +} + diff --git a/flake.nix b/flake.nix index 04527a2..d5d08da 100644 --- a/flake.nix +++ b/flake.nix @@ -18,6 +18,7 @@ go ninja pre-commit + libpq ]; }; }; diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index 11770e7..3e31c3c 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -1 +1,2 @@ add_subdirectory(wiremock) +add_subdirectory(postgresql) diff --git a/modules/README.md b/modules/README.md index b9885c9..ffee521 100644 --- a/modules/README.md +++ b/modules/README.md @@ -23,7 +23,8 @@ or even attach full-fledged API clients for fine-grain testing. The following modules are available for this project: - Embedded Generic container for DYI containers - [DEMO](../demo/generic-container/README.md) -- [WireMock for API Mocking](./wiremock/README.md) +- [WireMock for API Mocking](./wiremock/README.md)# +- [PostgreSQL database](./postgresql/README.md) ## Using Modules diff --git a/modules/postgresql/CMakeLists.txt b/modules/postgresql/CMakeLists.txt new file mode 100644 index 0000000..037aedf --- /dev/null +++ b/modules/postgresql/CMakeLists.txt @@ -0,0 +1,22 @@ +set(TARGET testcontainers-c-postgresql) +set(TARGET_NAME ${TARGET}) +set(TARGET_DESCRIPTION "PostgreSQL testcontainer abstractions for C") +set(TARGET_VERSION ${PROJECT_VERSION}) + +add_library(${TARGET} SHARED + impl.c +) + +target_sources(${TARGET} + PUBLIC FILE_SET HEADERS + BASE_DIRS . + FILES testcontainers-c-postgresql.h +) + +target_link_libraries(${TARGET} PRIVATE testcontainers-c) + +configure_file(cmake.pc.in ${TARGET}.pc @ONLY) +install(TARGETS ${TARGET} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(FILES ${CMAKE_BINARY_DIR}/${TARGET}.pc DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/pkgconfig) diff --git a/modules/postgresql/README.md b/modules/postgresql/README.md new file mode 100644 index 0000000..7ffcaba --- /dev/null +++ b/modules/postgresql/README.md @@ -0,0 +1,20 @@ +# PostgreSQL module for Testcontainers C + +This module allows starting up a PostgreSQL database container with a simpler API surface compared to +setting one up manually. + +PostgreSQL is a popular open-source SQL-based RDBMS which can be accessed from native apps by using +the `libpq` library for C or the `libpqxx` library for C++. + +See [this page](https://testcontainers.com/modules/postgresql/) +for the list of modules for other languages. + +## Examples + +- [Usage Demo](../../demo/postgresql/README.md) + +## Read more + +- [libpq: C library for PostgreSQL](https://www.postgresql.org/docs/current/libpq.html) +- [libpq Examples](https://www.postgresql.org/docs/current/libpq-example.html) +- [libpqxx: C++ library for PostgreSQL](https://pqxx.org/development/libpqxx/) \ No newline at end of file diff --git a/modules/postgresql/cmake.pc.in b/modules/postgresql/cmake.pc.in new file mode 100644 index 0000000..8c83ec9 --- /dev/null +++ b/modules/postgresql/cmake.pc.in @@ -0,0 +1,12 @@ +prefix=@CMAKE_INSTALL_PREFIX@ +exec_prefix=@CMAKE_INSTALL_PREFIX@ +libdir=${exec_prefix}/@CMAKE_INSTALL_LIBDIR@ +includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@ + +Name: @TARGET_NAME@ +Description: @TARGET_DESCRIPTION@ +Version: @TARGET_VERSION@ + +Requires: +Libs: -L${libdir} +Cflags: -I${includedir} diff --git a/modules/postgresql/impl.c b/modules/postgresql/impl.c new file mode 100644 index 0000000..3c85ec7 --- /dev/null +++ b/modules/postgresql/impl.c @@ -0,0 +1,93 @@ +#include "testcontainers-c-postgresql.h" +#include "testcontainers-c/container.h" + +#include +#include +#include +#include + +#define DEFAULT_POSTGRESQL_IMAGE_NAME "postgres" +#define DEFAULT_POSTGRESQL_VERSION "18.1" +#define DEFAULT_POSTGRESQL_PORT 5432 +#define DEFAULT_POSTGRESQL_IMAGE DEFAULT_POSTGRESQL_IMAGE_NAME ":" DEFAULT_POSTGRESQL_VERSION + +#define DEFAULT_POSTGRESQL_USERNAME "postgres" +#define DEFAULT_POSTGRESQL_PASSWORD "postgres" +#define DEFAULT_POSTGRESQL_DB "postgres" + +static bool pg_isready_is_success(int exitCode) { + return exitCode == 0; +} + +static const char* pgIsReadyCmd[] = { "pg_isready", "--host", "localhost", "--username", DEFAULT_POSTGRESQL_USERNAME, "--dbname", DEFAULT_POSTGRESQL_DB }; +static const size_t pgIsReadyCmdLen = sizeof(pgIsReadyCmd) / sizeof(char*); + +int tc_psql_new_default_container() { + return tc_psql_new_container(DEFAULT_POSTGRESQL_IMAGE); +} + +int tc_psql_new_container(const char *image) { + int requestId = tc_container_create(image); + + tc_container_with_env(requestId, "POSTGRES_USER", DEFAULT_POSTGRESQL_USERNAME); + tc_container_with_env(requestId, "POSTGRES_PASSWORD", DEFAULT_POSTGRESQL_PASSWORD); + tc_container_with_env(requestId, "POSTGRES_DB", DEFAULT_POSTGRESQL_DB); + tc_container_with_exposed_tcp_port(requestId, DEFAULT_POSTGRESQL_PORT); + + int strategyId = tc_container_with_wait_for_exec(requestId, pgIsReadyCmd, pgIsReadyCmdLen); + tc_exec_with_exit_code_matcher(strategyId, pg_isready_is_success); + + // TODO: in other Testcontainers implementations, they pass some command parameters to the container + // to disable certain hardening features and improve performance as the container is ephemeral. We + // should consider doing the same if we add the ability to modify container commands to the bridge. + // See: https://www.postgresql.org/docs/current/non-durability.html + + return requestId; +} + +int tc_psql_get_connection_string(int containerId, char* buffer, size_t bufferLen) { + if (buffer == NULL || bufferLen <= 0) { + // Invalid arguments + return -1; + } + + char* error; + + const char* hostname = tc_container_get_hostname(containerId, &error); + if (hostname == NULL) { + fprintf(stderr, "Failed to get PostgreSQL hostname: %s", error); + free(error); + return -1; + } + + int port = tc_container_get_mapped_port(containerId, DEFAULT_POSTGRESQL_PORT, &error); + if (port == -1) { + fprintf(stderr, "Failed to get PostgreSQL mapped port: %s", error); + free(error); + return -1; + } + + + int written = snprintf( + buffer, + bufferLen, + "host=%s port=%d user=%s password=%s dbname=%s", + hostname, + port, + DEFAULT_POSTGRESQL_USERNAME, + DEFAULT_POSTGRESQL_PASSWORD, + DEFAULT_POSTGRESQL_DB + ); + + if (written < 0) { + return -1; + } + + if (written >= bufferLen) { + // Provided buffer was not long enough; truncation has occurred + // If written == bufferLen, that means we missed 1 character because of the null terminator + return -1; + } + + return written; +} \ No newline at end of file diff --git a/modules/postgresql/testcontainers-c-postgresql.h b/modules/postgresql/testcontainers-c-postgresql.h new file mode 100644 index 0000000..0238e49 --- /dev/null +++ b/modules/postgresql/testcontainers-c-postgresql.h @@ -0,0 +1,24 @@ +#ifndef TESTCONTAINERS_POSTGRESQL_H +#define TESTCONTAINERS_POSTGRESQL_H + +#include + +/// @brief Creates a container request with a default image, exposed port and init logic +/// @return Container request ID +int tc_psql_new_default_container(); + +/// @brief Creates a container request with a specified image, exposed port and init logic +/// @param image Full image name +/// @return Container request ID +int tc_psql_new_container(const char* image); + +/// @brief Gets the connection string that can be used with libpq/libpqxx to connect to the database. +/// @param containerId Container ID +/// @param buffer A buffer to write the connection string into. The written value will be null-terminated. +/// @param bufferLen The length of the buffer. +/// @return The number of bytes written, excluding the null terminator, or -1 if there was an error. +int tc_psql_get_connection_string(int containerId, char* buffer, size_t bufferLen); + +// TODO: further container customisation to allow configuring database name, username, password, etc. + +#endif diff --git a/testcontainers-bridge/testcontainers-bridge.go b/testcontainers-bridge/testcontainers-bridge.go index 6acef3f..d837963 100644 --- a/testcontainers-bridge/testcontainers-bridge.go +++ b/testcontainers-bridge/testcontainers-bridge.go @@ -1,6 +1,10 @@ package main +// #include +// #include // typedef const char cchar_t; +// typedef bool (*exit_code_matcher)(int); +// static bool invoke_matcher(exit_code_matcher f, int x) { return f(x); } import "C" import ( @@ -9,6 +13,7 @@ import ( "io" "net/http" "strconv" + "unsafe" "github.com/docker/go-connections/nat" "github.com/testcontainers/testcontainers-go" @@ -18,6 +23,7 @@ import ( var containerRequests []*testcontainers.ContainerRequest var containers []*testcontainers.Container var customizers map[int][]*testcontainers.CustomizeRequestOption +var execStrategies map[int]*wait.ExecStrategy // Creates Unique container request and returns its ID // @@ -98,11 +104,37 @@ func tc_bridge_get_container_log(containerID int) (log *C.char) { func tc_bridge_get_uri(containerID int, port int) (uri *C.char, ok bool, errstr *C.char) { ctx := context.Background() container := *containers[containerID] - str, err := _GetURI(ctx, container, port) - if err != nil { - return nil, false, ToCString(err) - } - return C.CString(str), true, nil + str, err := _GetURI(ctx, container, port) + if err != nil { + return nil, false, ToCString(err) + } + return C.CString(str), true, nil +} + +//export tc_bridge_get_mapped_port +func tc_bridge_get_mapped_port(containerID int, containerPort int) (mappedPort int, ok bool, errstr *C.char) { + ctx := context.Background() + container := *containers[containerID] + + goPort, err := container.MappedPort(ctx, nat.Port(strconv.Itoa(containerPort))) + if err != nil { + return -1, false, ToCString(err) + } + + return goPort.Int(), true, nil +} + +//export tc_bridge_get_hostname +func tc_bridge_get_hostname(containerID int) (hostname *C.char, ok bool, errstr *C.char) { + ctx := context.Background() + container := *containers[containerID] + + hostIP, err := container.Host(ctx) + if err != nil { + return nil, false, ToCString(err) + } + + return C.CString(hostIP), true, nil } func _GetURI(ctx context.Context, container testcontainers.Container, port int) (string, error) { @@ -128,6 +160,35 @@ func tc_bridge_with_wait_for_http(requestID int, port int, url *C.cchar_t) { registerCustomizer(requestID, req) } +//export tc_bridge_with_wait_for_exec +func tc_bridge_with_wait_for_exec(requestID int, cmd **C.cchar_t, cmdLen C.size_t) (strategyID int) { + convertedCmd := ToGoStringSlice(cmd, cmdLen) + strategy := wait.ForExec(convertedCmd) + + if execStrategies == nil { + execStrategies = make(map[int]*wait.ExecStrategy) + } + execStrategies[requestID] = strategy + + req := func(req *testcontainers.GenericContainerRequest) { + req.WaitingFor = strategy + } + + registerCustomizer(requestID, req) + + return requestID +} + +//export tc_bridge_exec_with_exit_code_matcher +func tc_bridge_exec_with_exit_code_matcher(strategyID int, matcher C.exit_code_matcher) { + strategy := execStrategies[strategyID] + + execStrategies[strategyID] = strategy.WithExitCodeMatcher(func(exitCode int) bool { + result := C.invoke_matcher(matcher, C.int(exitCode)) + return bool(result) + }) +} + //export tc_bridge_with_file func tc_bridge_with_file(requestID int, filePath *C.cchar_t, targetPath *C.cchar_t) { req := func(req *testcontainers.GenericContainerRequest) { @@ -151,6 +212,18 @@ func tc_bridge_with_exposed_tcp_port(requestID int, port int) { registerCustomizer(requestID, req) } +//export tc_bridge_with_env +func tc_bridge_with_env(requestID int, name *C.cchar_t, value *C.cchar_t) { + req := func(req *testcontainers.GenericContainerRequest) { + if req.Env == nil { + req.Env = make(map[string]string) + } + req.Env[C.GoString(name)] = C.GoString(value) + } + + registerCustomizer(requestID, req) +} + func registerCustomizer(requestID int, customizer testcontainers.CustomizeRequestOption) int { if customizers == nil { customizers = make(map[int][]*testcontainers.CustomizeRequestOption) @@ -203,4 +276,15 @@ func ToCString(err error) *C.char { return nil } +func ToGoStringSlice(arrayStart **C.cchar_t, arrayLen C.size_t) []string { + outputSlice := make([]string, arrayLen) + inputSlice := unsafe.Slice(arrayStart, arrayLen) + + for idx, cStr := range inputSlice { + outputSlice[idx] = C.GoString(cStr) + } + + return outputSlice +} + func main() {} diff --git a/testcontainers-c/include/testcontainers-c/container.h b/testcontainers-c/include/testcontainers-c/container.h index 938ec19..b328585 100644 --- a/testcontainers-c/include/testcontainers-c/container.h +++ b/testcontainers-c/include/testcontainers-c/container.h @@ -1,6 +1,9 @@ #ifndef CONTAINER_H #define CONTAINER_H +#include +#include + /** * @param The container image name. * @return -1 if no image is provided or creation fails. Otherwise, @@ -29,11 +32,65 @@ char* tc_container_get_log(int container_id); */ char* tc_container_get_uri(int container_id, int port, char* error); +/** + * Retrieves the mapped/exposed port corresponding to the given container port. + * @param container_id The running container's ID. + * @param container_port The internal container port number. + * @param error Pointer to a memory location that will be replaced with a copy of the error message, if an error occurs. + * This argument is optional and NULL can be passed if capturing the error message is not required. + * If it is assigned to, the caller assumes ownership of the memory and should call free() on it when no longer needed. + * @return The mapped port number, or -1 if there was an error. + */ +int tc_container_get_mapped_port(int container_id, int container_port, char** error); + +/** + * Retrieves the container's hostname. + * @param container_id The running container's ID. + * @param error Pointer to a memory location that will be replaced with a copy of the error message, if an error occurs. + * This argument is optional and NULL can be passed if capturing the error message is not required. + * If it is assigned to, the caller assumes ownership of the memory and should call free() on it when no longer needed. + * @return The hostname, or NULL if there was an error. + */ +char* tc_container_get_hostname(int container_id, char** error); + /** * */ void tc_container_with_wait_for_http(int request_id, int port, const char* url); +/** + * Configure the container to wait for a specified command to execute successfully. + * Once configured, the intended exit code should be specified with tc_exec_with_exit_code_matcher. + * + * @param request_id The container request ID. + * @param cmd Pointer to the start of an array of strings, containing the command and its arguments. + * @param cmd_len Length of the passed array. + * + * @return An 'exec strategy' ID, which can be used to customise the strategy. + * + * @see tc_exec_with_exit_code_matcher + */ +int tc_container_with_wait_for_exec(int request_id, const char** cmd, size_t cmd_len); + +/** + * Configure how the exec wait strategy, as previously configured with tc_container_with_wait_for_exec, should + * interpret the exit code of the command it is executing. + * + * @param strategy_id The strategy ID returned from tc_container_with_wait_for_exec. + * @param exit_code_matcher Function that takes in the exit code of the command. If it returns true, the container + * will be considered to be ready. + * @see tc_exec_with_exit_code_matcher + */ +void tc_exec_with_exit_code_matcher(int strategy_id, bool (*exit_code_matcher)(int)); + +/** + * Apply an environment variable with the given name and value to the container. + * @param request_id The container request ID. + * @param name The name of the environment variable. + * @param value The value of the environment variable. + */ +void tc_container_with_env(int request_id, const char* name, const char* value); + /** * */ diff --git a/testcontainers-c/src/container.c b/testcontainers-c/src/container.c index 3f84567..ba44bbe 100644 --- a/testcontainers-c/src/container.c +++ b/testcontainers-c/src/container.c @@ -2,6 +2,8 @@ #include "testcontainers-c/container.h" +#include + int tc_container_create(const char* image) { if (image == NULL) { return -1; @@ -42,6 +44,47 @@ char* tc_container_get_uri(int container_id, int port, char* error) { return NULL; } +int tc_container_get_mapped_port(int container_id, int container_port, char** error) { + struct tc_bridge_get_mapped_port_return result = tc_bridge_get_mapped_port(container_id, container_port); + + int result_port = result.r0; + bool result_ok = result.r1; + char* result_error = result.r2; + + if (!result_ok) { + assert(result_error != NULL); + + if (error != NULL) { + *error = result_error; + } + + return -1; + } + + return result_port; +} + + +char* tc_container_get_hostname(int container_id, char** error) { + struct tc_bridge_get_hostname_return result = tc_bridge_get_hostname(container_id); + + char* result_hostname = result.r0; + bool result_ok = result.r1; + char* result_error = result.r2; + + if (!result_ok) { + assert(result_error != NULL); + + if (error != NULL) { + *error = result_error; + } + + return NULL; + } + + return result_hostname; +} + void tc_container_with_wait_for_http(int request_id, int port, const char* url) { tc_bridge_with_wait_for_http(request_id, port, url); } @@ -52,6 +95,18 @@ void tc_container_with_file(int request_id, const char* file_path, const char* t void tc_container_with_exposed_tcp_port(int request_id, int port) { tc_bridge_with_exposed_tcp_port(request_id, port); } +int tc_container_with_wait_for_exec(int request_id, const char** cmd, size_t cmd_len) { + return tc_bridge_with_wait_for_exec(request_id, cmd, cmd_len); +} + +void tc_exec_with_exit_code_matcher(int strategy_id, bool (*exit_code_matcher)(int)) { + tc_bridge_exec_with_exit_code_matcher(strategy_id, exit_code_matcher); +} + +void tc_container_with_env(int request_id, const char* name, const char* value) { + tc_bridge_with_env(request_id, name, value); +} + int tc_container_send_http_get(int container_id, int port, const char* endpoint, char* response_body, char* error) { struct tc_bridge_send_http_get_return result = tc_bridge_send_http_get(container_id, port, endpoint);