Automatisk oppsett av env-variabler i utviklermiljøet

· 887 ord · 5 min å lese

Noen ganger kan det være kjekt å ha env-filer i ulike nivå og mapper i et monorepo. Det å sette disse opp filene er derimot mindre kjekt. La oss derfor automatisere dette ved oppstart av devcontaineren!

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.

Demonstrasjon av oppsett av env-filer

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)