diff --git a/.ebignore b/.ebignore index 8276cb4..3750eac 100644 --- a/.ebignore +++ b/.ebignore @@ -1,3 +1,8 @@ +# Environment variables: +.env.local +.env.prod + +# Python stuff: .coverage .mypy_cache .pytest_cache diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..fbad578 --- /dev/null +++ b/.env.template @@ -0,0 +1 @@ +export SOME_VAR="Test" diff --git a/.gitignore b/.gitignore index c40d669..ffdc6fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ +# Elastic Beanstalk stuff: !.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.global.yml .elasticbeanstalk/* +# Environment variable files: +.env.local +.env.prod + +# Python stuff: .coverage .mypy_cache .pytest_cache diff --git a/Pipfile b/Pipfile index c878a96..1e9c690 100644 --- a/Pipfile +++ b/Pipfile @@ -13,6 +13,7 @@ mypy = "*" pytest = "*" invoke = "*" coverage = "*" +python-dotenv = "*" [requires] python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock index 0abd27a..b9572cc 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8cacbd70e662a07e1571dabf0ebb178f0e71a900a6737991307eca800a7509bd" + "sha256": "64e452780784b7ec69f1145f5a6eea1ef5637b8128e95f6143ab9557f9f689cb" }, "pipfile-spec": 6, "requires": { @@ -370,6 +370,15 @@ "markers": "python_version >= '3.8'", "version": "==8.1.1" }, + "python-dotenv": { + "hashes": [ + "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", + "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.0.1" + }, "tomlkit": { "hashes": [ "sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b", diff --git a/README.md b/README.md index 4abb676..6a566bd 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ For all other command line tasks, the affils project uses [Invoke](https://docs. We have diagrams for our current and desired affiliations workflow in the [diagrams directory](./doc/diagrams). +## Environment variables + +We use environment variables files (commonly referred to as `.env` +files) to configure the affiliations service. For more info on how we +use `.env` files, see the [doc on environment variables](./doc/envars.md). + ## Deployment Read the [doc](./doc/deploy.md) on deploying to production. diff --git a/doc/envars.md b/doc/envars.md new file mode 100644 index 0000000..a6bc4dc --- /dev/null +++ b/doc/envars.md @@ -0,0 +1,12 @@ +# Environment variables + +There's a `.env.template` file that is the source of truth for +environment variable keys. You're supposed to create a `.env.local` file +and a `.env.prod` file based on `.env.template`. The keys in each +environment variable file should stay the same at all times. This is +enforced programmatically using Invoke tasks. (See the `tasks.py` file +for more details on this.) The values of the keys can differ between +files, of course. + +When you deploy to production, the production environment variables are +automatically uploaded to production. diff --git a/requirements.txt b/requirements.txt index e6681e5..a542e07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ platformdirs==4.2.0 pluggy==1.4.0 pylint==3.1.0 pytest==8.1.1 +python-dotenv==1.0.1 tomlkit==0.12.4 typing_extensions==4.10.0 Werkzeug==3.0.1 diff --git a/src/app.py b/src/app.py index 8beca28..6e5a4fb 100644 --- a/src/app.py +++ b/src/app.py @@ -4,6 +4,7 @@ """ # Built-in libraries: +import os import subprocess # Third-party dependencies: @@ -28,3 +29,9 @@ def current_git_sha(): # Strip newline character from the end of the string. sha = output[0 : len(output) - 1] return sha + + +@app.route("/env") +def display_env_var(): + """Displays environment variable for testing purposes.""" + return os.environ.get("SOME_VAR") diff --git a/tasks.py b/tasks.py index cc1eb2d..63c41c9 100644 --- a/tasks.py +++ b/tasks.py @@ -5,8 +5,20 @@ """ # Third-party dependencies: +from dotenv import dotenv_values from invoke import task +# Environment variable files: +ENV_TRUTH = ".env.template" # The source of truth for all .env files. +ENV_LOCAL = ".env.local" +ENV_PROD = ".env.prod" + +# Configs: +TRUTH_CONFIG = dotenv_values(ENV_TRUTH) +LOCAL_CONFIG = dotenv_values(ENV_LOCAL) +PROD_CONFIG = dotenv_values(ENV_PROD) +CONFIGS = [LOCAL_CONFIG, PROD_CONFIG] + @task def fmt(c): @@ -32,18 +44,29 @@ def test(c): c.run("coverage run -m pytest && coverage report") -@task(pre=[fmt, lint, types, test]) +@task +def envsame(c): + """Ensure environment variable keys match in each .env file.""" + for config in CONFIGS: + if config.keys() != TRUTH_CONFIG.keys(): + print(".env keys do not match. Check your .env files.") + exit(1) + + +@task(pre=[fmt, lint, types, test, envsame]) def check(c): """Run all code checks.""" @task def reqs(c): - """Generate requirements.txt file for use in GitHub Actions. + """Generate requirements.txt file. The GitHub Actions workflow runners don't seem to play nicely with Pipenv. We generate a requirements.txt file and use it to install dependencies in GitHub Actions. + + We also use the requirements.txt file on Elastic Beanstalk. """ add_warning = ( "echo '# Do not edit directly. This file is generated.\n' > requirements.txt" @@ -61,10 +84,19 @@ def dev(c): Assumes you've activated the virtual environment. """ - c.run("cd src && flask run") + c.run(f"source {ENV_LOCAL} && cd src && flask run") -@task +@task(pre=[envsame]) +def envprod(c): + """Set production environment variables.""" + vars = " ".join([f"{key}={val}" for key, val in PROD_CONFIG.items()]) + c.run(f"eb setenv {vars}") + + +# Make sure our requirements.txt is up-to-date before we deploy. +# Set environment variables before we deploy. +@task(pre=[reqs, envprod]) def deploy(c): """Deploy the affils service.