Centralized Config Management¶
POC-style projects often have numerous hardcoded values, with some constant values being used multiple times. This pattern make projects difficult to maintain and prone to errors. In contrast, a production-ready project requires a centralized location to store all configurations. Once configurations are defined, we no longer allow hard-coded values and only reference configurations.
In this project, I implemented a light-weight config management system with the following folder structure:
/config
/config/define # config schema definition
/config/define/main.py # centralized config object
/config/define/app.py # app related configs, e.g. app name, app artifacts S3 bucket
/config/define/cloudformation.py # CloudFormation related configs
/config/define/deploy.py # deployment related configs
/config/define/lbd_deploy.py # Lambda function deployment related configs
/config/define/lbd_func.py # per Lambda function name, memory size, timeout configs
/config/define/name.py # AWS Resource name related configs
/config/init.py # config value initialization
- The
define
module defines the configuration data schema (field and value pairs). To improve maintainability, we break down the long list of configuration fields into sub-modules.
There are two types of configuration values: constant values and derived values. Constant values are static values that are hardcoded in the config.json file, typically a string or an integer. Derived values are calculated dynamically based on one or more constant values.
- The
- The
init
module defines how to read the configuration data from external storage. On a developer’s local laptop, the data is read from a config.json file.
During CI build runtime and AWS Lambda function runtime, the data is read from the AWS Parameter Store.
- The
Below is the implementation of the init
module:
# -*- coding: utf-8 -*-
import os
import json
from config_patterns.jsonutils import json_loads
from ..logger import logger
from ..paths import path_config_json, path_config_secret_json
from ..runtime import IS_LOCAL, IS_CI, IS_LAMBDA
from ..boto_ses import bsm
from .define import EnvEnum, Env, Config
if IS_LOCAL:
# ensure that the config-secret.json file exists
# it should be at the ${HOME}/.projects/aws_lambda_python_example/config-secret.json
# this code block is only used to onboard first time user of this
# project template. Once you know about how to handle the config-secret.json file,
# you can delete this code block.
if not path_config_secret_json.exists(): # pragma: no cover
path_config_secret_json.parent.mkdir(parents=True, exist_ok=True)
path_config_secret_json.write_text(
json.dumps(
{
"shared": {},
"envs": {
"dev": {"password": "dev.password"},
"int": {"password": "int.password"},
"prod": {"password": "prod.password"},
},
},
indent=4,
)
)
# read non-sensitive config and sensitive config from local file system
config = Config.read(
env_class=Env,
env_enum_class=EnvEnum,
path_config=path_config_json.abspath,
path_secret_config=path_config_secret_json.abspath,
)
elif IS_CI:
# read non-sensitive config from local file system
# and then figure out what is the parameter name
config = Config(
data=json_loads(path_config_json.read_text()),
secret_data=dict(),
Env=Env,
EnvEnum=EnvEnum,
)
# read config from parameter store
# we consider the value in parameter store is the ground truth for production
config = Config.read(
env_class=Env,
env_enum_class=EnvEnum,
bsm=bsm,
parameter_name=config.parameter_name,
parameter_with_encryption=True,
)
elif IS_LAMBDA:
# read the parameter name from environment variable
parameter_name = os.environ["PARAMETER_NAME"]
# read config from parameter store
config = Config.read(
env_class=Env,
env_enum_class=EnvEnum,
bsm=bsm,
parameter_name=parameter_name,
parameter_with_encryption=True,
)
else:
raise NotImplementedError