├── .cursorignore ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── genesis.yml ├── .gitignore ├── CHANGELOG.txt ├── MANIFEST.in ├── README.rst ├── razorpay ├── __init__.py └── alohomora │ ├── __init__.py │ ├── alohomora.py │ └── cli.py ├── setup.py └── tests ├── __init__.py ├── files ├── birdie.j2 ├── birdie2.j2 ├── birdie_env.j2 ├── birdie_fail.j2 └── birdie_fail_multiple.j2 └── test_alohomora.py /.cursorignore: -------------------------------------------------------------------------------- 1 | # Distribution and Environment 2 | dist/* 3 | build/* 4 | venv/* 5 | env/* 6 | *.env 7 | .env.* 8 | virtualenv/* 9 | .python-version 10 | .ruby-version 11 | .node-version 12 | 13 | # Logs and Temporary Files 14 | *.log 15 | *.tsv 16 | *.csv 17 | *.txt 18 | tmp/* 19 | temp/* 20 | .tmp/* 21 | *.temp 22 | *.cache 23 | .cache/* 24 | logs/* 25 | 26 | # Sensitive Data 27 | *.json 28 | *.xml 29 | *.yml 30 | *.yaml 31 | *.properties 32 | properties.json 33 | *.sqlite 34 | *.sqlite3 35 | *.dbsql 36 | secrets.* 37 | *secret* 38 | *password* 39 | *credential* 40 | .npmrc 41 | .yarnrc 42 | .aws/* 43 | .config/* 44 | 45 | # Credentials and Keys 46 | *.pem 47 | *.ppk 48 | *.key 49 | *.pub 50 | *.p12 51 | *.pfx 52 | *.htpasswd 53 | *.keystore 54 | *.jks 55 | *.truststore 56 | *.cer 57 | id_rsa* 58 | known_hosts 59 | authorized_keys 60 | .ssh/* 61 | .gnupg/* 62 | .pgpass 63 | 64 | # Config Files 65 | *.conf 66 | *.toml 67 | *.ini 68 | .env.local 69 | .env.development 70 | .env.test 71 | .env.production 72 | config/* 73 | 74 | # Documentation and Notes 75 | *.md 76 | *.mdx 77 | *.rst 78 | *.txt 79 | docs/* 80 | README* 81 | CHANGELOG* 82 | LICENSE* 83 | CONTRIBUTING* 84 | 85 | # Database Files 86 | *.sql 87 | *.db 88 | *.dmp 89 | *.dump 90 | *.backup 91 | *.restore 92 | *.mdb 93 | *.accdb 94 | *.realm* 95 | 96 | # Backup and Archive Files 97 | *.bak 98 | *.backup 99 | *.swp 100 | *.swo 101 | *.swn 102 | *~ 103 | *.old 104 | *.orig 105 | *.archive 106 | *.gz 107 | *.zip 108 | *.tar 109 | *.rar 110 | *.7z 111 | 112 | # Compiled and Binary Files 113 | *.pyc 114 | *.pyo 115 | **/__pycache__/** 116 | *.class 117 | *.jar 118 | *.war 119 | *.ear 120 | *.dll 121 | *.exe 122 | *.so 123 | *.dylib 124 | *.bin 125 | *.obj 126 | 127 | # IDE and Editor Files 128 | .idea/* 129 | *.iml 130 | .vscode/* 131 | .project 132 | .classpath 133 | .settings/* 134 | *.sublime-* 135 | .atom/* 136 | .eclipse/* 137 | *.code-workspace 138 | .history/* 139 | 140 | # Build and Dependency Directories 141 | node_modules/* 142 | bower_components/* 143 | vendor/* 144 | packages/* 145 | jspm_packages/* 146 | .gradle/* 147 | target/* 148 | out/* 149 | 150 | # Testing and Coverage Files 151 | coverage/* 152 | .coverage 153 | htmlcov/* 154 | .pytest_cache/* 155 | .tox/* 156 | junit.xml 157 | test-results/* 158 | 159 | # Mobile Development 160 | *.apk 161 | *.aab 162 | *.ipa 163 | *.xcarchive 164 | *.provisionprofile 165 | google-services.json 166 | GoogleService-Info.plist 167 | 168 | # Certificate and Security Files 169 | *.crt 170 | *.csr 171 | *.ovpn 172 | *.p7b 173 | *.p7s 174 | *.pfx 175 | *.spc 176 | *.stl 177 | *.pem.crt 178 | ssl/* 179 | 180 | # Container and Infrastructure 181 | *.tfstate 182 | *.tfstate.backup 183 | .terraform/* 184 | .vagrant/* 185 | docker-compose.override.yml 186 | kubernetes/* 187 | 188 | # Design and Media Files (often large and binary) 189 | *.psd 190 | *.ai 191 | *.sketch 192 | *.fig 193 | *.xd 194 | assets/raw/* 195 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://EditorConfig.org 3 | 4 | root = true 5 | ; Use 2 spaces for indentation in all files 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 4 13 | insert_final_newline = true 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | timezone: Asia/Calcutta 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [ push ] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest # nosemgrep : semgrep.dev/s/swati31196:github_provided_runner 6 | continue-on-error: true 7 | name: Tests 8 | # Only supported versions are supported: https://endoflife.date/python 9 | # But tests are run on more. 10 | strategy: 11 | matrix: 12 | python: ["3.7", "3.8", "3.9", "3.10"] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: ${{matrix.python}} 18 | - name: Install Dependencies 19 | run: | 20 | pip install -U setuptools pip 21 | python setup.py clean --all 22 | python setup.py install 23 | - name: Run Tests 24 | run: python setup.py test 25 | -------------------------------------------------------------------------------- /.github/workflows/genesis.yml: -------------------------------------------------------------------------------- 1 | name: Quality Checks 2 | on: 3 | schedule: 4 | - cron: "0 17 * * *" 5 | jobs: 6 | Analysis: 7 | uses: razorpay/genesis/.github/workflows/quality-checks.yml@master 8 | secrets: inherit 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | .pytest_cache/ 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # Generated files 102 | test/files/birdie 103 | test/files/birdie2 104 | test/files/birdie_env 105 | 106 | # IDE 107 | .idea/ 108 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.5.0] - 2022-08-18 11 | 12 | - Drops support for Python 3.6 and below 13 | - Dependency upgrades 14 | - Upgrades Jinja usage to fix a rare bug in some templates 15 | 16 | ## [0.4.7] - 2019-06-19 17 | 18 | - Accepts mock flag in listSecrets method for skipping credstash call 19 | 20 | ## [0.4.6] - 2019-01-08 21 | 22 | - Updates credstash to 1.15 23 | 24 | ## [0.4.5] - 2018-05-02 25 | 26 | - unpins dependencies 27 | 28 | ## [0.4.4] - 2018-04-16 29 | 30 | - dependency updates 31 | - Reports all lookup failures instead of just the first one 32 | 33 | ## [0.4.3] - 2018-04-16 34 | 35 | - Adds support for `--output` in the cast command. Only works with single files. 36 | 37 | ## [0.4.2] - 2017-11-22 38 | 39 | - Removes `requirements.txt` from the distribution to avoid conflicts from new boto3 release 40 | - Relaxes `botocore` requirement 41 | - Adds support for environment variables via `{{ENV['lookup']}}`. [#19](https://github.com/razorpay/alohomora/pull/19) 42 | 43 | ## [0.4.1] - 2017-10-11 44 | 45 | ### Fixed 46 | - Pinned versions of all dependencies in setup.py in response to [credstash#175](https://github.com/fugue/credstash/issues/175) 47 | 48 | ## [0.4.0] - 2017-08-21 49 | 50 | ### Added 51 | - Adds multi-target casting. You can pass multiple files to be rendered in a single command now 52 | 53 | ### Fixed 54 | - Compatibilty issues with Python 3 55 | 56 | ## [0.3.0] - 2017-07-17 57 | 58 | ### Added 59 | 60 | - Exception message on lookup failures now includes the key ([#7](https://github.com/razorpay/alohomora/pull/7)). 61 | - CodeDeploy mentions removed from README 62 | - CHANGELOG.txt file added to release 63 | 64 | ## [0.2] - 2017-05-26 65 | 66 | ### Added 67 | - botocore version dependency fixed. 68 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGELOG.txt 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | alohomora 2 | ========= 3 | 4 | .. image:: https://github.com/razorpay/alohomora/actions/workflows/ci.yml/badge.svg 5 | :target: https://github.com/razorpay/alohomora/actions/workflows/ci.yml 6 | 7 | Razorpay's Secret Credential management system. 8 | 9 | Installation 10 | ------------ 11 | 12 | alohomora is distributed via PyPi: 13 | 14 | .. code:: shell 15 | 16 | pip install razorpay.alohomora 17 | 18 | What? 19 | ----- 20 | 21 | Alohomora is an opinionated project that relies on our conventions to 22 | intelligently fetch secrets at run-time. 23 | 24 | We don't do our own crypto. We rely on these libraries instead: 25 | 26 | - https://github.com/fugue/credstash 27 | 28 | This is how the template file looks in our app 29 | repository: 30 | 31 | .. code:: 32 | 33 | # {{ alohomora_managed }} 34 | DB_PASSWORD = {{ lookup('db_password') }} 35 | APP_ENV = {{ env }} 36 | ENV_DEBUG = {{ ENV['DEBUG'] }} 37 | APP_NAME = {{ app }} 38 | 39 | This repo runs directly on the same template and generates the 40 | equivalent file as the output. 41 | 42 | The steps it follows are the following: 43 | 44 | 1. Figure out the tables from which to read. All secrets are stored in a 45 | ``credstash-env-app`` table structure in dynamoDB. 46 | 2. Fetch all secrets from that table using credstash 47 | 3. Render the template with the secrets using jinja 48 | 49 | How it Works? 50 | ------------- 51 | 52 | Alohomora expects the secrets for any application to be stored in a 53 | table called ``credstash-{env}-{app}``. The IAM roles for this table 54 | must be configured by you. Once you try to render a template, alohomora 55 | will do the following: 56 | 57 | 1. Read the entire table and decrypt all secrets and cache them locally. 58 | 2. Render the template with these files and 3 extra variables: ``env``, 59 | ``app``, and ``ENV`` variables. 60 | 61 | ``ENV`` is same as `os.environ` inside the jinja template. 62 | 63 | Configuration? 64 | -------------- 65 | 66 | Alohomora is designed to be a zero-config solution. 67 | 68 | We perform a few transforms on the arguments that are passed: 69 | 70 | - Change both ``app`` and ``env`` to lowercase 71 | - Replace ``production`` with ``prod`` in the ``env`` name 72 | - Ignore anything after ``-`` in the environment. So ``beta-birdie`` becomes ``beta`` 73 | 74 | Usage 75 | ----- 76 | 77 | Please see the wiki regarding alohomora binary usage. 78 | 79 | LICENSE 80 | ------- 81 | 82 | ``alohomora`` is released under the same license as credstash. 83 | -------------------------------------------------------------------------------- /razorpay/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razorpay/alohomora/436c046efbf6a0425de9caec368c3b7712bcd2a5/razorpay/__init__.py -------------------------------------------------------------------------------- /razorpay/alohomora/__init__.py: -------------------------------------------------------------------------------- 1 | from . import alohomora 2 | 3 | Alohomora = alohomora.Alohomora -------------------------------------------------------------------------------- /razorpay/alohomora/alohomora.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from __future__ import absolute_import 3 | from jinja2 import Template 4 | import json 5 | import credstash 6 | import click 7 | import botocore.exceptions 8 | import os 9 | import re 10 | from io import open 11 | 12 | GLOBALS = { 13 | 'alohomora_managed': "This file is managed by Alohomora", 14 | } 15 | 16 | 17 | class MockStash(object): 18 | """Credstash mock class""" 19 | 20 | def listSecrets(self, table='credential-store', 21 | region=credstash.DEFAULT_REGION): 22 | return { 23 | 'alohomora_app_key': 'fake_app_key', 24 | 'alohomora_db_password': 'fake_db_password', 25 | 'alohomora_app_secret': 'fake_secret', 26 | } 27 | 28 | 29 | class CredStash(object): 30 | """Actual Credstash class wrapper""" 31 | 32 | def listSecrets(self, table='credential-store', 33 | region=credstash.DEFAULT_REGION): 34 | return credstash.getAllSecrets( 35 | table=table, 36 | region=region 37 | ) 38 | 39 | 40 | class Alohomora(object): 41 | """Alohomora unlocks secrets""" 42 | 43 | def __init__(self, env, app, region=credstash.DEFAULT_REGION, mock=False): 44 | self.env = self.canonical_env(env) 45 | self.app = self.canonical_app(app) 46 | self.failed_lookups = [] 47 | if mock: 48 | self.stash = MockStash() 49 | else: 50 | self.stash = CredStash() 51 | self.region = region 52 | self.secrets = None 53 | 54 | def canonical_env(self, env): 55 | pattern = re.compile('^(\w+).*$') 56 | env = pattern.findall(env)[0] 57 | if env == 'Production': 58 | env = 'prod' 59 | return env.lower() 60 | 61 | def canonical_app(self, app): 62 | return app.lower() 63 | 64 | def cache_secrets(self): 65 | if self.secrets == None: 66 | self.secrets = self.stash.listSecrets( 67 | self.make_table_name(), 68 | region=self.region) 69 | 70 | def make_table_name(self): 71 | return "credstash-{self.env}-{self.app}".format(**locals()) 72 | 73 | def create_table(self): 74 | credstash.createDdbTable( 75 | table=self.make_table_name(), region=self.region) 76 | 77 | def store(self, key, secret): 78 | msg = '' 79 | try: 80 | credstash.putSecret(table=self.make_table_name(), 81 | region=self.region, 82 | name=key, 83 | secret=secret, 84 | ) 85 | msg = "secret saved" 86 | except Exception as e: 87 | if (e.response["Error"]["Code"] 88 | == "ConditionalCheckFailedException"): 89 | msg = ("store command failed\n" 90 | "Please try the command: credstash -r {0}" 91 | " -t {1} put -a {2} [secret]" 92 | ).format(self.region, self.make_table_name(), key) 93 | finally: 94 | return msg 95 | 96 | def lookup(self, key): 97 | self.cache_secrets() 98 | if key in self.secrets: 99 | return self.secrets[key] 100 | else: 101 | self.failed_lookups.append(key) 102 | 103 | def __cast_one_file(self, file, context, filename=None): 104 | self.validate_j2(file.name) 105 | 106 | if filename: 107 | vault_file = filename 108 | else: 109 | vault_file = file.name[0:-3] 110 | 111 | contents = file.read() 112 | 113 | # re-initialize the failed_lookups 114 | self.failed_lookups = [] 115 | # This is the template variable 116 | t = Template(contents) 117 | t.globals=context 118 | contents = t.render() 119 | 120 | if self.failed_lookups: 121 | msg = "Lookup failed: {}".format(", ".join(self.failed_lookups)) 122 | raise Exception(msg) 123 | 124 | if os.path.isfile(vault_file): 125 | """TODO: The file exists, we will output diff""" 126 | msg = vault_file + " updated" 127 | else: 128 | msg = vault_file + " created" 129 | 130 | with open(vault_file, 'w') as f: 131 | f.write(contents) 132 | 133 | return msg 134 | 135 | def cast(self, *files, **kwargs): 136 | filename = kwargs.get('filename') 137 | if len(files) > 2 and filename: 138 | raise Exception('Output name given for multiple files') 139 | 140 | variables = GLOBALS 141 | variables['env'] = self.env 142 | variables['ENV'] = os.environ 143 | variables['lookup'] = self.lookup 144 | 145 | msgs = [] 146 | 147 | # If a single j2 file is given and an output name, 148 | # Cast the j2 file with that output file name 149 | if len(files) == 1 and filename: 150 | file = files[0] 151 | msgs.append(self.__cast_one_file(file, variables, filename=filename)) 152 | else: 153 | for file in files: 154 | msgs.append(self.__cast_one_file(file, variables)) 155 | 156 | return msgs 157 | 158 | def validate_j2(self, file): 159 | if file[-3:] != '.j2': 160 | raise Exception('File must be a valid j2 template') 161 | 162 | def render(self, *files): 163 | return self.cast(*files) 164 | -------------------------------------------------------------------------------- /razorpay/alohomora/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import absolute_import 4 | from credstash import createDdbTable, putSecretAction 5 | from razorpay.alohomora import Alohomora 6 | import click 7 | 8 | 9 | @click.group() 10 | def cli(): 11 | """Alohomora is a secret distribution tool""" 12 | 13 | 14 | @click.option('--region', default='us-east-1', help='AWS region') 15 | @click.option('--env', default='prod', 16 | help='environment for the application, used for namespacing') 17 | @click.argument('app') 18 | @cli.command('create', 19 | short_help='Create a credstash database for an application') 20 | def create(region, env, app): 21 | spell = Alohomora(env, app, region) 22 | spell.create_table() 23 | 24 | 25 | @click.option('--region', default='us-east-1', help='AWS region') 26 | @click.option('--env', default='prod', 27 | help='environment for the application, used for namespacing') 28 | @click.argument('secret') 29 | @click.argument('key') 30 | @click.argument('app') 31 | @cli.command('store', short_help='Store a secret for an application') 32 | def store(region, env, secret, key, app): 33 | spell = Alohomora(env, app, region) 34 | click.echo(spell.store(key, secret)) 35 | 36 | 37 | @click.option('--region', default='eu-west-1', help='AWS region') 38 | @click.option('--app', help='application name, used for table name as well') 39 | @click.option('--env', default='prod', 40 | help='environment for the application, used for namespacing') 41 | @click.option('--output', default=None, 42 | help='Output file name of the vault file') 43 | @click.option('--mock', default=False, 44 | help='To mock all calls to credstash.') 45 | @click.argument('files', type=click.File('r', encoding='utf-8'), nargs=-1) 46 | @cli.command('cast', short_help='Render a ansible jinja template file') 47 | def cast(app, env, region, output, files, mock): 48 | is_mock = False 49 | 50 | if isinstance(mock, bool): 51 | is_mock = mock 52 | elif isinstance(mock, basestring) and mock.lower() == "true": 53 | is_mock = True 54 | 55 | spell = Alohomora(env, app, region, mock=is_mock) 56 | for msg in spell.cast(*files, filename=output): 57 | click.echo(msg) 58 | 59 | 60 | if __name__ == '__main__': 61 | cli() 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | def readme(): 5 | with open('README.rst') as f: 6 | return f.read() 7 | 8 | setup( 9 | name='razorpay.alohomora', 10 | version='0.5.0', 11 | description='Secret distribution tool, written as a wrapper on credstash', 12 | url='https://github.com/razorpay/alohomora', 13 | author='Team Razorpay', 14 | author_email='developers@razorpay.com', 15 | tests_require=['pytest', 'pytest-runner'], 16 | test_suite='tests', 17 | keywords=["credstash", "ansible", "secrets", "jinja"], 18 | license='MIT', 19 | long_description=readme(), 20 | long_description_content_type='text/x-rst', 21 | packages=find_packages(exclude=('tests')), 22 | python_requires='>=3.7.0', 23 | install_requires=[ 24 | 'credstash==1.17.1', 25 | 'click>=8.1', 26 | 'jinja2>=3.0', 27 | ], 28 | entry_points={ 29 | 'console_scripts': [ 30 | 'alohomora = razorpay.alohomora.cli:cli' 31 | ] 32 | }, 33 | package_dir={'razorpay.alohomora': 'razorpay/alohomora'}, 34 | include_package_data=True, 35 | zip_safe=False 36 | ) 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razorpay/alohomora/436c046efbf6a0425de9caec368c3b7712bcd2a5/tests/__init__.py -------------------------------------------------------------------------------- /tests/files/birdie.j2: -------------------------------------------------------------------------------- 1 | # {{ alohomora_managed }} 2 | ENV={{env}} 3 | {% if env == 'beta' %} 4 | APP_KEY = {{ lookup('alohomora_app_key') }} 5 | DB_HOST = beta.db.website.vpc 6 | DB_PASSWORD = {{ lookup('alohomora_db_password') }} 7 | APP_SECRET = {{ lookup('alohomora_app_secret') }} 8 | {% elif env == 'prod' %} 9 | APP_KEY = {{ lookup('alohomora_app_key') }} 10 | DB_HOST = prod-common.db.website.vpc 11 | DB_PASSWORD = {{ lookup('alohomora_db_password') }} 12 | APP_SECRET = {{ lookup('alohomora_app_secret') }} 13 | {% endif %} 14 | -------------------------------------------------------------------------------- /tests/files/birdie2.j2: -------------------------------------------------------------------------------- 1 | # {{ alohomora_managed }} 2 | ENV={{env}} 3 | {% if env == 'beta' %} 4 | APP_KEY = {{ lookup('alohomora_app_key') }} 5 | DB_HOST = beta.db.website.vpc 6 | DB_PASSWORD = {{ lookup('alohomora_db_password') }} 7 | APP_SECRET = {{ lookup('alohomora_app_secret') }} 8 | {% elif env == 'prod' %} 9 | APP_KEY = {{ lookup('alohomora_app_key') }} 10 | DB_HOST = prod-common.db.website.vpc 11 | DB_PASSWORD = {{ lookup('alohomora_db_password') }} 12 | APP_SECRET = {{ lookup('alohomora_app_secret') }} 13 | {% endif %} 14 | -------------------------------------------------------------------------------- /tests/files/birdie_env.j2: -------------------------------------------------------------------------------- 1 | # {{ alohomora_managed }} 2 | ENV={{env}} 3 | # The quotes in next statement are for a test on a bug 4 | ""{% if env == 'beta' %} 5 | APP_KEY = {{ lookup('alohomora_app_key') }} 6 | DB_HOST = beta.db.website.vpc 7 | DB_PASSWORD = {{ lookup('alohomora_db_password') }} 8 | APP_SECRET = {{ lookup('alohomora_app_secret') }} 9 | {% elif env == 'prod' %} 10 | APP_KEY = {{ lookup('alohomora_app_key') }} 11 | DB_HOST = prod-common.db.website.vpc 12 | DB_PASSWORD = {{ lookup('alohomora_db_password') }} 13 | APP_SECRET = {{ lookup('alohomora_app_secret') }} 14 | {% endif %} 15 | env_test = {{ ENV['README'] }} 16 | -------------------------------------------------------------------------------- /tests/files/birdie_fail.j2: -------------------------------------------------------------------------------- 1 | APP_KEY = {{ lookup('alohomora_app_key_non_existent') }} 2 | -------------------------------------------------------------------------------- /tests/files/birdie_fail_multiple.j2: -------------------------------------------------------------------------------- 1 | APP_REAL_KEY = {{ lookup('alohomora_app_key') }} 2 | APP_KEY = {{ lookup('alohomora_app_key_non_existent') }} 3 | FAKE_KEY = {{ lookup('alohomora_app_fake_key') }} 4 | -------------------------------------------------------------------------------- /tests/test_alohomora.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from razorpay.alohomora import Alohomora 3 | try: 4 | import configparser 5 | except ImportError: 6 | import ConfigParser as configparser 7 | import io 8 | from io import open 9 | import pytest 10 | import contextlib 11 | import os 12 | import unittest 13 | 14 | 15 | class TestAlohomora(unittest.TestCase): 16 | 17 | ''' 18 | Picked up from 19 | https://stackoverflow.com/a/34333710/368328 20 | ''' 21 | @contextlib.contextmanager 22 | def modified_environ(self, *remove, **update): 23 | """ 24 | Temporarily updates the ``os.environ`` dictionary in-place. 25 | 26 | The ``os.environ`` dictionary is updated in-place so that the modification 27 | is sure to work in all situations. 28 | 29 | :param remove: Environment variables to remove. 30 | :param update: Dictionary of environment variables and values to add/update. 31 | """ 32 | env = os.environ 33 | update = update or {} 34 | remove = remove or [] 35 | 36 | # List of environment variables being updated or removed. 37 | stomped = (set(update.keys()) | set(remove)) & set(env.keys()) 38 | # Environment variables and values to restore on exit. 39 | update_after = {k: env[k] for k in stomped} 40 | # Environment variables and values to remove on exit. 41 | remove_after = frozenset(k for k in update if k not in env) 42 | 43 | try: 44 | env.update(update) 45 | [env.pop(k, None) for k in remove] 46 | yield 47 | finally: 48 | env.update(update_after) 49 | [env.pop(k) for k in remove_after] 50 | """Alohomora cast and other tests""" 51 | 52 | def tearDown(self): 53 | files = [ 54 | 'tests/files/birdie', 55 | 'tests/files/birdie_env', 56 | 'tests/files/birdie2', 57 | 'tests/files/env.birdie.vault', 58 | ] 59 | 60 | for test_file in files: 61 | if os.path.isfile(test_file): 62 | os.remove(test_file) 63 | 64 | def read_generated_config(self, file, filename=None): 65 | if filename: 66 | vault_file = filename 67 | f = open(vault_file) 68 | else: 69 | vault_file = file 70 | f = open('tests/files/' + vault_file) 71 | string_config = '[default]\n' + f.read() 72 | f.close() 73 | 74 | config = configparser.ConfigParser(allow_no_value=True) 75 | config.read_file(io.StringIO(string_config)) 76 | 77 | return config 78 | 79 | def cast_and_read(self, spell, filename=None): 80 | fd = open('tests/files/birdie.j2') 81 | spell.cast(fd, filename=filename) 82 | fd.close() 83 | 84 | return self.read_generated_config('birdie', filename=filename) 85 | 86 | def test_multi_target_cast(self): 87 | spell = Alohomora('prod', 'birdie', mock=True) 88 | with open('tests/files/birdie.j2') as f1, open('tests/files/birdie2.j2') as f2: 89 | res = spell.cast(f1,f2) 90 | 91 | config1 = self.read_generated_config('birdie') 92 | config2 = self.read_generated_config('birdie2') 93 | 94 | assert 'fake_app_key' == config1.get('default', 'APP_KEY') 95 | assert 'fake_app_key' == config2.get('default', 'APP_KEY') 96 | 97 | def test_lookup(self): 98 | spell = Alohomora('prod', 'birdie', mock=True) 99 | config = self.cast_and_read(spell) 100 | assert 'prod-common.db.website.vpc' == config.get('default', 'DB_HOST') 101 | assert 'fake_app_key' == config.get('default', 'APP_KEY') 102 | assert 'fake_db_password' == config.get('default', 'DB_PASSWORD') 103 | assert 'fake_secret' == config.get('default', 'APP_SECRET') 104 | 105 | def test_output_file_name(self): 106 | spell = Alohomora('prod', 'birdie', mock=True) 107 | vault_file = 'tests/files/env.birdie.vault' 108 | config = self.cast_and_read(spell, filename=vault_file) 109 | assert os.path.isfile('tests/files/env.birdie.vault') 110 | assert 'fake_secret' == config.get('default', 'APP_SECRET') 111 | 112 | def test_lookup_failure(self): 113 | spell = Alohomora('prod', 'birdie', mock=True) 114 | msg = r'Lookup failed: alohomora_app_key_non_existent' 115 | with pytest.raises(Exception, 116 | match=msg): 117 | with open('tests/files/birdie_fail.j2') as f: 118 | spell.cast(f) 119 | 120 | def test_canonical(self): 121 | spell = Alohomora('prod', 'birdie', mock=True) 122 | assert 'prod' == spell.canonical_env('Production') 123 | assert 'prod' == spell.canonical_env('Production-API') 124 | assert 'prod' == spell.canonical_env('prod-birdie') 125 | assert 'beta' == spell.canonical_env('beta-birdie') 126 | assert 'beta' == spell.canonical_env('beta') 127 | 128 | def test_multi_lookup_failure(self): 129 | spell = Alohomora('prod', 'birdie', mock=True) 130 | msg = r'Lookup failed: alohomora_app_key_non_existent, alohomora_app_fake_key' 131 | with pytest.raises(Exception, 132 | match=msg) as excinfo: 133 | with open('tests/files/birdie_fail_multiple.j2') as f: 134 | spell.cast(f) 135 | assert excinfo.value.args[0] == msg 136 | 137 | def test_environment(self): 138 | with self.modified_environ(README='VALUE'): 139 | spell = Alohomora('prod', 'birdie', mock=True) 140 | with open('tests/files/birdie_env.j2') as f: 141 | spell.cast(f) 142 | 143 | conf = self.read_generated_config('birdie_env') 144 | assert 'VALUE' == conf.get('default', 'ENV_TEST') 145 | --------------------------------------------------------------------------------