Spring til indhold
← Blog Blog

Slet opsætningsguiden

Ethvert repository udleverer instruktioner, ingen holder ved lige, og en .env.example, som nogen skal udfylde ud fra en Slack-besked. To værktøjer — mise til værktøjskæden, fnox til hemmelighederne — flytter begge opgaver ud af README'en og ind i repoet. Med et ærligt regnskab over, hvad det koster, og hvad det ikke løser.

En kasket ved en servicelem sat ned i en fairway

Tænk på sidste gang du klonede et repository, du aldrig havde arbejdet i før.

Du læste README’en. Den bad dig installere en JDK, og du havde den forkerte, så du gik ud og fandt den rigtige. Den bad dig installere Node, og versionsfilen sagde 18, mens lockfilen tydeligvis ville have 20. Et sted nede i bunden stod der cp .env.example .env og så, med mindre skrift, udfyld værdierne. Udfyld dem hvorfra? Fra en kollega. Så du spurgte på Slack, nogen indsatte en blok KEY=value-linjer til dig, og nu ligger et produktionsnært login i en chathistorik for altid: søgbart, backet op, eksporteret til hvilket compliance-værktøj virksomheden nu købte sidste år.

Ingen har sat sig for at bygge det. Det er, hvad der sker, når instruktionerne bor i et dokument i stedet for i repoet.

Jeg er tech lead hos GolfNext, en dansk virksomhed der bygger software til golfklubber, og mit første indlæg på denne blog var en opgørelse over, hvad jeg var trådt ind i. Otteogtres repositories, fire produktlinjer, tretten års akkumulerede teknologivalg: Java og Maven og Spring, Kotlin og Gradle, Node, Python, PHP, JavaFX, React Native. At komme i gang i et af dem fra kold start tog mellem to og seks timer afhængigt af, hvilket man valgte, og jeg satte mig selv et mål om fem minutter fra klon til kørende kode.

Det her er det første seriøse forsøg på det. To værktøjer, begge fra samme person, jdx. mise lægger de rigtige værktøjer på din PATH, fnox lægger de rigtige hemmeligheder i dit miljø, og det er de to halvdele af ét problem. mise er kedeligt og bredt anvendt. fnox er ingen af delene endnu, og det vender jeg tilbage til. Det interessante er, hvad der falder væk, når begge er på plads, og hvad det gør ved de kodeagenter, jeg har kørende i de her repositories hele dagen.

Det er mise som i mise en place, det fra køkkenet. Udtales “meez”, /miːz/, én stavelse, uden -uh til sidst. Det vil du gerne have styr på, inden du siger det højt til et møde. fnox er nemmere: Fort Knox med de fleste bogstaver banket af, altså “eff-nox”, og navnet fortæller dig, hvad det er til.

Halvdel et: værktøjerne

Har du brugt nvm eller pyenv, kender du formen: erklær en version, cd ind, få den version. Forskellen er, at mise er ligeglad med, hvad versionen er af.

# .mise.toml
[tools]
java = "21"
maven = "latest"
node = "lts"
python = "latest"
gitleaks = "latest"

Det er en JVM, et build-værktøj, en JavaScript-runtime, en Python og en hemmelighedsscanner (gitleaks) i én fil, installeret med én kommando, på din PATH kun inde i dette katalogtræ. Tilføj mise activate til din shell-profil, og selv kommandoen forsvinder: cd ind i repoet, og værktøjerne er der, cd ud, og det er de ikke. Den sætter også de miljøvariable, der hører til, så JAVA_HOME peger på den JDK, dette projekt har fastlåst, i stedet for hvad dit styresystem sidst besluttede.

“Versionsstyring” underspiller det her, og jeg vil gerne være konkret om hvorfor. Det dyre ved en polyglot kodebase er skiftet, ikke den enkelte teknologi. Du bruger en formiddag i en Spring-service, hopper så til et Android-repo, så et lille Python-værktøj, og hvert hop er en chance for, at din shell er forkert på en måde, der giver en forvirrende fejl frem for en tydelig. I et af vores repositories fejler en nyere system-JDK ikke højlydt. Den ødelægger et mocking-bibliotek under test, adskillige lag nede, med en besked der intet har med Java-versioner at gøre. Det er en time af nogens liv, og du betaler den igen, hver gang en ny person starter, eller en computer bliver skiftet ud.

