NUKE Umieszczanie aplikacji na środowiskach i ich kompilacja zazwyczaj nie są problemami programistów. 

Od tego są inni pracownicy, którzy mają tam swoje Jenksins-y i tym podobne narzędzie służące do automatyzacji budowania, testowania, i wdrażania aplikacji na środowiska testowe. Jenkins to jeden z najpopularniejszych systemów CI/CD. A co to jest ten CI/CD?

To skóry oznaczające odpowiednio Continuous Integration i Continuous Delivery.

Continous integration : ciągła integracja polega na tym, że zespół programistów wprowadza zmiany w kodzie, te zmiany przechodzą przez testy, po czym są one późnień integrowane z innymi zmianami i są one umieszczane np. do środowisk testowych.

Continuous Delivery : czyli ciągle dostarczanie polega na przygotowaniu struktury serwerowej, aby nasze zmiany się znalazły na środowiskach testowy albo nawet na produkcji.

Istnieje jeszcze inne pojęcie jak Continous Testing, które mówi o ciągłym testowaniu albo przez automaty, albo przez ludzi.

Wszystkie te filozofie mówią o zwiększaniu jakości kodu i procesu wdrażania poprzez zwiększenie ich częstotliwości, a żeby zwiększyć częstotliwość scalania zmian w kodzie i jego testowania to trzeba ten proces jak najbardziej jest to możliwe zautomatyzować.

Zazwyczaj programiści się tym nie interesują, bo bądźmy szczerze to nie jest na twojej głowie, jakie komendy CMD są odpalane i  czy to jest PowerShell, czy innym język jak BASH, aby twoje zmiany przeszły przez tę rurę procesów, aby w końcu trafić na inne środowisko testowe albo nawet produkcyjne.

Istnieją jednak frameworki, które pozwoliłby Ci napisać tę logikę wdrażania aplikacji napisać w C#. Oto one :

  • Cake.NET
  • Nuke
  • Bullseye z SimpleExec

Chciałem przetestować każdy z nich, ale miałem problemy z uruchomieniem prostego przykładu w Cake.NET więc w tym wpisie skupię tylko na rozwiązaniu NUKE.

Zanim jednak przejdziemy do Nuke musimy stworzyć prosty projekt, który będziemy mówiąc slagowo po angielsku deplojować. 

Oto proces tworzenia takiego projektu demo od zera. Zaczynam od Blank Solution.

Blank Solution w Visual Studio

Do niego dodaje pusty projekt ASP.NET Core Empty

Dodanie projektu ASP.NET CORE Empty w Visual Studio

Stwórzmy proste API do gier, które przejdzie przez nasze procesy wdrażania.

Nasze API Game API jako przykład

Nasze API zawiera tylko klasę domenową, która reprezentuje grę. Szczerze mówiąc idąc z duchem czasu C# 9.0 taka klasa może być rekordem.

public record Game(int Id, string Name, int Score, int Year);

//public class Game
//{
//    public Game(int id, string name, int score, int year)
//    {
//        Id = id;
//        Name = name;
//        Score = score;
//        Year = year;
//    }

//    public string Name { get; set; }

//    public int Score { get; set; }

//    public int Year { get; set; }

//    public int Id { get; set; }
//}

Do stworzenia prostego, API nie potrzebujemy kontrolerów. W klasie Startup możemy powiedzieć, że dla adresu "/games" zwracamy wszystkie gry, a dla ścieżki "/game/{id}" jedną grę o danym ID.

public class Startup
{
    private static readonly List<Game> Games = new()
    {
        new(1, "Settlers", 70, 1993),
        new(2, "Dune II", 80, 1993),
        new(3, "Silent Hill 2", 87, 2001),
        new(4, "Resident Evil", 73, 1996),

    };


    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.Map("/games", async context =>
            {
                await context.Response.WriteAsJsonAsync(Games);
            });

