diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79289aa..3351c86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,10 @@ jobs: cmake -B build \ -DCMAKE_TOOLCHAIN_FILE="$VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" \ -DCMAKE_BUILD_TYPE=Release \ + -DVCPKG_MANIFEST_FEATURES=tests \ + -DGO_PLUGIN_LOG_ABSL=ON \ + -DGO_PLUGIN_BUILD_TESTS=ON \ + -DGO_PLUGIN_BUILD_EXAMPLES=ON \ -G Ninja - name: Build diff --git a/CMakeLists.txt b/CMakeLists.txt index 3959eea..d76196a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(go-plugin-cpp VERSION 0.1.0 LANGUAGES CXX) +project(go-plugin-cpp VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -9,16 +9,27 @@ find_package(gRPC CONFIG REQUIRED) find_package(Protobuf CONFIG REQUIRED) find_package(OpenSSL REQUIRED) +# Explicit, not detected: the installed package's dependencies should not vary +# with whatever happened to be present when it was built. +option(GO_PLUGIN_LOG_ABSL "Build the Abseil logging bridge" OFF) + +if(GO_PLUGIN_LOG_ABSL) + find_package(absl CONFIG REQUIRED) +endif() + add_subdirectory(src) -option(GO_PLUGIN_BUILD_TESTS "Build tests" ON) +# gtest is a manifest feature, so a consumer of the library never installs it. +option(GO_PLUGIN_BUILD_TESTS "Build tests" OFF) if(GO_PLUGIN_BUILD_TESTS) find_package(GTest CONFIG REQUIRED) enable_testing() add_subdirectory(tests) endif() -option(GO_PLUGIN_BUILD_EXAMPLES "Build examples" ON) +# The only thing here that generates protobuf code, so with it off the library +# needs neither protoc nor grpc_cpp_plugin. +option(GO_PLUGIN_BUILD_EXAMPLES "Build examples" OFF) if(GO_PLUGIN_BUILD_EXAMPLES) add_subdirectory(example) endif() @@ -38,7 +49,12 @@ write_basic_package_version_file( COMPATIBILITY SameMajorVersion ) -install(TARGETS go_plugin +set(GO_PLUGIN_INSTALL_TARGETS go_plugin) +if(GO_PLUGIN_LOG_ABSL) + list(APPEND GO_PLUGIN_INSTALL_TARGETS go_plugin_log_absl) +endif() + +install(TARGETS ${GO_PLUGIN_INSTALL_TARGETS} EXPORT go_plugin-targets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib diff --git a/README.md b/README.md index e68f65a..1bf3a83 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,56 @@ This library handles: configured automatically. - **Handshake line output** – writes the correctly formatted line to stdout so the host can connect. - **Health-check service** – the built-in gRPC health-check service is registered automatically. +- **Logging the host can read** – see below. + +## Logging + +A host parses a plugin's standard error as hclog JSON and reads nothing else. A line in any other shape reaches the +host's logs as one opaque string at the host's own level, with the plugin's severity and fields buried inside it — so +a plugin error cannot surface as an error, and nothing downstream can filter on a field. + +`go_plugin::log` writes that format. It depends on nothing but the standard library: + +```cpp +#include "go_plugin/log.hpp" + +go_plugin::log::Info("sink opened", {{"rate", 48000}, {"path", pipe_path}}); +go_plugin::log::Error("write failed", {{"error", strerror(errno)}}); +``` + +### Bridging an existing logging library + +A plugin that already logs through a library keeps its call sites and installs a bridge. Each backend is a separate +target, so a plugin links only the one it uses: + +| Backend | Target | Header | Build with | Install with | +|---------|--------|--------|------------|--------------| +| Abseil (`LOG`/`VLOG`) | `go_plugin::go_plugin_log_absl` | `go_plugin/log_absl.hpp` | `-DGO_PLUGIN_LOG_ABSL=ON` | `go_plugin::log::InstallAbslBridge()` | + +```cpp +absl::InitializeLog(); +go_plugin::log::InstallAbslBridge(); // LOG(WARNING) now reaches the host as a warning +``` + +Abseil has no debug or trace severity of its own — they exist only as `VLOG` verbosities — so the bridge maps `VLOG(1)` +to debug and `VLOG(2)` and above to trace, and it stops Abseil writing its own copy of each line to standard error. + +To add another backend, translate its records into `go_plugin::log::Submit` and add a target beside the Abseil one; +nothing in the core changes. + +### Logging from a C library + +A C library that writes its own diagnostics can be routed through the same path rather than left to print unattributed +text. FFmpeg, for example, takes a callback, which lets the library's own name travel as a field instead of a pointer +address that makes every line unique: + +```cpp +av_log_set_level(AV_LOG_WARNING); +av_log_set_callback([](void *avcl, int level, const char *fmt, va_list args) { + // format into a buffer, then: + go_plugin::log::Write(LevelFor(level), text, {{"avclass", av_default_item_name(avcl)}}); +}); +``` ## Building @@ -37,6 +87,20 @@ cmake -B build \ cmake --build build -j ``` +That builds the library alone. The tests and the example are opt-in, so a consumer installs neither gtest nor the +protobuf code generators they need: + +```bash +cmake -B build \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DCMAKE_BUILD_TYPE=Release \ + -DVCPKG_MANIFEST_FEATURES=tests \ + -DGO_PLUGIN_LOG_ABSL=ON \ + -DGO_PLUGIN_BUILD_TESTS=ON \ + -DGO_PLUGIN_BUILD_EXAMPLES=ON +cmake --build build -j +``` + ### Running the tests ```bash diff --git a/cmake/go_plugin-config.cmake.in b/cmake/go_plugin-config.cmake.in index 20a7f22..0b71a72 100644 --- a/cmake/go_plugin-config.cmake.in +++ b/cmake/go_plugin-config.cmake.in @@ -5,6 +5,13 @@ find_dependency(gRPC CONFIG REQUIRED) find_dependency(OpenSSL REQUIRED) find_dependency(Protobuf CONFIG REQUIRED) +# The Abseil bridge's exported target names absl:: libraries, so they have to +# exist by the time the export is read — a consumer that never links the bridge +# would otherwise fail on find_package(go_plugin) alone. +if(@GO_PLUGIN_LOG_ABSL@) + find_dependency(absl CONFIG REQUIRED) +endif() + include("${CMAKE_CURRENT_LIST_DIR}/go_plugin-targets.cmake") check_required_components(go_plugin) diff --git a/include/go_plugin/log.hpp b/include/go_plugin/log.hpp new file mode 100644 index 0000000..75b7493 --- /dev/null +++ b/include/go_plugin/log.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace go_plugin::log { + +/** + * The only five severities go-plugin understands. Anything else leaves the host + * unable to tell the level and it files the line at its own. + */ +enum class Level { Trace, Debug, Info, Warn, Error }; + +class Field { +public: + Field(std::string_view key, std::string_view value); + Field(std::string_view key, const char* value); + Field(std::string_view key, const std::string& value); + Field(std::string_view key, bool value); + Field(std::string_view key, int value); + Field(std::string_view key, long long value); + Field(std::string_view key, unsigned long long value); + Field(std::string_view key, double value); + + const std::string& key() const { return key_; } + const std::string& value() const { return value_; } + + /** Whether the value is written as a JSON literal rather than a string. */ + bool literal() const { return literal_; } + +private: + Field(std::string_view key, std::string value, bool literal); + + std::string key_; + std::string value_; + bool literal_ = false; +}; + +struct Record { + Level level = Level::Info; + std::string_view message; + const Field* fields = nullptr; + std::size_t field_count = 0; + std::chrono::system_clock::time_point timestamp; +}; + +/** Receives every record. The default encodes it and writes it to stderr. */ +using Sink = std::function; + +/** Pass nullptr to restore the default. */ +void SetSink(Sink sink); + +/** Governs Write only, not Submit. Info by default. */ +void SetLevel(Level min); +Level GetLevel(); +bool Enabled(Level level); + +void Write(Level level, std::string_view message, std::initializer_list fields = {}); + +inline void Trace(std::string_view message, std::initializer_list fields = {}) { + Write(Level::Trace, message, fields); +} +inline void Debug(std::string_view message, std::initializer_list fields = {}) { + Write(Level::Debug, message, fields); +} +inline void Info(std::string_view message, std::initializer_list fields = {}) { + Write(Level::Info, message, fields); +} +inline void Warn(std::string_view message, std::initializer_list fields = {}) { + Write(Level::Warn, message, fields); +} +inline void Error(std::string_view message, std::initializer_list fields = {}) { + Write(Level::Error, message, fields); +} + +/** + * The seam a backend adapter sits on, so a library that already knows a line's + * time, severity and origin does not lose them to a second timestamp. + * + * SetLevel is deliberately not applied: the record comes from a library that + * has already decided to emit it, and dropping it again here would lose what a + * plugin meant to say. + */ +void Submit(const Record& record); + +/** Renders a record in the host's format, without the trailing newline. */ +std::string Encode(const Record& record); + +/** + * Exactly six fractional digits, and an offset written either as "Z" or with a + * colon. A timestamp in any other shape makes the host reject the whole line + * and report it as unparsed text at its own level. + */ +std::string FormatTimestamp(std::chrono::system_clock::time_point tp); + +std::string_view LevelName(Level level); + +} // namespace go_plugin::log diff --git a/include/go_plugin/log_absl.hpp b/include/go_plugin/log_absl.hpp new file mode 100644 index 0000000..75e7fa7 --- /dev/null +++ b/include/go_plugin/log_absl.hpp @@ -0,0 +1,32 @@ +#pragma once + +namespace go_plugin::log { + +struct AbslBridgeOptions { + /** + * Abseil has no debug or trace severity of its own — they exist only as + * VLOG verbosities — so the mapping has to be stated. VLOG at or above this + * verbosity is reported as trace, below it as debug. + */ + int trace_from_verbosity = 2; + + /** Carry Abseil's source file and line as a `caller` field. */ + bool include_caller = true; + + /** + * Stop Abseil writing its own copy of every line. Left on, each line + * reaches the host twice: once as unparsed text, once in the format it can + * read. + */ + bool silence_absl_stderr = true; +}; + +/** + * Routes Abseil's LOG() and VLOG() through the format the host parses, so a + * plugin keeps its own severity without touching a call site. + * + * Call after absl::InitializeLog(). Only the first call installs a sink. + */ +void InstallAbslBridge(const AbslBridgeOptions& options = {}); + +} // namespace go_plugin::log diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3c1bea6..56c3a20 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,5 @@ add_library(go_plugin STATIC + log.cpp server.cpp tls.cpp ) @@ -12,12 +13,28 @@ target_include_directories(go_plugin target_link_libraries(go_plugin PUBLIC gRPC::grpc++ - gRPC::grpc++_reflection protobuf::libprotobuf OpenSSL::SSL OpenSSL::Crypto ) +# One target per backend, so a consumer links only the one it uses. +if(GO_PLUGIN_LOG_ABSL) + add_library(go_plugin_log_absl STATIC log_absl.cpp) + target_link_libraries(go_plugin_log_absl + PUBLIC + go_plugin + absl::log + absl::log_sink + absl::log_sink_registry + absl::log_entry + absl::log_globals + absl::log_severity + absl::time + ) + target_compile_options(go_plugin_log_absl PRIVATE -Wall -Wextra -Wpedantic) +endif() + target_compile_options(go_plugin PRIVATE -Wall -Wextra -Wpedantic ) diff --git a/src/log.cpp b/src/log.cpp new file mode 100644 index 0000000..e7163db --- /dev/null +++ b/src/log.cpp @@ -0,0 +1,217 @@ +#include "go_plugin/log.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace go_plugin::log { +namespace { + +std::atomic g_level{static_cast(Level::Info)}; + +std::mutex& SinkMutex() { + static std::mutex m; + return m; +} + +std::mutex& WriteMutex() { + static std::mutex m; + return m; +} + +Sink& CurrentSink() { + static Sink sink; + return sink; +} + +void AppendEscaped(std::string& out, std::string_view s) { + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[7]; + std::snprintf(buf, sizeof buf, "\\u%04x", c); + out += buf; + } else { + out += static_cast(c); + } + } + } +} + +void WriteToStderr(const Record& record) { + std::string line = Encode(record); + line += '\n'; + + // One write of the whole line: a plugin logs from its gRPC threads and from + // whatever library it drives, and the host reads a line at a time. + std::lock_guard lock(WriteMutex()); + ssize_t written = 0; + while (written < static_cast(line.size())) { + ssize_t n = ::write(STDERR_FILENO, line.data() + written, line.size() - written); + if (n <= 0) { + if (n < 0 && errno == EINTR) continue; + return; + } + written += n; + } +} + +} // namespace + +Field::Field(std::string_view key, std::string value, bool literal) + : key_(key), value_(std::move(value)), literal_(literal) {} + +Field::Field(std::string_view key, std::string_view value) : Field(key, std::string(value), false) {} +Field::Field(std::string_view key, const char* value) + : Field(key, std::string(value ? value : ""), false) {} +Field::Field(std::string_view key, const std::string& value) : Field(key, value, false) {} +Field::Field(std::string_view key, bool value) : Field(key, value ? "true" : "false", true) {} +Field::Field(std::string_view key, int value) : Field(key, std::to_string(value), true) {} +Field::Field(std::string_view key, long long value) : Field(key, std::to_string(value), true) {} +Field::Field(std::string_view key, unsigned long long value) + : Field(key, std::to_string(value), true) {} + +Field::Field(std::string_view key, double value) : Field(key, std::string(), true) { + // printf would write "0,5" under a comma-decimal locale, and "nan" for a + // NaN — either makes the host reject the line. + if (!std::isfinite(value)) { + value_ = std::isnan(value) ? "\"NaN\"" : (value > 0 ? "\"+Inf\"" : "\"-Inf\""); + return; + } + + std::ostringstream out; + out.imbue(std::locale::classic()); + out << std::setprecision(17) << value; + value_ = out.str(); +} + +std::string_view LevelName(Level level) { + switch (level) { + case Level::Trace: return "trace"; + case Level::Debug: return "debug"; + case Level::Info: return "info"; + case Level::Warn: return "warn"; + case Level::Error: return "error"; + } + return "info"; +} + +std::string FormatTimestamp(std::chrono::system_clock::time_point tp) { + using namespace std::chrono; + + // floor, not a cast: a cast truncates towards zero, handing back a negative + // remainder for a clock still set before the epoch. + const auto secs = floor(tp); + const auto micros = duration_cast(tp - secs).count(); + + const std::time_t t = system_clock::to_time_t(secs); + std::tm tm{}; + if (localtime_r(&t, &tm) == nullptr) { + gmtime_r(&t, &tm); + } + + char stamp[32]; + std::strftime(stamp, sizeof stamp, "%Y-%m-%dT%H:%M:%S", &tm); + + char frac[16]; + std::snprintf(frac, sizeof frac, ".%06lld", static_cast(micros)); + + std::string out = stamp; + out += frac; + + // "Z" or "+hh:mm" — strftime's %z writes "+0200", which the host rejects. + const long offset = tm.tm_gmtoff; + if (offset == 0) { + out += 'Z'; + } else { + const long abs_offset = offset < 0 ? -offset : offset; + const int hours = static_cast((abs_offset / 3600) % 100); + const int minutes = static_cast((abs_offset % 3600) / 60); + char zone[8]; + std::snprintf(zone, sizeof zone, "%c%02d:%02d", offset < 0 ? '-' : '+', hours, minutes); + out += zone; + } + return out; +} + +std::string Encode(const Record& record) { + std::string out; + out.reserve(128 + record.message.size()); + + out += "{\"@level\":\""; + out += LevelName(record.level); + out += "\",\"@message\":\""; + AppendEscaped(out, record.message); + out += "\",\"@timestamp\":\""; + out += FormatTimestamp(record.timestamp); + out += '"'; + + for (std::size_t i = 0; i < record.field_count; ++i) { + const Field& field = record.fields[i]; + out += ",\""; + AppendEscaped(out, field.key()); + out += "\":"; + if (field.literal()) { + out += field.value(); + } else { + out += '"'; + AppendEscaped(out, field.value()); + out += '"'; + } + } + + out += '}'; + return out; +} + +void SetSink(Sink sink) { + std::lock_guard lock(SinkMutex()); + CurrentSink() = std::move(sink); +} + +void SetLevel(Level min) { g_level.store(static_cast(min), std::memory_order_relaxed); } + +Level GetLevel() { return static_cast(g_level.load(std::memory_order_relaxed)); } + +bool Enabled(Level level) { return static_cast(level) >= g_level.load(std::memory_order_relaxed); } + +void Submit(const Record& record) { + Sink sink; + { + std::lock_guard lock(SinkMutex()); + sink = CurrentSink(); + } + if (sink) { + sink(record); + } else { + WriteToStderr(record); + } +} + +void Write(Level level, std::string_view message, std::initializer_list fields) { + if (!Enabled(level)) return; + + Record record; + record.level = level; + record.message = message; + record.fields = fields.begin(); + record.field_count = fields.size(); + record.timestamp = std::chrono::system_clock::now(); + Submit(record); +} + +} // namespace go_plugin::log diff --git a/src/log_absl.cpp b/src/log_absl.cpp new file mode 100644 index 0000000..827f3d7 --- /dev/null +++ b/src/log_absl.cpp @@ -0,0 +1,78 @@ +#include "go_plugin/log_absl.hpp" + +#include +#include + +#include "absl/base/log_severity.h" +#include "absl/log/globals.h" +#include "absl/log/log_entry.h" +#include "absl/log/log_sink.h" +#include "absl/log/log_sink_registry.h" +#include "absl/time/time.h" + +#include "go_plugin/log.hpp" + +namespace go_plugin::log { +namespace { + +Level LevelFor(const absl::LogEntry& entry, const AbslBridgeOptions& options) { + // A VLOG is filed at info severity; its verbosity is the only thing that + // separates it from an ordinary info line. + if (entry.verbosity() != absl::LogEntry::kNoVerbosityLevel) { + return entry.verbosity() >= options.trace_from_verbosity ? Level::Trace : Level::Debug; + } + + switch (entry.log_severity()) { + case absl::LogSeverity::kInfo: return Level::Info; + case absl::LogSeverity::kWarning: return Level::Warn; + case absl::LogSeverity::kError: return Level::Error; + case absl::LogSeverity::kFatal: return Level::Error; + } + return Level::Info; +} + +class Bridge final : public absl::LogSink { +public: + explicit Bridge(const AbslBridgeOptions& options) : options_(options) {} + + void Send(const absl::LogEntry& entry) override { + Record record; + record.level = LevelFor(entry, options_); + record.message = entry.text_message(); // prefix-free + record.timestamp = absl::ToChronoTime(entry.timestamp()); + + if (!options_.include_caller) { + Submit(record); + return; + } + + std::string caller(entry.source_basename()); + caller += ':'; + caller += std::to_string(entry.source_line()); + const Field field("caller", caller); + record.fields = &field; + record.field_count = 1; + Submit(record); + } + +private: + AbslBridgeOptions options_; +}; + +} // namespace + +void InstallAbslBridge(const AbslBridgeOptions& options) { + static std::once_flag once; + std::call_once(once, [&options] { + // Never destroyed: Abseil holds the pointer for the life of the process + // and a LOG(FATAL) unwinds nothing. + static Bridge bridge(options); + absl::AddLogSink(&bridge); + + if (options.silence_absl_stderr) { + absl::SetStderrThreshold(absl::LogSeverityAtLeast::kInfinity); + } + }); +} + +} // namespace go_plugin::log diff --git a/src/server.cpp b/src/server.cpp index 5f86038..c9f6843 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -14,13 +14,11 @@ #include #include -// POSIX socket headers for port probing #include #include #include #include -#include #include #include @@ -135,7 +133,6 @@ bool PluginServer::Start(std::string *out_error) { // ── 3. Build gRPC server ───────────────────────────────────────────── grpc::EnableDefaultHealthCheckService(true); - grpc::reflection::InitProtoReflectionServerBuilderPlugin(); grpc::ServerBuilder builder; int selected_port = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6860a7b..a1b6e0a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,13 +1,52 @@ find_package(GTest CONFIG REQUIRED) -# ── test_handshake (no gRPC server, purely logical tests) ───────────────────── +# Generated into the build tree, never into the source tree. +set(PROBE_PROTO "${CMAKE_CURRENT_SOURCE_DIR}/proto/probe.proto") +set(PROBE_OUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen") + +file(MAKE_DIRECTORY "${PROBE_OUT_DIR}") + +add_custom_command( + OUTPUT + "${PROBE_OUT_DIR}/probe.pb.cc" "${PROBE_OUT_DIR}/probe.pb.h" + "${PROBE_OUT_DIR}/probe.grpc.pb.cc" "${PROBE_OUT_DIR}/probe.grpc.pb.h" + COMMAND protobuf::protoc + ARGS + --proto_path "${CMAKE_CURRENT_SOURCE_DIR}/proto" + --cpp_out "${PROBE_OUT_DIR}" + --grpc_out "${PROBE_OUT_DIR}" + --plugin=protoc-gen-grpc=$ + "${PROBE_PROTO}" + DEPENDS "${PROBE_PROTO}" protobuf::protoc gRPC::grpc_cpp_plugin + COMMENT "Generating the tests' Probe service" + VERBATIM +) + +add_library(probe_proto STATIC + "${PROBE_OUT_DIR}/probe.pb.cc" + "${PROBE_OUT_DIR}/probe.grpc.pb.cc" +) +target_link_libraries(probe_proto PUBLIC gRPC::grpc++ protobuf::libprotobuf) +target_include_directories(probe_proto PUBLIC "${PROBE_OUT_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}") + add_executable(test_handshake test_handshake.cpp) -target_link_libraries(test_handshake PRIVATE go_plugin GTest::gtest GTest::gtest_main) +target_link_libraries(test_handshake PRIVATE go_plugin probe_proto GTest::gtest GTest::gtest_main) -# ── test_server (starts a real gRPC server in-process) ─────────────────────── add_executable(test_server test_server.cpp) -target_link_libraries(test_server PRIVATE go_plugin GTest::gtest GTest::gtest_main) +target_link_libraries(test_server PRIVATE go_plugin probe_proto GTest::gtest GTest::gtest_main) + +add_executable(test_log test_log.cpp) +target_link_libraries(test_log PRIVATE go_plugin GTest::gtest GTest::gtest_main) + +if(GO_PLUGIN_LOG_ABSL) + add_executable(test_log_absl test_log_absl.cpp) + target_link_libraries(test_log_absl PRIVATE go_plugin_log_absl GTest::gtest GTest::gtest_main) +endif() include(GoogleTest) gtest_discover_tests(test_handshake) gtest_discover_tests(test_server) +gtest_discover_tests(test_log) +if(GO_PLUGIN_LOG_ABSL) + gtest_discover_tests(test_log_absl) +endif() diff --git a/tests/plugin_fixture.hpp b/tests/plugin_fixture.hpp new file mode 100644 index 0000000..674a9d1 --- /dev/null +++ b/tests/plugin_fixture.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "go_plugin/server.hpp" +#include "probe.grpc.pb.h" + +namespace go_plugin::test { + +/** + * gRPC starts a server only if a registered service has a synchronous method, + * and a plugin with no services could not answer its host either. + */ +class ProbeService final : public Probe::Service { +public: + grpc::Status Ping(grpc::ServerContext*, const PingRequest* request, PingReply* reply) override { + reply->set_text(request->text()); + return grpc::Status::OK; + } +}; + +/** Takes the environment back down so no test leaks a cookie into the next. */ +class PluginFixture : public ::testing::Test { +protected: + static constexpr const char* kCookieKey = "GO_PLUGIN_TEST_COOKIE"; + static constexpr const char* kCookieValue = "test-cookie"; + + void SetUp() override { + SetEnv(kCookieKey, kCookieValue); + config_.handshake.magic_cookie_key = kCookieKey; + config_.handshake.magic_cookie_value = kCookieValue; + config_.output = &handshake_; + config_.services = {&probe_}; + } + + void TearDown() override { + if (started_) { + server_->Shutdown(); + server_->Wait(); + } + UnsetEnv(kCookieKey); + UnsetEnv("PLUGIN_MIN_PORT"); + UnsetEnv("PLUGIN_MAX_PORT"); + } + + static void SetEnv(const char* key, const char* value) { ::setenv(key, value, 1); } + static void UnsetEnv(const char* key) { ::unsetenv(key); } + + bool Start(std::string* error = nullptr) { + std::string ignored; + server_ = std::make_unique(config_); + started_ = server_->Start(error != nullptr ? error : &ignored); + return started_; + } + + go_plugin::PluginServer& server() { return *server_; } + + std::string handshake() const { return handshake_.str(); } + + std::string target() const { return "127.0.0.1:" + std::to_string(server_->port()); } + + go_plugin::ServeConfig config_; + +private: + ProbeService probe_; + std::ostringstream handshake_; + std::unique_ptr server_; + bool started_ = false; +}; + +} // namespace go_plugin::test diff --git a/tests/proto/probe.proto b/tests/proto/probe.proto new file mode 100644 index 0000000..cd39c14 --- /dev/null +++ b/tests/proto/probe.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package go_plugin.test; + +// Probe is the service the tests serve. gRPC will not start a server that has +// no synchronous method to answer, and neither could a plugin with no services, +// so a test that starts a server registers this one. +service Probe { + rpc Ping (PingRequest) returns (PingReply); +} + +message PingRequest { + string text = 1; +} + +message PingReply { + string text = 1; +} diff --git a/tests/test_handshake.cpp b/tests/test_handshake.cpp index 795bd74..063bde5 100644 --- a/tests/test_handshake.cpp +++ b/tests/test_handshake.cpp @@ -1,184 +1,87 @@ -#include - -#include -#include +#include #include -#include -#include "go_plugin/server.hpp" +#include -// ── helpers ─────────────────────────────────────────────────────────────────── +#include "plugin_fixture.hpp" -static void SetEnv(const char *key, const char *val) { ::setenv(key, val, 1); } -static void UnsetEnv(const char *key) { ::unsetenv(key); } +namespace go_plugin::test { +namespace { -// ── magic-cookie validation ─────────────────────────────────────────────────── +using Handshake = PluginFixture; -TEST(HandshakeTest, FailsWithMissingCookie) { - UnsetEnv("TEST_MAGIC_MISSING"); - std::ostringstream out; - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_MAGIC_MISSING"; - cfg.handshake.magic_cookie_value = "expected"; - cfg.output = &out; +TEST_F(Handshake, FailsWithMissingCookie) { + UnsetEnv(kCookieKey); - go_plugin::PluginServer server(cfg); - std::string err; - EXPECT_FALSE(server.Start(&err)); - EXPECT_FALSE(err.empty()); - // Nothing should have been written to the output - EXPECT_TRUE(out.str().empty()); + std::string error; + EXPECT_FALSE(Start(&error)); + EXPECT_FALSE(error.empty()); + EXPECT_TRUE(handshake().empty()) << "a refused plugin must advertise nothing"; } -TEST(HandshakeTest, FailsWithWrongCookieValue) { - SetEnv("TEST_MAGIC_WRONG", "bad_value"); - std::ostringstream out; - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_MAGIC_WRONG"; - cfg.handshake.magic_cookie_value = "expected_value"; - cfg.output = &out; - - go_plugin::PluginServer server(cfg); - std::string err; - EXPECT_FALSE(server.Start(&err)); - EXPECT_FALSE(err.empty()); - EXPECT_TRUE(out.str().empty()); - UnsetEnv("TEST_MAGIC_WRONG"); -} +TEST_F(Handshake, FailsWithWrongCookieValue) { + SetEnv(kCookieKey, "not-the-expected-value"); -TEST(HandshakeTest, SucceedsWithCorrectCookie) { - SetEnv("TEST_MAGIC_OK", "correct_value"); - std::ostringstream out; - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_MAGIC_OK"; - cfg.handshake.magic_cookie_value = "correct_value"; - cfg.output = &out; - - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << "Start failed: " << err; - server.Shutdown(); - server.Wait(); - UnsetEnv("TEST_MAGIC_OK"); + std::string error; + EXPECT_FALSE(Start(&error)); + EXPECT_FALSE(error.empty()); + EXPECT_TRUE(handshake().empty()); } -// ── handshake line format ───────────────────────────────────────────────────── - -TEST(HandshakeTest, OutputContainsCoreProtocolVersion) { - SetEnv("TEST_FORMAT_KEY", "test_value"); - std::ostringstream out; - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_FORMAT_KEY"; - cfg.handshake.magic_cookie_value = "test_value"; - cfg.handshake.protocol_version = 3; - cfg.output = &out; - - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; - - std::string line = out.str(); - // Must start with "1|" (core protocol version is always 1) - EXPECT_EQ(line.substr(0, 2), "1|"); - // App protocol version field must be "3" - EXPECT_NE(line.find("|3|"), std::string::npos); - server.Shutdown(); - server.Wait(); - UnsetEnv("TEST_FORMAT_KEY"); +TEST_F(Handshake, SucceedsWithCorrectCookie) { + std::string error; + ASSERT_TRUE(Start(&error)) << error; + EXPECT_FALSE(handshake().empty()); } -TEST(HandshakeTest, OutputContainsTcpAndGrpc) { - SetEnv("TEST_PROTO_KEY", "proto_val"); - std::ostringstream out; - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_PROTO_KEY"; - cfg.handshake.magic_cookie_value = "proto_val"; - cfg.output = &out; - - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; - - std::string line = out.str(); - EXPECT_NE(line.find("|tcp|"), std::string::npos); - EXPECT_NE(line.find("|grpc|"), std::string::npos); - EXPECT_EQ(line.back(), '\n'); - - server.Shutdown(); - server.Wait(); - UnsetEnv("TEST_PROTO_KEY"); -} +TEST_F(Handshake, StatesTheCoreAndAppProtocolVersions) { + config_.handshake.protocol_version = 3; + + std::string error; + ASSERT_TRUE(Start(&error)) << error; -TEST(HandshakeTest, OutputContainsListeningAddress) { - SetEnv("TEST_ADDR_KEY", "addr_val"); - std::ostringstream out; - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_ADDR_KEY"; - cfg.handshake.magic_cookie_value = "addr_val"; - cfg.output = &out; - - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; - - EXPECT_GT(server.port(), 0); - std::string expected_addr = "127.0.0.1:" + std::to_string(server.port()); - EXPECT_NE(out.str().find(expected_addr), std::string::npos); - - server.Shutdown(); - server.Wait(); - UnsetEnv("TEST_ADDR_KEY"); + // The core protocol version is always 1; the app version is the plugin's. + EXPECT_EQ(handshake().substr(0, 2), "1|"); + EXPECT_NE(handshake().find("|3|"), std::string::npos) << handshake(); } -TEST(HandshakeTest, HandshakeHasSixPipeFields) { - SetEnv("TEST_FIELDS_KEY", "fields_val"); - std::ostringstream out; - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_FIELDS_KEY"; - cfg.handshake.magic_cookie_value = "fields_val"; - cfg.output = &out; - - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; - - // go-plugin expects exactly 6 pipe-separated fields (5 separators + trailing - // pipe before optional cert): CORE|APP|NET|ADDR|PROTO|CERT\n - std::string line = out.str(); - // strip trailing newline - if (!line.empty() && line.back() == '\n') - line.pop_back(); - int pipes = static_cast(std::count(line.begin(), line.end(), '|')); - EXPECT_EQ(pipes, 5); - - server.Shutdown(); - server.Wait(); - UnsetEnv("TEST_FIELDS_KEY"); +TEST_F(Handshake, NamesTheTransportAndProtocol) { + std::string error; + ASSERT_TRUE(Start(&error)) << error; + + EXPECT_NE(handshake().find("|tcp|"), std::string::npos) << handshake(); + EXPECT_NE(handshake().find("|grpc|"), std::string::npos) << handshake(); + EXPECT_EQ(handshake().back(), '\n') << "the host reads the line, so it has to be terminated"; } -// ── port range env vars ─────────────────────────────────────────────────────── +TEST_F(Handshake, AdvertisesTheListeningAddress) { + std::string error; + ASSERT_TRUE(Start(&error)) << error; -TEST(HandshakeTest, RespectsPortRange) { - SetEnv("TEST_RANGE_KEY", "range_val"); - SetEnv("PLUGIN_MIN_PORT", "19900"); - SetEnv("PLUGIN_MAX_PORT", "19999"); - std::ostringstream out; + EXPECT_GT(server().port(), 0); + EXPECT_NE(handshake().find(target()), std::string::npos) << handshake(); +} - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "TEST_RANGE_KEY"; - cfg.handshake.magic_cookie_value = "range_val"; - cfg.output = &out; +TEST_F(Handshake, HasTheSixFieldsTheHostSplitsOn) { + std::string error; + ASSERT_TRUE(Start(&error)) << error; - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; + // CORE|APP|NET|ADDR|PROTO|CERT — six fields, so five separators. + std::string line = handshake(); + if (!line.empty() && line.back() == '\n') line.pop_back(); + EXPECT_EQ(std::count(line.begin(), line.end(), '|'), 5) << line; +} - EXPECT_GE(server.port(), 19900); - EXPECT_LE(server.port(), 19999); +TEST_F(Handshake, RespectsThePortRangeTheHostAsksFor) { + SetEnv("PLUGIN_MIN_PORT", "19900"); + SetEnv("PLUGIN_MAX_PORT", "19999"); - server.Shutdown(); - server.Wait(); - UnsetEnv("TEST_RANGE_KEY"); - UnsetEnv("PLUGIN_MIN_PORT"); - UnsetEnv("PLUGIN_MAX_PORT"); + std::string error; + ASSERT_TRUE(Start(&error)) << error; + + EXPECT_GE(server().port(), 19900); + EXPECT_LE(server().port(), 19999); } + +} // namespace +} // namespace go_plugin::test diff --git a/tests/test_log.cpp b/tests/test_log.cpp new file mode 100644 index 0000000..8e3718e --- /dev/null +++ b/tests/test_log.cpp @@ -0,0 +1,179 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "go_plugin/log.hpp" + +using go_plugin::log::Field; +using go_plugin::log::Level; +using go_plugin::log::Record; + +namespace { + +class Capture { +public: + Capture() { + go_plugin::log::SetSink([this](const Record& record) { lines_.push_back(go_plugin::log::Encode(record)); }); + } + ~Capture() { go_plugin::log::SetSink(nullptr); } + + const std::vector& lines() const { return lines_; } + +private: + std::vector lines_; +}; + +} // namespace + +// A shape the host cannot parse makes it discard the parse and report the raw +// line at its own level, so the failure looks like the bug this format fixes. +TEST(Log, TimestampMatchesTheHostsLayout) { + const std::regex layout(R"(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}(Z|[+-]\d{2}:\d{2})$)"); + + for (auto tp : {std::chrono::system_clock::now(), + std::chrono::system_clock::time_point{}, + std::chrono::system_clock::now() + std::chrono::hours(24 * 365)}) { + const std::string stamp = go_plugin::log::FormatTimestamp(tp); + EXPECT_TRUE(std::regex_match(stamp, layout)) << "timestamp not in the host's layout: " << stamp; + } +} + +TEST(Log, TimestampKeepsSixFractionalDigits) { + const auto whole = std::chrono::time_point_cast(std::chrono::system_clock::now()); + const std::string stamp = go_plugin::log::FormatTimestamp(whole); + EXPECT_NE(stamp.find(".000000"), std::string::npos) << stamp; +} + +TEST(Log, CarriesTheHostsKeys) { + Capture capture; + go_plugin::log::Info("agent registered", {Field("capability", "NoInputNoOutput")}); + + ASSERT_EQ(capture.lines().size(), 1u); + const std::string& line = capture.lines().front(); + EXPECT_NE(line.find(R"("@level":"info")"), std::string::npos) << line; + EXPECT_NE(line.find(R"("@message":"agent registered")"), std::string::npos) << line; + EXPECT_NE(line.find(R"("@timestamp":")"), std::string::npos) << line; + EXPECT_NE(line.find(R"("capability":"NoInputNoOutput")"), std::string::npos) << line; +} + +TEST(Log, LevelNamesAreTheOnesTheHostAccepts) { + Capture capture; + go_plugin::log::SetLevel(Level::Trace); + go_plugin::log::Trace("t"); + go_plugin::log::Debug("d"); + go_plugin::log::Info("i"); + go_plugin::log::Warn("w"); + go_plugin::log::Error("e"); + go_plugin::log::SetLevel(Level::Info); + + ASSERT_EQ(capture.lines().size(), 5u); + const char* expected[] = {"trace", "debug", "info", "warn", "error"}; + for (std::size_t i = 0; i < 5; ++i) { + const std::string want = std::string(R"("@level":")") + expected[i] + R"(")"; + EXPECT_NE(capture.lines()[i].find(want), std::string::npos) << capture.lines()[i]; + } +} + +// The host reads a line at a time, so an unescaped newline would split one +// record into two. +TEST(Log, EscapesWhatWouldBreakTheLine) { + Capture capture; + go_plugin::log::Info("first\nsecond\ttabbed \"quoted\" back\\slash", + {Field("ctl", std::string_view("\x01", 1))}); + + ASSERT_EQ(capture.lines().size(), 1u); + const std::string& line = capture.lines().front(); + EXPECT_EQ(line.find('\n'), std::string::npos) << "an unescaped newline splits the record: " << line; + EXPECT_NE(line.find("first\\nsecond"), std::string::npos) << line; + EXPECT_NE(line.find("\\t"), std::string::npos) << line; + EXPECT_NE(line.find("\\\"quoted\\\""), std::string::npos) << line; + EXPECT_NE(line.find("back\\\\slash"), std::string::npos) << line; + EXPECT_NE(line.find("\\u0001"), std::string::npos) << line; +} + +TEST(Log, NumbersAndBoolsAreJsonLiterals) { + Capture capture; + go_plugin::log::Info("readings", {Field("count", 7), Field("ok", true), Field("ratio", 0.5)}); + + const std::string& line = capture.lines().front(); + EXPECT_NE(line.find(R"("count":7)"), std::string::npos) << line; + EXPECT_NE(line.find(R"("ok":true)"), std::string::npos) << line; + EXPECT_NE(line.find(R"("ratio":0.5)"), std::string::npos) << line; +} + +TEST(Log, TakesFieldsAsBracedPairs) { + Capture capture; + go_plugin::log::Info("sink opened", {{"rate", 48000}, {"path", std::string("/tmp/pipe")}}); + + ASSERT_EQ(capture.lines().size(), 1u); + const std::string& line = capture.lines().front(); + EXPECT_NE(line.find(R"("rate":48000)"), std::string::npos) << line; + EXPECT_NE(line.find(R"("path":"/tmp/pipe")"), std::string::npos) << line; +} + + +// printf's %g follows the locale, which would write "0,5" and be rejected. +TEST(Log, WritesDoublesTheSameInAnyLocale) { + // setlocale returns the locale it just set, so the one to restore has to be + // read first. Without a comma-decimal locale installed there is nothing to + // test against, and asserting anyway would pass without exercising it. + const std::string original = std::setlocale(LC_NUMERIC, nullptr); + if (std::setlocale(LC_NUMERIC, "de_DE.UTF-8") == nullptr) { + GTEST_SKIP() << "no de_DE.UTF-8 locale to test against"; + } + + Capture capture; + go_plugin::log::Info("readings", {Field("ratio", 0.5)}); + std::setlocale(LC_NUMERIC, original.c_str()); + + ASSERT_EQ(capture.lines().size(), 1u); + EXPECT_NE(capture.lines().front().find(R"("ratio":0.5)"), std::string::npos) + << capture.lines().front(); + EXPECT_EQ(std::string(std::setlocale(LC_NUMERIC, nullptr)), original) + << "a leaked locale would follow this test into the next"; +} + +// JSON has no NaN or infinity. +TEST(Log, QuotesNonFiniteDoubles) { + Capture capture; + go_plugin::log::Info("a", {Field("v", std::numeric_limits::quiet_NaN())}); + go_plugin::log::Info("b", {Field("v", std::numeric_limits::infinity())}); + + ASSERT_EQ(capture.lines().size(), 2u); + EXPECT_NE(capture.lines()[0].find(R"("v":"NaN")"), std::string::npos) << capture.lines()[0]; + EXPECT_NE(capture.lines()[1].find(R"("v":"+Inf")"), std::string::npos) << capture.lines()[1]; +} + +// The backend's library already decided to emit these; gating them again would +// drop what a plugin meant to say. +TEST(Log, DoesNotRegateARecordFromABackend) { + Capture capture; + go_plugin::log::SetLevel(Level::Error); + + Record record; + record.level = Level::Debug; + record.message = "a backend already let this through"; + record.timestamp = std::chrono::system_clock::now(); + go_plugin::log::Submit(record); + + go_plugin::log::SetLevel(Level::Info); + + ASSERT_EQ(capture.lines().size(), 1u); + EXPECT_NE(capture.lines().front().find(R"("@level":"debug")"), std::string::npos); +} + +TEST(Log, DropsRecordsBelowTheLevel) { + Capture capture; + go_plugin::log::SetLevel(Level::Warn); + go_plugin::log::Info("dropped"); + go_plugin::log::Error("kept"); + go_plugin::log::SetLevel(Level::Info); + + ASSERT_EQ(capture.lines().size(), 1u); + EXPECT_NE(capture.lines().front().find("kept"), std::string::npos); +} diff --git a/tests/test_log_absl.cpp b/tests/test_log_absl.cpp new file mode 100644 index 0000000..6d15411 --- /dev/null +++ b/tests/test_log_absl.cpp @@ -0,0 +1,74 @@ +#include +#include + +#include + +#include "absl/log/globals.h" +#include "absl/log/initialize.h" +#include "absl/log/log.h" + +#include "go_plugin/log.hpp" +#include "go_plugin/log_absl.hpp" + +using go_plugin::log::Record; + +namespace { + +std::vector& Lines() { + static std::vector lines; + return lines; +} + +class AbslBridge : public ::testing::Test { +protected: + static void SetUpTestSuite() { + absl::InitializeLog(); + go_plugin::log::InstallAbslBridge(); + go_plugin::log::SetSink([](const Record& record) { Lines().push_back(go_plugin::log::Encode(record)); }); + } + + void SetUp() override { + Lines().clear(); + go_plugin::log::SetLevel(go_plugin::log::Level::Trace); + absl::SetMinLogLevel(absl::LogSeverityAtLeast::kInfo); + absl::SetGlobalVLogLevel(4); + } +}; + +} // namespace + +// A plugin's own severity is what was being lost, so this is the point. +TEST_F(AbslBridge, KeepsTheSeverity) { + LOG(INFO) << "an info line"; + LOG(WARNING) << "a warning line"; + LOG(ERROR) << "an error line"; + + ASSERT_EQ(Lines().size(), 3u); + EXPECT_NE(Lines()[0].find(R"("@level":"info")"), std::string::npos) << Lines()[0]; + EXPECT_NE(Lines()[1].find(R"("@level":"warn")"), std::string::npos) << Lines()[1]; + EXPECT_NE(Lines()[2].find(R"("@level":"error")"), std::string::npos) << Lines()[2]; +} + +// Abseil has no debug or trace severity; only VLOG verbosities. +TEST_F(AbslBridge, MapsVerbosityOntoDebugAndTrace) { + VLOG(1) << "a verbose line"; + VLOG(3) << "a very verbose line"; + + ASSERT_EQ(Lines().size(), 2u); + EXPECT_NE(Lines()[0].find(R"("@level":"debug")"), std::string::npos) << Lines()[0]; + EXPECT_NE(Lines()[1].find(R"("@level":"trace")"), std::string::npos) << Lines()[1]; +} + +TEST_F(AbslBridge, CarriesTheMessageWithoutAbseilsPrefix) { + LOG(INFO) << "plain message"; + + ASSERT_EQ(Lines().size(), 1u); + EXPECT_NE(Lines()[0].find(R"("@message":"plain message")"), std::string::npos) << Lines()[0]; +} + +TEST_F(AbslBridge, CarriesTheCaller) { + LOG(INFO) << "located"; + + ASSERT_EQ(Lines().size(), 1u); + EXPECT_NE(Lines()[0].find(R"("caller":"test_log_absl.cpp:)"), std::string::npos) << Lines()[0]; +} diff --git a/tests/test_server.cpp b/tests/test_server.cpp index 1d04ab7..6316282 100644 --- a/tests/test_server.cpp +++ b/tests/test_server.cpp @@ -1,135 +1,78 @@ -#include - #include #include -#include -#include #include #include #include +#include -#include "go_plugin/server.hpp" - -// ── helpers ─────────────────────────────────────────────────────────────────── - -static void SetEnv(const char *k, const char *v) { ::setenv(k, v, 1); } -static void UnsetEnv(const char *k) { ::unsetenv(k); } - -// ── basic server lifecycle ──────────────────────────────────────────────────── - -TEST(ServerTest, StartAndShutdown) { - SetEnv("SRV_MAGIC", "srv_val"); - std::ostringstream out; +#include "plugin_fixture.hpp" - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "SRV_MAGIC"; - cfg.handshake.magic_cookie_value = "srv_val"; - cfg.output = &out; +namespace go_plugin::test { +namespace { - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; - EXPECT_GT(server.port(), 0); +using Server = PluginFixture; - server.Shutdown(); - server.Wait(); - UnsetEnv("SRV_MAGIC"); +TEST_F(Server, ReportsNoPortUntilStarted) { + go_plugin::PluginServer server(config_); + EXPECT_EQ(server.port(), 0); } -TEST(ServerTest, PortIsPositiveAfterStart) { - SetEnv("SRV_PORT_MAGIC", "portval"); - std::ostringstream out; - - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "SRV_PORT_MAGIC"; - cfg.handshake.magic_cookie_value = "portval"; - cfg.output = &out; - - go_plugin::PluginServer server(cfg); - EXPECT_EQ(server.port(), 0); // before Start() +TEST_F(Server, StartsAndShutsDown) { + std::string error; + ASSERT_TRUE(Start(&error)) << error; + EXPECT_GT(server().port(), 0); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; - EXPECT_GT(server.port(), 0); // after Start() - - server.Shutdown(); - server.Wait(); - UnsetEnv("SRV_PORT_MAGIC"); + server().Shutdown(); + server().Wait(); } -// ── connectivity check ──────────────────────────────────────────────────────── - -TEST(ServerTest, ChannelConnects) { - SetEnv("SRV_HEALTH_MAGIC", "health_val"); - std::ostringstream out; - - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "SRV_HEALTH_MAGIC"; - cfg.handshake.magic_cookie_value = "health_val"; - cfg.output = &out; +// A socket accepting a connection is not the same as the plugin answering. +TEST_F(Server, AnswersACallOnTheAdvertisedAddress) { + std::string error; + ASSERT_TRUE(Start(&error)) << error; - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; + auto channel = grpc::CreateChannel(target(), grpc::InsecureChannelCredentials()); + ASSERT_TRUE(channel->WaitForConnected(std::chrono::system_clock::now() + std::chrono::seconds(5))); - std::string target = "127.0.0.1:" + std::to_string(server.port()); - auto channel = grpc::CreateChannel(target, grpc::InsecureChannelCredentials()); + auto stub = Probe::NewStub(channel); + PingRequest request; + request.set_text("hello"); - // Wait up to 5 s for the channel to reach READY state. - bool connected = channel->WaitForConnected( - std::chrono::system_clock::now() + std::chrono::seconds(5)); - EXPECT_TRUE(connected); + grpc::ClientContext context; + context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(5)); + PingReply reply; + const grpc::Status status = stub->Ping(&context, request, &reply); - server.Shutdown(); - server.Wait(); - UnsetEnv("SRV_HEALTH_MAGIC"); + ASSERT_TRUE(status.ok()) << status.error_message(); + EXPECT_EQ(reply.text(), "hello"); } -// ── Serve() convenience function ───────────────────────────────────────────── - -TEST(ServerTest, ServeFailsWithWrongCookie) { - UnsetEnv("SRV_WRONG_MAGIC"); - std::ostringstream out; - - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "SRV_WRONG_MAGIC"; - cfg.handshake.magic_cookie_value = "expected"; - cfg.output = &out; +TEST_F(Server, ServeRefusesTheWrongCookie) { + UnsetEnv(kCookieKey); - // Serve() should return immediately with ok=false (no blocking Wait). - auto result = go_plugin::Serve(cfg); - EXPECT_FALSE(result.ok); - EXPECT_FALSE(result.error.empty()); + const auto result = go_plugin::Serve(config_); + EXPECT_FALSE(result.ok); + EXPECT_FALSE(result.error.empty()); } -// ── concurrent shutdown ─────────────────────────────────────────────────────── +TEST_F(Server, WaitUnblocksAfterShutdown) { + std::string error; + ASSERT_TRUE(Start(&error)) << error; -TEST(ServerTest, WaitUnblocksAfterShutdown) { - SetEnv("SRV_CONC_MAGIC", "conc_val"); - std::ostringstream out; + std::atomic wait_returned{false}; + std::thread waiter([&] { + server().Wait(); + wait_returned = true; + }); - go_plugin::ServeConfig cfg; - cfg.handshake.magic_cookie_key = "SRV_CONC_MAGIC"; - cfg.handshake.magic_cookie_value = "conc_val"; - cfg.output = &out; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + EXPECT_FALSE(wait_returned) << "Wait must block while the server is up"; - go_plugin::PluginServer server(cfg); - std::string err; - ASSERT_TRUE(server.Start(&err)) << err; - - std::atomic wait_returned{false}; - std::thread t([&] { - server.Wait(); - wait_returned = true; - }); - - // Wait() should be blocking now - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - EXPECT_FALSE(wait_returned); - - server.Shutdown(); - t.join(); - EXPECT_TRUE(wait_returned); - UnsetEnv("SRV_CONC_MAGIC"); + server().Shutdown(); + waiter.join(); + EXPECT_TRUE(wait_returned); } + +} // namespace +} // namespace go_plugin::test diff --git a/vcpkg.json b/vcpkg.json index 2eec772..df48bd6 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,12 +1,17 @@ { "name": "go-plugin-cpp", - "version": "0.1.1", + "version": "0.2.0", "dependencies": [ + "abseil", "grpc", - "openssl", - { - "name": "gtest", - "host": true + "openssl" + ], + "features": { + "tests": { + "description": "Build the test suite", + "dependencies": [ + "gtest" + ] } - ] + } }