Et sprogspecifikt værktøj kan ikke hjælpe, fordi problemet ligger mellem sprogene.

En detalje, du lige så godt kan stjæle som opdage selv. Vi lader bevidst nogle af versionerne flyde: latest til scanneren, lts til Node. Hvad et build er lavet af, er fastlåst i CI-workflowet, og det er den fastlåsning, dependency-automatikken holder øje med og laver pull requests på. Fastlåste vi de samme versioner i .mise.toml også, ville vi have to steder at holde i sync, og det ene ville stille og roligt sakke bagud. Det ved vi, fordi det allerede var sket: en .mise.toml der påstod Node 22, mens alle fire workflows sagde 24. Kun én fil får lov at være den autoritative, og det er workflowet.

JDK’en er undtagelsen, fastlåst hårdt begge steder med vilje. En forkert major-version dér er præcis den mocking-fejl ovenfor, og jeg holder hellere to filer i sync end genopdager den fejl klokken fire om eftermiddagen. Alt andet flyder.

Den del, jeg ikke havde regnet med at holde af

Ethvert repository har en håndfuld ting, man gør ved det: bygger det, tester det, kører det, formaterer det. I et Maven-projekt er det mvn spring-boot:run -Dspring-boot.run.profiles=local. I et Gradle-projekt er det ./gradlew og et task-navn med fire ord klistret sammen. I et Node-projekt er det, hvad scripts-blokken nu siger i denne uge. Intet af det er svært. Alt af det er noget, man skal slå op, når man er i et ukendt repo, hvilket er præcis dér, hvor man har mindst tålmodighed til at slå noget op.

mise lader dig lægge de udsagnsord i samme fil som værktøjerne:

[tasks.build]
description = "Package the artifact"
run = "mvn clean package -B"

[tasks.test]
description = "Full test suite (integration tests need Docker)"
run = "mvn clean test -B"

[tasks.serve]
description = "Run the API on http://127.0.0.1:5001 (starts the stack first)"
depends = ["stack:up"]
run = "mvn spring-boot:run -Dspring-boot.run.profiles=local"

[tasks."stack:up"]
description = "Start the local database and the mocks. Idempotent"
run = "docker compose up -d --wait"

Så bliver grænsefladen mise run build, mise run test, mise run serve. I Android-repoet ved siden af ligger de samme udsagnsord oven på Gradle i stedet, og et af dem pakker et task-navn på fire ord, jeg stadig ikke kan skrive udenad. Lige meget. mise tasks viser, hvad et repository kan, med beskrivelser, og svaret ligger i repoet frem for på en wiki-side, der holdt op med at være sand i 2023.

To små konventioner har gjort sig fortjent. Et bart udsagnsord er det almindelige tilfælde, og verb:qualifier indsnævrer det: test og test:signer, stack:up og stack:down og stack:clean. Der er også altid en nødudgang, som pakker det underliggende build-værktøj direkte, så alt, den kuraterede liste ikke dækker, stadig er én kommando væk:

[tasks.mvn]
description = "Any Maven command, with this repo's JDK and environment"
run = "mvn"

Den nødudgang får betydning i anden halvdel af historien.

Halvdel to: hemmelighederne

Det her er den del, jeg faktisk ville til livs.

.env.example-mønstret er så udbredt, at det læses som best practice. Det er en konvention, der gav op. Se på, hvad den beder et team om.

En ny udvikler kopierer en fil med tomme nøgler og skal så skaffe rigtige værdier fra et sted, repoet ikke kan fortælle dem om. I praksis betyder det en person, hvilket betyder en chatbesked, og nu findes en hemmelighed i endnu et system med andre opbevaringsregler og anden adgangsstyring end det, man med vilje havde valgt. Når en værdi roteres, er enhver computer med den gamle stille og roligt forkert, indtil nogen opdager det, og fejlen dukker som regel op som en forvirrende 401 frem for en besked der siger “dit login er fire måneder gammelt”. Når en ny hemmelighed tilføjes, er alle andres .env ufuldstændig, og ingen får det at vide. Og filen selv ligger i dit arbejdskatalog i klartekst, ét uheldigt git add -A fra at blive committet, hvilket er grunden til, at ethvert repository med dette mønster også har en .gitignore-linje, nogen kunne slette.