            endpoints.Map("/game/{id}", async context =>
            {
                var id = int.Parse(context.GetRouteValue("id").ToString());
                var game = Games.FirstOrDefault(g => g.Id == id);
                await context.Response.WriteAsJsonAsync(game);
            });
        });
    }


    public void ConfigureServices(IServiceCollection services)
    {
    }
}

Możemy sprawdzić, czy nasze API w ASP.NET Core działa, ale nie jest to ważne dla reszty wpisu.

Sprawdzenie czy aplikacja działa

Do naszej solucji teraz będziemy dodawać testy. Na temat TDD ma całą serię wpisów.

Dodajemy nowy projekt do Visual Studio

Wybieram framework XUnit do testów. 

XUnit  test project

Nazywam go odpowiednio.

XUnit  nazwa projektu

Nasz projekt testowy będzie zawierał tylko jedno sprawdzenie.

Cała solucja w Visual Studio

Pamiętaj my się skupiamy na tym, aby to miało sens. Chodzi bardziej o to, aby w naszym procesie wdrażania aplikacji napisać logikę, która będzie uruchamiać te testy.

public class TestGame
{
    [Fact]
    public void CanCreateGame()
    {
        Game game = new(1, "A", 100, 2000);

        var (a, b, c, d) = game;

        Assert.Equal(1, a);
        Assert.Equal("A", b);
        Assert.Equal(100, c);
        Assert.Equal(2000, d);
    }
}

Sam test jak widzisz sprawdza tylko, czy można poprawnie utworzyć obiekt Gry.

Do projektu dodajemy jeszcze test funkcjonalny.

Dodanie testu funkcjonalnego

Ten test będzie uruchamiać aplikację pod localhost, aby sprawdzić, czy punkt RestApi działają poprawnie.

public class GameEndpointTest
{
    private static readonly HttpClient _httpClient = new()
    {
        BaseAddress = new("http://localhost:5000")
    };

    [Fact]
    public async Task ListGames()
    {
        var games = await _httpClient.GetFromJsonAsync<List<Game>>("/games");

        Assert.NotNull(games);
        Assert.Equal(4, games.Count);

    }

    [Fact]
    public async Task GetGame()
    {
        var game = await _httpClient.GetFromJsonAsync<Game>("/game/1");

        Assert.NotNull(game);
        Assert.Equal(1, game.Id);

    }

}

Mamy więc nasz projekt demo. Pora utworzyć projekt NUKE, który będzie zawierał logikę budowania naszej aplikacji.

Folder aplikacji jaka ścieżka

Najpierw do narzędzi i poleceń dotnet musimy zainstalować globalnie framework Nuke.

dotnet tool install Nuke.GlobalTool --global

dotnet tool install Nuke.GlobalTool --global

Gdy uruchomimy polecenie NUKE na ścieżce naszego projektu to zostanie zapytany oto, czy chcesz utworzyć projekt kompilacyjny. Wybieramy "Y"

Uruchomienie NUKE w CMD

Potem NUKE pyta się o nazwę naszego projektu.

Uruchomienie NUKE w CMD Pytania

Potem on się pyta o wersję oraz jaki konkretny projekt powinien być domyślny.

Potem on się pyta o wersję oraz jaki konkretny projekt powinien być domyślny.

Pyta mnie on się czy chce mieć podstawowy kod tutorial, który będzie mi pokazywał podstawy takiego projektu NUKE.

NUKE dalszy CMD

Potem odpowiadam na następujące pytania tak jak widać to na obrazku. Projekty testowe są w tym samym folderze więc wybieram "same as source"

NUKE dalszy CMD 2

W pytaniu o GIT w sumie mógłbym wybrać cokolwiek.

NUKE dalszy CMD 3

Podsumowując o to wszystkie odpowiedzi na te pytania.

Cały NUKE

Po tym procesie możemy sobie gratulować, ponieważ utworzyliśmy nasz projekt NUKE.

Stworzony projekt NUKE

Co potrafi NUKE

