--- title: "A beautiful GitOps day VIII - Further deployment with DB" date: 2023-08-26 description: "Follow this opinionated guide as starter-kit for your own Kubernetes platform..." tags: ["kubernetes", "postgresql", "efcore"] --- {{< lead >}} Use GitOps workflow for building a production grade on-premise Kubernetes cluster on cheap VPS provider, with complete CI/CD 🎉 {{< /lead >}} This is the **Part VIII** of more global topic tutorial. [Back to guide summary]({{< ref "/posts/10-a-beautiful-gitops-day" >}}) for intro. ## Real DB App sample Let's add some DB usage to our sample app. We'll use the classical `Articles<->Authors<->Comments` relationships. First create `docker-compose.yml` file in root of demo project: {{< highlight host="kuberocks-demo" file="docker-compose.yml" >}} ```yaml version: "3" services: db: image: postgres:15 environment: POSTGRES_USER: main POSTGRES_PASSWORD: main POSTGRES_DB: main ports: - 5432:5432 ``` {{< /highlight >}} Launch it with `docker compose up -d` and check database running with `docker ps`. Time to create basic code that list plenty of articles from an API endpoint. Go back to `kuberocks-demo` and create a new separate project dedicated to app logic: ```sh dotnet new classlib -o src/KubeRocks.Application dotnet sln add src/KubeRocks.Application dotnet add src/KubeRocks.WebApi reference src/KubeRocks.Application dotnet add src/KubeRocks.Application package Microsoft.EntityFrameworkCore dotnet add src/KubeRocks.Application package Npgsql.EntityFrameworkCore.PostgreSQL dotnet add src/KubeRocks.WebApi package Microsoft.EntityFrameworkCore.Design ``` {{< alert >}} This is not a DDD course ! We will keep it simple and focus on Kubernetes part. {{< /alert >}} ### Define the entities {{< highlight host="kuberocks-demo" file="src/KubeRocks.Application/Entities/Article.cs" >}} ```cs using System.ComponentModel.DataAnnotations; namespace KubeRocks.Application.Entities; public class Article { public int Id { get; set; } public required User Author { get; set; } [MaxLength(255)] public required string Title { get; set; } [MaxLength(255)] public required string Slug { get; set; } public required string Description { get; set; } public required string Body { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public ICollection Comments { get; } = new List(); } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.Application/Entities/Comment.cs" >}} ```cs namespace KubeRocks.Application.Entities; public class Comment { public int Id { get; set; } public required Article Article { get; set; } public required User Author { get; set; } public required string Body { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.Application/Entities/User.cs" >}} ```cs using System.ComponentModel.DataAnnotations; namespace KubeRocks.Application.Entities; public class User { public int Id { get; set; } [MaxLength(255)] public required string Name { get; set; } [MaxLength(255)] public required string Email { get; set; } public ICollection
Articles { get; } = new List
(); public ICollection Comments { get; } = new List(); } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.Application/Contexts/AppDbContext.cs" >}} ```cs namespace KubeRocks.Application.Contexts; using KubeRocks.Application.Entities; using Microsoft.EntityFrameworkCore; public class AppDbContext : DbContext { public DbSet Users => Set(); public DbSet
Articles => Set
(); public DbSet Comments => Set(); public AppDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity() .HasIndex(u => u.Email).IsUnique() ; modelBuilder.Entity
() .HasIndex(u => u.Slug).IsUnique() ; } } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.Application/Extensions/ServiceExtensions.cs" >}} ```cs using KubeRocks.Application.Contexts; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace KubeRocks.Application.Extensions; public static class ServiceExtensions { public static IServiceCollection AddKubeRocksServices(this IServiceCollection services, IConfiguration configuration) { return services.AddDbContext((options) => { options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")); }); } } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/Program.cs" >}} ```cs using KubeRocks.Application.Extensions; //... // Add services to the container. builder.Services.AddKubeRocksServices(builder.Configuration); //... ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/appsettings.Development.json" >}} ```json { //... "ConnectionStrings": { "DefaultConnection": "Host=localhost;Username=main;Password=main;Database=main;" } } ``` {{< /highlight >}} Now as all models are created, we can generate migrations and update database accordingly: ```sh dotnet new tool-manifest dotnet tool install dotnet-ef dotnet dotnet-ef -p src/KubeRocks.Application -s src/KubeRocks.WebApi migrations add InitialCreate dotnet dotnet-ef -p src/KubeRocks.Application -s src/KubeRocks.WebApi database update ``` ### Inject some dummy data We'll use Bogus on a separate console project: ```sh dotnet new console -o src/KubeRocks.Console dotnet sln add src/KubeRocks.Console dotnet add src/KubeRocks.WebApi reference src/KubeRocks.Application dotnet add src/KubeRocks.Console package Bogus dotnet add src/KubeRocks.Console package ConsoleAppFramework dotnet add src/KubeRocks.Console package Respawn ``` {{< highlight host="kuberocks-demo" file="src/KubeRocks.Console/appsettings.json" >}} ```json { "ConnectionStrings": { "DefaultConnection": "Host=localhost;Username=main;Password=main;Database=main;" } } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.Console/KubeRocks.Console.csproj" >}} ```xml $(MSBuildProjectDirectory) PreserveNewest ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.Console/Commands/DbCommand.cs" >}} ```cs using Bogus; using KubeRocks.Application.Contexts; using KubeRocks.Application.Entities; using Microsoft.EntityFrameworkCore; using Npgsql; using Respawn; using Respawn.Graph; namespace KubeRocks.Console.Commands; [Command("db")] public class DbCommand : ConsoleAppBase { private readonly AppDbContext _context; public DbCommand(AppDbContext context) { _context = context; } [Command("migrate", "Migrate database")] public async Task Migrate() { await _context.Database.MigrateAsync(); } [Command("fresh", "Wipe data")] public async Task FreshData() { await Migrate(); using var conn = new NpgsqlConnection(_context.Database.GetConnectionString()); await conn.OpenAsync(); var respawner = await Respawner.CreateAsync(conn, new RespawnerOptions { TablesToIgnore = new Table[] { "__EFMigrationsHistory" }, DbAdapter = DbAdapter.Postgres }); await respawner.ResetAsync(conn); } [Command("seed", "Fake data")] public async Task SeedData() { await Migrate(); await FreshData(); var users = new Faker() .RuleFor(m => m.Name, f => f.Person.FullName) .RuleFor(m => m.Email, f => f.Person.Email) .Generate(50); await _context.Users.AddRangeAsync(users); await _context.SaveChangesAsync(); var articles = new Faker
() .RuleFor(a => a.Title, f => f.Lorem.Sentence().TrimEnd('.')) .RuleFor(a => a.Description, f => f.Lorem.Paragraphs(1)) .RuleFor(a => a.Body, f => f.Lorem.Paragraphs(5)) .RuleFor(a => a.Author, f => f.PickRandom(users)) .RuleFor(a => a.CreatedAt, f => f.Date.Recent(90).ToUniversalTime()) .RuleFor(a => a.Slug, (f, a) => a.Title.Replace(" ", "-").ToLowerInvariant()) .Generate(500) .Select(a => { new Faker() .RuleFor(a => a.Body, f => f.Lorem.Paragraphs(2)) .RuleFor(a => a.Author, f => f.PickRandom(users)) .RuleFor(a => a.CreatedAt, f => f.Date.Recent(7).ToUniversalTime()) .Generate(new Faker().Random.Number(10)) .ForEach(c => a.Comments.Add(c)); return a; }); await _context.Articles.AddRangeAsync(articles); await _context.SaveChangesAsync(); } } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.Console/Program.cs" >}} ```cs using KubeRocks.Application.Extensions; using KubeRocks.Console.Commands; var builder = ConsoleApp.CreateBuilder(args); builder.ConfigureServices((ctx, services) => { services.AddKubeRocksServices(ctx.Configuration); }); var app = builder.Build(); app.AddSubCommands(); app.Run(); ``` {{< /highlight >}} Then launch the command: ```sh dotnet run --project src/KubeRocks.Console db seed ``` Ensure with your favorite DB client that data is correctly inserted. ### Define endpoint access All that's left is to create the endpoint. Let's define all DTO first: ```sh dotnet add src/KubeRocks.WebApi package Mapster ``` {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/Models/ArticleListDto.cs" >}} ```cs namespace KubeRocks.WebApi.Models; public class ArticleListDto { public required string Title { get; set; } public required string Slug { get; set; } public required string Description { get; set; } public required string Body { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } public required AuthorDto Author { get; set; } } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/Models/ArticleDto.cs" >}} ```cs namespace KubeRocks.WebApi.Models; public class ArticleDto : ArticleListDto { public List Comments { get; set; } = new(); } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/Models/AuthorDto.cs" >}} ```cs namespace KubeRocks.WebApi.Models; public class AuthorDto { public required string Name { get; set; } } ``` {{< /highlight >}} {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/Models/CommentDto.cs" >}} ```cs namespace KubeRocks.WebApi.Models; public class CommentDto { public required string Body { get; set; } public DateTime CreatedAt { get; set; } public required AuthorDto Author { get; set; } } ``` {{< /highlight >}} And finally the controller: {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/Controllers/ArticlesController.cs" >}} ```cs using KubeRocks.Application.Contexts; using KubeRocks.WebApi.Models; using Mapster; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace KubeRocks.WebApi.Controllers; [ApiController] [Route("[controller]")] public class ArticlesController { private readonly AppDbContext _context; public record ArticlesResponse(IEnumerable Articles, int ArticlesCount); public ArticlesController(AppDbContext context) { _context = context; } [HttpGet(Name = "GetArticles")] public async Task Get([FromQuery] int page = 1, [FromQuery] int size = 10) { var articles = await _context.Articles .OrderByDescending(a => a.Id) .Skip((page - 1) * size) .Take(size) .ProjectToType() .ToListAsync(); var articlesCount = await _context.Articles.CountAsync(); return new ArticlesResponse(articles, articlesCount); } [HttpGet("{slug}", Name = "GetArticleBySlug")] public async Task> GetBySlug(string slug) { var article = await _context.Articles .Include(a => a.Author) .Include(a => a.Comments.OrderByDescending(c => c.Id)) .ThenInclude(c => c.Author) .FirstOrDefaultAsync(a => a.Slug == slug); if (article is null) { return new NotFoundResult(); } return article.Adapt(); } } ``` {{< /highlight >}} Launch the app and check that `/Articles` and `/Articles/{slug}` endpoints are working as expected. ## Deployment with database ### Database connection It's time to connect our app to the production database. Create a demo DB & user through pgAdmin and create the appropriate secret: {{< highlight host="demo-kube-flux" file="clusters/demo/kuberocks/secrets-demo-db.yaml" >}} ```yaml apiVersion: v1 kind: Secret metadata: name: demo-db type: Opaque data: password: ZGVtbw== ``` {{< /highlight >}} Generate the according sealed secret like previously chapters with `kubeseal` under `sealed-secret-demo-db.yaml` file and delete `secret-demo-db.yaml`. ```sh cat clusters/demo/kuberocks/secret-demo.yaml | kubeseal --format=yaml --cert=pub-sealed-secrets.pem > clusters/demo/kuberocks/sealed-secret-demo.yaml rm clusters/demo/kuberocks/secret-demo.yaml ``` Let's inject the appropriate connection string as environment variable: {{< highlight host="demo-kube-flux" file="clusters/demo/kuberocks/deploy-demo.yaml" >}} ```yaml # ... spec: # ... template: # ... spec: # ... containers: - name: api # ... env: - name: DB_PASSWORD valueFrom: secretKeyRef: name: demo-db key: password - name: ConnectionStrings__DefaultConnection value: Host=postgresql-primary.postgres;Username=demo;Password='$(DB_PASSWORD)';Database=demo; #... ``` {{< /highlight >}} ### Database migration The DB connection should be done, but the database isn't migrated yet, the easiest is to add a migration step directly in startup app: {{< highlight host="kuberocks-demo" file="src/KubeRocks.WebApi/Program.cs" >}} ```cs // ... var app = builder.Build(); using var scope = app.Services.CreateScope(); await using var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(); // ... ``` {{< /highlight >}} The database should be migrated on first app launch on next deploy. Go to `https://demo.kube.rocks/Articles` to confirm all is ok. It should return next empty response: ```json { articles: [] articlesCount: 0 } ``` {{< alert >}} Don't hesitate to abuse of `klo -n kuberocks deploy/demo` to debug any troubleshooting when pod is on error state. {{< /alert >}} ### Database seeding We'll try to seed the database directly from local. Change temporarily the connection string in `appsettings.json` to point to the production database: {{< highlight host="kuberocks-demo" file="src/KubeRocks.Console/appsettings.json" >}} ```json { "ConnectionStrings": { "DefaultConnection": "Host=localhost:54321;Username=demo;Password='xxx';Database=demo;" } } ``` {{< /highlight >}} Then: ```sh # forward the production database port to local kpf svc/postgresql -n postgres 54321:tcp-postgresql # launch the seeding command dotnet run --project src/KubeRocks.Console db seed ``` {{< alert >}} We may obviously never do this on real production database, but as it's only for seeding, it will never concern them. {{< /alert >}} Return to `https://demo.kube.rocks/Articles` to confirm articles are correctly returned. ## 8th check ✅ We now have a little more realistic app. Go [next part]({{< ref "/posts/19-a-beautiful-gitops-day-9" >}}), we'll talk about further monitoring integration and tracing with OpenTelemetry.