From 51ff7fc9b3ec695f2723c73640b81b0a83792922 Mon Sep 17 00:00:00 2001 From: kai lin Date: Tue, 14 Oct 2025 16:23:22 -0400 Subject: [PATCH] feat: Add service-specific endpoints support to FSM config parser Add support for parsing [services] sections with service-specific endpoint_url configuration following AWS SDKs & Tools specification. --- .../aws/core/config/AWSProfileConfig.h | 25 ++ .../AWSConfigFileProfileConfigLoader.cpp | 98 ++++- .../ServiceEndpointsConfigFileLoaderTest.cpp | 398 ++++++++++++++++++ 3 files changed, 515 insertions(+), 6 deletions(-) create mode 100644 tests/aws-cpp-sdk-core-tests/aws/config/ServiceEndpointsConfigFileLoaderTest.cpp diff --git a/src/aws-cpp-sdk-core/include/aws/core/config/AWSProfileConfig.h b/src/aws-cpp-sdk-core/include/aws/core/config/AWSProfileConfig.h index 4f08231d9cc..87aa95afa89 100644 --- a/src/aws-cpp-sdk-core/include/aws/core/config/AWSProfileConfig.h +++ b/src/aws-cpp-sdk-core/include/aws/core/config/AWSProfileConfig.h @@ -8,6 +8,7 @@ #include #include #include +#include namespace Aws { @@ -19,6 +20,24 @@ namespace Aws class Profile { public: + /* + * Data container for service endpoints. + */ + class Services + { + public: + Services() = default; + Services(Aws::Map&& endpoints, Aws::String name) + : m_endpoints(std::move(endpoints)), m_name(std::move(name)) {} + + inline Aws::Map GetEndpoints() const { return m_endpoints; } + inline Aws::String GetServiceBlockName() const { return m_name; } + inline bool IsSet() const { return !m_name.empty(); } + private: + Aws::Map m_endpoints; + Aws::String m_name; + }; + /* * Data container for a sso-session config entry. * This is independent of the general profile configuration and used by a bearer auth token provider. @@ -84,6 +103,10 @@ namespace Aws inline void SetSourceProfile(const Aws::String& value ) { m_sourceProfile = value; } inline const Aws::String& GetCredentialProcess() const { return m_credentialProcess; } inline void SetCredentialProcess(const Aws::String& value ) { m_credentialProcess = value; } + inline const Aws::String& GetGlobalEndpointUrl() const { return m_endpointUrl; } + inline void SetGlobalEndpointUrl(const Aws::String& value) { m_endpointUrl = value; } + inline Services GetServices() const { return m_services; } + inline void SetServices(Services&& services) { m_services = std::move(services); } inline void SetAllKeyValPairs(const Aws::Map& map) { m_allKeyValPairs = map; } inline void SetAllKeyValPairs(Aws::Map&& map) { m_allKeyValPairs = std::move(map); } inline const Aws::String GetValue(const Aws::String& key) const @@ -111,7 +134,9 @@ namespace Aws Aws::String m_ssoAccountId; Aws::String m_ssoRoleName; Aws::String m_defaultsMode; + Aws::String m_endpointUrl; Aws::Map m_allKeyValPairs; + Services m_services; bool m_ssoSessionSet = false; SsoSession m_ssoSession; diff --git a/src/aws-cpp-sdk-core/source/config/AWSConfigFileProfileConfigLoader.cpp b/src/aws-cpp-sdk-core/source/config/AWSConfigFileProfileConfigLoader.cpp index dcdd4d0beb7..083f92d80c4 100644 --- a/src/aws-cpp-sdk-core/source/config/AWSConfigFileProfileConfigLoader.cpp +++ b/src/aws-cpp-sdk-core/source/config/AWSConfigFileProfileConfigLoader.cpp @@ -40,6 +40,9 @@ namespace Aws static const char PROFILE_SECTION[] = "profile"; static const char DEFAULT[] = "default"; static const char SSO_SESSION_SECTION[] = "sso-session"; + static const char SERVICES_SECTION[] = "services"; + static const char ENDPOINT_URL_KEY[] = "endpoint_url"; + static const char IGNORE_CONFIGURED_ENDPOINT_URLS_KEY[] = "ignore_configured_endpoint_urls"; static const char DEFAULTS_MODE_KEY[] = "defaults_mode"; static const char EQ = '='; static const char LEFT_BRACKET = '['; @@ -74,7 +77,8 @@ namespace Aws {EXTERNAL_ID_KEY, &Profile::SetExternalId, &Profile::GetExternalId}, {CREDENTIAL_PROCESS_COMMAND, &Profile::SetCredentialProcess, &Profile::GetCredentialProcess}, {SOURCE_PROFILE_KEY, &Profile::SetSourceProfile, &Profile::GetSourceProfile}, - {DEFAULTS_MODE_KEY, &Profile::SetDefaultsMode, &Profile::GetDefaultsMode}}; + {DEFAULTS_MODE_KEY, &Profile::SetDefaultsMode, &Profile::GetDefaultsMode}, + {ENDPOINT_URL_KEY, &Profile::SetGlobalEndpointUrl, &Profile::GetGlobalEndpointUrl}}; template const EntryT* FindInStaticArray(const EntryT (&array)[N], const Aws::String& searchKey) @@ -119,6 +123,7 @@ namespace Aws static const size_t ASSUME_EMPTY_LEN = 3; State currentState = START; Aws::String currentSectionName; + Aws::String activeServiceId; Aws::Map currentKeyValues; Aws::String rawLine; @@ -142,6 +147,7 @@ namespace Aws { FlushSection(currentState, currentSectionName, currentKeyValues); currentKeyValues.clear(); + activeServiceId.clear(); ParseSectionDeclaration(line, currentSectionName, currentState); continue; } @@ -158,6 +164,36 @@ namespace Aws } } + if(SERVICES_FOUND == currentState) + { + auto equalsPos = line.find(EQ); + if (equalsPos == std::string::npos) { + continue; // ignore garbage/blank in services section + } + + auto left = StringUtils::Trim(line.substr(0, equalsPos).c_str()); + auto right = StringUtils::Trim(line.substr(equalsPos + 1).c_str()); + + // New service block: "s3 =" (right hand side empty) + if (!left.empty() && right.empty()) { + activeServiceId = StringUtils::ToUpper(left.c_str()); + StringUtils::Replace(activeServiceId, " ", "_"); + continue; + } + + // Ignore global endpoint_url in [services name] section + if (activeServiceId.empty() && StringUtils::CaselessCompare(left.c_str(), ENDPOINT_URL_KEY) == 0) { + AWS_LOGSTREAM_DEBUG(PARSER_TAG, "Ignoring global endpoint_url in [services " << currentSectionName << "]"); + continue; + } + + // Property inside an active block: "endpoint_url = http://..." + if (!activeServiceId.empty() && left == ENDPOINT_URL_KEY) { + m_services[currentSectionName][activeServiceId] = right; + continue; + } + } + if(UNKNOWN_SECTION_FOUND == currentState) { // skip any unknown sections @@ -171,6 +207,22 @@ namespace Aws FlushSection(currentState, currentSectionName, currentKeyValues); + // Resolve service endpoints + for (auto& profilePair : m_foundProfiles) + { + Profile& profile = profilePair.second; + const Aws::String& servicesRef = profile.GetValue("services"); + if (!servicesRef.empty()) + { + auto servicesBlk = m_services.find(servicesRef); + Aws::Map endpoints; + if (servicesBlk != m_services.end()) { + endpoints = servicesBlk->second; + } + profile.SetServices(Profile::Services(std::move(endpoints), servicesRef)); + } + } + // Put sso-sessions into profiles for(auto& profile : m_foundProfiles) { @@ -222,6 +274,7 @@ namespace Aws START = 0, PROFILE_FOUND, SSO_SESSION_FOUND, + SERVICES_FOUND, UNKNOWN_SECTION_FOUND, FAILURE }; @@ -271,8 +324,9 @@ namespace Aws /** * A helper function to parse config section declaration line - * @param line, an input line, e.g. "[profile default]" + * @param line, an input line, e.g. "[profile default]" or "[services s3]" * @param ioSectionName, a return argument representing parsed section Identifier, e.g. "default" + * @param ioServiceId, a return argument representing parsed service ID for services sections * @param ioState, a return argument representing parser state, e.g. PROFILE_FOUND */ void ParseSectionDeclaration(const Aws::String& line, @@ -331,13 +385,13 @@ namespace Aws if(defaultProfileOrSsoSectionRequired) { - if (sectionIdentifier != DEFAULT && sectionIdentifier != SSO_SESSION_SECTION) + if (sectionIdentifier != DEFAULT && sectionIdentifier != SSO_SESSION_SECTION && sectionIdentifier != SERVICES_SECTION) { AWS_LOGSTREAM_ERROR(PARSER_TAG, "In configuration files, the profile name must start with " "profile keyword (except default profile): " << line); break; } - if (sectionIdentifier != SSO_SESSION_SECTION) + if (sectionIdentifier != SSO_SESSION_SECTION && sectionIdentifier != SERVICES_SECTION) { // profile found, still pending check for closing bracket ioState = PROFILE_FOUND; @@ -345,7 +399,7 @@ namespace Aws } } - if(!m_useProfilePrefix || sectionIdentifier != SSO_SESSION_SECTION) + if(!m_useProfilePrefix || (sectionIdentifier != SSO_SESSION_SECTION && sectionIdentifier != SERVICES_SECTION)) { // profile found, still pending check for closing bracket ioState = PROFILE_FOUND; @@ -374,6 +428,32 @@ namespace Aws ioSectionName = sectionIdentifier; } + if(sectionIdentifier == SERVICES_SECTION) + { + // Check if this is [services] or [services name] + pos = line.find_first_not_of(WHITESPACE_CHARACTERS, pos); + if(pos == Aws::String::npos || line[pos] == RIGHT_BRACKET) + { + // This is just [services] section + AWS_LOGSTREAM_ERROR(PARSER_TAG, "[services] section without name is not supported: " << line); + break; + } + else + { + // This is [services name] section + sectionIdentifier = ParseIdentifier(line, pos, errorMsg); + if (!errorMsg.empty()) + { + AWS_LOGSTREAM_ERROR(PARSER_TAG, "Failed to parse services definition name: " << errorMsg << " " << line); + break; + } + pos += sectionIdentifier.length(); + // services definition found, still pending check for closing bracket + ioState = SERVICES_FOUND; + ioSectionName = sectionIdentifier; + } + } + pos = line.find_first_not_of(WHITESPACE_CHARACTERS, pos); if(pos == Aws::String::npos) { @@ -394,7 +474,7 @@ namespace Aws break; } // the rest is a comment, and we don't care about it. - if ((ioState != SSO_SESSION_FOUND && ioState != PROFILE_FOUND) || ioSectionName.empty()) + if ((ioState != SSO_SESSION_FOUND && ioState != PROFILE_FOUND && ioState != SERVICES_FOUND) || ioSectionName.empty()) { AWS_LOGSTREAM_FATAL(PARSER_TAG, "Unexpected parser state after attempting to parse section " << line); break; @@ -412,6 +492,7 @@ namespace Aws * (i.e. [profile default] and its key1=val1 under). * @param currentState, a current parser State, e.g. PROFILE_FOUND * @param currentSectionName, a current section identifier, e.g. "default" + * @param currentServiceId, a current service identifier for services sections * @param currentKeyValues, a map of parsed key-value properties of a section definition being recorded */ void FlushSection(const State currentState, const Aws::String& currentSectionName, Aws::Map& currentKeyValues) @@ -529,6 +610,10 @@ namespace Aws ssoSession.SetName(currentSectionName); ssoSession.SetAllKeyValPairs(std::move(currentKeyValues)); } + else if (SERVICES_FOUND == currentState) { + // Handle [services name] section - service endpoints are parsed inline during stream processing + AWS_LOGSTREAM_DEBUG(PARSER_TAG, "Processed [services " << currentSectionName << "] section"); + } else { AWS_LOGSTREAM_FATAL(PARSER_TAG, "Unknown parser error: unexpected state " << currentState); @@ -557,6 +642,7 @@ namespace Aws Aws::Map m_foundProfiles; Aws::Map m_foundSsoSessions; + Aws::Map> m_services; }; static const char* const CONFIG_FILE_LOADER = "Aws::Config::AWSConfigFileProfileConfigLoader"; diff --git a/tests/aws-cpp-sdk-core-tests/aws/config/ServiceEndpointsConfigFileLoaderTest.cpp b/tests/aws-cpp-sdk-core-tests/aws/config/ServiceEndpointsConfigFileLoaderTest.cpp new file mode 100644 index 00000000000..94ab7c8f2a5 --- /dev/null +++ b/tests/aws-cpp-sdk-core-tests/aws/config/ServiceEndpointsConfigFileLoaderTest.cpp @@ -0,0 +1,398 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +#include +#include +#include +#include +#include + +using namespace Aws::Utils; +using namespace Aws::Config; + +class ServiceEndpointsConfigFileLoaderTest : public Aws::Testing::AwsCppSdkGTestSuite +{ +}; + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestServiceSpecificEndpoints) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[profile default]\n"; + configFile << "region = us-west-2\n"; + configFile << "endpoint_url = https://global.example.com\n"; + configFile << "services = myservices\n"; + configFile << "\n[services myservices]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = http://localhost:9000\n"; + configFile << "dynamodb =\n"; + configFile << " endpoint_url = http://localhost:8000\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + + ASSERT_EQ(1u, profiles.size()); + ASSERT_NE(profiles.end(), profiles.find("default")); + + // Test global endpoint + auto profileIt = profiles.find("default"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + auto globalEndpoint = profile.GetGlobalEndpointUrl(); + ASSERT_FALSE(globalEndpoint.empty()); + ASSERT_STREQ("https://global.example.com", globalEndpoint.c_str()); + + // Test services endpoints are parsed correctly + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(2u, endpoints.size()); + ASSERT_EQ("http://localhost:9000", endpoints.at("S3")); + ASSERT_EQ("http://localhost:8000", endpoints.at("DYNAMODB")); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestServiceSpecificEndpointsOnly) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[services s3-minio]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = https://play.min.io:9000\n"; + configFile << "\n[profile dev-minio]\n"; + configFile << "services = s3-minio\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev-minio"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test that global endpoint is null when not set + auto globalEndpoint = profile.GetGlobalEndpointUrl(); + ASSERT_TRUE(globalEndpoint.empty()); + + // Test services endpoints are parsed correctly + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(1u, endpoints.size()); + ASSERT_EQ("https://play.min.io:9000", endpoints.at("S3")); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestGlobalEndpointOnly) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[profile dev-global]\n"; + configFile << "endpoint_url = https://play.min.io:9000\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev-global"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test global endpoint + auto globalEndpoint = profile.GetGlobalEndpointUrl(); + ASSERT_FALSE(globalEndpoint.empty()); + ASSERT_STREQ("https://play.min.io:9000", globalEndpoint.c_str()); + + // Test that services endpoints are not set + const auto& services = profile.GetServices(); + ASSERT_FALSE(services.IsSet()); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestServiceSpecificAndGlobalEndpoints) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[services s3-specific-and-global]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = https://play.min.io:9000\n"; + configFile << "\n[profile dev-s3-specific-and-global]\n"; + configFile << "endpoint_url = http://localhost:1234\n"; + configFile << "services = s3-specific-and-global\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev-s3-specific-and-global"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test services endpoints are parsed correctly + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(1u, endpoints.size()); + ASSERT_EQ("https://play.min.io:9000", endpoints.at("S3")); + + // Test global endpoint + auto globalEndpoint = profile.GetGlobalEndpointUrl(); + ASSERT_FALSE(globalEndpoint.empty()); + ASSERT_STREQ("http://localhost:1234", globalEndpoint.c_str()); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestMultipleServicesInDefinition) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[services testing-s3-and-eb]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = http://localhost:4567\n"; + configFile << "elastic_beanstalk =\n"; + configFile << " endpoint_url = http://localhost:8000\n"; + configFile << "\n[profile dev]\n"; + configFile << "services = testing-s3-and-eb\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test services endpoints are parsed correctly + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(2u, endpoints.size()); + ASSERT_EQ("http://localhost:4567", endpoints.at("S3")); + ASSERT_EQ("http://localhost:8000", endpoints.at("ELASTIC_BEANSTALK")); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestIgnoreGlobalEndpointInServicesSection) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[profile testing]\n"; + configFile << "services = bad-service-definition\n"; + configFile << "\n[services bad-service-definition]\n"; + configFile << "endpoint_url = http://do-not-use.aws/\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("testing"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test that global endpoint in services section is ignored + auto globalEndpoint = profile.GetGlobalEndpointUrl(); + ASSERT_TRUE(globalEndpoint.empty()); + + // Test that services endpoints are empty (global endpoint_url ignored) + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(0u, endpoints.size()); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestSourceProfileEndpointIsolation) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[profile A]\n"; + configFile << "credential_source = Ec2InstanceMetadata\n"; + configFile << "endpoint_url = https://profile-a-endpoint.aws/\n"; + configFile << "\n[profile B]\n"; + configFile << "source_profile = A\n"; + configFile << "role_arn = arn:aws:iam::123456789012:role/roleB\n"; + configFile << "services = profileB\n"; + configFile << "\n[services profileB]\n"; + configFile << "ec2 =\n"; + configFile << " endpoint_url = https://profile-b-ec2-endpoint.aws\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileBIt = profiles.find("B"); + ASSERT_NE(profiles.end(), profileBIt); + const auto& profileB = profileBIt->second; + auto profileAIt = profiles.find("A"); + ASSERT_NE(profiles.end(), profileAIt); + const auto& profileA = profileAIt->second; + + // Test that profile B has services endpoints + const auto& servicesB = profileB.GetServices(); + ASSERT_TRUE(servicesB.IsSet()); + const auto& endpointsB = servicesB.GetEndpoints(); + ASSERT_EQ(1u, endpointsB.size()); + ASSERT_EQ("https://profile-b-ec2-endpoint.aws", endpointsB.at("EC2")); + + // Test that profile B has no global endpoint (doesn't inherit from profile A) + auto globalEndpointB = profileB.GetGlobalEndpointUrl(); + ASSERT_TRUE(globalEndpointB.empty()); + + // Test that profile A still has its own global endpoint + auto globalEndpointA = profileA.GetGlobalEndpointUrl(); + ASSERT_FALSE(globalEndpointA.empty()); + ASSERT_STREQ("https://profile-a-endpoint.aws/", globalEndpointA.c_str()); + + // Test that profile A has no services name + const auto& servicesA = profileA.GetServices(); + ASSERT_FALSE(servicesA.IsSet()); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestIgnoreConfiguredEndpointUrls) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[profile default]\n"; + configFile << "ignore_configured_endpoint_urls = true\n"; + configFile << "endpoint_url = https://should-be-ignored.com\n"; + configFile << "\n[profile test]\n"; + configFile << "ignore_configured_endpoint_urls = TRUE\n"; + configFile << "\n[profile empty]\n"; + configFile << "ignore_configured_endpoint_urls =\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + + // Test flag is parsed and stored + ASSERT_STREQ("true", profiles.find("default")->second.GetValue("ignore_configured_endpoint_urls").c_str()); + ASSERT_STREQ("TRUE", profiles.find("test")->second.GetValue("ignore_configured_endpoint_urls").c_str()); + ASSERT_STREQ("", profiles.find("empty")->second.GetValue("ignore_configured_endpoint_urls").c_str()); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestMultipleServicesDefinitions) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[services foo]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = http://foo.com\n"; + configFile << "\n[services bar]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = http://bar.com\n"; + configFile << "\n[profile dev]\n"; + configFile << "services = foo\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test services endpoints are parsed correctly + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(1u, endpoints.size()); + ASSERT_EQ("http://foo.com", endpoints.at("S3")); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestDuplicateGlobalEndpointUrl) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[profile dev-global]\n"; + configFile << "endpoint_url = https://play.min.io:9000\n"; + configFile << "endpoint_url = https://play2.min.io:9000\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev-global"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test that last value wins for duplicate global endpoint_url + auto globalEndpoint = profile.GetGlobalEndpointUrl(); + ASSERT_FALSE(globalEndpoint.empty()); + ASSERT_STREQ("https://play2.min.io:9000", globalEndpoint.c_str()); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestDuplicateServiceEndpointUrl) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[services s3test]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = https://play.min.io:9000\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = https://play2.min.io:9000\n"; + configFile << "\n[profile dev]\n"; + configFile << "services = s3test\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test that last value wins for duplicate service endpoint_url + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(1u, endpoints.size()); + ASSERT_EQ("https://play2.min.io:9000", endpoints.at("S3")); +} + +TEST_F(ServiceEndpointsConfigFileLoaderTest, TestMixedDuplicateEndpoints) +{ + TempFile configFile(std::ios_base::out | std::ios_base::trunc); + ASSERT_TRUE(configFile.good()); + + configFile << "[profile dev-mixed]\n"; + configFile << "endpoint_url = https://global1.example.com\n"; + configFile << "services = mixed-services\n"; + configFile << "endpoint_url = https://global2.example.com\n"; + configFile << "\n[services mixed-services]\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = https://s3-first.example.com\n"; + configFile << "dynamodb =\n"; + configFile << " endpoint_url = https://dynamo.example.com\n"; + configFile << "s3 =\n"; + configFile << " endpoint_url = https://s3-last.example.com\n"; + configFile.flush(); + + AWSConfigFileProfileConfigLoader loader(configFile.GetFileName(), true); + ASSERT_TRUE(loader.Load()); + auto profiles = loader.GetProfiles(); + auto profileIt = profiles.find("dev-mixed"); + ASSERT_NE(profiles.end(), profileIt); + const auto& profile = profileIt->second; + + // Test that last global endpoint_url wins + auto globalEndpoint = profile.GetGlobalEndpointUrl(); + ASSERT_FALSE(globalEndpoint.empty()); + ASSERT_STREQ("https://global2.example.com", globalEndpoint.c_str()); + + // Test that last service endpoint_url wins, but other services remain + const auto& services = profile.GetServices(); + ASSERT_TRUE(services.IsSet()); + const auto& endpoints = services.GetEndpoints(); + ASSERT_EQ(2u, endpoints.size()); + ASSERT_EQ("https://s3-last.example.com", endpoints.at("S3")); + ASSERT_EQ("https://dynamo.example.com", endpoints.at("DYNAMODB")); +} \ No newline at end of file