diff --git a/Gemfile b/Gemfile index 60e00dcd..d70190b2 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,8 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '3.4.8' gem 'aws-sdk-lambda' +gem 'aws-sdk-sts' +gem 'aws-sigv4' gem 'bootsnap', require: false gem 'devise' gem 'faraday_middleware-aws-sigv4' @@ -12,6 +14,7 @@ gem 'graphql' gem 'jwt' gem 'lograge' gem 'mitlibraries-theme', git: 'https://github.com/mitlibraries/mitlibraries-theme', tag: 'v1.4' +gem 'opensearch-aws-sigv4' gem 'opensearch-ruby' gem 'puma' gem 'rack-attack' @@ -24,7 +27,7 @@ gem 'sentry-ruby' gem 'uglifier' group :production do - gem 'connection_pool', '< 3' # 3.x requires keyword args; pin to 2.x for Rails 7.2.3 + gem 'connection_pool', '< 3' # 3.x requires keyword args; pin to 2.x for Rails 7.2.3 gem 'pg' end diff --git a/Gemfile.lock b/Gemfile.lock index 4aa8bf36..4341e3b9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,6 +102,9 @@ GEM aws-sdk-lambda (1.176.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) + aws-sdk-sts (1.12.0) + aws-sdk-core (~> 3, >= 3.110.0) + aws-sigv4 (~> 1.1) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) @@ -277,6 +280,9 @@ GEM nokogiri (1.19.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) + opensearch-aws-sigv4 (1.3.0) + aws-sigv4 (>= 1) + opensearch-ruby (>= 1.0.1, < 4.0) opensearch-ruby (3.4.0) faraday (>= 1.0, < 3) multi_json (>= 1.0) @@ -478,6 +484,8 @@ PLATFORMS DEPENDENCIES annotate aws-sdk-lambda + aws-sdk-sts + aws-sigv4 bootsnap byebug capybara @@ -497,6 +505,7 @@ DEPENDENCIES minitest (< 6) mitlibraries-theme! mocha + opensearch-aws-sigv4 opensearch-ruby pg puma diff --git a/README.md b/README.md index 36e8d52b..d9c9ce89 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,26 @@ This application interfaces with an OpenSearch backend and exposes a GraphQL endpoint to allow anonymous users to query our data. +- [Architecture Decision Records](#architecture-decision-records) +- [Developing this application](#developing-this-application) +- [Generating cassettes for tests](#generating-cassettes-for-tests) +- [Confirming functionality after updating dependencies](#confirming-functionality-after-updating-dependencies) +- [Publishing User Facing Documentation](#publishing-user-facing-documentation) + - [Running jekyll documentation locally](#running-jekyll-documentation-locally) + - [Automatic generation of technical specifications from GraphQL](#automatic-generation-of-technical-specifications-from-graphql) +- [General Configuration](#general-configuration) + - [Name and Domain](#name-and-domain) + - [Authentication](#authentication) + - [Email Configuration](#email-configuration) + - [Observability (Optional)](#observability-optional) + - [Rate Limiting (Optional)](#rate-limiting-optional) +- [AWS Configuration](#aws-configuration) + - [OpenSearch Configuration](#opensearch-configuration) + - [AWS Credentials (Used for AWS-based OpenSearch and timdex-semantic-builder)](#aws-credentials-used-for-aws-based-opensearch-and-timdex-semantic-builder) + - [AWS OpenSearch Service (Legacy)](#aws-opensearch-service-legacy) + - [AWS OpenSearch Serverless (AOSS)](#aws-opensearch-serverless-aoss) + - [TIMDEX Semantic Builder Lambda](#timdex-semantic-builder-lambda) + ## Architecture Decision Records This repository contains Architecture Decision Records in the @@ -127,7 +147,7 @@ to ensure everything looks as expected. bundle exec jekyll serve --incremental --source ./docs ``` -Once the jekyll server is running, you can access the local docs at http://localhost:4000/timdex/ +Once the jekyll server is running, you can access the local docs at Note: it is important to load the documentation from the `/timdex/` path locally as that is how it works when built and deployed to GitHub Pages so testing locally the same way will ensure our asset paths will work when deployed. @@ -146,51 +166,70 @@ The config file `./docs/reference/_spectaql_config.yml` controls the build proce and making changes to this file (which is included in version control) would be the main reason to run the process locally. -## Required Environment Variables (all ENVs) - -- `EMAIL_FROM`: email address to send message from, including the registration - and forgot password messages. -- `EMAIL_URL_HOST` - base url to use when sending emails that link back to the - application. In development, often `localhost:3000`. On heroku, often - `yourapp.herokuapp.com`. However, if you use a custom domain in production, - that should be the value you use in production. -- `JWT_SECRET_KEY`: generate with `rails secret` +## General Configuration -## Production required Environment Variables +### Name and Domain -- `AWS_ACCESS_KEY_ID`: AWS credentials for OpenSearch and Lambda -- `AWS_SECRET_ACCESS_KEY`: AWS credentials for OpenSearch and Lambda -- `AWS_REGION`: AWS region for OpenSearch and Lambda services -- `AWS_OPENSEARCH`: boolean. Set to true to enable AWSv4 Signing for OpenSearch -- `OPENSEARCH_INDEX`: Opensearch index or alias to query, default will be to search all indexes which is generally not - expected. `timdex` or `all-current` are aliases used consistently in our data pipelines, with - `timdex` being most likely what most use cases will want. -- `OPENSEARCH_URL`: Opensearch URL, defaults to `http://localhost:9200` -- `TIMDEX_SEMANTIC_BUILDER_FUNCTION_NAME`: AWS Lambda function name with alias for semantic query building. - Configurable to use alternative deployment tiers (e.g., dev1, stage, prod). -- `SMTP_ADDRESS` -- `SMTP_PASSWORD` -- `SMTP_PORT` -- `SMTP_USER` - -## Optional Environment Variables (all ENVs) - -- `AWS_SESSION_TOKEN`: AWS session token for temporary credentials when using expiring AWS credentials -- `OPENSEARCH_LOG` if `true`, verbosely logs OpenSearch queries. - - ```text - NOTE: do not set this ENV at all if you want ES logging fully disabled. - Setting it to `false` is still setting it and you will be annoyed and - confused. - ``` -- `OPENSEARCH_SOURCE_EXCLUDES` comma separated list of fields to exclude from the OpenSearch `_source` field. Leave unset to return all fields. - - recommended value: `embedding_full_record,fulltext` - `PLATFORM_NAME`: The value set is added to the header after the MIT Libraries logo. The logic and CSS for this comes from our theme gem. -- `PREFERRED_DOMAIN` - set this to the domain you would like to to use. Any - other requests that come to the app will redirect to the root of this domain. - This is useful to prevent access to herokuapp.com domains. -- `REQUESTS_PER_PERIOD` - requests allowed before throttling. Default is 100. -- `REQUEST_PERIOD` - number of minutes for the period in `REQUESTS_PER_PERIOD`. - Default is 1. +- `PREFERRED_DOMAIN`: set this to the domain you would like to use. Any other requests that come to the app will redirect to the root of this domain. This is useful to prevent access to herokuapp.com domains. + +### Authentication + +- `JWT_SECRET_KEY`: generate with `rails secret` **required** + +### Email Configuration + +- `EMAIL_FROM`: email address to send message from, including the registration and forgot password messages. **required** +- `EMAIL_URL_HOST`: base url to use when sending emails that link back to the application. In development, often `localhost:3000`. On heroku, often `yourapp.herokuapp.com`. However, if you use a custom domain in production, that should be the value you use in production. **required** +- `SMTP_ADDRESS`: SMTP server address (Required for production) +- `SMTP_PORT`: SMTP server port (Required for production) +- `SMTP_USER`: SMTP authentication user (Required for production) +- `SMTP_PASSWORD`: SMTP authentication password (Required for production) + +### Observability (Optional) + +- `RAILS_LOG_LEVEL`: defaults to debug in development and info in production - `SENTRY_DSN`: client key for Sentry exception logging - `SENTRY_ENV`: Sentry environment for the application. Defaults to 'unknown' if unset. + +### Rate Limiting (Optional) + +- `REQUESTS_PER_PERIOD`: requests allowed before throttling. Default is 100. +- `REQUEST_PERIOD`: number of minutes for the period in `REQUESTS_PER_PERIOD`. Default is 1. + +## AWS Configuration + +### OpenSearch Configuration + +- `OPENSEARCH_URL`: OpenSearch endpoint URL, defaults to `http://localhost:9200` +- `OPENSEARCH_INDEX`: OpenSearch index or alias to query. Defaults to searching all indexes (generally not recommended). `timdex` or `all-current` are aliases used consistently in our data pipelines, with `timdex` being most likely what most use cases will want. **required** +- `OPENSEARCH_LOG`: if set to `true` (case-insensitive), verbosely logs OpenSearch queries. Leave unset, or set to any other value such as `false`, to keep OpenSearch logging disabled. +- `OPENSEARCH_SOURCE_EXCLUDES`: comma-separated list of fields to exclude from the OpenSearch `_source` field. Leave unset to return all fields. Recommended value: `embedding_full_record,fulltext` + +### AWS Credentials (Used for AWS-based OpenSearch and timdex-semantic-builder) + +- `AWS_ACCESS_KEY_ID`: AWS access key for OpenSearch and Lambda +- `AWS_SECRET_ACCESS_KEY`: AWS secret key for OpenSearch and Lambda +- `AWS_REGION`: AWS region for OpenSearch and Lambda services +- `AWS_SESSION_TOKEN`: (Optional) AWS session token for temporary credentials when using expiring AWS credentials. + Use this with temporary AWS credentials for AWS-based OpenSearch access and Lambda. + For AOSS, when this is set, temporary credentials are used directly and `AWS_AOSS_ROLE_ARN` is not needed. + +### AWS OpenSearch Service (Legacy) + +This is our legacy AWS OpenSearch Service Cluster. All production instances should use this until our migration to Serverless (AOSS) is complete. + +- `AWS_OPENSEARCH`: boolean. Set to `true` to enable AWS SigV4 signing for AWS OpenSearch Service. This is the legacy approach and will be replaced with `AWS_AOSS` when we complete our migration to Serverless. + +### AWS OpenSearch Serverless (AOSS) + +This is our upcoming configuration once migration is complete. This uses a different [authentication mechanism](https://github.com/awsdocs/amazon-opensearch-service-developer-guide/blob/master/doc_source/serverless-clients.md#ruby) than our legacy AWS OpenSearch Service. + +- `AWS_AOSS`: boolean. Set to `true` to enable AWS OpenSearch Serverless (AOSS). +- `AWS_AOSS_ROLE_ARN`: AWS IAM role ARN to assume for AOSS authentication. **Required when** `AWS_AOSS=true` **and** `AWS_SESSION_TOKEN` is not set. This enables automatic credential refresh via role assumption. + When `AWS_SESSION_TOKEN` is present, temporary credentials are used directly and `AWS_AOSS_ROLE_ARN` is not needed. This is only used in local development. `AWS_AOSS_ROLE_ARN` is used in production. + +### TIMDEX Semantic Builder Lambda + +- `TIMDEX_SEMANTIC_BUILDER_FUNCTION_NAME`: AWS Lambda function name with alias for semantic query building. + Configurable to use alternative deployment tiers (e.g., dev1, stage, prod). Generally takes the format `function_name:live` where `live` is the alias. Failure to include the alias will result in extremely slow performance at best. Use the alias. Note: the lambda must be in the same AWS account as OpenSearch. If you want to test dev1 OpenSearch, you must also switch the lambda name to a dev1 variant. diff --git a/app/graphql/timdex_field_usage_analyzer.rb b/app/graphql/timdex_field_usage_analyzer.rb index aca1131b..41aa96c2 100644 --- a/app/graphql/timdex_field_usage_analyzer.rb +++ b/app/graphql/timdex_field_usage_analyzer.rb @@ -5,7 +5,7 @@ class TimdexFieldUsageAnalyzer < GraphQL::Analysis::AST::FieldUsage # This overrides a GraphQL::Analysis::AST::FieldUsage method def result - Rails.logger.info("GraphQL used fields: #{@used_fields.to_a}") + Rails.logger.debug("GraphQL used fields: #{@used_fields.to_a}") Rails.logger.info("GraphQL used deprecated fields: #{@used_deprecated_fields.to_a}") Rails.logger.info("GraphQL used deprecated arguments: #{@used_deprecated_arguments.to_a}") { diff --git a/config/environments/development.rb b/config/environments/development.rb index cdc6c972..5be627de 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -65,6 +65,9 @@ # Suppress logger output for asset requests. config.assets.quiet = true + # Allow changing log level in development + config.log_level = ENV['RAILS_LOG_LEVEL'] || :debug + # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/initializers/lambda.rb b/config/initializers/lambda.rb index 9e1321ed..452c58ef 100644 --- a/config/initializers/lambda.rb +++ b/config/initializers/lambda.rb @@ -1,11 +1,20 @@ require 'aws-sdk-lambda' def configure_lambda_client - Aws::Lambda::Client.new( - region: ENV.fetch('AWS_REGION', 'us-east-1'), - access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'), - secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY') - ) + if ENV['AWS_SESSION_TOKEN'].present? + Aws::Lambda::Client.new( + region: ENV.fetch('AWS_REGION', 'us-east-1'), + access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'), + secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY'), + session_token: ENV.fetch('AWS_SESSION_TOKEN') + ) + else + Aws::Lambda::Client.new( + region: ENV.fetch('AWS_REGION', 'us-east-1'), + access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'), + secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY') + ) + end end Timdex::LambdaClient = configure_lambda_client diff --git a/config/initializers/opensearch.rb b/config/initializers/opensearch.rb index e04681f7..07459a63 100644 --- a/config/initializers/opensearch.rb +++ b/config/initializers/opensearch.rb @@ -1,36 +1,132 @@ -require 'faraday_middleware/aws_sigv4' +require 'faraday_middleware/aws_sigv4' if ENV['AWS_OPENSEARCH'] == 'true' && ENV.fetch('AWS_AOSS', 'false') == 'false' +require 'opensearch-aws-sigv4' +require 'aws-sigv4' +require 'opensearch_config_validator' +# Helper method to parse OPENSEARCH_LOG as a boolean +# Environment variables are always strings, so 'false' is truthy +# Only treat as true if explicitly set to 'true' (case-insensitive) +def opensearch_logging_enabled? + ENV.fetch('OPENSEARCH_LOG', '').downcase == 'true' +end + +# Priority is given to AWS AOSS, then AWS OpenSearch, and finally vanilla OpenSearch def configure_opensearch - if ENV['AWS_OPENSEARCH'] == 'true' + if ENV['AWS_AOSS'] == 'true' + OpensearchConfigValidator.validate_aws_aoss_config + aws_aoss_client + elsif ENV['AWS_OPENSEARCH'] == 'true' + OpensearchConfigValidator.validate_aws_os_config aws_os_client else os_client end end +# os_client is used to connect to a standard OpenSearch cluster that does not require AWS SigV4 signing for +# authentication. It creates a new OpenSearch::Client with logging enabled based on the OPENSEARCH_LOG +# environment variable. +# +# @return [OpenSearch::Client] a client for connecting to a standard OpenSearch cluster +# @note This is mostly used for connecting to a locally running OpenSearch instance def os_client - OpenSearch::Client.new log: ENV.fetch('OPENSEARCH_LOG', false) + OpenSearch::Client.new log: opensearch_logging_enabled? end +# aws_os_client is used to connect to AWS OpenSearch Service which requires AWS SigV4 signing for authentication. It +# creates a new OpenSearch::Client and configures it to use the aws_sigv4 middleware for request signing. The middleware +# is configured with the AWS region, access key ID, secret access key, and optionally a session token if using temporary +# credentials. The OPENSEARCH_URL environment variable is used to specify the endpoint of the OpenSearch cluster. +# +# @return [OpenSearch::Client] a client for connecting to AWS OpenSearch Service (ES) +# @note This is the legacy method for this application and will be removed when we migrate to AOSS. +# @note AWS OpenSearch Service can use long-lived access keys, unlike AWS AOSS which requires temporary credentials +# obtained by assuming a role. def aws_os_client - OpenSearch::Client.new log: ENV.fetch('OPENSEARCH_LOG', false), url: ENV['OPENSEARCH_URL'] do |config| + OpenSearch::Client.new log: opensearch_logging_enabled?, url: ENV.fetch('OPENSEARCH_URL', nil) do |config| + Rails.logger.debug "Configuring Legacy AWS OpenSearch Service client" # personal keys use expiring credentials with tokens if ENV['AWS_SESSION_TOKEN'].present? + Rails.logger.debug 'Using temporary credentials with session token' config.request :aws_sigv4, - service: 'es', - region: ENV['AWS_REGION'], - access_key_id: ENV['AWS_ACCESS_KEY_ID'], - secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'], - session_token: ENV['AWS_SESSION_TOKEN'] + service: 'es', + region: ENV.fetch('AWS_REGION', nil), + access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID', nil), + secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY', nil), + session_token: ENV['AWS_SESSION_TOKEN'] # application keys don't use tokens else + Rails.logger.debug 'Using long-lived credentials without session token' config.request :aws_sigv4, - service: 'es', - region: ENV['AWS_REGION'], - access_key_id: ENV['AWS_ACCESS_KEY_ID'], - secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] + service: 'es', + region: ENV.fetch('AWS_REGION', nil), + access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID', nil), + secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY', nil) end end end +# aws_aoss_client is used to connect to AWS OpenSearch Serverless (AOSS) which has a different authentication mechanism +# than AWS OpenSearch Service. It uses AWS SigV4 signing for authentication, and the OpenSearch::Aws::Sigv4Client is +# specifically designed to handle this type of authentication. +# +# @return [OpenSearch::Aws::Sigv4Client] a client for connecting to AWS OpenSearch Serverless (AOSS) +# @note this configuration uses temporary credentials obtained by assuming a role or via the AWS console, unlike +# AWS OpenSearch Service which can use long-lived access keys directly. +def aws_aoss_client + Rails.logger.debug "Configuring AWS AOSS client" + + signer = Aws::Sigv4::Signer.new( + service: 'aoss', + region: ENV.fetch('AWS_REGION', nil), + credentials_provider: credentials + ) + + OpenSearch::Aws::Sigv4Client.new( + { + host: ENV.fetch('OPENSEARCH_URL', nil), + log: opensearch_logging_enabled? + }, + signer + ) +end + +def credentials + if ENV.fetch('AWS_SESSION_TOKEN', false).present? + Rails.logger.debug 'Using temporary credentials with session token' + temporary_credentials + else + Rails.logger.debug 'Using long-lived credentials and assuming role' + assume_role_credentials + end +end + +# personal keys use expiring credentials with tokens, so we use them directly without assuming a role +# application keys use long-lived credentials and assume a role to get temporary credentials for AOSS +def temporary_credentials + Aws::Credentials.new( + ENV.fetch('AWS_ACCESS_KEY_ID', nil), + ENV.fetch('AWS_SECRET_ACCESS_KEY', nil), + ENV.fetch('AWS_SESSION_TOKEN', nil) + ) +end + +# AWS AOSS uses temporary credentials that are obtained by assuming a role. The +# Aws::AssumeRoleCredentials class is used to get these temporary credentials. It requires the ARN of +# the role to assume, a session name, and a client for the AWS Security Token Service (STS) which is +# used to perform the AssumeRole operation. It uses the AWS region and access keys from the +# environment variables to create the STS client. When the session token expires, the +# Aws::AssumeRoleCredentials will automatically refresh the credentials by calling AssumeRole again. +def assume_role_credentials + Aws::AssumeRoleCredentials.new( + role_arn: ENV.fetch('AWS_AOSS_ROLE_ARN', nil), + role_session_name: 'timdex-opensearch', + client: Aws::STS::Client.new( + region: ENV.fetch('AWS_REGION', nil), + access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID', nil), + secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY', nil) + ) + ) +end + Timdex::OSClient = configure_opensearch diff --git a/lib/opensearch_config_validator.rb b/lib/opensearch_config_validator.rb new file mode 100644 index 00000000..9795e67d --- /dev/null +++ b/lib/opensearch_config_validator.rb @@ -0,0 +1,41 @@ +# OpensearchConfigValidator validates required environment variables for OpenSearch connections +# This is a separate class to allow for clean testing of initialization logic. +class OpensearchConfigValidator + # Validates that all required environment variables for AWS AOSS are present + # @raise [RuntimeError] if any required variable is missing + def self.validate_aws_aoss_config + # Always required for AWS AOSS + required_vars = { + 'OPENSEARCH_URL' => ENV.fetch('OPENSEARCH_URL', nil), + 'AWS_REGION' => ENV.fetch('AWS_REGION', nil), + 'AWS_ACCESS_KEY_ID' => ENV.fetch('AWS_ACCESS_KEY_ID', nil), + 'AWS_SECRET_ACCESS_KEY' => ENV.fetch('AWS_SECRET_ACCESS_KEY', nil) + } + + # Required only when AWS_SESSION_TOKEN is not present (using role assumption) + required_vars['AWS_AOSS_ROLE_ARN'] = ENV.fetch('AWS_AOSS_ROLE_ARN', nil) if ENV['AWS_SESSION_TOKEN'].blank? + + missing_vars = required_vars.select { |_key, value| value.blank? }.keys + + return unless missing_vars.any? + + raise "AWS AOSS Config Error: These required environment variables are not set: #{missing_vars.join(', ')}" + end + + # Validates that all required environment variables for AWS OpenSearch Service are present + # @raise [RuntimeError] if any required variable is missing + def self.validate_aws_os_config + required_vars = { + 'OPENSEARCH_URL' => ENV.fetch('OPENSEARCH_URL', nil), + 'AWS_REGION' => ENV.fetch('AWS_REGION', nil), + 'AWS_ACCESS_KEY_ID' => ENV.fetch('AWS_ACCESS_KEY_ID', nil), + 'AWS_SECRET_ACCESS_KEY' => ENV.fetch('AWS_SECRET_ACCESS_KEY', nil) + } + + missing_vars = required_vars.select { |_key, value| value.blank? }.keys + + return unless missing_vars.any? + + raise "AWS OpenSearch Config Error: These required environment variables are not set: #{missing_vars.join(', ')}" + end +end diff --git a/test/initializers/opensearch_config_test.rb b/test/initializers/opensearch_config_test.rb new file mode 100644 index 00000000..b5fdb063 --- /dev/null +++ b/test/initializers/opensearch_config_test.rb @@ -0,0 +1,224 @@ +require 'test_helper' +require 'opensearch_config_validator' + +class OpensearchConfigTest < ActiveSupport::TestCase + # AWS AOSS validation tests + test 'validate_aws_aoss_config raises error when required vars are missing' do + ClimateControl.modify( + AWS_AOSS: 'true', + OPENSEARCH_URL: nil, + AWS_REGION: nil, + AWS_AOSS_ROLE_ARN: nil, + AWS_ACCESS_KEY_ID: nil, + AWS_SECRET_ACCESS_KEY: nil + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_aoss_config + end + + assert_match(/AWS AOSS Config Error/, error.message) + assert_match(/OPENSEARCH_URL/, error.message) + assert_match(/AWS_REGION/, error.message) + assert_match(/AWS_ACCESS_KEY_ID/, error.message) + assert_match(/AWS_SECRET_ACCESS_KEY/, error.message) + end + end + + test 'validate_aws_aoss_config raises error when OPENSEARCH_URL is missing' do + ClimateControl.modify( + OPENSEARCH_URL: nil, + AWS_REGION: 'us-east-1', + AWS_AOSS_ROLE_ARN: 'arn:aws:iam::123456789:role/MyRole', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_aoss_config + end + + assert_match(/OPENSEARCH_URL/, error.message) + end + end + + test 'validate_aws_aoss_config raises error when AWS_REGION is missing' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.aoss.amazonaws.com', + AWS_REGION: nil, + AWS_AOSS_ROLE_ARN: 'arn:aws:iam::123456789:role/MyRole', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_aoss_config + end + + assert_match(/AWS_REGION/, error.message) + end + end + + test 'validate_aws_aoss_config raises error when AWS_ACCESS_KEY_ID is missing' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.aoss.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_AOSS_ROLE_ARN: 'arn:aws:iam::123456789:role/MyRole', + AWS_ACCESS_KEY_ID: nil, + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_aoss_config + end + + assert_match(/AWS_ACCESS_KEY_ID/, error.message) + end + end + + test 'validate_aws_aoss_config raises error when AWS_SECRET_ACCESS_KEY is missing' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.aoss.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_AOSS_ROLE_ARN: 'arn:aws:iam::123456789:role/MyRole', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: nil + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_aoss_config + end + + assert_match(/AWS_SECRET_ACCESS_KEY/, error.message) + end + end + + test 'validate_aws_aoss_config requires AWS_AOSS_ROLE_ARN when AWS_SESSION_TOKEN is not present' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.aoss.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_AOSS_ROLE_ARN: nil, + AWS_SESSION_TOKEN: nil, + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_aoss_config + end + + assert_match(/AWS_AOSS_ROLE_ARN/, error.message) + end + end + + test 'validate_aws_aoss_config does not require AWS_AOSS_ROLE_ARN when AWS_SESSION_TOKEN is present' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.aoss.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_AOSS_ROLE_ARN: nil, + AWS_SESSION_TOKEN: 'FwoGZXIvYXdzEBEaDKB...', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + assert_nil OpensearchConfigValidator.validate_aws_aoss_config + end + end + + test 'validate_aws_aoss_config succeeds with all required values present' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.aoss.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_AOSS_ROLE_ARN: 'arn:aws:iam::123456789:role/MyRole', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + assert_nil OpensearchConfigValidator.validate_aws_aoss_config + end + end + + # AWS OpenSearch validation tests + test 'validate_aws_os_config raises error when required vars are missing' do + ClimateControl.modify( + AWS_OPENSEARCH: 'true', + OPENSEARCH_URL: nil, + AWS_REGION: nil, + AWS_ACCESS_KEY_ID: nil, + AWS_SECRET_ACCESS_KEY: nil + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_os_config + end + + assert_match(/AWS OpenSearch Config Error/, error.message) + assert_match(/OPENSEARCH_URL/, error.message) + assert_match(/AWS_REGION/, error.message) + assert_match(/AWS_ACCESS_KEY_ID/, error.message) + assert_match(/AWS_SECRET_ACCESS_KEY/, error.message) + end + end + + test 'validate_aws_os_config raises error when OPENSEARCH_URL is missing' do + ClimateControl.modify( + OPENSEARCH_URL: nil, + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_os_config + end + + assert_match(/OPENSEARCH_URL/, error.message) + end + end + + test 'validate_aws_os_config raises error when AWS_REGION is missing' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.es.amazonaws.com', + AWS_REGION: nil, + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_os_config + end + + assert_match(/AWS_REGION/, error.message) + end + end + + test 'validate_aws_os_config raises error when AWS_ACCESS_KEY_ID is missing' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.es.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: nil, + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_os_config + end + + assert_match(/AWS_ACCESS_KEY_ID/, error.message) + end + end + + test 'validate_aws_os_config raises error when AWS_SECRET_ACCESS_KEY is missing' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.es.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: nil + ) do + error = assert_raises(RuntimeError) do + OpensearchConfigValidator.validate_aws_os_config + end + + assert_match(/AWS_SECRET_ACCESS_KEY/, error.message) + end + end + + test 'validate_aws_os_config succeeds with all required values present' do + ClimateControl.modify( + OPENSEARCH_URL: 'https://example.us-east-1.es.amazonaws.com', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) do + assert_nil OpensearchConfigValidator.validate_aws_os_config + end + end +end