For å motivere ideen om hvorfor det kan være hensiktsmessig å ha *.env
-filer i ulike nivå og mapper i et monorepo, vil jeg ta for meg et eksempel.
Koden fra dette innlegget er tilgjengelig på repoet endrekrohn/monorepo-env-loader 🧑💻
Eksempel 🔗
Si du utvikler en Next JS
-applikasjon i et større monorepo. I og med at det er et monorepo har du gjerne en eller flere docker-compose.yml
-filer med konfigurasjon for å launche applikasjonene. Disse docker-compose.yml
-filene setter gjerne egne .env
-filer avhengig av kontekst. Samtidig ønsker du gjerne også fleksibiliteten av å kunne kjøre applikasjonen normalt i terminal ved hjelp av next dev
(eller yarn dev
/npm run dev
).
Du gjør deg dermed avhengig av å definere .env
-filer inni applikasjonens mappe i monorepoet. Dette er spesielt kjedelig etterhvert som antallet tjenester i monorepoet øker.
docker-compose.yml
.env
web/
services/
app/
.env.local
other-app/
.env
Idet du onboarder nye utviklere til prosjektet er de nødt å definere hver og en slik .env
-fil før de kan kjøre prosjektet. Ouch 😨.
En løsning er å committe .env.dev
-filer til repoet. Men dette fungerer ikke for hemmelige nøkler du trenger til utvikling, eksempelvis OAuth
-provider-nøkler.
Samtidig gjør spredningen av .env
-filer det vanskelig å revisjonsføre endringer. Det hadde derfor vært fint om alle hemmeligheter var plassert i en og samme fil.
Løsning 🔗
Løsningen jeg endte opp med besto i av å ha en enkelt .toml
-fil i roten av repoet som ikke committes. Valget falt på en toml
-fil da disse ryddig representerer hierarkisk data.
Eksempel på innhold.
[".env"]
SECRET="hush"
[web]
[web.services]
[web.services.app]
[web.services.app.".env.local"]
SECRET="top-secret"
TOKEN="shhh"
[web.services.other-app]
[web.services.other-app.".env"]
AREA_52="tonopah-test-range"
Denne toml
-filen blir videre parset ved oppstart av devcontaineren
, og lager/overskriver .env
-filene dersom den definerte mappestrukturen eksisterer. Se gjerne eksempel av bruk under i gif
.
Jeg vil videre gå gjennom selve løsningen i detalj.
Parsing 🔗
Vi har allerede definert strukturen på toml
-filen. Denne blir utgangspunktet for innlesing. For denne oppgaven bruker jeg et provisorisk mindre Python
-skript.
Innlesing av fil 🔗
For å lese filen bruker vi os
fra Pythons
standardbibliotek. For å parse den innleste filen bruker vi biblioteket tomli
. Fra og med Python 3.11
behøver du ikke et separat bibliotek for å parse toml
-filer, da er det tomllib
som gjelder.
GRN = "\x1b[1;32m"
YLW = "\x1b[33;20m"
BRD = "\x1b[31;1m"
CLR = "\x1b[0m"
BLU = "\x1b[1;34m"
def read_file(path: str) -> dict[str, Any]:
error = None
if not path.endswith(".toml"):
error = f"Type of file must be a '.toml'. Were given '{path}'"
elif not os.path.isfile(path):
error = f"File not found at location '{path}'"
if error is not None:
logger.error(BRD + error + CLR)
sys.exit(1)
logger.info(
f"Initializing environment variables with the file: '{BLU + path + CLR}'"
)
with open(path, mode="rb") as fp:
return tomli.load(fp)
Legg merke til terminalfargekodene definert øverst. Da får vi farge i
vscode
-terminalen vår 🌈.
Formater innlest fil 🔗
Med filen innlest og parset av tomli
har vi fått en JSON-lik dictionary
tilbake. Denne kan normaliseres med følgende kode (mye rom for forbedring):
_KeyValPair: TypeAlias = "dict[tuple[str, ...], str]"
_FileKeyValPair: TypeAlias = "dict[tuple[str], list[tuple[str, str]]]"
def flatten_dict(y: dict) -> _KeyValPair:
out = {}
def flatten(x: dict | tuple | Any, name: tuple[str, ...] = tuple()):
if type(x) is dict:
for a in x:
flatten(x[a], name + (a,))
elif type(x) is list:
for a in x:
if type(a) is str:
flatten(a, name + (a,))
else:
out[name] = x
flatten(y)
return out
def get_by_file(pairs: _KeyValPair) -> _FileKeyValPair:
out: _FileKeyValPair = {}
for k, v in pairs.items():
file = k[:-1]
key = k[-1]
if out.get(file) is None:
out[file] = []
out[file].append((key, v))
return out
Her har jeg definert noen types for å bedre lesbarheten noe. Men det er fortsatt stor takhøyde for forbedringer 🧑💻.
Sammenføying 🔗
Til slutt kan vi kombinere øvrige komponenter til en endelig main
-metode.
def main(path: str):
config = read_file(path)
flat = get_by_file(flatten_dict(config))
for k, l in flat.items():
dir = "./" + "/".join(k[:-1])
path = "./" + "/".join(k)
if not os.path.isdir(dir):
logger.warning(
YLW + f"- Folder not found. Could not write to '{path}'" + CLR
)
continue
with open(path, "w") as f:
lines = [f"{v[0]}={v[1]}" for v in l]
f.writelines("\n".join(lines))
logger.info(GRN + "Added credentials! 🔑" + CLR)
if __name__ == "__main__":
path = "env.toml"
if len(sys.argv) > 1:
path = sys.argv[1]
main(path)
Her kan man velge å ta inn en filsti ulik defaulten env.toml
i kjøring av skriptet. Man kan også gjøre noen endringer om man eksempelvis ønsker å kun appende
til filene, altså ikke overskrive som i open(path, "w") as f
.
Ved oppstart av devcontainer
🔗
I og med at devcontainere
kan være svært ulik vil jeg ikke gå i detalj på hvordan dette er satt opp. Full oppsett er uansett å finne i repoet.
Kort oppsummert kan jeg likevel ta for endringer i .devcontainer/Dockerfile
:
# Om du bruker ubuntu
# Installer Python 3.10
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
apt-get -y install --no-install-recommends \
software-properties-common gcc curl && \
add-apt-repository ppa:deadsnakes/ppa && \
apt-get update && \
apt-get -y install python3-pip && \
apt-get -y install python3.10 && \
apt-get -y install python3.10-dev python3.10-venv python3.10-distutils && \
curl -sS https://bootstrap.pypa.io/get-pip.py | python3.10
Legg videre til en kommando som kjører etter bygging av devcontainer:
{
"postCreateCommand": "sudo bash ./.devcontainer/post-create-command.sh"
}
I skriptet post-create-command.sh
lag et miljø og installer tomli
. Kjør deretter skriptet for oppsett av miljøvariabler.
# Initialize enviornment-files
(python3.10 -m venv ./.devcontainer/.venv && \
./.devcontainer/.venv/bin/python3.10 -m pip --quiet install --upgrade pip && \
./.devcontainer/.venv/bin/python3.10 -m pip --quiet install tomli && \
./.devcontainer/.venv/bin/python3.10 ./.devcontainer/scripts/load_envs.py env.toml)