Beschrijven van processen om ze geautomatiseerd te draaien

26 Apr 2021

Achtergrond

In het kader van een betrouwbaar en veilig IT domein, wordt het ontwikkelen en uitrollen van de processen beheerd vanuit Git. In de bron wordt het haarfijn beschreven met als doel de juiste versleutelde versie te exporteren. Het runtime systeem gebruikt die om de processen als beveiligde Services te laten draaien. Een Service is een repeterend proces dat bestaat uit 2 stappen, de eerste stap bepaalt of de tweede stap uitgevoerd gaat worden. Een proces dat ieder uur repeteert, controleert of het dagelijks mutatie bestand aanwezig is, dat gebeurt normaal maar 1 keer in 24 uur, dus stap 1 draait 24 keer en stap 2 één keer per dag.

Omdat er geen hand meer aan te pas komt, moet de beschrijving formeel en volledig getypeerd worden. Zo kan er automatisch worden uitgevoerd. Ik vergelijk de geëxporteerde instructies van de services van een systeem, beter gezegd domein, met het orgelboek van een draaiorgel.
Er zijn echter verschillende boeken. Vanwege het volledige beheer met nieuwe onderhanden aanpassingen, krijg je te maken met verschillende versies en test-omgevingen. Je kunt dus meerdere variaties van dezelfde service onderhouden en er zijn verschillende boeken voor iedere omgeving één.

Hoe ga je de beschrijving vastleggen? Die keuze is belangrijk. Om enkele criteria te noemen: makkelijk te onderhouden, ondersteuning bij het invoeren, dat uitbreiding of wijziging van de vast te leggen gegevens zonder verplichte migratie en eigen templates te maken voor veel voorkomende patronen .

Niet opnieuw het wiel uitvinden. Dus ga je op zoek naar iets bestaands waarvan je weet dat het goed werkt, support aanwezig is en wordt onderhouden.

Zo kom je bij een programmeertaal, met sterke typering en een eenvoudige syntax en een sterk ecosysteem. Er moeten records getypeerd kunnen worden en de velden van de records moeten de bekende primitieven aan kunnen, zoals string, integer, date, time, boolean en nog meer. Een record moet ook een record als veld kunnen hebben en ook zichzelf, en heel belangrijk is ook dat je generieke types kan gebruiken waarvoor je een specifiek type kan invullen. Natuurlijk moet de recordstructuur te exporteren zijn naar Json of Xml of iets anders, als de import maar hetzelfde oplevert als de recordstructuur voor de export. Die controle is belangrijk en moet uitgevoerd kunnen worden.

Je ziet nog al eens de keuze op een exportformaat vallen. Maar handmatig onderhouden van een exportformaat is een lastig karwei, een getypeerde programmeertaal is daar veel beter in. Denk maar aan hergebruik, conditioneel genereren, indelen in sourcefiles, gebruik van intellisense, pre-processing zoals compileren, controleren zoals testen en nog veel meer. Uiteindelijk ben ik op F# terecht gekomen het heeft de voordelen van een dotnet omgeving met zijn uitgebreide library, het heeft een eenvoudige syntax, het kan goed de types afleiden, het heeft string interpolatie voor templates, condities zijn expressies en geen statements, en het allerbelangrijkste is de Discriminated Union. C# kwam in de buurt maar heeft (nog) niet alles wat F# kan. Ik heb me alleen beperkt tot DotNet en daarbinnen gericht op sterk getypeerde talen met mogelijkheid tot reflectie.

Wat moet je beschrijven?

Je beschrijft de services die in het domein gaan draaien en de levensloop van aanpassingen in het domein waar versies van de service aan gekoppeld zijn.

De service

Een service bestaat uit een programma flow, de main Step, die periodiek wordt gestart om zijn werk te doen. Zo’n flow is een recursieve structuur: de flow bestaat uit 1 of meerdere stappen en een stap is een flow of een Task. Zie de definitie in F#:

// type Program is hier terwille van de eenvoud niet gespecificeerd
 type Task = {
    Id: TaskId;
    ShowLog: bool;
    Program: Program
    Params: string[]
}
type FlowType =
    | UntilError
    | UntilOk
    | Unconditional
    | Parallel
type Step = 
    | Task of Task
    | Flow of Flow 
and Flow = {
    Id: TaskId;
    ShowLog: bool;
    Type: FlowType
    Steps: list<Step>
 }

// een proces draait periodiek en kan op events reageren door de ...
// optionele hier terwille van de eenvoud niet gespecificeerde WatchParam

type TriggerConditionRec = {
    Interval: int;
    WatchParam: WatchParam option;
}
type Service = {
    Name: string;
    Program: Step;
    TriggerConditionRec: TriggerConditionRec
}

Je bent vrij een flow samen te stellen van van verschillende programma’s zoals je dat ook zou doen als je er een shell script van maakt. Het flowtype “UntilError” geeft aan of je de flow wil stoppen bij de eerste stap die fout gaat, dit zal je zeker gebruiken als jouw service pas kan starten als er aan bepaalde voorwaarden is voldaan, bijvoorbeeld bij de aanwezigheid van een bestand, is het niet aanwezig of nog niet oud genoeg dan wacht de service tot de volgende periode. Met de watch parameter kan je de wacht periode bekorten om sneller te reageren op bijvoorbeeld de aanwezigheid van een bestand. Er zijn meerdere flowtypes.

Hieronder staat de definitie van Program:

// Terwille van de eenvoud is niet alles weergegeven
type ExternScript = {
    Runner: string;
    ContentZipOfProgDir: ContentZipOfProgDir option;
    StdinRedirect: Redirect option;
    StdoutRedirect: Redirect option;
}
type ExternProgram = {
    Id: string;
    Env: Dtap;
}
type InputFileSelect = {
    FileSelectExpression: FileSelectExpression;
    FileAgeInMilliSeconds: int;
}
type FileProgram = {
    Dir: string;
    InputFileSelect: InputFileSelect;
    OutputParameter: OutputParameter option;
    Program: Program;
}
and Program =
    | ExternScript of ExternScript
    | ExternProgram of ExternProgram
    | FileProgram of FileProgram

Program is zo’n generieke typering, het kan een ExternScript, ExternProgram of FileProgram zijn.

ExternScript wordt als child process opgestart, met de mogelijkheid tot redirect van stdin en stdout en ook de mogelijkheid om een eigen directory tree door te geven.

ExternProgram is een DLL aanroep dus kent het ook geen redirects, deze mogelijkheid is gemaakt voor snelle checks en enigzins gevaarlijk omdat het niet in een child process draait.

FileProgram is een koppeling van bestanden aan een Program, eigenlijk een pseudo Program, met property “InputFileSelect” geef je aan dat voor ieder bestand dat voldoet aan die conditie, het programma wordt opgestart. Hiermee kan je binnen de service toch bedoeld parallel verwerken.

Hieronder volgt een definitie voorbeeld:

let pythonScript env  =  $"""
{DemoScriptV1.envScript env}
{System.IO.File.ReadAllText "services/demoscript/demoscript.v2.py"}
"""

let Service name env = 
    // definitie "program" dit is de flow die bestaat uit één Task van het type ExternScript wat python uitvoert.  
    let program = Task {
            Id = sprintf "%s.%A.%d.%d" name env version revision
            ShowLog = true;
            Program  = ExternScript {Runner = "python"; ContentZipOfProgDir = None; StdinRedirect = None; StdoutRedirect = None }
            Params = [|"-c"; (pythonScript env).Replace("\r\n","\n"); name|]
        }

    // hier wordt een "optionele Service" teruggegeven:
    // in omgeving DEVL en TEST resulteert dat in "Some Service"
    // in de andere omgevingen is het resultaat "None" 
    match env with
    | DEVL | TEST -> Some {
        Name = name;
        Program = program;
        TriggerConditionRec = DemoScriptV1.TriggerConditionRec
        }
    | _ -> None

Ter verduidelijking: De source bedient alle mogelijke omgevingen het bevat een functie die als parameter de omgeving mee krijgt en die als resultaat het item voor de bewuste omgeving terug geeft.

Zelf schrijf je functies die aangeroepen worden door het generatie systeem. Genereren gebeurt altijd voor alle omgevingen die in het domein bestaan. In mijn voorbeeld zijn er 4 omgevingen (DEVL, TEST, ACPT en PROD), dus ontstaan er 4 export files. Bij generatie worden jouw functies aangeroepen met de juiste versie voor de juiste omgeving, dat weet het systeem omdat jij verplicht bent (dank aan de typering) de versie van de service aan een change te koppelen en een change een levensloop heeft van DEVL, TEST, ACPT tot PROD, die jij ook bij moet houden. Als de change wisselt van omgeving moet er opnieuw gegenereerd worden. Je hebt dus de mogelijkheid variatie aan te brengen op basis van de omgeving, of zelfs geen service te exporteren. Dat heet een option, je geeft terug “Some Service” of “None

Als eerste wordt hier het pythonScript aangemaakt. Het bestaat uit environment variabelen en een python source die ingelezen wordt vanuit een file. Er wordt van het zo ontstane python script een Task aangemaakt met een Program van het type ExternScript met als runner python waaraan ons script als parameter wordt doorgegeven. Dit Program wordt gebruikt in ons Process en dat wordt alleen gegenereerd als we in DEVL of TEST omgeving zitten. In de overige omgevingen wordt None geproduceerd.

Changes

Bij iedere functionele wijziging wordt een change aangemaakt. Als een service geraakt wordt door die change, wordt er een nieuwe versie van gemaakt en gekoppeld aan de change. Ontstaat er een nieuwe service dan wordt dat versie 1 en gekoppeld aan de change, wordt een service overbodig dan komt er toch een nieuwe versie maar die geeft een None terug (zie boven).

Er zijn wat controles nodig, verschillende versies van een service mogen niet naar dezelfde change verwijzen en iedere versie is gekoppeld aan een bestaande change, die controles worden automatisch en zelfs deels door de compiler en de Ide uitgevoerd.

Door de change van omgeving te laten veranderen worden automatisch de juiste versies van de services voor hun omgevingen gegenereerd. Die omgevingen hebben een volgorde, bijvoorbeeld OTAP, van Ontwikkeling via Test, Acceptatie naar Productie. De nieuwste versie begint bij O. Er mag geen nieuwere versie in een omgeving verder in de lijst staan. Dus als versie 3 in P staat dan is versie 2 niet meer actief.

Als een change uiteindelijk in Productie komt, kunnen oude versies verwijderd worden, ze worden nooit meer in een export meegenomen. Je wordt niet gedwongen maar het is wel verstandig om ze te verwijderen om zoek resultaten op je source tree niet te vertroebelen met resultaten uit files die er niet toedoen.

Een change hoeft niet altijd voorwaarts te gaan, je wilt soms terug bij problemen. Als van A naar P geen feest is, kan je de situatie redden door van P naar A te gaan.

Waar rekening mee gehouden is bij het beschrijven.