Alternativet er ikke kompliceret. Læg værdierne i et hemmelighedslager, du allerede stoler på, læg referencer til dem i repoet, og lad den enkelte udviklers egen identitet afgøre, om de kan slås op.

Det er fnox. Her er stage-konfigurationen for en service, committet, uden værdier nogen steder:

# fnox.stage.toml
env = "exec"

[providers]
store = { type = "…", prefix = "/apps/checkout/stage/" }

[secrets]
DB_PASSWORD    = { provider = "store", value = "DB_PASSWORD",    if_missing = "error" }
PAYMENT_API_KEY = { provider = "store", value = "PAYMENT_API_KEY", if_missing = "error" }
SIGNING_KEY    = { provider = "store", value = "SIGNING_KEY",    if_missing = "error" }

type er den eneste linje, der er specifik for dig, og jeg har ladet den stå tom med vilje, fordi hvilket lager man bruger, er den mindst interessante beslutning i hele opsætningen. fnox taler med Vault, 1Password, SOPS og de store cloud-udbyderes egne hemmelighedslagre. Vi tog det, vores udbyder allerede gav os, af den kedelige grund at vi betalte for det, og det allerede havde en adgangsstyring, vi stolede på. Alt andet i filen er det samme, uanset hvad man vælger.

Hvad den fil køber:

Repoet dokumenterer sine egne hemmeligheder. Hvert navn, en service har brug for, står ét sted, i git, med reviewhistorik. Ny hemmelighed? Den står i filen, den kommer med git pull, og næste kørsel finder den enten eller fejler højlydt på grund af det if_missing = "error". Ingen skal have noget at vide.

Rotation holder op med at være en begivenhed. Skift værdien i lageret; enhver maskine samler den op ved næste kørsel. Der er ingen flåde af forældede kopier.

Adgang bliver igen et autorisationsspørgsmål. Om du kan køre mod et givent miljø, afgøres af din identitet i hemmelighedslageret, ikke af om nogen huskede at sende dig en fil. At fjerne nogens adgang bliver en rettighedsændring i stedet for et håb om, at de sletter en fil.

Og der er en tørkørsel. fnox check fortæller dig, om hver erklæret hemmelighed kan slås op af dig, uden at udskrive nogen af dem. Det er det hurtigst mulige svar på “er det mig eller er det i stykker”, hvilket er det spørgsmål, der æder onboarding-dage.

Så én bevidst tilbageholdenhed. Den env = "exec"-linje betyder, at hemmeligheder aldrig injiceres i en interaktiv shell, kun i miljøet for en underproces, fnox starter. fnox vil med glæde gøre det modsatte; der findes en tilstand, som lægger alt ind i din shell i det øjeblik, du træder ind i kataloget. Vi bruger den ikke. Jeg vil ikke have, at enhver proces startet fra det katalog arver produktionsnære credentials, og det gælder også editorer, og det gælder de kodeagenter, jeg har kørende derinde det meste af dagen. At begrænse hemmeligheder til fnox exec -- <kommando> er hele forskellen mellem “buildet kan se signeringsnøglen” og “alt på min maskine kan se signeringsnøglen”.

Netop den del havde jeg undervurderet. Med intet på disken og intet i shellen findes en hemmelighed kun inde i en proces, som noget bevidst har startet, hvilket gør “hvad kan agenten nå” til en helt almindelig rettighedsbeslutning. Nægt den fnox og den håndfuld tasks, der kalder det, tillad den alt andet, og den kan læse hele repoet, bygge, teste og formatere, uden at en credential nogensinde havner i dens kontekst.

Så hullet, som jeg hellere selv vil sætte navn på, end at få det gjort for mig. fnox læser lageret som mig, med credentials der ligger på min maskine alligevel. En agent med en shell har slet ikke brug for fnox; den kan spørge lageret med udbyderens egen CLI. At nægte fnox alene lukker hoveddøren i en bygning uden vægge. Det, der gør det egentlige arbejde, er, at den samme tilladelsesliste heller ikke indeholder den CLI, og at det, der er inden for rækkevidde fra en udviklermaskine, er stage frem for produktion. En agent, der kan redigere .mise.toml, kan også skrive sig selv en task, der kalder hvad som helst. Nægtelsen er et hegn, ikke et bevis.

