Inde i junglen
En del af min onboarding var en opgave med mit navn på: få hver enkelt kodebase til at køre lokalt. Enoghalvfjerds repositories, et årti af teknologivalg og to uger med at finde ud af, hvilke af dem der stadig kunne starte på en frisk maskine — inklusive den, hvis lokale database slet ikke kunne bygges, og hvad der skulle til for at få én kommando til at bygge den alligevel.
Jeg startede hos GolfNext i begyndelsen af august. Et sted i onboardingen, mellem konti og hardware, lå en opgave med mit navn på: få hver enkelt kodebase til at køre lokalt.
Ikke læse dem. Køre dem. Klon repositoryet, installér hvad det nu kræver, start tingen, og se den komme op på din egen maskine.
Det lyder som noget, man giver en ny person for at holde ham beskæftiget en dag. Det løb gennem det meste af mine første to uger, det gav mig det mest brugbare kort over denne virksomhed, jeg har, og det blev til en pæn mængde reparationsarbejde, ingen havde bedt om — for flere af de repositories kunne slet ikke starte.
Alene i min første uge arbejdede jeg i et Vue-administrationspanel, en Android-app der kører på en selvbetjeningskiosk, en Java-backend og en desktop-kasseapplikation, der udsendes som en Windows-eksekverbar fil.
Det er formen på virksomheden. GolfNext bygger den software, golfklubber kører på — booking og betaling for en runde, kassen i proshoppen, de ubemandede maskiner ude på træningsbanen — og efter mere end et årti med det er der enoghalvfjerds repositories i vores GitHub-organisation. Mest TypeScript, JavaScript og Java, med PHP bagefter, og så nogle få enkeltstående: Kotlin, HCL til infrastrukturen, shell.
Intet af det er usædvanligt for en virksomhed på den alder, og intet af det er en klage. Det findes, fordi mennesker har leveret et årti af fungerende produkt under virkelige begrænsninger, og den oprydning, jeg er ved at beskrive, er kun mulig, fordi produktet allerede virker. Det betyder til gengæld, at hvert repository er et lille stykke jungle. To ting var for alvor svære, og ingen af dem var selve programmeringen: at finde ud af, hvilket af enoghalvfjerds repositories den ting, jeg havde brug for, overhovedet lå i — og derefter at finde ud af, hvordan man startede det. Det sidste var opgaven.
Hvad junglen opkræver
Store forhindringer er interessante, og dem husker man. Junglen opkræver i stedet i små identiske beløb, igen og igen.
Man kloner noget, og buildet fejler, fordi din JDK er én major-version forkert. Det retter du, og så vil byggeværktøjet have en anden. Du går videre til et webprojekt, og versionsfilen siger én Node, mens lockfilen er bygget mod en anden. Et sted findes en miljøfil, der skal udfyldes, og værdierne bor hos en kollega. Hver af dem er femten eller tyve minutter, ingen af dem lærer dig noget, og du betaler dem igen på den næste maskine og igen for den næste, der starter.
Intet af det var ét enkelt repositorys skyld. Det var fraværet af et fælles svar: hver Java-applikation havde lagt sig fast på sin egen JVM-version, og hvert webprojekt på sin egen Node-version — i praksis afgjort af, hvad der tilfældigvis var aktuelt, sidst nogen satte det op, uden at nogen nogensinde gik tilbage og bragte dem i overensstemmelse. Afhængighederne nedenunder skred på samme måde — de samme biblioteker i forskellige versioner i forskellige repositories — så det, man lærte ved at få ét repository til at bygge, kunne ikke overføres til det næste.
Og at få nogen af dem til at køre lokalt var en afgift i sig selv — i en ny valuta hver gang. Det tydeligste eksempel: to af vores Java-desktopapplikationer er samme slags program, bygget til samme opgave — den ene bygger med Gradle, den anden med Maven. Ingen af de to repositories skriver en grund ned. På et tidspunkt holdt jeg op med at tage noter og stillede spørgsmålet højt, og det er i virkeligheden det spørgsmål, hele dette indlæg handler om: hvor er standardiseringen? Ikke som en klage. Som et reelt spørgsmål om, hvordan et landskab ender med at have to svar på et problem, der kun har ét.
Én person, én applikation
Der er en strukturel grund til det, og jeg tror, den forklarer det meste af, hvad jeg fandt. I størstedelen af virksomhedens levetid er softwaren blevet bygget af fjernudviklere, der arbejdede på afstand fra andre lande og, så vidt jeg kan se, i høj grad hver for sig: én person, én applikation, fra start til slut. Er man den eneste, der nogensinde skal bygge en ting, giver det ingenting at erklære dens værktøjskæde i repositoryet. Man har den allerede — den ligger på ens maskine, og den er rigtig, fordi den er ens egen. Konventioner nærmer sig hinanden, når mennesker er nødt til at arbejde inde i hinandens repositories, og i årevis var der næsten ingen, der gjorde det.
Jeg vil tilføje en blødere teori, og jeg vil gerne være klar på, at det er en teori. Afstand er ikke kun geografisk. At arbejde på tværs af lande, tidszoner og andetsprog gør de billige samtaler dyre — dem, hvor to udviklere i forbifarten opdager, at de har løst det samme problem forskelligt, og bliver enige om én måde. Ingen behøver at være uenige, før et landskab skrider fra hinanden. Det er nok, at samtalen aldrig lige er belejlig. Intet af det er en kritik af nogen, der arbejdede her før mig; det er omtrent det, jeg ville forvente af den slags arrangement, og jeg havde formodentlig selv bygget den samme jungle i deres position.
Alt det har et navn, og det er branchens ældste vittighed: det virker på min maskine. Ingen her siger det med et skuldertræk, og ingen forsvarede det — men det er det, landskabet stille og roligt havde indrettet sig omkring. Hvis alle maskiner, der bygger et givet repository, allerede er sat op, så bor opsætningen ikke i repositoryet. Den bor i nogle få menneskers hoveder og i deres shell-historik. At få en ny person i gang betyder at læse den ud af dem igen, og den bliver aldrig komplet, for de dele, de gør automatisk, er præcis de dele, de glemmer at nævne.
Det repository, der ikke kan starte
Det værre tilfælde er det repository, der slet ikke kan starte.
I en af vores backends var den lokale udviklingsprofil — den konfiguration, der peger applikationen mod en database på din egen maskine — blevet slettet måneder tidligere, og ingen havde bemærket det, fordi ingen, der allerede var sat op, nogensinde havde brug for den. Klon den forfra, og du får en datakildefejl, der intet fortæller dig om hvorfor. Under den lå noget værre: en databasemigrering skrevet for år tilbage, som ikke lader skemaet blive færdigt, før den har kaldt ud til en tredjepartstjeneste — en, denne kodebase ikke længere kan nå. Så en reelt ny lokal database kunne slet ikke bygges, på nogen profil.
Ingen af de to stod i et tracker-system. De er ikke fejl i almindelig forstand, og ingen gjorde noget forkert for at skabe dem. De er, hvad et repository glider ind i, når ingen frisk maskine nogensinde er den, der bliver testet — og man finder først ud af det den dag, nogen starter, eller nogens laptop bliver skiftet, hvor hele regningen så kommer på én gang.
To hundrede og enoghalvtreds migreringer
Den sværeste at få startet var vores GolfMore-administrationspanel — det backoffice, klubpersonalet logger ind i, og som serverer det offentlige API ud af samme kodebase. PHP og Symfony, MySQL nedenunder, og et årti af skemahistorik liggende på disken: et 114 KB base-dump, der skal importeres først, og derefter 251 migreringsfiler, der skal køres oven på det.
Den dokumenterede vej til en lokal database var præcis det. Importér dumpet, kør migreringskæden, få et fungerende skema. Det virker ikke, og har ikke gjort det i årevis, fordi fire migreringer fra 2022 og 2023 ikke kan køre mod en database bygget på den måde:
- Én dropper den bogholderitabel, der er en rest fra det migreringsværktøj, projektet brugte før Doctrine, og som en frisk dump-import aldrig har haft.
- Én kaster ubetinget en fejl i sin
postUp(), fordi den kalderaddSql()på en migrering, der allerede er frosset. - Én refererer til en tabel flere måneder før den migrering, der opretter den.
- Én erklærer
AUTO_INCREMENTpå en kolonne uden nøgle, hvilket MySQL afviser, så den har aldrig kunnet køre overhovedet.
Ingen har skylden her på nogen interessant måde. Den samlede virkning er problemet: den ene vej, en ny udvikler bliver bedt om at gå, er den ene vej, der er brudt, og alle, der allerede er sat op, har en database, der ligger før problemet, så ingen andre støder nogensinde ind i det.
Hvordan en kæde rådner
Hvordan det er endt sådan, kan jeg kun gætte på, og det vil jeg hellere sige end lade som andet. Mit bedste gæt er, at skemaet i produktion på et tidspunkt er blevet ændret i hånden. Den fjerde af de defekter er næsten bevis på det: MySQL har aldrig accepteret en auto-increment-kolonne, der ikke er en nøgle, så den migrering kan aldrig have kørt nogen steder — og alligevel findes den tabel, den beskriver, alle de steder, applikationen kører. Noget andet end migreringskæden har lagt den der. Samme forklaring dækker den fremadrettede reference: fandtes den tabel allerede på den maskine, hvor migreringen blev skrevet, virkede det fint at referere til den dér, og kun en database bygget strengt ud fra kæden ville nogensinde bemærke det.
To andre mekanismer kræver slet ingen skurk. Den ene er base-dumpet. Det er et snapshot, og er det blevet opdateret på et tidspunkt senere end de migreringer, der følger efter det, så holder det op med at være den historik, der faktisk fandt sted, når man importerer det og kører kæden igen — nu beder man gamle migreringer om at gøre det, dumpet allerede indeholder, eller om at droppe det, det aldrig har haft, hvilket er præcis tilfældet med den efterladte bogholderitabel. Den anden er opgraderinger af afhængigheder. Den migrering, der kaster en fejl i sit efterkørsels-hook, var formodentlig lovlig den dag, den blev skrevet, og en senere version af migreringsbiblioteket gjorde det kald ulovligt. Ingen kører den gamle ende af en kæde igen, så den slags ændring lander lydløst og venter på den næste, der bygger en database.
Og det er den generelle form på det. En migreringskæde bliver kun testet i sin forreste ende. Halen bliver kun rørt af én slags begivenhed — at nogen bygger et miljø op fra ingenting — og er det ikke sket i nogle år, er der ingen steder, noget fortæller dig, at den er rådnet.
Var det mig eller koden?
Det, der gjorde det langsomt, var ikke nogen af de fire i sig selv. Det var, at jeg i et godt stykke tid ikke vidste, om problemet var koden eller mig. En ny person med en kommando, der fejler, har ingen mulighed for at skelne et brudt repository fra sin egen manglende viden, og standardantagelsen — den høflige, og oftest den rigtige — er, at det er en selv. Så læser man README’en igen, kontrollerer sine versioner, smider det hele væk og starter fra en frisk klon og mister en eftermiddag på at bevise noget, der aldrig var sandt til at begynde med.
Da jeg først havde accepteret, at det var kæden, var metoden kedelig, og den virkede. Kør migreringerne frem til den version, hvor de stopper. Find ud af, hvad netop den havde brug for og ikke kunne få. Skriv patchen i hånden, markér den version som kørt uden at køre den, og fortsæt til næste blindgyde. Det gjorde jeg en håndfuld gange, hver runde en smule hurtigere end den før, indtil hele kæden kunne køre fra ende til anden uden at ødelægge noget.
Derefter pakkede jeg den resulterende liste af hacks ind i én enkelt mise-opgave, som er den eneste del, nogen anden nogensinde bør se — og grunden til, at det nu føles som en leg at starte tingen lokalt i stedet for et arkæologisk udgravningsprojekt.
Hvad én kommando skjuler
Hvad jeg gjorde ved det, er ikke elegant. Én opgave, mise run db, starter containeren og stiller derefter databasen et enkelt spørgsmål: findes migreringstabellen? Alt det følgende hænger på svaret.
Er tabellen der, er det en eksisterende database: migrér den frem, kontrollér at den endte som aktuel, færdig. Er den ikke, importerer opgaven dumpet og går kæden igennem i etaper og stopper ved hver brudt migrering for at gøre i hånden præcis den ene ting, den migrering ikke kan gøre selv. Opret den efterladte bogholderitabel, så den migrering, der dropper den, lykkes. Tilføj den kolonne, den fejlkastende migrering ville have tilføjet, og markér derefter migreringen som kørt uden at køre den. Rejs en midlertidig udgave af den tabel, der endnu ikke findes, netop længe nok til, at den fremadrettede reference kan gå op, og drop den igen bagefter. Opret direkte den ene tabel, hvis migrering aldrig kan udføres. Kør så resten normalt, verificér at skemaet er opdateret, og udskriv testloginet.
Jeg ville hellere have rettet de fire migreringer. Det er et større stykke arbejde, end det ser ud til, for de er del af en kæde, som alle udrullede miljøer allerede har kørt, så at røre dem betyder at være sikker på, hvad hvert af de miljøer faktisk indeholder. Så det, der blev leveret, er omgåelsen — med årsagerne skrevet ned og en note over blokken om, at det hele kan slettes, når de er rettet. Grimt og dokumenteret slår elegant og opdigtet. Den fejlsituation, jeg går op i, er den næste person, der bruger to dage på at opdage alle fire forfra.
Resultatet er den del, jeg vil forsvare. På en maskine med intet andet end mise og Docker installeret rejser én kommando panelet, API’et, et skema i den rigtige version og en konto at logge ind med. Det er sikkert at køre den igen. Og mise run db:reset smider databasen væk, så man kan bevise, at det hele virker fra tomt — hvilket er den egentlige test, og den, ingen havde været i stand til at køre.
Hvad antallet ganger op
To omkostninger gentager sig ikke bare med antallet af repositories. De skalerer med det.
Den første er opdatering af afhængigheder. I samme uge opdaterede jeg i hånden en HTTP-klient i to frontends, et JSON-bibliotek i et tredje repository og et PDF-bibliotek i et fjerde: fire branches, fire pull requests, fire reviewrunder, og ingen blev klogere. Det sædvanlige argument for at automatisere det væk er sikkerhed. Ved enoghalvfjerds repositories er det et regnestykke.
Den anden er branch-topologi. Nogle repositories bærer stadig én branch til udvikling, én til staging og én til produktion, og ændringer rejser mellem dem via merges. Fra den uge har jeg en commit af min egen, hvis samlede besked lyder “Fixed merge conflict error”. Hver langlivet branch er endnu et sted, hvor den samme ændring findes i en lidt anden tilstand. Vi bevæger os mod én, hvor en merge deployer til staging og en udgivet release deployer til produktion, og hvor der ikke er noget tilbage at forlige.
Macheten
To værktøjer står for det meste af rydningen, og ingen af dem var min idé — vores tech lead tog begge ind i sine egne første uger. mise erklærer et repositorys værktøjskæde — og de kommandoer, man kører mod den — i en fil i repositoryet, og fnox gør det samme for referencerne til dets hemmeligheder. Repositoryet committer pegepinde; værdierne bor i et administreret lager, der giver adgang per person, frem for i en fil, nogen indsætter til dig.
Android-appen fik sine Java- og Gradle-versioner fastlåst. Backendene fik Java og Maven fastlåst. Flere repositories fik en README, der var sand igen, og et CI-workflow, hvor der ikke havde været et.
Den, jeg bedst kunne lide, var desktopapplikationen. Den udsendes som en Windows-eksekverbar fil, og den krydsbygger nu fra Linux gennem en task defineret i den samme fil, der fastlåser dens værktøjskæde — og skjuler dermed en værktøjskæde, jeg ellers skulle lære, før jeg kunne røre en ændring på én linje.
Og for at være ærlig om dækningen: det er en håndfuld repositories ud af enoghalvfjerds. Det er mindre et program end en vane, et par af os bliver ved med at tage op.
Hvad macheten ikke skærer igennem
Alt det forkorter afstanden fra klon til kørende. Det gør absolut intet ved afstanden fra kørende til forstået.
Min seneste feature er en superadmin-skærm — den, vores eget personale bruger til at administrere de interne konti, der har adgang til backofficen. Vis dem, opret en, og siden i denne uge: rediger en. En formular, en tabel, et par kald.
Intet i repositoryet dokumenterede, hvad de kald forventede. Kontrakten fandtes godt nok, som et udgivet API-dokument uden for kodebasen, så typerne kom derfra frem for fra gætværk. En let uværdig måde at få en kontrakt på, og en langt bedre end at læse den eksisterende klientkode og håbe, at den, der skrev den, havde ramt rigtigt.
Så modsagde dokumentet sig selv. Ét endpoint beskrives som returnerende alle konti. Det svar, det erklærer, er en enkelt konto. Begge udsagn står i samme fil, og jeg kunne ikke afgøre, hvad der var sandt, ved at kalde tingen, fordi jeg ikke havde adgang til det miljø.
Så den klientkode, jeg skrev, accepterer en liste, eller et enkelt objekt, eller en tom body, og afleverer altid et array. Tre tests, én per form, og én manuel gennemgang af selve skærmen. Alternativet var at vælge den form, jeg vurderede mest sandsynlig, og fejle højt på den anden, hvilket ville have forvandlet en dokumentationstvetydighed til et driftsudfald. Formen på den kode er en tvetydighed, jeg ikke kunne opklare, og den eneste måde nogen finder ud af det senere, er, at jeg skrev det ved siden af.
Redigeringsformularen gav tre mere. Et adgangskodefelt, som API’et ikke returnerer tomt, så formularen må åbne blank og sende ingenting, medmindre nogen taster en ny — at sende tilbage, hvad der kom ind, ville skrive volapyk oven i en fuldstændig fin adgangskode. Roller matchet på deres gemte identifikator, så en ændring skal bære den identifikator med sig; send den nye rolle uden den, og kontoen ender stille og roligt med to. Og en gemmefunktion, der behandler en række udeladt fra payloaden som uændret frem for slettet, så der slet ikke er nogen måde at fjerne en gennem den — hvilket er grunden til, at netop den sletteknap er slået fra i stedet for at stå og lade som om.
Hver af dem er én kommentar på stedet, der forklarer, hvorfor koden har den form, den har.
Hvor agenterne passer ind
Jeg er generelt begejstret for kodeagenter, så her er endnu en commit fra samme uge. Dens samlede besked lyder “revering claude slop” — min stavefejl, min commit, der ruller tilbage, hvad en agent havde produceret.
Den commit svækker ikke begejstringen så meget som den placerer den. En agent er kun så god som den grund, den står på. Peg en mod et repository med en klar struktur og en fastlåst værktøjskæde og tests, der faktisk kører, og resultatet er som regel noget, man kan arbejde med. Peg en mod et repository, hvor intet af det gælder, og den gætter, flydende og selvsikkert, og så bruger man eftermiddagen på at finde ud af, hvilke dele der var gæt.
Det betyder, at værktøjsarbejdet og AI-arbejdet ikke er to projekter. Hver fastlåst version og hver ærlig README hæver gulvet under alt, hvad en agent gør i det repository bagefter, og de repositories, hvor jeg stoler mest på en agent, er uden undtagelse dem, nogen allerede har ryddet op i.
To afstande
Afstanden fra klon til kørende er et værktøjsproblem, og den lader sig presse smukt sammen. Den går fra en eftermiddag til én kommando, der staves ens alle steder, og den presses sammen igen for den næste, der kommer.
Afstanden fra kørende til forstået lader sig ikke presse sammen. Ingen konfigurationsfil vil fortælle dig, at et endpoints beskrivelse og dets skema er uenige, eller at en migrering fra år tilbage holder din database som gidsel over en tjeneste, den ikke kan nå. Hvad der hjælper der, er langsommere og mindre tilfredsstillende: læs API’ets egen beskrivelse af sig selv frem for koden, nogen har skrevet mod det, og når du ender med at skrive defensiv kode, så skriv ned, hvad du forsvarede dig mod.
Den anden afstand er størstedelen af arbejdet. Værdien af strømlinet værktøj er ikke, at det gør arbejdet mindre. Det er, at man ankommer til det virkelige arbejde med sine tyve minutter og sin tålmodighed i behold — og at vejen ind er ved at være den samme i hvert repository.
Og hvad angår opgaven: alt det, jeg pegede mig selv mod, kom i sidste ende op, og flere af dem først efter at være blevet repareret. Det er den del, jeg ikke havde regnet med ved en onboarding-opgave. Den ser ud, som om den findes for at lære den nye person landskabet, og det gør den også. Hvad den i virkeligheden producerer, er en liste over alt det, der stille og roligt var holdt op med at virke, mens ingen så efter.
Og det er, som jeg forstår det, netop opgaven. Johann, vores tech lead, og jeg kom til med en enkelt dags mellemrum — til en virksomhed, hvis produkt virker, og hvis tekniske fundament er vokset én applikation ad gangen, fordi det var det, den måde at bygge på lagde op til. At bringe struktur ind i det og få det til at skalere ud over den størrelse, vi har nu, er det, vi to er her for at gøre. Ikke som et oprydningsprojekt med en slutdato, men som det blivende arbejde. Alt det ovenfor er uge ét af det.
Og ambitionen bag det er ikke oprydning. Vi vil bygge den motor, der kan bære produktet ud i verden — den software, golfklubber kører på, i så mange lande som vil have os — og det eneste, der nogensinde giver en position som den, er kunder, der bliver forbløffede over det, de får. Struktur og skalerbarhed er måden, hvorpå man bliver i stand til at levere det, når der er ti gange så mange af dem. Et fundament, der ikke kan skalere, fejler ikke højt. Det sætter blot stille og roligt et loft over, hvor godt produktet får lov at blive.
