From 2466d0367ddb14fb49a6ad08071558df53e1090a Mon Sep 17 00:00:00 2001 From: Doug Tabacco Date: Tue, 26 May 2026 14:17:36 -0700 Subject: [PATCH 1/2] aurora: Detect blue/green deployment switchovers via DNS mismatch After each successful Aurora monitor check (before evaluating results), compare the pooled connection's peer IP against the hostname's DNS resolution. If the hostname no longer resolves to the connected IP, trigger switchover response: destroy stale monitor connections, purge the connection pool with switchover timestamps to reject in-flight pre-switchover connections when returned, trigger DNS cache refresh, and shun backend servers to force proxy connection pool drain. When a switchover is detected, stale topology data from the old cluster is discarded (not stored in the history ring buffer) and the evaluate/ autopurge logic is skipped for that iteration. Co-Authored-By: Claude Opus 4.6 (1M context) --- include/AuroraMonitorDecision.h | 34 ++++++ include/DNS_Cache.hpp | 10 ++ include/MySQL_Monitor.hpp | 6 + lib/DNS_Cache.cpp | 30 +++++ lib/MySQL_Monitor.cpp | 208 ++++++++++++++++++++++++++++---- 5 files changed, 265 insertions(+), 23 deletions(-) create mode 100644 include/AuroraMonitorDecision.h 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: From b4bbbb1cb3df7463ec19e845c62eb56904093a91 Mon Sep 17 00:00:00 2001 From: Doug Tabacco Date: Tue, 26 May 2026 14:17:36 -0700 Subject: [PATCH 2/2] test: Add unit tests for Aurora blue/green switchover detection - dns_cache_unit-t: 18 assertions covering DNS_Cache::contains_ip() - aurora_monitor_decision_unit-t: 13 assertions covering should_reject_pooled_connection() timestamp logic Co-Authored-By: Claude Opus 4.6 (1M context) --- test/tap/tests/unit/Makefile | 4 +- .../unit/aurora_monitor_decision_unit-t.cpp | 71 ++++++++++ test/tap/tests/unit/dns_cache_unit-t.cpp | 128 ++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 test/tap/tests/unit/aurora_monitor_decision_unit-t.cpp create mode 100644 test/tap/tests/unit/dns_cache_unit-t.cpp 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(); +}