Men hold det op mod det, den erstatter: en .env-fil i arbejdskataloget i klartekst, læsbar for alt, der kan kalde cat, hvilket er enhver agent, nogen nogensinde har kørt. Injektion udelukkende ved kørsel plus en nægtelsesregel er, så vidt jeg kan se, omtrent så godt, som det bliver lige nu.

Hvor de to halvdele mødes

Hver for sig er det to udmærkede værktøjer. Sammen fjerner de en hel kategori af manuelle skridt.

Fordi mise-tasks bare er kommandoer, kan en task være fnox exec -- noget:

[tasks.gradle]
description = "The escape hatch from earlier, now with the build secrets loaded"
run = "fnox exec -- ./gradlew"

[tasks.secrets]
description = "Check that every build secret resolves for you"
run = "fnox check"

[tasks."serve:stage"]
description = "Run locally against stage secrets (needs cloud access)"
depends = ["stack:up"]
run = "fnox -c fnox.stage.toml exec -- mvn spring-boot:run"

Nu er mise run gradle :app:assembleRelease et signeret release-build, og den, der kører det, skriver aldrig ordet fnox, lærer aldrig hvilke af buildets input der er hemmelige, og har aldrig en kopi af nogen af dem. Ingen skal huske, hvilke kommandoer der kræver hemmeligheder. Det gør tasken.

Der ligger også en til dem, der lever i en IDE:

[tasks.ide]
description = "Launch the IDE with the build secrets in its environment (macOS)"
run = 'fnox exec -- open -na "Android Studio" --args "$PWD"'

En IDE ved intet om noget af det her, så et build startet indefra den kan ikke signere. Start IDE’en gennem fnox, og dens Gradle arver miljøet. Det er én linje, og den er forskellen på et fungerende release-build og en signeringsfejl, ingen kan forklare, så den bliver i filen. Jeg griber ikke selv ud efter den ret tit: det meste af min dag er en terminal, en prompt og en agent, der skriver. Det er en præference, ikke en anbefaling, og tasken ligger der til den, der vil det modsatte.

Så er der opdelingen efter miljø. Én fil hver, de samme variabelnavne i dem alle, forskellige værdier bag navnene:

fnox.toml         # local
fnox.stage.toml   # stage
fnox.prod.toml    # production

En task peger på den fil, den har brug for. Alt, der hører til et miljø, ligger i det miljøs fil, så ingen kan komme til at parre stage-credentials med lokale indstillinger.

Den lokale er den fil, jeg ville pege på, hvis du kun læste én. Den erklærer næsten ingen hemmeligheder, fordi alt, hvad en lokal kørsel har brug for, allerede har en harmløs standardværdi committet i repoet: en lokal database, en mock foran alt eksternt, åbenlyst falske nøgler til de betalte tjenester. En frisk klon booter og svarer for en, der slet ikke har adgang til hemmelighedslageret, og alt, der ville nå en rigtig udbyder, fejler autentificering frem for at gøre noget dyrt. Adgang køber dig stage. Det er ikke prisen for at komme ind.

At committe referencer betyder at scanne efter værdier

At committe referencer til hemmeligheder virker kun, hvis du er sikker på, at intet ægte nogensinde smutter med. Så den samme opsætning bærer en scanner rundt, og mise installerer den, uden at nogen bliver bedt om det:

[tools]
gitleaks = "latest"

[hooks]
postinstall = "git config core.hooksPath .githooks"

mise trust er det første, nogen kører, og den første mise run derefter installerer værktøjskæden, hvilket også er det, der udløser postinstall-hooket og peger git på et katalog med en pre-commit-scanning. Den kommer som en sidegevinst ved det skridt, de alligevel skulle tage, frem for som en separat instruktion, folk springer over.

Hooket scanner kun staged indhold, så det er hurtigt, og hvis mise ikke er på PATH, springer det over med en høj advarsel i stedet for at fejle. En grafisk git-klients miljø er ofte minimalt, og at blokere nogens commit på grund af et PATH-problem er en god måde at lære dem at bruge --no-verify for altid. CI scanner de samme commits igen efter push.

Og deployments

Folk går som regel ud fra, at netop denne del virker anderledes, end den gør.

