To CLI-verktøy

Glenn Bitar

tl;dr: https://github.com/glennib/envoke & https://github.com/glennib/hemli

Noe av det jeg synes er veldig gøy med softwareutvikling er at man på egenhånd er i stand til å gjøre mye for å håndtere problemer og frustrasjoner som dukker opp. Når man utvikler tjenester og applikasjoner må man ofte gå noen omveier for å få dem til å kjøre godt lokalt, med riktige miljøvariabler eller konfigurasjonsverdier.

Det hender jeg har behov for å kjøre applikasjoner med lokalt oppsett, men også tilkobla databaser, PubSub-køer e.l. i test- eller snapshot-miljøene våre. Da er jeg ikke så god til å huske hverken riktig URL eller hemmelighets-ID-er og -prosjekter når jeg svitsjer kjøremiljø.

export DATABASE_HOST='172.0.10.10'
export DATABASE_PASSWORD="$(gcloud \
  secrets versions access latest \
  --project secret-project \
  --secret myapp-database-password-test \
  )"
export DATABASE_URL="postgresql://bruker:$DATABASE_PASSWORD@$DATABASE_HOST:5432/thedb"
export DATABASE_HOST='127.0.0.1'
export DATABASE_PASSWORD='nisselue'
export DATABASE_URL="postgresql://bruker:$DATABASE_PASSWORD@$DATABASE_HOST:5432/thedb"

Disse er kjekke å ha i shell-historikken, eller i en README, men jeg synes det hadde vært enda bedre om man kunne satt opp dette automatisk med en kildefil sjekket inn i repoet.

Vi har laget et eget verktøy for dette i et av Amedias repoer, hvor hemmelighetenes ID-er og andre verdier er hardkodet for hvert miljø. Da vi nylig skulle opprette et nytt repo med behov for liknende funksjonalitet, hadde en kollega av meg den gode idéen om å generalisere dette verktøyet, istedenfor å hardkode dette i et nytt program.

envoke

Dette utviklet seg til en plan om å ha en konfigurasjonsfil som gir oss oppslag fra miljønavn (f.eks. test, local, snapshot) til en liste av variabler. Eksempel:

# envoke.yaml
variables:
  DATABASE_HOST:
    envs:
      test: '172.0.10.10'
      local: '127.0.0.1'
  DATABASE_PASSWORD:
    envs:
      test:
        sh: |
          gcloud secrets versions access latest
              --project secret-project
              --secret myapp-database-password-test
      local:
        literal: nisselue
  DATABASE_URL:
    default:
      template: "postgresql://bruker:{{ DATABASE_PASSWORD | urlencode }}@{{ DATABASE_HOST }}:5432/thedb"

Denne konfigurasjonsfilen leses av et program som tar inn hvilket kjøremiljø vi ønsker å lage konfigurasjon for, og spytter ut variabler:

$ envoke local
DATABASE_HOST='127.0.0.1'
DATABASE_PASSWORD='nisselue'
DATABASE_URL='postgresql://bruker:nisselue@127.0.0.1:5432/thedb'
$ envoke test
DATABASE_HOST='172.0.10.10'
DATABASE_PASSWORD='my@password%'
DATABASE_URL='postgresql://bruker:my%40password%25@172.0.10.10:5432/thedb'

Verktøyet kan også skrive til fil:

$ envoke local -o .env # eller bare envoke local > .env

Og har et knippe med features som gjør det fleksibelt nok til daglig bruk:

$ envoke -h
Resolve environment variables from envoke.yaml

Usage: envoke [OPTIONS] [ENVIRONMENT]

Arguments:
  [ENVIRONMENT]  Target environment (e.g. local, prod). Not required with --schema or --list-* flags

Options:
  -o, --output <OUTPUT>       Write output to a file instead of stdout
  -t, --tag <TAGS>            Only include tagged variables with a matching tag. Repeatable. Untagged variables are always included
      --all-tags              Include all tagged variables regardless of their tags
  -O, --override <OVERRIDES>  Select named overrides for source selection. Repeatable. Per variable, at most one active override may be defined
      --prepend-export        Prefix each line with `export`. Ignored when `--template` is used
  -c, --config <CONFIG>       Path to config file [default: envoke.yaml]
      --template <TEMPLATE>   Use a custom output template file instead of the built-in format
      --schema                Print the JSON Schema for envoke.yaml and exit
      --list-environments     List all environment names found in the config and exit
      --list-overrides        List all override names found in the config and exit
      --list-tags             List all tag names found in the config and exit
  -q, --quiet                 Suppress informational messages on stderr
  -h, --help                  Print help (see more with '--help')
  -V, --version               Print version