Projekt NUKE jak widzisz ma folder "boot" w nim znajdują się 3 podobne skrypty, które uruchamiają ten cały proces wdrażania, który zaraz napiszemy w C#

Nas interesuje klasa Build. 

class Build : NukeBuild
{
    /// Support plugins are available for:
    ///   - JetBrains ReSharper        https://nuke.build/resharper
    ///   - JetBrains Rider            https://nuke.build/rider
    ///   - Microsoft VisualStudio     https://nuke.build/visualstudio
    ///   - Microsoft VSCode           https://nuke.build/vscode

    public static int Main () => Execute<Build>(x => x.Compile);

    [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
    readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;

    [Solution] readonly Solution Solution;
    [GitRepository] readonly GitRepository GitRepository;

    Target Clean => _ => _
        .Before(Restore)
        .Executes(() =>
        {
        });

    Target Restore => _ => _
        .Executes(() =>
        {
            DotNetRestore(s => s
                .SetProjectFile(Solution));
        });

    Target Compile => _ => _
        .DependsOn(Restore)
        .Executes(() =>
        {
            DotNetBuild(s => s
                .SetProjectFile(Solution)
                .SetConfiguration(Configuration)
                .EnableNoRestore());
        });

}

Widzimy tutaj wiele delegat Target, które definiują kroki naszej rury wdrażania aplikacji. Domyślnie robimy :

  • Czyszczenie projektu zbudowanych już dll-ek
  • Przywrócenia projektu, czyli pobraniu brakujących paczek NuGet
  • Na koniec kompilujemy nasz projekt według konfiguracji, którą przekażemy jako argument w wierszu poleceń.

Aby te kroki wykonały się w odpowiedniej kolejności w tych delegatach także mówimy które kroki są zależne, od których poprzez "DependsOn". Możemy też skorzystać z "Before", aby powiedzieć co musi się wykonać przed danym krokiem.

Zobaczmy jak ten przykładowy projekt Nuke działa. Uruchamiamy polecenie "nuke" na naszym folderze projektowym.

Wywołanie NUK w CMD

Na razie tylko dostajemy ostrzeżenia od NUKE, że nie mamy skonfigurowanego Git-a w projekcie,a poza tym, jak widzisz mamy informację o tym, jakie fazy wdrażania aplikacji zrobiliśmy i ile one trwały.

NUKE jest okej

Co możemy jeszcze zrobić? Wpiszmy polecenie nuke --help

nuke --help

Dostajemy teraz informację o procesie wdrażania naszej aplikacji. Jakie mamy kroki. Jakie parametry ta aplikacja wdrażająca przyjmuje. 

Mamy też domyślnie opcję od NUKE jak możliwość uruchamiania tego procesu bez logo "--no-logo".

Nuke parametry

To, co teraz widzisz jest zależne od kodu w C#. Mogę np. szybko dodać swój własny parametry. Musi on mieć tylko atrybut "Parameter".

class Build : NukeBuild
{
    public static int Main() => Execute<Build>(x => x.Compile);

    [Parameter("MyParameter Cezary Walenciuk")]
    readonly string MyParameter;

