├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── console ├── global.gemspec ├── lib ├── global.rb └── global │ ├── backend │ ├── aws_parameter_store.rb │ ├── filesystem.rb │ └── gcp_secret_manager.rb │ ├── base.rb │ ├── configuration.rb │ └── version.rb └── spec ├── files ├── aws.test.yml ├── aws.yml ├── bool_config.yml ├── nested_config.yml ├── rspec │ └── config.yml └── rspec_config.yml ├── global ├── backend │ ├── aws_parameter_store_spec.rb │ └── gcp_secret_manager_spec.rb └── configuration_spec.rb ├── global_merge_backends_spec.rb ├── global_spec.rb └── spec_helper.rb /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Runs linter and tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | linters_and_tests: 7 | runs-on: ${{ matrix.os }}-latest 8 | continue-on-error: ${{ matrix.experimental == true }} 9 | name: Linter and tests on ${{ matrix.os }}-ruby-${{ matrix.ruby-version }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu, macos] 13 | ruby-version: 14 | - 3.3 15 | - 3.2 16 | - 3.1 17 | - 3.0 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby-version }} 25 | bundler-cache: true 26 | 27 | - name: Runs linter and tests 28 | run: bundle exec rake 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | .rspec 6 | coverage/** 7 | .DS_Store 8 | 9 | .ruby-gemset 10 | .ruby-version 11 | .tool-versions 12 | 13 | .vscode 14 | .idea 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.0 7 | NewCops: enable 8 | 9 | Metrics/MethodLength: 10 | Enabled: true 11 | Max: 11 12 | 13 | Layout/EmptyLinesAroundBlockBody: 14 | Enabled: false 15 | 16 | Layout/SpaceInsideBlockBraces: 17 | Enabled: true 18 | EnforcedStyle: space 19 | EnforcedStyleForEmptyBraces: no_space 20 | SpaceBeforeBlockParameters: true 21 | 22 | Layout/SpaceBeforeBlockBraces: 23 | Enabled: true 24 | EnforcedStyle: space 25 | EnforcedStyleForEmptyBraces: space 26 | 27 | Layout/SpaceInsideHashLiteralBraces: 28 | Enabled: true 29 | EnforcedStyle: compact 30 | EnforcedStyleForEmptyBraces: no_space 31 | 32 | Layout/EmptyLinesAroundClassBody: 33 | Enabled: true 34 | EnforcedStyle: empty_lines_except_namespace 35 | 36 | Layout/EmptyLinesAroundModuleBody: 37 | Enabled: true 38 | EnforcedStyle: empty_lines_except_namespace 39 | 40 | Layout/MultilineMethodCallIndentation: 41 | Enabled: true 42 | EnforcedStyle: indented 43 | 44 | Layout/HashAlignment: 45 | Enabled: true 46 | EnforcedLastArgumentHashStyle: always_ignore 47 | 48 | Lint/RaiseException: 49 | Enabled: true 50 | 51 | Lint/StructNewOverride: 52 | Enabled: true 53 | 54 | Style/RaiseArgs: 55 | EnforcedStyle: compact 56 | 57 | Style/YodaCondition: 58 | Enabled: false 59 | 60 | Style/FrozenStringLiteralComment: 61 | Enabled: true 62 | EnforcedStyle: always 63 | 64 | Style/HashEachMethods: 65 | Enabled: true 66 | 67 | Style/HashTransformKeys: 68 | Enabled: true 69 | 70 | Style/HashTransformValues: 71 | Enabled: true 72 | 73 | Style/RegexpLiteral: 74 | Enabled: true 75 | EnforcedStyle: slashes 76 | AllowInnerSlashes: false 77 | 78 | Layout/FirstHashElementIndentation: 79 | EnforcedStyle: consistent 80 | 81 | Style/Documentation: 82 | Enabled: false 83 | 84 | Style/SafeNavigation: 85 | Enabled: true 86 | AllowedMethods: 87 | - present? 88 | - blank? 89 | - presence 90 | 91 | Lint/AmbiguousBlockAssociation: 92 | Exclude: 93 | - "spec/**/*" 94 | 95 | Naming/PredicateName: 96 | Exclude: 97 | - "spec/**/*" 98 | 99 | Style/NumericPredicate: 100 | Exclude: 101 | - "spec/**/*" 102 | 103 | Metrics/BlockLength: 104 | Exclude: 105 | - "spec/**/*" 106 | 107 | Layout/LineLength: 108 | Max: 120 109 | Exclude: 110 | - "spec/**/*" 111 | 112 | Metrics/AbcSize: 113 | Max: 22 114 | Exclude: 115 | - "spec/**/*" 116 | 117 | Style/ModuleFunction: 118 | EnforcedStyle: extend_self 119 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.6 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.0.0] - 2024-12-04 2 | 3 | - Predicate methods now cast the value to a boolean 4 | ```ruby 5 | Global.foo.enabled # => "0" 6 | Global.foo.enabled? # => false 7 | ``` 8 | - Dropped Ruby 2.7 support 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at leopard.not.a@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'aws-sdk-ssm', '~> 1' 8 | gem 'google-cloud-secret_manager', '~> 0' 9 | 10 | gem 'rake', '>= 13' 11 | gem 'rspec', '>= 3.0' 12 | gem 'simplecov' 13 | 14 | gem 'rubocop', '~> 1.68' 15 | gem 'rubocop-rake' 16 | gem 'rubocop-rspec' 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Railsware LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Global [![Runs linter and tests](https://github.com/railsware/global/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/railsware/global/actions/workflows/tests.yml) [![Code Climate](https://codeclimate.com/github/railsware/global.png)](https://codeclimate.com/github/railsware/global) 2 | 3 | The 'global' gem provides accessor methods for your configuration data and share configuration across backend and frontend. 4 | 5 | The data can be stored in [YAML](https://yaml.org) files on disk, or in the [AWS SSM Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html). 6 | 7 | ## Installation 8 | 9 | Add to Gemfile: 10 | 11 | ```ruby 12 | gem 'global' 13 | ``` 14 | 15 | Refer to the documentation on your chosen backend class for other dependencies. 16 | 17 | ## Configuration 18 | 19 | Refer to the documentation on your chosen backend class for configuration options. 20 | 21 | ```ruby 22 | > Global.backend(:filesystem, environment: "YOUR_ENV_HERE", path: "PATH_TO_DIRECTORY_WITH_FILES") 23 | ``` 24 | 25 | Or you can use `configure` block: 26 | 27 | ```ruby 28 | Global.configure do |config| 29 | config.backend :filesystem, environment: "YOUR_ENV_HERE", path: "PATH_TO_DIRECTORY_WITH_FILES" 30 | # set up multiple backends and have them merged together: 31 | config.backend :aws_parameter_store, prefix: '/prod/MyApp/' 32 | config.backend :gcp_secret_manager, prefix: 'prod-myapp-', project_id: 'example' 33 | end 34 | ``` 35 | 36 | ### Using multiple backends 37 | 38 | Sometimes it is practical to store some configuration data on disk (and perhaps, commit it to source control), but 39 | keep some other data in a secure remote location. Which is why you can use more than one backend with Global. 40 | 41 | You can declare as many backends as you want; the configuration trees from the backends are deep-merged together, 42 | so that the backend declared later overwrites specific keys in the backend declared prior: 43 | 44 | ```ruby 45 | Global.configure do |config| 46 | config.backend :foo # loads tree { credentials: { hostname: 'api.com', username: 'dev', password: 'dev' } } 47 | config.backend :bar # loads tree { credentials: { username: 'xxx', password: 'yyy' } } 48 | end 49 | 50 | Global.credentials.hostname # => 'api.com' 51 | Global.credentials.username # => 'xxx' 52 | Global.credentials.password # => 'yyy' 53 | ``` 54 | 55 | For Rails, put initialization into `config/initializers/global.rb`. 56 | 57 | There are some sensible defaults, check your backend class for documentation. 58 | 59 | ```ruby 60 | Global.configure do |config| 61 | config.backend :filesystem 62 | end 63 | ``` 64 | 65 | ### Filesystem storage 66 | 67 | The `yaml_whitelist_classes` configuration allows you to deserialize other classes from your `.yml` 68 | 69 | ### AWS Parameter Store 70 | 71 | The `aws_options` configuration allows you to customize the AWS credentials and connection. 72 | 73 | ### Google Cloud Secret Manager 74 | 75 | The `gcp_options` configuration allows you to customize the Google Cloud credentials and timeout. 76 | 77 | ## Usage 78 | 79 | ### Filesystem 80 | 81 | For file `config/global/hosts.yml`: 82 | 83 | ```yml 84 | test: 85 | web: localhost 86 | api: api.localhost 87 | development: 88 | web: localhost 89 | api: api.localhost 90 | production: 91 | web: myhost.com 92 | api: api.myhost.com 93 | ``` 94 | 95 | In the development environment we now have: 96 | 97 | ```ruby 98 | > Global.hosts 99 | => { "api" => "api.localhost", "web" => "localhost" } 100 | > Global.hosts.api 101 | => "api.localhost" 102 | ``` 103 | 104 | #### Deserialize other classes from `.yml` 105 | 106 | Config file `config/global/validations.yml`: 107 | 108 | ```yml 109 | default: 110 | regexp: 111 | email: !ruby/regexp /.@.+\../ 112 | ``` 113 | 114 | Ensure that `Regexp` is included in the `yaml_whitelist_classes` array 115 | 116 | ```ruby 117 | Global.validations.regexp.email === 'mail@example.com' 118 | => true 119 | ``` 120 | 121 | #### Per-environment sections 122 | 123 | You can define environment sections at the top level of every individual YAML file 124 | 125 | For example, having a config file `config/global/web/basic_auth.yml` with: 126 | 127 | ```yml 128 | test: 129 | username: test_user 130 | password: secret 131 | development: 132 | username: development_user 133 | password: secret 134 | production: 135 | username: production_user 136 | password: supersecret 137 | ``` 138 | 139 | You get the correct configuration in development 140 | 141 | ```ruby 142 | > Global.web.basic_auth 143 | => { "username" => "development_user", "password" => "secret" } 144 | > Global.web.basic_auth.username 145 | => "development_user" 146 | ``` 147 | 148 | #### Default section 149 | 150 | Config file example: 151 | 152 | ```yml 153 | default: 154 | web: localhost 155 | api: api.localhost 156 | production: 157 | web: myhost.com 158 | api: api.myhost.com 159 | ``` 160 | 161 | Data from the default section is used until it's overridden in a specific environment. 162 | 163 | #### Nested configurations 164 | 165 | Config file `global/nested.yml` with: 166 | 167 | ```yml 168 | test: 169 | group: 170 | key: "test value" 171 | development: 172 | group: 173 | key: "development value" 174 | production: 175 | group: 176 | key: "production value" 177 | ``` 178 | 179 | Nested options can then be accessed as follows: 180 | 181 | ```ruby 182 | > Global.nested.group.key 183 | => "development value" 184 | ``` 185 | 186 | #### Environment files 187 | 188 | Config file `global/aws.yml` with: 189 | ```yml 190 | :default: 191 | activated: false 192 | 193 | staging: 194 | activated: true 195 | api_key: 'nothing' 196 | 197 | ``` 198 | 199 | And file `global/aws.production.yml` with: 200 | ```yml 201 | :activated: true 202 | :api_key: 'some api key' 203 | :api_secret: 'some secret' 204 | 205 | ``` 206 | 207 | Provide such configuration on `Global.environment = 'production'` environment: 208 | 209 | ```ruby 210 | > Global.aws.activated 211 | => true 212 | > Global.aws.api_key 213 | => 'some api key' 214 | > Global.aws.api_secret 215 | => 'some secret' 216 | ``` 217 | 218 | **Warning**: files with dot(s) in name will be skipped by Global (except this env files). 219 | 220 | #### ERB support 221 | 222 | Config file `global/file_name.yml` with: 223 | 224 | ```yml 225 | test: 226 | key: <%=1+1%> 227 | development: 228 | key: <%=2+2%> 229 | production: 230 | key: <%=3+3%> 231 | ``` 232 | 233 | As a result, in the development environment we have: 234 | 235 | ```ruby 236 | > Global.file_name.key 237 | => 4 238 | ``` 239 | 240 | ### AWS Parameter Store 241 | 242 | Parameter Store is a secure configuration storage with at-rest encryption. Access is controlled through AWS IAM. You do not need to be hosted on AWS to use Parameter Store. 243 | 244 | Refer to the [official documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) to set up the store. 245 | 246 | Some steps you will need to follow: 247 | 248 | - Allocate an AWS IAM role for your app. 249 | - Create an IAM user for the role and pass credentials in standard AWS env vars (applications on Fargate get roles automatically). 250 | - Choose a prefix for the parameters. By default, the prefix is `/environment_name/AppClassName/`. You can change it with backend parameters (prefer to use '/' as separator). 251 | - Allow the role to read parameters from AWS SSM. Scope access by the prefix that you're going to use. 252 | - If you will use encrypted parameters: create a KMS key and allow the role to decrypt using the key. 253 | - Create parameters in Parameter Store. Use encryption for sensitive data like private keys and API credentials. 254 | 255 | #### Usage with Go 256 | 257 | You can reuse the same configuration in your Go services. For this, we developed a Go module that loads the same configuration tree into Go structs. 258 | 259 | See [github.com/railsware/go-global](https://github.com/railsware/go-global) for further instructions. 260 | 261 | #### Configuration examples 262 | 263 | Backend setup: 264 | 265 | ```ruby 266 | # in config/environments/development.rb 267 | # you don't need to go to Parameter Store for dev machines 268 | Global.backend(:filesystem) 269 | 270 | # in config/environments/production.rb 271 | # enterprise grade protection for your secrets 272 | Global.backend(:aws_parameter_store, app_name: 'my_big_app') 273 | ``` 274 | 275 | Create parameters: 276 | 277 | ``` 278 | /production/my_big_app/basic_auth/username => "bill" 279 | /production/my_big_app/basic_auth/password => "secret" # make sure to encrypt this one! 280 | /production/my_big_app/api_endpoint => "https://api.myapp.com" 281 | ``` 282 | 283 | Get configuration in the app: 284 | 285 | ```ruby 286 | # Encrypted parameters are automatically decrypted: 287 | > Global.basic_auth.password 288 | => "secret" 289 | > Global.api_endpoint 290 | => "https://api.myapp.com" 291 | ``` 292 | 293 | ### Google Cloud Secret Manager 294 | 295 | Google Cloud Secret Manager allows you to store, manage, and access secrets as binary blobs or text strings. With the appropriate permissions, you can view the contents of the secret. 296 | Google Cloud Secret Manager works well for storing configuration information such as database passwords, API keys, or TLS certificates needed by an application at runtime. 297 | 298 | Refer to the [official documentation](https://cloud.google.com/secret-manager/docs) to set up the secret manager. 299 | 300 | Some steps you will need to follow: 301 | 302 | - Choose a prefix for the secret key name. By default, the prefix is `environment_name-AppClassName-`. You can change it with backend parameters (prefer to use '-' as separator). 303 | 304 | #### Configuration examples 305 | 306 | Backend setup: 307 | 308 | ```ruby 309 | # in config/environments/development.rb 310 | # you don't need to go to Parameter Store for dev machines 311 | Global.backend(:filesystem) 312 | 313 | # in config/environments/production.rb 314 | # enterprise grade protection for your secrets 315 | Global.backend(:gcp_secret_manager, prefix: 'prod-myapp-', project_id: 'example') 316 | ``` 317 | 318 | Create secrets: 319 | 320 | ``` 321 | prod-myapp-basic_auth-username => "bill" 322 | prod-myapp-basic_auth-password => "secret" 323 | prod-myapp-api_endpoint => "https://api.myapp.com" 324 | ``` 325 | 326 | Get configuration in the app: 327 | 328 | ```ruby 329 | # Encrypted parameters are automatically decrypted: 330 | > Global.basic_auth.password 331 | => "secret" 332 | > Global.api_endpoint 333 | => "https://api.myapp.com" 334 | ``` 335 | 336 | ### Reload configuration data 337 | 338 | ```ruby 339 | > Global.reload! 340 | ``` 341 | 342 | ## Using YAML configuration files with Rails Webpacker 343 | 344 | If you use the `:filesystem` backend, you can reuse the same configuration files on the frontend: 345 | 346 | Add [js-yaml](https://www.npmjs.com/package/js-yaml) npm package to `package.json` (use command `yarn add js-yaml`). 347 | 348 | Then create a file at `config/webpacker/global/index.js` with the following: 349 | 350 | ```js 351 | const yaml = require('js-yaml') 352 | const fs = require('fs') 353 | const path = require('path') 354 | 355 | const FILE_ENV_SPLIT = '.' 356 | const YAML_EXT = '.yml' 357 | 358 | let globalConfig = { 359 | environment: null, 360 | configDirectory: null 361 | } 362 | 363 | const globalConfigure = (options = {}) => { 364 | globalConfig = Object.assign({}, globalConfig, options) 365 | } 366 | 367 | const getGlobalConfig = (key) => { 368 | let config = {} 369 | const filename = path.join(globalConfig.configDirectory, `${key}${YAML_EXT}`) 370 | if (fs.existsSync(filename)) { 371 | const configurations = yaml.safeLoad(fs.readFileSync(filename, 'utf8')) 372 | config = Object.assign({}, config, configurations['default'] || {}) 373 | config = Object.assign({}, config, configurations[globalConfig.environment] || {}) 374 | 375 | const envFilename = path.join(globalConfig.configDirectory, `${key}${FILE_ENV_SPLIT}${globalConfig.environment}${YAML_EXT}`) 376 | if (fs.existsSync(envFilename)) { 377 | const envConfigurations = yaml.safeLoad(fs.readFileSync(envFilename, 'utf8')) 378 | config = Object.assign({}, config, envConfigurations || {}) 379 | } 380 | } 381 | return config 382 | } 383 | 384 | module.exports = { 385 | globalConfigure, 386 | getGlobalConfig 387 | } 388 | ``` 389 | 390 | After this, modify file `config/webpacker/environment.js`: 391 | 392 | ```js 393 | const path = require('path') 394 | const {environment} = require('@rails/webpacker') 395 | const {globalConfigure, getGlobalConfig} = require('./global') 396 | 397 | globalConfigure({ 398 | environment: process.env.RAILS_ENV || 'development', 399 | configDirectory: path.resolve(__dirname, '../global') 400 | }) 401 | 402 | const sentrySettings = getGlobalConfig('sentry') 403 | 404 | environment.plugins.prepend('Environment', new webpack.EnvironmentPlugin({ 405 | GLOBAL_SENTRY_ENABLED: sentrySettings.enabled, 406 | GLOBAL_SENTRY_JS_KEY: sentrySettings.js, 407 | ... 408 | })) 409 | 410 | ... 411 | 412 | module.exports = environment 413 | ``` 414 | 415 | Now you can use these `process.env` keys in your code: 416 | 417 | ```js 418 | import {init} from '@sentry/browser' 419 | 420 | if (process.env.GLOBAL_SENTRY_ENABLED) { 421 | init({ 422 | dsn: process.env.GLOBAL_SENTRY_JS_KEY 423 | }) 424 | } 425 | ``` 426 | 427 | ## Contributing to global 428 | 429 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet. 430 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it. 431 | * Fork the project. 432 | * Start a feature/bugfix branch. 433 | * Commit and push until you are happy with your contribution. 434 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 435 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 436 | 437 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/railsware/global/blob/master/CODE_OF_CONDUCT.md). 438 | 439 | ## License 440 | 441 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 442 | 443 | ## Code of Conduct 444 | 445 | Everyone interacting in the Global project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/railsware/global/blob/master/CODE_OF_CONDUCT.md). 446 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'rubocop/rake_task' 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new(:rubocop) do |task| 9 | task.options = ['-D'] # Display cop name 10 | task.fail_on_error = true 11 | end 12 | 13 | desc 'Run all tests' 14 | task default: %i[rubocop spec] 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'global' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /global.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/global/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'global' 7 | s.version = Global::VERSION 8 | s.required_ruby_version = '>= 3.0.0' 9 | s.authors = ['Railsware LLC'] 10 | s.email = 'contact@railsware.com' 11 | s.description = 'Simple way to load your configs from yaml/aws/gcp' 12 | 13 | s.homepage = 'https://github.com/railsware/global' 14 | s.licenses = ['MIT'] 15 | s.summary = 'Simple way to load your configs from yaml/aws/gcp' 16 | 17 | s.metadata['rubygems_mfa_required'] = 'true' 18 | 19 | s.files = `git ls-files`.split("\n") 20 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 21 | s.require_paths = ['lib'] 22 | 23 | s.add_dependency 'activesupport', '>= 2.0' 24 | end 25 | -------------------------------------------------------------------------------- /lib/global.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | require 'yaml' 5 | 6 | require 'active_support/core_ext/hash/indifferent_access' 7 | require 'active_support/core_ext/hash/deep_merge' 8 | 9 | require 'global/configuration' 10 | require 'global/base' 11 | require 'global/version' 12 | 13 | module Global 14 | 15 | extend Base 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/global/backend/aws_parameter_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Global 4 | module Backend 5 | # Loads Global configuration from the AWS Systems Manager Parameter Store 6 | # https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html 7 | # 8 | # This backend requires the `aws-sdk` or `aws-sdk-ssm` gem, so make sure to add it to your Gemfile. 9 | # 10 | # Available options: 11 | # - `prefix` (required): the prefix in Parameter Store; all parameters within the prefix will be loaded; 12 | # make sure to add a trailing slash, if you want it 13 | # see https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-su-organize.html 14 | # - `client`: pass you own Aws::SSM::Client instance, or alternatively set: 15 | # - `aws_options`: credentials and other AWS configuration options that are passed to AWS::SSM::Client.new 16 | # see https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SSM/Client.html#initialize-instance_method 17 | # If AWS access is already configured through environment variables, 18 | # you don't need to pass the credentials explicitly. 19 | # 20 | # For Rails: 21 | # - the `prefix` is optional and defaults to `/[Rails enviroment]/[Name of the app class]/`, 22 | # for example: `/production/MyApp/` 23 | # - to use a different app name, pass `app_name`, 24 | # for example: `backend :aws_parameter_store, app_name: 'new_name_for_my_app'` 25 | class AwsParameterStore 26 | 27 | PATH_SEPARATOR = '/' 28 | 29 | def initialize(options = {}) 30 | require_aws_gem 31 | init_prefix(options) 32 | init_client(options) 33 | end 34 | 35 | def load 36 | build_configuration_from_parameters(load_all_parameters_from_ssm) 37 | end 38 | 39 | private 40 | 41 | def require_aws_gem 42 | require 'aws-sdk-ssm' 43 | rescue LoadError 44 | begin 45 | require 'aws-sdk' 46 | rescue LoadError 47 | raise 'Either the `aws-sdk-ssm` or `aws-sdk` gem must be installed.' 48 | end 49 | end 50 | 51 | def init_prefix(options) 52 | @prefix = if defined?(Rails) 53 | options.fetch(:prefix) do 54 | environment = Rails.env.to_s 55 | app_name = options.fetch(:app_name) { Rails.application.class.module_parent_name } 56 | "/#{environment}/#{app_name}/" 57 | end 58 | else 59 | options.fetch(:prefix) 60 | end 61 | end 62 | 63 | def init_client(options) 64 | if options.key?(:client) 65 | @ssm = options[:client] 66 | else 67 | aws_options = options.fetch(:aws_options, {}) 68 | @ssm = Aws::SSM::Client.new(aws_options) 69 | end 70 | end 71 | 72 | def load_all_parameters_from_ssm 73 | response = load_parameters_from_ssm 74 | all_parameters = response.parameters 75 | loop do 76 | break unless response.next_token 77 | 78 | response = load_parameters_from_ssm(response.next_token) 79 | all_parameters.concat(response.parameters) 80 | end 81 | 82 | all_parameters 83 | end 84 | 85 | def load_parameters_from_ssm(next_token = nil) 86 | @ssm.get_parameters_by_path( 87 | path: @prefix, 88 | recursive: true, 89 | with_decryption: true, 90 | next_token: next_token 91 | ) 92 | end 93 | 94 | # builds a nested configuration hash from the array of parameters from SSM 95 | def build_configuration_from_parameters(parameters) 96 | configuration = {} 97 | parameters.each do |parameter| 98 | parameter_parts = parameter.name[@prefix.length..].split(PATH_SEPARATOR).map(&:to_sym) 99 | param_container = parameter_parts[0..-2].reduce(configuration) do |container, part| 100 | container[part] ||= {} 101 | end 102 | param_container[parameter_parts[-1]] = parameter.value 103 | end 104 | 105 | configuration 106 | end 107 | 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/global/backend/filesystem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Global 4 | module Backend 5 | # Loads Global configuration from the filesystem 6 | # 7 | # Available options: 8 | # - `path` (required): the directory with config files 9 | # - `environment` (required): the environment to load 10 | # - `yaml_whitelist_classes`: the set of classes that are permitted to unmarshal from the configuration files 11 | # 12 | # For Rails: 13 | # - the `path` is optional and defaults to `config/global` 14 | # - the `environment` is optional and defaults to the current Rails environment 15 | class Filesystem 16 | 17 | FILE_ENV_SPLIT = '.' 18 | YAML_EXT = '.yml' 19 | 20 | def initialize(options = {}) 21 | if defined?(Rails) 22 | @path = options.fetch(:path) { Rails.root.join('config', 'global').to_s } 23 | @environment = options.fetch(:environment) { Rails.env.to_s } 24 | else 25 | @path = options.fetch(:path) 26 | @environment = options.fetch(:environment) 27 | end 28 | @yaml_whitelist_classes = options.fetch(:yaml_whitelist_classes, []) 29 | end 30 | 31 | def load 32 | load_from_path(@path) 33 | end 34 | 35 | private 36 | 37 | def load_from_path(path) 38 | load_from_file(path).deep_merge(load_from_directory(path)) 39 | end 40 | 41 | def load_from_file(path) 42 | config = {} 43 | 44 | if File.exist?(file = "#{path}#{YAML_EXT}") 45 | configurations = load_yml_file(file) 46 | config = get_config_by_key(configurations, 'default') 47 | config.deep_merge!(get_config_by_key(configurations, @environment)) 48 | if File.exist?(env_file = "#{path}#{FILE_ENV_SPLIT}#{@environment}#{YAML_EXT}") 49 | config.deep_merge!(load_yml_file(env_file) || {}) 50 | end 51 | end 52 | 53 | config 54 | end 55 | 56 | def get_config_by_key(config, key) 57 | return {} if config.empty? 58 | 59 | config[key.to_sym] || config[key.to_s] || {} 60 | end 61 | 62 | def load_yml_file(file) 63 | file_contents = ERB.new(File.read(file)).result 64 | permitted_classes = [Date, Time, DateTime, Symbol].concat(@yaml_whitelist_classes) 65 | 66 | if Gem::Version.new(Psych::VERSION) >= Gem::Version.new('4') 67 | YAML.safe_load(file_contents, permitted_classes: permitted_classes, aliases: true) 68 | else 69 | YAML.safe_load(file_contents, permitted_classes, [], true) 70 | end 71 | end 72 | 73 | def load_from_directory(path) 74 | config = {} 75 | 76 | if File.directory?(path) 77 | Dir["#{path}/*"].each do |entry| 78 | namespace = File.basename(entry, YAML_EXT) 79 | next if namespace.include? FILE_ENV_SPLIT # skip files with dot(s) in name 80 | 81 | file_with_path = File.join(File.dirname(entry), File.basename(entry, YAML_EXT)) 82 | config.deep_merge!(namespace => load_from_path(file_with_path)) 83 | end 84 | end 85 | 86 | config 87 | end 88 | 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/global/backend/gcp_secret_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Global 4 | module Backend 5 | # Loads Global configuration from the Google Cloud Secret Manager 6 | # https://cloud.google.com/secret-manager/docs 7 | # 8 | # This backend requires the `google-cloud-secret_manager` gem, so make sure to add it to your Gemfile. 9 | # 10 | # Available options: 11 | # - `project_id` (required): Google Cloud project name 12 | # - `prefix` (required): the prefix in Secret Manager; all parameters within the prefix will be loaded; 13 | # make sure to add a undescore, if you want it 14 | # see https://cloud.google.com/secret-manager/docs/overview 15 | # - `client`: pass you own Google::Cloud::SecretManager instance, or alternatively set: 16 | # - `gcp_options`: credentials and other Google cloud configuration options 17 | # that are passed to Google::Cloud::SecretManager.configure 18 | # see https://googleapis.dev/ruby/google-cloud-secret_manager/latest/index.html 19 | # If Google Cloud access is already configured through environment variables, 20 | # you don't need to pass the credentials explicitly. 21 | # 22 | # For Rails: 23 | # - the `prefix` is optional and defaults to `[Rails enviroment]-[Name of the app class]-`, 24 | # for example: `production-myapp-` 25 | # - to use a different app name, pass `app_name`, 26 | # for example: `backend :gcp_secret_manager, app_name: 'new_name_for_my_app'` 27 | class GcpSecretManager 28 | 29 | GCP_SEPARATOR = '/' 30 | PATH_SEPARATOR = '-' 31 | 32 | def initialize(options = {}) 33 | @project_id = options.fetch(:project_id) 34 | require_gcp_gem 35 | init_prefix(options) 36 | init_client(options) 37 | end 38 | 39 | def load 40 | pages = load_all_parameters_from_gcsm 41 | 42 | configuration = {} 43 | pages.each do |page| 44 | configuration.deep_merge!(build_configuration_from_page(page)) 45 | end 46 | 47 | configuration 48 | end 49 | 50 | private 51 | 52 | def require_gcp_gem 53 | require 'google/cloud/secret_manager' 54 | rescue LoadError 55 | raise 'The `google-cloud-secret_manager` gem must be installed.' 56 | end 57 | 58 | def init_prefix(options) 59 | @prefix = if defined?(Rails) 60 | options.fetch(:prefix) do 61 | environment = Rails.env.to_s 62 | app_name = options.fetch(:app_name) { Rails.application.class.module_parent_name } 63 | "#{environment}-#{app_name}-" 64 | end 65 | else 66 | options.fetch(:prefix) 67 | end 68 | end 69 | 70 | def init_client(options) 71 | if options.key?(:client) 72 | @gcsm = options[:client] 73 | else 74 | gcp_options = options.fetch(:gcp_options, {}) 75 | @gcsm = Google::Cloud::SecretManager.secret_manager_service do |config| 76 | config.credentials = gcp_options[:credentials] if gcp_options[:credentials] 77 | config.timeout = gcp_options[:timeout] if gcp_options[:timeout] 78 | end 79 | end 80 | end 81 | 82 | def load_all_parameters_from_gcsm 83 | response = load_parameters_from_gcsm 84 | all_pages = [response] 85 | loop do 86 | break if response.next_page_token.empty? 87 | 88 | response = load_parameters_from_gcsm(response.next_page_token) 89 | all_pages << response 90 | end 91 | 92 | all_pages 93 | end 94 | 95 | def load_parameters_from_gcsm(next_token = nil) 96 | @gcsm.list_secrets( 97 | parent: @gcsm.project_path(project: @project_id), 98 | page_size: 25_000, 99 | page_token: next_token 100 | ) 101 | end 102 | 103 | # builds a nested configuration hash from the array of parameters from Secret Manager 104 | def build_configuration_from_page(page) 105 | configuration = {} 106 | 107 | page.each do |parameter| 108 | key_name = get_gcp_key_name(parameter) 109 | next unless key_name.start_with?(@prefix) 110 | 111 | parameter_parts = key_name[@prefix.length..].split(PATH_SEPARATOR).map(&:to_sym) 112 | param_container = parameter_parts[0..-2].reduce(configuration) do |container, part| 113 | container[part] ||= {} 114 | end 115 | param_container[parameter_parts[-1]] = get_latest_key_value(key_name) 116 | end 117 | 118 | configuration 119 | end 120 | 121 | def get_gcp_key_name(parameter) 122 | parameter.name.split(GCP_SEPARATOR).last 123 | end 124 | 125 | def get_latest_key_value(key_name) 126 | name = @gcsm.secret_version_path( 127 | project: @project_id, 128 | secret: key_name, 129 | secret_version: 'latest' 130 | ) 131 | version = @gcsm.access_secret_version(name: name) 132 | version.payload.data 133 | end 134 | 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/global/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'erb' 4 | require 'json' 5 | 6 | module Global 7 | module Base 8 | 9 | extend self 10 | 11 | def configure 12 | yield self 13 | end 14 | 15 | def configuration 16 | raise 'Backend must be defined' unless @backends 17 | 18 | @configuration ||= begin 19 | configuration_hash = @backends.reduce({}) do |configuration, backend| 20 | configuration.deep_merge(backend.load.with_indifferent_access) 21 | end 22 | Configuration.new(configuration_hash) 23 | end 24 | end 25 | 26 | def reload! 27 | @configuration = nil 28 | configuration 29 | end 30 | 31 | # Add a backend to load configuration from. 32 | # 33 | # You can define several backends; they will all be loaded 34 | # and the configuration hashes will be merged. 35 | # 36 | # Configure with either: 37 | # Global.backend :filesystem, path: 'config', environment: Rails.env 38 | # or: 39 | # Global.backend YourConfigurationBackend.new 40 | # 41 | # backend configuration classes MUST have a `load` method that returns a configuration Hash 42 | def backend(backend, options = {}) 43 | @backends ||= [] 44 | if backend.is_a?(Symbol) 45 | require "global/backend/#{backend}" 46 | backend_class = Global::Backend.const_get(camel_case(backend.to_s)) 47 | @backends.push backend_class.new(options) 48 | elsif backend.respond_to?(:load) 49 | @backends.push backend 50 | else 51 | raise 'Backend must be either a Global::Backend class or a symbol' 52 | end 53 | end 54 | 55 | protected 56 | 57 | def respond_to_missing?(method, include_private = false) 58 | configuration.key?(method) || super 59 | end 60 | 61 | def method_missing(method, *args, &block) 62 | configuration.key?(method) ? configuration.get_configuration_value(method) : super 63 | end 64 | 65 | # from Bundler::Thor::Util.camel_case 66 | def camel_case(str) 67 | return str if str !~ /_/ && str =~ /[A-Z]+.*/ 68 | 69 | str.split('_').map(&:capitalize).join 70 | end 71 | 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/global/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Global 6 | class Configuration 7 | 8 | extend Forwardable 9 | 10 | attr_reader :hash 11 | 12 | def_delegators :hash, :key?, :has_key?, :include?, 13 | :member?, :[], :[]=, :to_hash, :to_json, 14 | :inspect, :fetch 15 | 16 | # rubocop:disable Lint/BooleanSymbol 17 | # @see ActiveModel::Type::Boolean::FALSE_VALUES 18 | FALSE_VALUES = [ 19 | false, 0, 20 | '0', :'0', 21 | 'f', :f, 22 | 'F', :F, 23 | 'false', :false, 24 | 'FALSE', :FALSE, 25 | 'off', :off, 26 | 'OFF', :OFF 27 | ].to_set.freeze 28 | private_constant :FALSE_VALUES 29 | # rubocop:enable Lint/BooleanSymbol 30 | 31 | def initialize(hash) 32 | @hash = hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash 33 | end 34 | 35 | def filter(options = {}) 36 | keys = filtered_keys_list(options) 37 | hash.select { |key, _| keys.include?(key) } 38 | end 39 | 40 | def get_configuration_value(key) 41 | return nil unless key?(key) 42 | 43 | value = hash[key] 44 | value.is_a?(Hash) ? Global::Configuration.new(value) : value 45 | end 46 | 47 | private 48 | 49 | def filtered_keys_list(options) 50 | return hash.keys - options[:except].map(&:to_s) if options[:except].is_a?(Array) 51 | return hash.keys & options[:only].map(&:to_s) if options[:only].is_a?(Array) 52 | 53 | return hash.keys if options[:only] == :all 54 | return [] if options[:except] == :all 55 | 56 | [] 57 | end 58 | 59 | protected 60 | 61 | def respond_to_missing?(method_name, include_private = false) 62 | method = normalize_key_by_method(method_name) 63 | key?(method) || boolean_method?(method) || super 64 | end 65 | 66 | def method_missing(method, *args, &block) 67 | normalized_method = normalize_key_by_method(method) 68 | if key?(normalized_method) 69 | value = get_configuration_value(normalized_method) 70 | boolean_method?(method) ? cast_boolean(value) : value 71 | else 72 | super 73 | end 74 | end 75 | 76 | def boolean_method?(method) 77 | '?' == method.to_s[-1] 78 | end 79 | 80 | # @see ActiveModel::Type::Boolean#cast_value 81 | def cast_boolean(value) 82 | if value == '' || value.nil? 83 | false 84 | else 85 | !FALSE_VALUES.include?(value) 86 | end 87 | end 88 | 89 | def normalize_key_by_method(method) 90 | boolean_method?(method) ? method.to_s[0..-2].to_sym : method 91 | end 92 | 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/global/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Global 4 | 5 | VERSION = '3.0.0' 6 | 7 | end 8 | -------------------------------------------------------------------------------- /spec/files/aws.test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :api_key: 'some api key' 3 | :api_secret: 'some secret' 4 | -------------------------------------------------------------------------------- /spec/files/aws.yml: -------------------------------------------------------------------------------- 1 | :default: 2 | activated: false 3 | 4 | test: 5 | activated: true 6 | api_key: 'nothing' 7 | -------------------------------------------------------------------------------- /spec/files/bool_config.yml: -------------------------------------------------------------------------------- 1 | :default: 2 | works: false 3 | 4 | test: 5 | works: true -------------------------------------------------------------------------------- /spec/files/nested_config.yml: -------------------------------------------------------------------------------- 1 | :default: 2 | nested: 3 | test_value: "default value" 4 | some_array_value: 5 | - "First" 6 | - "Second" 7 | - "Third" 8 | 9 | test: 10 | nested: 11 | test_value: "test value" 12 | 13 | development: 14 | nested: 15 | test_value: "development value" -------------------------------------------------------------------------------- /spec/files/rspec/config.yml: -------------------------------------------------------------------------------- 1 | :default: 2 | default_value: "default nested value" 3 | test: 4 | test_value: "test nested value" -------------------------------------------------------------------------------- /spec/files/rspec_config.yml: -------------------------------------------------------------------------------- 1 | :default: 2 | default_value: "default value" 3 | 4 | test: 5 | test_value: "test value" 6 | 7 | development: 8 | test_value: "development value" -------------------------------------------------------------------------------- /spec/global/backend/aws_parameter_store_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-ssm' 4 | require 'global/backend/aws_parameter_store' 5 | 6 | RSpec.describe Global::Backend::AwsParameterStore do 7 | subject(:parameter_store) do 8 | described_class.new(prefix: '/testapp/', client: client) 9 | end 10 | 11 | let(:client) do 12 | Aws::SSM::Client.new(stub_responses: true) 13 | end 14 | 15 | it 'reads parameters from the parameter store' do # rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations 16 | client.stub_responses( 17 | :get_parameters_by_path, 18 | [ 19 | lambda { |req_context| 20 | expect(req_context.params[:next_token]).to be_nil 21 | { 22 | parameters: [ 23 | { name: '/testapp/foo', value: 'foo-value' }, 24 | { name: '/testapp/bar/baz', value: 'baz-value' } 25 | ], 26 | next_token: 'next-token' 27 | } 28 | }, 29 | lambda { |req_context| 30 | expect(req_context.params[:next_token]).to eq('next-token') 31 | { 32 | parameters: [ 33 | { name: '/testapp/bar/qux', value: 'qux-value' } 34 | ], 35 | next_token: nil 36 | } 37 | } 38 | ] 39 | ) 40 | expect(parameter_store.load).to eq( 41 | foo: 'foo-value', 42 | bar: { 43 | baz: 'baz-value', 44 | qux: 'qux-value' 45 | } 46 | ) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/global/backend/gcp_secret_manager_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'google/cloud/secret_manager' 4 | require 'google/cloud/secret_manager/v1' 5 | require 'global/backend/gcp_secret_manager' 6 | 7 | RSpec.describe Global::Backend::GcpSecretManager do 8 | subject(:secret_manager) do 9 | described_class.new(prefix: 'prod-myapp-', client: client, project_id: 'example') 10 | end 11 | 12 | let(:client) { instance_double(Google::Cloud::SecretManager::V1::SecretManagerService::Client) } 13 | 14 | before do 15 | # rubocop:disable RSpec/VerifiedDoubles 16 | match_item = double(name: 'prod-myapp-example-test_key') 17 | not_match_item = double(name: 'different_key') 18 | 19 | secret_data = double(data: 'secret value') 20 | secret_version_response = double(payload: secret_data) 21 | 22 | page = instance_double(Gapic::PagedEnumerable) 23 | allow(page).to receive(:next_page_token).and_return('') 24 | allow(page).to receive(:each).and_yield(match_item).and_yield(not_match_item) 25 | 26 | allow(client).to receive(:secret_version_path) 27 | .with(project: 'example', secret: 'prod-myapp-example-test_key', secret_version: 'latest') 28 | .and_return('some_key_path') 29 | allow(client).to receive(:access_secret_version).with(name: 'some_key_path').and_return(secret_version_response) 30 | allow(client).to receive_messages(project_path: 'projects/example', list_secrets: page) 31 | # rubocop:enable RSpec/VerifiedDoubles 32 | end 33 | 34 | it 'reads parameters from the secret manager' do 35 | expect(secret_manager.load).to eq({ example: { test_key: 'secret value' }}) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/global/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Global::Configuration do 4 | subject(:configuration) { described_class.new hash } 5 | 6 | let(:hash) do 7 | { 8 | 'key' => 'value', 9 | 'boolean_key' => true, 10 | 'nested' => { 'key' => 'value' } 11 | } 12 | end 13 | 14 | describe '#hash' do 15 | it { expect(configuration.hash).to eq(hash) } 16 | end 17 | 18 | describe '#to_hash' do 19 | it { expect(configuration.to_hash).to eq(hash) } 20 | end 21 | 22 | describe '#key?' do 23 | it { expect(configuration.key?(:key)).to be(true) } 24 | end 25 | 26 | describe '#has_key?' do 27 | it { expect(configuration).to have_key(:key) } 28 | end 29 | 30 | describe 'include?' do 31 | it { expect(configuration.include?(:key)).to be(true) } 32 | end 33 | 34 | describe 'member?' do 35 | it { expect(configuration.member?(:key)).to be(true) } 36 | end 37 | 38 | describe '#[]' do 39 | it { expect(configuration[:key]).to eq('value') } 40 | end 41 | 42 | describe '#[]=' do 43 | it 'sets new value' do 44 | configuration[:new_key] = 'new_value' 45 | expect(configuration[:new_key]).to eq('new_value') 46 | end 47 | end 48 | 49 | describe '#inspect' do 50 | it { expect(configuration.inspect).to eq(hash.inspect) } 51 | end 52 | 53 | describe '#filter' do 54 | subject(:filter) { configuration.filter(filter_options) } 55 | 56 | context 'when include all' do 57 | let(:filter_options) { { only: :all } } 58 | 59 | it { expect(filter).to eq('key' => 'value', 'boolean_key' => true, 'nested' => { 'key' => 'value' }) } 60 | end 61 | 62 | context 'when except all' do 63 | let(:filter_options) { { except: :all } } 64 | 65 | it { expect(filter).to eq({}) } 66 | end 67 | 68 | context 'when except present' do 69 | let(:filter_options) { { except: %w[key] } } 70 | 71 | it { expect(filter).to eq('boolean_key' => true, 'nested' => { 'key' => 'value' }) } 72 | end 73 | 74 | context 'when include present' do 75 | let(:filter_options) { { only: %w[key] } } 76 | 77 | it { expect(filter).to eq('key' => 'value') } 78 | end 79 | 80 | context 'when empty options' do 81 | let(:filter_options) { {} } 82 | 83 | it { expect(filter).to eq({}) } 84 | end 85 | end 86 | 87 | describe '#method_missing' do 88 | it 'returns key value' do 89 | expect(configuration.key).to eq('value') 90 | end 91 | 92 | it 'returns boolean key value' do 93 | expect(configuration.boolean_key?).to be(true) 94 | end 95 | 96 | it 'raises on missing key' do 97 | expect { configuration.some_key }.to raise_error(NoMethodError) 98 | end 99 | 100 | it 'raises on missing boolean key' do 101 | expect { configuration.some_boolean_key? }.to raise_error(NoMethodError) 102 | end 103 | 104 | it 'returns nested key value' do 105 | expect(configuration.nested.key).to eq('value') 106 | end 107 | end 108 | 109 | describe 'predicate methods' do 110 | let(:hash) do 111 | { 112 | false_string: 'false', 113 | false_symbol: :false, # rubocop:disable Lint/BooleanSymbol 114 | off_string: 'off', 115 | zero_string: '0', 116 | zero_integer: 0, 117 | true_string: 'true', 118 | true_symbol: :true, # rubocop:disable Lint/BooleanSymbol 119 | on_string: 'on', 120 | one_string: '1', 121 | one_integer: 1, 122 | random_string: ' Offset ', 123 | empty_string: '', 124 | nil_value: nil 125 | } 126 | end 127 | 128 | it { expect(configuration.false_string?).to be(false) } 129 | it { expect(configuration.false_symbol?).to be(false) } 130 | it { expect(configuration.off_string?).to be(false) } 131 | it { expect(configuration.zero_string?).to be(false) } 132 | it { expect(configuration.zero_integer?).to be(false) } 133 | it { expect(configuration.empty_string?).to be(false) } 134 | it { expect(configuration.nil_value?).to be(false) } 135 | 136 | it { expect(configuration.true_string?).to be(true) } 137 | it { expect(configuration.true_symbol?).to be(true) } 138 | it { expect(configuration.on_string?).to be(true) } 139 | it { expect(configuration.one_string?).to be(true) } 140 | it { expect(configuration.one_integer?).to be(true) } 141 | it { expect(configuration.random_string?).to be(true) } 142 | end 143 | 144 | describe '#respond_to_missing?' do 145 | it 'responds to key' do 146 | expect(configuration.respond_to?(:key)).to be(true) 147 | end 148 | 149 | it 'does not respond to unknown key' do 150 | expect(configuration.respond_to?(:some_key)).to be(false) 151 | end 152 | 153 | it 'responds to nested key' do 154 | expect(configuration.nested.respond_to?(:key)).to be(true) 155 | end 156 | 157 | it 'calls a method' do 158 | expect(configuration.method(:key).call).to eq('value') 159 | end 160 | 161 | it 'raised on a missing method call' do 162 | expect { configuration.method(:some_key) }.to raise_error(NameError) 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /spec/global_merge_backends_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Global do 4 | subject(:global) { described_class } 5 | 6 | describe 'merging backends' do 7 | # rubocop:disable RSpec/VerifiedDoubles 8 | let(:backend_alpha) { double('backend_alpha', load: { foo: 'foo', bar: 'bar-alpha' }) } 9 | let(:backend_beta) { double('backend_beta', load: { 'bar' => 'bar-beta', 'baz' => 'baz' }) } 10 | # rubocop:enable RSpec/VerifiedDoubles 11 | 12 | before do 13 | global.backend backend_alpha 14 | global.backend backend_beta 15 | end 16 | 17 | it 'merges data from two backends together' do 18 | expect(global.configuration.to_hash).to eq( 19 | 'foo' => 'foo', 20 | 'bar' => 'bar-beta', 21 | 'baz' => 'baz' 22 | ) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/global_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Global do 4 | let(:config_path) { File.join(Dir.pwd, 'spec/files') } 5 | 6 | before do 7 | described_class.configure do |config| 8 | config.backend :filesystem, path: config_path, environment: 'test' 9 | end 10 | end 11 | 12 | describe '.configuration' do 13 | subject(:configuration) { described_class.configuration } 14 | 15 | it { is_expected.to be_instance_of(Global::Configuration) } 16 | 17 | context 'when load from directory' do 18 | it 'loads configuration' do 19 | expect(configuration.rspec_config.to_hash).to eq( 20 | 'default_value' => 'default value', 21 | 'test_value' => 'test value' 22 | ) 23 | end 24 | end 25 | 26 | context 'when load from file' do 27 | let(:config_path) { File.join(Dir.pwd, 'spec/files/rspec_config') } 28 | 29 | it 'loads configuration' do 30 | expect(configuration.to_hash).to eq( 31 | 'default_value' => 'default value', 32 | 'test_value' => 'test value' 33 | ) 34 | end 35 | end 36 | 37 | context 'when nested directories' do 38 | it { expect(configuration.rspec['config'].to_hash).to eq('default_value' => 'default nested value', 'test_value' => 'test nested value') } 39 | end 40 | 41 | context 'when boolean' do 42 | it { expect(configuration.bool_config.works).to be(true) } 43 | it { expect(configuration.bool_config.works?).to be(true) } 44 | end 45 | 46 | describe 'environment file' do 47 | it { expect(configuration.aws.activated).to be(true) } 48 | it { expect(configuration.aws.api_key).to eq('some api key') } 49 | it { expect(configuration.aws.api_secret).to eq('some secret') } 50 | end 51 | 52 | describe 'skip files with dots in name' do 53 | it { expect(configuration['aws.test']).to be_nil } 54 | it { expect { configuration.fetch('aws.test') }.to raise_error(KeyError, /key not found/) } 55 | end 56 | end 57 | 58 | describe '.reload!' do 59 | subject(:reloaded_configuration) { described_class.reload! } 60 | 61 | before do 62 | described_class.configuration 63 | described_class.instance_variable_set('@backends', []) 64 | described_class.backend :filesystem, path: config_path, environment: 'development' 65 | end 66 | 67 | it 'returns configuration' do 68 | expect(reloaded_configuration).to be_instance_of(Global::Configuration) 69 | end 70 | 71 | it 'reloads configuration' do 72 | expect(reloaded_configuration.rspec_config.to_hash).to eq( 73 | 'default_value' => 'default value', 74 | 'test_value' => 'development value' 75 | ) 76 | end 77 | end 78 | 79 | describe '.respond_to_missing?' do 80 | it 'responds to present config' do 81 | expect(described_class.respond_to?(:rspec_config)).to be(true) 82 | end 83 | 84 | it 'does not respond to missing config' do 85 | expect(described_class.respond_to?(:some_file)).to be(false) 86 | end 87 | end 88 | 89 | describe '.method_missing' do 90 | it 'returns configuration' do 91 | expect(described_class.rspec_config).to be_a(Global::Configuration) 92 | end 93 | 94 | it 'raises on missing configuration' do 95 | expect { described_class.some_file }.to raise_error(NoMethodError) 96 | end 97 | 98 | it 'returns config with nested hash' do 99 | expect(described_class.nested_config).to be_a(Global::Configuration) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | 5 | SimpleCov.start do 6 | add_filter '/spec/' 7 | 8 | add_group 'Libraries', '/lib/' 9 | end 10 | 11 | require 'global' 12 | 13 | RSpec.configure do |config| 14 | config.disable_monkey_patching! 15 | 16 | config.expect_with :rspec do |expectations| 17 | expectations.syntax = :expect 18 | # This option will default to `true` in RSpec 4. It makes the `description` 19 | # and `failure_message` of custom matchers include text for helper methods 20 | # defined using `chain`, e.g.: 21 | # be_bigger_than(2).and_smaller_than(4).description 22 | # # => "be bigger than 2 and smaller than 4" 23 | # ...rather than: 24 | # # => "be bigger than 2" 25 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 26 | end 27 | 28 | config.mock_with :rspec do |mocks| 29 | # Prevents you from mocking or stubbing a method that does not exist on 30 | # a real object. This is generally recommended, and will default to 31 | # `true` in RSpec 4. 32 | mocks.verify_partial_doubles = true 33 | end 34 | 35 | config.run_all_when_everything_filtered = true 36 | config.filter_run :focus 37 | 38 | config.order = 'random' 39 | 40 | config.before do 41 | Global.remove_instance_variable(:@backends) if Global.instance_variable_defined?(:@backends) 42 | Global.remove_instance_variable(:@configuration) if Global.instance_variable_defined?(:@configuration) 43 | end 44 | end 45 | --------------------------------------------------------------------------------