Dette var nok til å erstatte spesialverktøyet nevnt over med en mer generell tilnærming. envoke.yaml sjekkes inn i repoet, og utviklerne kan bruke envoke til å lagre miljøvariablene slik de måtte ønske.

Dersom KEY=VALUE-formatet ikke er nok for ditt prosjekt, er det mulig å benytte seg av en egen, Jinja2-aktige template-fil. Kjekt i tilfelle du konfigurerer med JSON, TOML eller noe annet :)

Kildekoden finnes på github, og verktøyet kan f.eks. installeres med mise use github:glennib/envoke.

hemli

Dersom man ofte svitsjer miljø med envoke i eksempelet over, vil shell-scriptet som henter hemmeligheter (gcloud secrets ...) bruke en stund på å kjøre hver gang. Det ville jo ha vært kjekt å cachet disse verdiene lokalt, eller?

Dersom du erstatter

sh: |
  gcloud secrets versions access latest
    --project secret-project
    --secret myapp-database-password-test

med

sh: |
  hemli get --namespace myapp database-password-test
    --source-sh
    'gcloud secrets versions access latest --project secret-project --secret myapp-database-password-test'

så gjøres akkurat dette.

hemli sjekker først om hemmeligheten myapp/database-password-test finnes i operativsystemet ditt sin nøkkelring, før den eksekverer --source-sh-skriptet og mellomlagrer resultatet lokalt.

Kildekoden er også på github, og kan også installeres med mise use -g github:glennib/hemli.

Ikke så farlig når det er én hemmelighet som hentes en gang i blant, men her er et use case som gjør dette aktuelt:

Kombinasjonen

Dersom man bruker envoke til å lage en .env-fil, har man i praksis skrevet hemmeligheter til disk i klartekst. Det er kanskje ikke det lureste å gjøre, med tanke på sikkerhet. I stedet kan man wrappe alle programmer man starter med et wrapperscript, f.eks. ~/.local/bin/e:

#!/usr/bin/env sh
ENV_NAME="${ENVOKE_ENV:-local}"
eval "$(envoke --prepend-export $ENV_NAME)"
exec "$@"

og kjøre

e cargo run # kjører med lokalt oppsett
ENVOKE_ENV=test e cargo run # kjører med test-oppsett

Da vil programmet du kjører ha et miljø hvor de riktige hemmelighetene er tilgjengelige. Men dersom du må gå til gcloud secrets ... for hver kommando du kjører, vil dette ta altfor lang tid. Dersom du bruker hemli så reduseres sekundene til millisekunder, og du kan fly avgårde! 🚀

Claude og jeg laget dette

Både envoke og hemli ble skrevet av Claude Code. Med envoke så presenterte jeg en grov idé til Claude som vi itererte på før den satte i gang å jobbe med planen. Grensesnittet til CLI-verktøyet og til konfigurasjonen gikk igjennom flere iterasjoner for å få alle funksjonene jeg ville ha. Med hemli så skrev jeg et langt prompt, og så laget Claude verktøyet i én omgang. Jeg har gått igjennom koden til begge verktøyene og synes den ser ganske fornuftig ut.

Selv om dette er to verktøy som kommer til å gjøre hverdagen min som utvikler litt enklere, ville jeg antagelig ikke ha brukt tid på å lage dem, om det ikke hadde vært for AI. Med AI synes jeg det er mye lettere å ta fatt på oppgaver som tidligere ikke ville ha vært verdt arbeidet og tiden som hadde gått med. Nå har jeg fått to verktøy som er akkurat slik jeg ønsket at de skulle være, og det har kostet minimalt med tid og arbeid.

P.S.: Sjekk også ut https://fnox.jdx.dev/, som i stor grad kan erstatte hemli 🙃.