diff --git a/include/AuroraMonitorDecision.h b/include/AuroraMonitorDecision.h new file mode 100644 index 0000000000..382f2fbb0d --- /dev/null +++ b/include/AuroraMonitorDecision.h @@ -0,0 +1,34 @@ +/** + * @file AuroraMonitorDecision.h + * @brief Pure decision functions for Aurora monitor blue/green detection. + * + * Extracted from MySQL_Monitor_Connection_Pool for unit testability. + * These functions have no global state dependencies. + */ + +#ifndef __CLASS_AURORA_MONITOR_DECISION_H +#define __CLASS_AURORA_MONITOR_DECISION_H + +/** + * @brief Determine if a returned connection should be rejected based on + * switchover timing. + * + * A connection is considered stale (pre-switchover) if it was checked out + * before the switchover was detected for its hostname. + * + * @param checkout_time Monotonic timestamp when the connection was checked out or created. + * 0 means unknown (non-Aurora monitor thread); always accepted. + * @param switchover_time Timestamp when the switchover was detected for the hostname. + * 0 means no switchover recorded. + * @return true if the connection should be rejected (stale), false if it can be re-pooled. + */ +inline bool should_reject_pooled_connection( + unsigned long long checkout_time, + unsigned long long switchover_time +) { + if (switchover_time == 0) return false; + if (checkout_time == 0) return false; + return checkout_time < switchover_time; +} + +#endif // __CLASS_AURORA_MONITOR_DECISION_H diff --git a/include/DNS_Cache.hpp b/include/DNS_Cache.hpp index a994a23b7f..29613a3849 100644 --- a/include/DNS_Cache.hpp +++ b/include/DNS_Cache.hpp @@ -83,6 +83,16 @@ class DNS_Cache { void clear(); bool empty() const; std::string lookup(const std::string& hostname, size_t* ip_count) const; + /** + * @brief Check if a hostname's cached IPs include the given IP. + * @param hostname The hostname to look up in the cache. + * @param ip The IP address to search for. + * @return true if the IP is found, or if the cache is disabled, or if + * the hostname is not in the cache (no evidence of mismatch). + * Returns false only when the hostname IS cached and the IP is + * NOT among its resolved addresses. + */ + bool contains_ip(const std::string& hostname, const std::string& ip) const; private: struct IP_ADDR { diff --git a/include/MySQL_Monitor.hpp b/include/MySQL_Monitor.hpp index ed1020fe39..8d602006c4 100644 --- a/include/MySQL_Monitor.hpp +++ b/include/MySQL_Monitor.hpp @@ -264,6 +264,12 @@ class MySQL_Monitor_State_Data { * @details Currently only used by 'group_replication'. */ uint64_t init_time = 0; + /** + * @brief Monotonic time when a pooled connection was checked out via get_connection(). + * @details Used by blue/green switchover detection to reject pre-switchover connections. + * Set once at checkout, never modified. 0 if connection was newly created. + */ + unsigned long long pool_checkout_time = 0; /** * @brief Used by GroupReplication to determine if servers reported by cluster 'members' are already monitored. * @details This way we avoid non-needed locking on 'MySQL_HostGroups_Manager' for server search. diff --git a/lib/DNS_Cache.cpp b/lib/DNS_Cache.cpp index 689e0a0279..d28b4e138a 100644 --- a/lib/DNS_Cache.cpp +++ b/lib/DNS_Cache.cpp @@ -347,3 +347,33 @@ bool DNS_Cache::empty() const { return result; } + +/** + * @brief Check if a hostname's cached IPs include the given IP. + * @param hostname The hostname to look up in the cache. + * @param ip The IP address to search for. + * @return true if found, cache disabled, or hostname not cached. false only + * when hostname IS cached and ip is NOT among its resolved addresses. + */ +bool DNS_Cache::contains_ip(const std::string& hostname, const std::string& ip) const { + if (!enabled) return true; + + int rc = pthread_rwlock_rdlock(&rwlock_); + assert(rc == 0); + + bool found = true; + auto itr = records.find(hostname); + if (itr != records.end()) { + found = false; + for (const auto& cached_ip : itr->second.ips) { + if (cached_ip == ip) { + found = true; + break; + } + } + } + + rc = pthread_rwlock_unlock(&rwlock_); + assert(rc == 0); + return found; +} diff --git a/lib/MySQL_Monitor.cpp b/lib/MySQL_Monitor.cpp index 1ea5077175..450015dab8 100644 --- a/lib/MySQL_Monitor.cpp +++ b/lib/MySQL_Monitor.cpp @@ -23,6 +23,7 @@ using json = nlohmann::json; #include "MySQL_Protocol.h" #include "MySQL_HostGroups_Manager.h" #include "MySQL_Monitor.hpp" +#include "AuroraMonitorDecision.h" #include "ProxySQL_Cluster.hpp" #include "proxysql.h" #include "cpp.h" @@ -240,11 +241,17 @@ class MySQL_Monitor_Connection_Pool { #endif // DEBUG // std::map, std::vector > my_connections; std::unique_ptr servers; + std::unordered_map switchover_timestamps; public: MYSQL * get_connection(char *hostname, int port, MySQL_Monitor_State_Data *mmsd); void put_connection(char *hostname, MySQL_Monitor_State_Data* mmsd); void purge_some_connections(); void purge_all_connections(); + /** + * @brief Close and remove all pooled connections for the given hostnames. + * @param hostnames List of hostnames whose connections should be purged. + */ + void purge_connections_for_hostnames(const std::vector& hostnames); void destroy_mysql_connection(MySQL_Monitor_State_Data* mmsd); MySQL_Monitor_Connection_Pool() { servers = std::unique_ptr(new PtrArray()); @@ -337,6 +344,7 @@ void MySQL_Monitor_Connection_Pool::purge_all_connections() { #endif } +/** @brief Close and unregister the MYSQL connection held by the given mmsd. */ void MySQL_Monitor_Connection_Pool::destroy_mysql_connection(MySQL_Monitor_State_Data* mmsd) { if (mmsd->mysql) { #ifdef DEBUG @@ -349,6 +357,7 @@ void MySQL_Monitor_Connection_Pool::destroy_mysql_connection(MySQL_Monitor_State } } +/** @brief Retrieve a pooled connection for the given hostname:port, or NULL if none available. */ MYSQL * MySQL_Monitor_Connection_Pool::get_connection(char *hostname, int port, MySQL_Monitor_State_Data *mmsd) { std::lock_guard lock(mutex); #ifdef DEBUG @@ -389,6 +398,9 @@ MYSQL * MySQL_Monitor_Connection_Pool::get_connection(char *hostname, int port, my = mysql; break; } + if (my && mmsd) { + mmsd->pool_checkout_time = now; + } #ifdef DEBUG // 'my' can be NULL due to connection cleanup, and can cause crash if (my) { @@ -417,16 +429,33 @@ MYSQL * MySQL_Monitor_Connection_Pool::get_connection(char *hostname, int port, return my; } +/** @brief Return a connection to the pool, or close it if the hostname is under switchover quarantine. */ void MySQL_Monitor_Connection_Pool::put_connection(char* hostname, MySQL_Monitor_State_Data* mmsd) { - + if (!mmsd->mysql) return; - + unsigned long long now = monotonic_time(); int port = mmsd->port; MYSQL* my = mmsd->mysql; std::lock_guard lock(mutex); + auto sit = switchover_timestamps.find(hostname); + unsigned long long checkout_time = mmsd->pool_checkout_time; + unsigned long long sw_time = (sit != switchover_timestamps.end()) ? sit->second : 0; + if (should_reject_pooled_connection(checkout_time, sw_time)) { +#ifdef DEBUG + pthread_mutex_lock(&m2); + for (unsigned int j = 0; j < conns->len; ++j) { + if (conns->index(j) == my) { conns->remove_index_fast(j); break; } + } + pthread_mutex_unlock(&m2); +#endif + close_mysql(my); + mmsd->mysql = NULL; + return; + } + #ifdef DEBUG pthread_mutex_lock(&m2); #endif @@ -470,6 +499,7 @@ void MySQL_Monitor_Connection_Pool::put_connection(char* hostname, MySQL_Monitor mmsd->mysql = NULL; } +/** @brief Close connections that have been idle longer than the configured TTL. */ void MySQL_Monitor_Connection_Pool::purge_some_connections() { unsigned long long now = monotonic_time(); std::lock_guard lock(mutex); @@ -500,6 +530,37 @@ void MySQL_Monitor_Connection_Pool::purge_some_connections() { #endif // DEBUG } +/** + * @brief Close and remove all pooled connections for the given hostnames. + * @details Also records switchover timestamps so that in-flight connections + * returned later via put_connection() are rejected. + * @param hostnames List of hostnames whose connections should be purged. + */ +void MySQL_Monitor_Connection_Pool::purge_connections_for_hostnames(const std::vector& hostnames) { + std::lock_guard lock(mutex); +#ifdef DEBUG + pthread_mutex_lock(&m2); +#endif + unsigned long long now = monotonic_time(); + std::set to_purge; + for (const auto& hostname : hostnames) { + switchover_timestamps[hostname] = now; + to_purge.insert(hostname); + } + for (unsigned int i = 0; i < servers->len; i++) { + MonMySrvC *srv = (MonMySrvC *)servers->index(i); + if (to_purge.find(srv->address) == to_purge.end()) continue; + while (srv->conns->len) { + MYSQL *my = (MYSQL *)srv->conns->remove_index_fast(0); + if (!my) continue; + close_mysql(my); + } + } +#ifdef DEBUG + pthread_mutex_unlock(&m2); +#endif +} + /* void MySQL_Monitor_Connection_Pool::purge_idle_connections() { unsigned long long now = monotonic_time(); @@ -1590,6 +1651,7 @@ bool MySQL_Monitor_State_Data::set_wait_timeout() { return ret; } +/** @brief Create a new MySQL connection for monitoring, resolving DNS and setting pool_checkout_time. */ bool MySQL_Monitor_State_Data::create_new_connection() { mysql=mysql_init(NULL); assert(mysql); @@ -1641,6 +1703,7 @@ bool MySQL_Monitor_State_Data::create_new_connection() { fcntl(mysql->net.fd, F_SETFL, f|O_NONBLOCK); #endif /* FD_CLOEXEC */ MySQL_Monitor::update_dns_cache_from_mysql_conn(mysql); + pool_checkout_time = monotonic_time(); } return true; } @@ -5811,6 +5874,7 @@ typedef struct _host_def_t { int use_ssl; } host_def_t; +/** @brief Fisher-Yates shuffle of the host array for randomized connection selection. */ static void shuffle_hosts(host_def_t *array, size_t n) { char tmp[sizeof(host_def_t)]; char *arr = (char *)array; @@ -5828,6 +5892,97 @@ static void shuffle_hosts(host_def_t *array, size_t n) { } } +/** + * @brief Detect Aurora blue/green deployment switchover via peer IP vs DNS mismatch. + * @return true if switchover was detected and response actions were taken. + */ +static bool aws_aurora_check_blue_green_switchover( + MySQL_Monitor_State_Data *mmsd, unsigned int wHG, + host_def_t *hpa, unsigned int num_hosts +) { + if (mmsd->interr != 0 || mmsd->mysql_error_msg || !mmsd->mysql || !GloMyMon->dns_cache) return false; + + std::string peer_ip = get_connected_peer_ip_from_socket(mmsd->mysql->net.fd); + if (peer_ip.empty()) return false; + if (GloMyMon->dns_cache->contains_ip(mmsd->hostname, peer_ip)) return false; + + struct addrinfo hints, *res = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_protocol = IPPROTO_TCP; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_ADDRCONFIG; + hints.ai_family = mysql_resolution_family_to_ai_family(mysql_thread___resolution_family); + + int gai_rc = getaddrinfo(mmsd->hostname, NULL, &hints, &res); + if (gai_rc != 0 || !res) { + if (res) freeaddrinfo(res); + return false; + } + + bool ip_found = false; + char ip_buf[INET6_ADDRSTRLEN]; + std::vector resolved_ips; + for (struct addrinfo *p = res; p != NULL; p = p->ai_next) { + if (p->ai_family == AF_INET) { + inet_ntop(AF_INET, &((struct sockaddr_in*)p->ai_addr)->sin_addr, ip_buf, sizeof(ip_buf)); + } else if (p->ai_family == AF_INET6) { + inet_ntop(AF_INET6, &((struct sockaddr_in6*)p->ai_addr)->sin6_addr, ip_buf, sizeof(ip_buf)); + } else { + continue; + } + resolved_ips.push_back(ip_buf); + if (peer_ip == ip_buf) { + ip_found = true; + } + } + freeaddrinfo(res); + + GloMyMon->dns_cache->add(std::string(mmsd->hostname), std::move(resolved_ips)); + + if (ip_found) return false; + + proxy_warning( + "AWS Aurora: Blue/Green switchover detected for HG %u. " + "Host %s:%d peer IP %s not in current DNS resolution. " + "Purging stale connections and triggering re-discovery.\n", + wHG, mmsd->hostname, mmsd->port, peer_ip.c_str() + ); + + GloMyMon->My_Conn_Pool->destroy_mysql_connection(mmsd); + + std::vector hg_hostnames; + hg_hostnames.reserve(num_hosts); + for (unsigned int i = 0; i < num_hosts; i++) { + hg_hostnames.push_back(hpa[i].host); + } + GloMyMon->My_Conn_Pool->purge_connections_for_hostnames(hg_hostnames); + + MySQL_Monitor::trigger_dns_cache_update(); + + for (unsigned int i = 0; i < num_hosts; i++) { + MyHGM->shun_and_killall(hpa[i].host, hpa[i].port); + } + + time_t __timer; + char lut[30]; + struct tm __tm_info; + time(&__timer); + localtime_r(&__timer, &__tm_info); + strftime(lut, 25, "%Y-%m-%d %H:%M:%S", &__tm_info); + std::string hostname_escaped(mmsd->hostname); + size_t pos = 0; + while ((pos = hostname_escaped.find('\'', pos)) != std::string::npos) { + hostname_escaped.replace(pos, 1, "''"); + pos += 2; + } + std::string q = std::string("INSERT INTO mysql_server_aws_aurora_failovers VALUES (") + + std::to_string(wHG) + ", '" + hostname_escaped + "', '" + lut + "')"; + GloMyMon->monitordb->execute(q.c_str()); + + return true; +} + +/** @brief Per-hostgroup Aurora monitor thread: checks topology, detects switchovers, evaluates lag. */ void * monitor_AWS_Aurora_thread_HG(void *arg) { unsigned int wHG = *(unsigned int *)arg; unsigned int rHG = 0; @@ -6185,31 +6340,38 @@ void * monitor_AWS_Aurora_thread_HG(void *arg) { mmsd->result=NULL; } - if (lasts_ase[ase_idx]) { - AWS_Aurora_status_entry * l_ase = lasts_ase[ase_idx]; - delete l_ase; - } - lasts_ase[ase_idx] = ase_l; - GloMyMon->evaluate_aws_aurora_results(wHG, rHG, &lasts_ase[0], ase_idx, max_lag_ms, add_lag_ms, min_lag_ms, lag_num_checks); + // Blue/Green Deployment Switchover Detection + // NOTE: The live getaddrinfo() inside this call is blocking and can stall + // this thread if DNS is unreachable (OS-dependent timeout, typically 5-30s). + // It only fires when the DNS cache already disagrees with the peer IP. + bool blue_green_detected = aws_aurora_check_blue_green_switchover(mmsd, wHG, hpa, num_hosts); - // Auto-purge servers that disappear from REPLICA_HOST_STATUS - // Only process if autopurge is enabled and query was successful with results - if (autopurge_missing_checks > 0 && mmsd->interr == 0 && ase->host_statuses->size() > 0) { - GloMyMon->aws_aurora_autopurge_servers(wHG, rHG, ase, autopurge_missing_checks, autopurge_counter, domain_name); - } + if (blue_green_detected) { + delete ase_l; + ase_l = NULL; + } else { + if (lasts_ase[ase_idx]) { + AWS_Aurora_status_entry * l_ase = lasts_ase[ase_idx]; + delete l_ase; + } + lasts_ase[ase_idx] = ase_l; + GloMyMon->evaluate_aws_aurora_results(wHG, rHG, &lasts_ase[0], ase_idx, max_lag_ms, add_lag_ms, min_lag_ms, lag_num_checks); + + if (autopurge_missing_checks > 0 && mmsd->interr == 0 && ase->host_statuses->size() > 0) { + GloMyMon->aws_aurora_autopurge_servers(wHG, rHG, ase, autopurge_missing_checks, autopurge_counter, domain_name); + } - for (auto h : *(ase_l->host_statuses)) { - for (auto h2 : *(ase->host_statuses)) { - if (strcmp(h2->server_id, h->server_id) == 0) { - h2->estimated_lag_ms = h->estimated_lag_ms; + for (auto h : *(ase_l->host_statuses)) { + for (auto h2 : *(ase->host_statuses)) { + if (strcmp(h2->server_id, h->server_id) == 0) { + h2->estimated_lag_ms = h->estimated_lag_ms; + } } } - } - // remember that we call evaluate_aws_aurora_results() - // *before* shifting ase_idx - ase_idx++; - if (ase_idx == N_L_ASE) { - ase_idx = 0; + ase_idx++; + if (ase_idx == N_L_ASE) { + ase_idx = 0; + } } //__end_process_aws_aurora_result: diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile index 7f0beea241..a004bf557a 100644 --- a/test/tap/tests/unit/Makefile +++ b/test/tap/tests/unit/Makefile @@ -372,7 +372,9 @@ UNIT_TESTS := smoke_test-t query_cache_unit-t query_processor_unit-t \ gtid_server_data_unit-t \ admin_disk_upgrade_unit-t \ glovars_unit-t \ - pgsql_servers_ssl_params_unit-t + pgsql_servers_ssl_params_unit-t \ + dns_cache_unit-t \ + aurora_monitor_decision_unit-t # Plugin-chassis + mysqlx-plugin unit tests — built only when # libproxysql.a was compiled with -DPROXYSQL40 (autodetected higher up diff --git a/test/tap/tests/unit/aurora_monitor_decision_unit-t.cpp b/test/tap/tests/unit/aurora_monitor_decision_unit-t.cpp new file mode 100644 index 0000000000..87937428e9 --- /dev/null +++ b/test/tap/tests/unit/aurora_monitor_decision_unit-t.cpp @@ -0,0 +1,71 @@ +/** + * @file aurora_monitor_decision_unit-t.cpp + * @brief Unit tests for Aurora monitor blue/green switchover decision logic. + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "AuroraMonitorDecision.h" + +/** @brief Test that no rejection occurs when no switchover is recorded. */ +static void test_no_switchover_recorded() { + ok(should_reject_pooled_connection(1000, 0) == false, + "no rejection when no switchover recorded (switchover_time=0)"); + ok(should_reject_pooled_connection(0, 0) == false, + "no rejection when both timestamps are zero"); +} + +/** @brief Test that newly created connections (checkout_time=0) are always accepted. */ +static void test_newly_created_connection() { + ok(should_reject_pooled_connection(0, 200) == false, + "accept: newly created connection (checkout_time=0) even with active switchover"); + ok(should_reject_pooled_connection(0, 1000000) == false, + "accept: newly created connection always passes regardless of switchover time"); +} + +/** @brief Test that connections checked out before switchover are rejected. */ +static void test_connection_before_switchover() { + ok(should_reject_pooled_connection(100, 200) == true, + "reject: connection pooled at 100, switchover at 200"); + ok(should_reject_pooled_connection(199, 200) == true, + "reject: connection pooled at 199, switchover at 200"); + ok(should_reject_pooled_connection(1, 1000000) == true, + "reject: connection pooled long before switchover"); +} + +/** @brief Test that connections checked out after switchover are accepted. */ +static void test_connection_after_switchover() { + ok(should_reject_pooled_connection(200, 200) == false, + "accept: connection pooled at same time as switchover"); + ok(should_reject_pooled_connection(201, 200) == false, + "accept: connection pooled at 201, switchover at 200"); + ok(should_reject_pooled_connection(1000000, 200) == false, + "accept: connection pooled long after switchover"); +} + +/** @brief Test rejection logic across multiple successive switchovers. */ +static void test_multiple_switchovers() { + ok(should_reject_pooled_connection(150, 100) == false, + "accept: connection pooled after first switchover"); + ok(should_reject_pooled_connection(150, 200) == true, + "reject: same connection is stale relative to second switchover"); +} + +/** @brief Entry point for aurora_monitor_decision unit tests. */ +int main() { + plan(13); + + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_no_switchover_recorded(); // 2 + test_newly_created_connection(); // 2 + test_connection_before_switchover(); // 3 + test_connection_after_switchover(); // 3 + test_multiple_switchovers(); // 2 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/dns_cache_unit-t.cpp b/test/tap/tests/unit/dns_cache_unit-t.cpp new file mode 100644 index 0000000000..9312c5964b --- /dev/null +++ b/test/tap/tests/unit/dns_cache_unit-t.cpp @@ -0,0 +1,128 @@ +/** + * @file dns_cache_unit-t.cpp + * @brief Unit tests for DNS_Cache::contains_ip() used by Aurora blue/green detection. + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "MySQL_Monitor.hpp" + +/** @brief Test basic IP lookup in a populated cache. */ +static void test_contains_ip_basic() { + DNS_Cache cache; + + // Add a hostname with two IPs + cache.add("host1.cluster.amazonaws.com", {"10.0.0.1", "10.0.0.2"}); + + ok(cache.contains_ip("host1.cluster.amazonaws.com", "10.0.0.1") == true, + "contains_ip: finds first IP in cache"); + ok(cache.contains_ip("host1.cluster.amazonaws.com", "10.0.0.2") == true, + "contains_ip: finds second IP in cache"); + ok(cache.contains_ip("host1.cluster.amazonaws.com", "10.0.0.3") == false, + "contains_ip: returns false for IP not in cache"); +} + +/** @brief Test that unknown hostnames return true (no mismatch evidence). */ +static void test_contains_ip_unknown_hostname() { + DNS_Cache cache; + + cache.add("known.host.com", {"192.168.1.1"}); + + ok(cache.contains_ip("unknown.host.com", "192.168.1.1") == true, + "contains_ip: returns true for unknown hostname (no evidence of mismatch)"); + ok(cache.contains_ip("unknown.host.com", "10.0.0.1") == true, + "contains_ip: returns true for unknown hostname with any IP"); +} + +/** @brief Test cache update simulating blue/green DNS switchover. */ +static void test_contains_ip_after_update() { + DNS_Cache cache; + + // Initial state: hostname resolves to blue cluster IP + cache.add("writer.cluster.amazonaws.com", {"10.0.1.100"}); + ok(cache.contains_ip("writer.cluster.amazonaws.com", "10.0.1.100") == true, + "contains_ip: finds blue cluster IP before switchover"); + + // Simulate DNS update after blue/green switchover: hostname now resolves to green cluster IP + cache.add("writer.cluster.amazonaws.com", {"10.0.2.200"}); + ok(cache.contains_ip("writer.cluster.amazonaws.com", "10.0.1.100") == false, + "contains_ip: old blue IP not found after DNS update"); + ok(cache.contains_ip("writer.cluster.amazonaws.com", "10.0.2.200") == true, + "contains_ip: new green IP found after DNS update"); +} + +/** @brief Test that disabled cache returns true (skip detection). */ +static void test_contains_ip_disabled_cache() { + DNS_Cache cache; + cache.add("host.com", {"10.0.0.1"}); + + // Disable the cache — returns true (no evidence of mismatch, skip detection) + cache.set_enabled_flag(false); + + ok(cache.contains_ip("host.com", "10.0.0.1") == true, + "contains_ip: returns true when cache is disabled (skip detection)"); + ok(cache.contains_ip("host.com", "99.99.99.99") == true, + "contains_ip: returns true for any IP when cache is disabled"); + + // Re-enable + cache.set_enabled_flag(true); + ok(cache.contains_ip("host.com", "10.0.0.1") == true, + "contains_ip: returns true again when cache re-enabled"); + ok(cache.contains_ip("host.com", "99.99.99.99") == false, + "contains_ip: returns false for wrong IP when cache re-enabled"); +} + +/** @brief Test that empty cache returns true (hostname not tracked). */ +static void test_contains_ip_empty_cache() { + DNS_Cache cache; + + ok(cache.contains_ip("any.host.com", "1.2.3.4") == true, + "contains_ip: returns true on empty cache (hostname not tracked)"); +} + +/** @brief Test IPv6 address lookup. */ +static void test_contains_ip_ipv6() { + DNS_Cache cache; + + cache.add("host.com", {"2001:db8::1", "2001:db8::2"}); + + ok(cache.contains_ip("host.com", "2001:db8::1") == true, + "contains_ip: finds IPv6 address"); + ok(cache.contains_ip("host.com", "2001:db8::3") == false, + "contains_ip: returns false for missing IPv6 address"); +} + +/** @brief Test that removed hostname returns true (no longer tracked). */ +static void test_contains_ip_after_remove() { + DNS_Cache cache; + + cache.add("host.com", {"10.0.0.1"}); + ok(cache.contains_ip("host.com", "10.0.0.1") == true, + "contains_ip: finds IP before removal"); + + cache.remove("host.com"); + ok(cache.contains_ip("host.com", "10.0.0.1") == true, + "contains_ip: returns true after hostname removed (no longer tracked)"); +} + +/** @brief Entry point for dns_cache unit tests. */ +int main() { + plan(18); + + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_contains_ip_basic(); // 3 + test_contains_ip_unknown_hostname();// 2 + test_contains_ip_after_update(); // 3 + test_contains_ip_disabled_cache(); // 4 + test_contains_ip_empty_cache(); // 1 + test_contains_ip_ipv6(); // 2 + test_contains_ip_after_remove(); // 2 + + test_cleanup_minimal(); + return exit_status(); +}