    [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
    readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;

Gdy wpiszę ponownie polecenie "nuke --help" wtedy zobaczę, że mój parametr też został dodany.

Mój parametr w NUKE

Mój parametr jest typu string. Jak to wygląda dla bardziej złożonego typu. Możesz zobaczyć jak jest napisana klasa Configuration. 

[TypeConverter(typeof(TypeConverter<Configuration>))]
public class Configuration : Enumeration
{
    public static Configuration Debug = new Configuration { Value = nameof(Debug) };
    public static Configuration Release = new Configuration { Value = nameof(Release) };

    public static implicit operator string(Configuration configuration)
    {
        return configuration.Value;
    }
}

Ta klasa zawiera prosty operator konwersji z typu string na typ Configuration. Czyli jak napiszemy Debug to dostaniemy obiekt Configuration z wartością Debug, a jeśli napiszemy Relase to dostaniem obiekt Configuration z wartością Relase.

Co jeszcze Nuke potrafi? Gdy wpiszesz polecenie "nuke --plan" możesz zobaczyć obecny plan wdrażania swojej aplikacji w formie grafu.

nuke --plan REZULTAT

Jak i jego opis :

nuke --plan Execution Plan

Dodajmy nowy krok "Test" oraz jego zależność do kroku "Compile" i zobaczmy jak teraz nasz graf wygląda.

Target Clean => _ => _
    .Before(Restore)
    .Executes(() =>
    {
    });

Target Test => _ => _
.Before(Restore)
.Executes(() =>
{
    Logger.Normal("Starting Test");
});

Target Restore => _ => _
    .Executes(() =>
    {
        DotNetRestore(s => s
            .SetProjectFile(Solution));
    });

Target Compile => _ => _
    .DependsOn(Restore,Test)
    .Executes(() =>
    {
        DotNetBuild(s => s
            .SetProjectFile(Solution)
            .SetConfiguration(Configuration)
            .EnableNoRestore());
    });

Jak widzimy Test musi się wykonać przed Restore, a Compile jest zależne od kroku "Testowego"

Dodanie nowego kroku

Do każdego kroku możesz także dodać swoje informacje na temat procesu. Jak na przykład dodałem prosty komunikat "Starting testing".

Przykład użycie loggera

Możesz też zacząć proces wdrażania zaczynając od pewnego punktu w procesie. Musisz go tylko podać.

Nuke Compile czyli pomijanie kroków

To ci tylko pokazuje, jaka jest różnica pomiędzy określeniem, że coś jest zależne,  a tym, że coś musi się wykonać przed.

Powiedziałem CMD, że chce zacząć od procesu kompilacji, ale proces testowania i restore i tak się wykonał. Natomiast krok Clean został pominięty.  

Nuke uznaje, że Clean może zostać pominięty, ponieważ użyłem definicji na innych krokach względem niego poprzez "before".

Natomiast jeśli krok jak Compile jest zależny, czyli DependOn to i tak one się uruchomią. 

Kolejność możesz deklarować jeszcze w inny sposób. Możesz ustalić odpowiednie wyzwalacze.

Triggers

Możesz powiedzieć, że odpowiedni krok powinien wykonać się po czymś lub po czymś, jeśli dany krok zakończył się błędem.

After

Możesz też ustawić fakt, że dany krok wymaga twojego parametru. Kto wie może wysyłasz tam jakie dane konfiguracyjne i logiczne jest to, że bez nich nie uruchomisz danego kroku.

Requires

Działa to w taki sposób, że teraz po wpisaniu polecenia NUKE dostane także pytanie o podanie parametru

e-10.PNG

Metoda OnlyWhenDynamic sprawdzić wartość logiczną naszego parametru i wykona dany krok, jeśli ten parametr będzie prawdą.

OnlyWhenDynamic

OnlyWhenStatic działa podobnie tylko jest on dla pól statycznych, które się nie zmieniają.

Mamy tutaj także metody jak Unlisted() : która sprawi, że dany krok nie wyświetli się w pomocy NUKE.

Gdybyś chciał wyrzucić błąd lub przerwać dany krok to możesz zrobić to w taki sposób poprzez klasę statyczną ControlFlow.

ControlFlow.Fail

Możesz też skorzystać ze zbudowanych zdarzeń, które mogą zajść w twoim procesie budowania aplikacji.

Możesz też skorzystać z zbudowanych zdarzeń w NUKE

Gdybyś chciał wywołać inne polecenia w trakcie działania tej rury Continuous Integration to nie ma problemu. Oto przykład wywołania polecenia "git status" przy kroku Clean.

[PathExecutable]
readonly Tool Git;

Target Clean => _ => _
    .Before(Restore)
    .Executes(() =>
    {
        Git("status");
    });

A co jeśli chcesz wykonać jakąś akcję GitHub na podstawie pliku YAML.

[CheckBuildProjectConfigurations]
[ShutdownDotNetAfterServerBuild]
[GitHubActions("build-and-test",GitHubActionsImage.WindowsLatest,OnPushBranches =new[] { "master"})]
class Build : NukeBuild

Niestety na ten temat wiem za mało, aby rozwinąć taki przykład.

Wracamy do Tutorialu

Skasuj nasz krok test i napiszmy porządne kroki, które uruchomią nasz test jednostkowy.

Target UnitTest  => _ => _
.DependsOn(Compile)
.Executes(() =>
{
    DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.UnitTest")
    .EnableNoRestore()
    .EnableNoBuild());
});

Po kompilacji naszej aplikacji chce uruchomić Test dlatego jest "DependsOn". Teraz jest to także nasz ostatni krok aplikacji więc musi coś jeszcze zmienić w kodzie. Niech UnitTest będzie domyślnym startem naszego procesu, który rozpocznie to całe drzewo działania.

class Build : NukeBuild
{
    public static int Main() => Execute<Build>(x => x.UnitTest);

Teraz mogę zobaczyć jak po kompilacji aplikacji uruchamia się test.

Dodanie kroku UnitTest

Analogicznie dodajmy krok testu funkcjonalnego

Target FunctionalTest => _ => _
   .DependsOn(UnitTest)
.Executes(() => { DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.FunctionalTest") .EnableNoRestore() .EnableNoBuild()); });

Ustalmy też ostatni proces wykonawczy.

class Build : NukeBuild
{
    public static int Main() => Execute<Build>(x => x.FunctionalTest);

Na chwilę obecną mamy problem z testem funkcjonalnym, ponieważ potrzebuje on uruchomionej aplikacji pod adresem localhost.

Musi więc dodać krok, który uruchomi nasze API poprzez polecenie dotnet.

Target StartAPI => _ => _
.Executes(() =>
{
    ProcessTasks.StartProcess("dotnet", "run", RootDirectory / "GamesAPI");
});

Teraz gdy mamy zależność kroków to warto ją zadeklarować.

Target FunctionalTest => _ => _
.DependsOn(UnitTest,StartAPI)
.Executes(() =>
{
   DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.FunctionalTest")
   .EnableNoRestore()
   .EnableNoBuild());
});

Potrzebujemy też krok, który zatrzyma nasze API. NUKE za nas tego nie zrobi.

Musi więc przetrzymać proces w zmiennej tak, abyś mogli go zabić w kroku "StopAPI".

private IProcess _ApiProcess;
Target StartAPI => _ => _
.Executes(() =>
{
    _ApiProcess = ProcessTasks.StartProcess("dotnet", "run", RootDirectory / "GamesAPI");
});

Target StopAPI => _ => _
   .Executes(() =>
   {
   _ApiProcess.Kill();
   });

Target FunctionalTest => _ => _
.After(UnitTest)
.DependsOn(StartAPI)
.Triggers(StopAPI)
.Executes(() =>
{
    DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.FunctionalTest")
    .EnableNoRestore()
    .EnableNoBuild());
});

W kodzie też mówimy, że krok FunctionaTest wyzwala później krok StopAPI. Zobaczmy, czy teraz wszystko działa

Wszystkie kroki. Cały diagram NUKE i mojej aplikacji

Zobaczmy jak wygląda nasz diagram działania aplikacji.

e-15.PNG

To tyle, jeśli chodzi o ten przykład. Korzystając z NUKE możesz także wywołać API Azura czy GitHub Action więc można napisać w nim jeszcze bardziej złożoną logikę wdrożeniową.

Możesz też napisać akcję, która stworzy paczkę NuGet na końcu tego procesu.

Jak jednak widzisz w C# można napisać swoją logikę procesu Continuous Integration, Continuous Delivery i Continous Testing.