Vores deployede miljøer kører ikke fnox. Platformen, de kører på, læser selv de samme værdier fra det samme lager og giver dem til processen som ganske almindelige miljøvariable. Ingen applikationskode ved noget om det. Intet i runtimen importerer et bibliotek, og intet build skal have at vide, hvilket miljø det er til.

Det er med vilje. Lageret er sandheden. fnox er, hvordan et menneske læser den sandhed, med sine egne credentials, på sin egen maskine, og deploymentet læser samme sandhed med maskinens identitet. De to kan ikke komme fra hinanden, og applikationen ser på begge sider bare miljøvariable, som den altid har gjort.

Hvad det ikke løser

Den ærlige version, så.

Du skal stadig installere mise og lægge mise activate i din shell-profil. Det er et rigtigt opsætningsskridt, og det vil jeg ikke lade som om ikke findes. Det er ét skridt, det er det samme skridt i alle otteogtres repositories, og det bliver aldrig forældet, hvilket er tre ting, det afsnit det erstattede ikke kunne sige.

Docker er stadig dit eget. mise installerer værktøjer, ikke dæmoner. Vores lokale stacks kræver containere, og førstegangsoplevelsen for enhver uden Docker sat op er stadig et manuelt skridt og en download målt i gigabyte. Det samme gælder Xcode, som fortsat er sit eget vejrsystem.

At få værktøjskæden rigtigt fjerner den dumme del af en koldstart. Det gør intet ved den svære del, som er at forstå, hvad servicen egentlig laver. Det er stadig at læse kode, og det tager stadig dage.

Du lægger to afhængigheder oven i enhver udviklers hverdag. mise er modent og bredt anvendt. fnox er ungt. Vi har lagt rigtige arbejdsgange over i det, og går det i stå, står vi med en migrering. Jeg synes, det er et rimeligt væddemål: filen er deklarativ, værdierne ligger i et lager, vi ville beholde alligevel, og faldbacken er et shell-script, der læser de samme parametre. Men det er et væddemål, og at lade som om noget andet ville være uærligt.

Én linje i den konfiguration fortjener et grundigt review. fnox kan få besked på at køre en kommando, der logger dig ind i lageret, når din session er udløbet, hvilket er oprigtigt praktisk og samtidig betyder, at en committet konfigurationsfil nu bestemmer noget, der eksekverer på enhver reviewers computer. Læs den linje, som du ville læse et CI-workflow.

Der er også en fejltilstand med en vildledende besked. Peg udbyderen på den forkerte konto, og det, der kommer tilbage, er “not found”, hvilket læses som en manglende hemmelighed frem for en forkert identitet, og sender folk på jagt det helt forkerte sted. Begge vores filer bærer en kommentar, der siger præcis hvilken konto credentials skal slå op i.

Hvor vi faktisk er

To repositories helt på fnox. En håndfuld mere på mise. Tres og lidt til tilbage, hvoraf de fleste aldrig får brug for hemmelighedshalvdelen.

Jeg har ikke taget tid på det med et stopur, og jeg vil ikke opfinde et tal, jeg ikke har målt. Formen på svaret er alligevel klar nok. På en maskine, der har været igennem det her én gang før, med mise installeret og værktøjskæden i cachen og Docker kørende, er en klon til en svarende API under et minut. Rigeligt. Det, der er tilbage af de gamle to til seks timer, er alt sammen første-gang-på-denne-computer: at hente en JDK, trække images, installere mise selv. Den betaler man én gang frem for én gang per repository, og netop den anden del er hele forskellen fra, hvor vi startede.

I de repositories, hvor det her er landet, gik README’ens opsætningsafsnit fra en liste over ting at installere og et afsnit om at spørge en kollega ned til det her:

mise trust          # once, to allow this repository's config
mise run serve

To linjer, og den anden er den, du ville have alligevel. Den første er mise, der spørger, om du nu også stoler på en konfigurationsfil, som er ved at installere software og sætte miljøvariable, hvilket, i betragtning af at den fil nu kan køre kommandoer på din maskine, er en dialog jeg er glad for findes.

Mønstret generaliserer et godt stykke ud over vores egen teknologiske virkelighed, og derfor synes jeg, det er værd at skrive ned frem for at arkivere som en intern pligtopgave.

Slet så opsætningsguiden, for nu lyver den om, hvor meget arbejde der er.