From 67077ddcc5b42d6941f3cbb50cd04918df0612f4 Mon Sep 17 00:00:00 2001
From: oleitao <72361786+oleitao@users.noreply.github.com>
Date: Fri, 22 May 2026 23:21:47 +0100
Subject: [PATCH 1/8] dotnet core app
---
.gitignore | 52 ++++++++
DevOps-Project-41/app/.dockerignore | 19 +++
DevOps-Project-41/app/AiPlatform.sln | 40 ++++++
DevOps-Project-41/app/Dockerfile | 57 +++++++++
DevOps-Project-41/app/docker-compose.yml | 111 ++++++++++++++++
DevOps-Project-41/app/src/AiApi/AiApi.csproj | 28 +++++
.../app/src/AiApi/JobRepository.cs | 86 +++++++++++++
DevOps-Project-41/app/src/AiApi/Models.cs | 25 ++++
DevOps-Project-41/app/src/AiApi/Program.cs | 118 ++++++++++++++++++
.../app/src/AiApi/appsettings.json | 14 +++
.../app/src/AiProvider/AiProvider.csproj | 15 +++
.../app/src/AiProvider/IAiProvider.cs | 11 ++
.../app/src/AiProvider/MockLlmProvider.cs | 43 +++++++
.../app/src/AiProvider/OllamaProvider.cs | 37 ++++++
.../AiProvider/OpenAiCompatibleProvider.cs | 46 +++++++
.../app/src/AiWorker/AiWorker.csproj | 25 ++++
.../app/src/AiWorker/JobUpdater.cs | 54 ++++++++
DevOps-Project-41/app/src/AiWorker/Program.cs | 67 ++++++++++
DevOps-Project-41/app/src/AiWorker/Worker.cs | 98 +++++++++++++++
.../app/src/AiWorker/appsettings.json | 13 ++
.../app/tests/AiApi.Tests/AiApi.Tests.csproj | 24 ++++
.../tests/AiApi.Tests/MockLlmProviderTests.cs | 59 +++++++++
.../AiWorker.Tests/AiProviderContractTests.cs | 34 +++++
.../AiWorker.Tests/AiWorker.Tests.csproj | 24 ++++
24 files changed, 1100 insertions(+)
create mode 100644 .gitignore
create mode 100644 DevOps-Project-41/app/.dockerignore
create mode 100644 DevOps-Project-41/app/AiPlatform.sln
create mode 100644 DevOps-Project-41/app/Dockerfile
create mode 100644 DevOps-Project-41/app/docker-compose.yml
create mode 100644 DevOps-Project-41/app/src/AiApi/AiApi.csproj
create mode 100644 DevOps-Project-41/app/src/AiApi/JobRepository.cs
create mode 100644 DevOps-Project-41/app/src/AiApi/Models.cs
create mode 100644 DevOps-Project-41/app/src/AiApi/Program.cs
create mode 100644 DevOps-Project-41/app/src/AiApi/appsettings.json
create mode 100644 DevOps-Project-41/app/src/AiProvider/AiProvider.csproj
create mode 100644 DevOps-Project-41/app/src/AiProvider/IAiProvider.cs
create mode 100644 DevOps-Project-41/app/src/AiProvider/MockLlmProvider.cs
create mode 100644 DevOps-Project-41/app/src/AiProvider/OllamaProvider.cs
create mode 100644 DevOps-Project-41/app/src/AiProvider/OpenAiCompatibleProvider.cs
create mode 100644 DevOps-Project-41/app/src/AiWorker/AiWorker.csproj
create mode 100644 DevOps-Project-41/app/src/AiWorker/JobUpdater.cs
create mode 100644 DevOps-Project-41/app/src/AiWorker/Program.cs
create mode 100644 DevOps-Project-41/app/src/AiWorker/Worker.cs
create mode 100644 DevOps-Project-41/app/src/AiWorker/appsettings.json
create mode 100644 DevOps-Project-41/app/tests/AiApi.Tests/AiApi.Tests.csproj
create mode 100644 DevOps-Project-41/app/tests/AiApi.Tests/MockLlmProviderTests.cs
create mode 100644 DevOps-Project-41/app/tests/AiWorker.Tests/AiProviderContractTests.cs
create mode 100644 DevOps-Project-41/app/tests/AiWorker.Tests/AiWorker.Tests.csproj
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..94bf7519
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,52 @@
+# Build artifacts
+bin/
+obj/
+out/
+
+# IDE
+.vs/
+.vscode/
+.idea/
+*.user
+*.suo
+
+# .NET
+TestResults/
+*.trx
+*.coverage
+*.coveragexml
+
+# Docker
+*.env
+.env.*
+!.env.example
+
+# SBOM and scan reports
+sbom-*.json
+*.sarif
+
+# Terraform
+*.tfstate
+*.tfstate.*
+.terraform/
+.terraform.lock.hcl
+*.tfplan
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
+
+# Kubernetes secrets (never commit)
+*secret*.yaml
+!*secret*.example.yaml
+!*secret*.template.yaml
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Node (if any tooling added)
+node_modules/
+
+# Logs
+*.log
diff --git a/DevOps-Project-41/app/.dockerignore b/DevOps-Project-41/app/.dockerignore
new file mode 100644
index 00000000..c2bcf722
--- /dev/null
+++ b/DevOps-Project-41/app/.dockerignore
@@ -0,0 +1,19 @@
+.git
+.gitignore
+.github
+.vs
+.vscode
+.idea
+**/bin
+**/obj
+**/out
+**/.env
+**/*.env
+**/*.user
+**/*.suo
+**/TestResults
+**/node_modules
+**/*.md
+**/Dockerfile*
+**/docker-compose*
+**/.dockerignore
diff --git a/DevOps-Project-41/app/AiPlatform.sln b/DevOps-Project-41/app/AiPlatform.sln
new file mode 100644
index 00000000..4898f0bf
--- /dev/null
+++ b/DevOps-Project-41/app/AiPlatform.sln
@@ -0,0 +1,40 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AiApi", "src\AiApi\AiApi.csproj", "{86BE0D76-23F2-406B-A4F6-7652EFFDC1AF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AiWorker", "src\AiWorker\AiWorker.csproj", "{31D57DDD-AB5B-44F8-988A-4EA4DE137062}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AiProvider", "src\AiProvider\AiProvider.csproj", "{2421F9DB-BCB0-4B67-9D1F-6728B7347E56}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AiApi.Tests", "tests\AiApi.Tests\AiApi.Tests.csproj", "{0F9F347C-F9F2-4A47-929B-AE9AC2198BCF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AiWorker.Tests", "tests\AiWorker.Tests\AiWorker.Tests.csproj", "{1DE477AB-4D6A-49BD-B599-68FBC647107C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {86BE0D76-23F2-406B-A4F6-7652EFFDC1AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {86BE0D76-23F2-406B-A4F6-7652EFFDC1AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {86BE0D76-23F2-406B-A4F6-7652EFFDC1AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {86BE0D76-23F2-406B-A4F6-7652EFFDC1AF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {31D57DDD-AB5B-44F8-988A-4EA4DE137062}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {31D57DDD-AB5B-44F8-988A-4EA4DE137062}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {31D57DDD-AB5B-44F8-988A-4EA4DE137062}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {31D57DDD-AB5B-44F8-988A-4EA4DE137062}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2421F9DB-BCB0-4B67-9D1F-6728B7347E56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2421F9DB-BCB0-4B67-9D1F-6728B7347E56}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2421F9DB-BCB0-4B67-9D1F-6728B7347E56}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2421F9DB-BCB0-4B67-9D1F-6728B7347E56}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0F9F347C-F9F2-4A47-929B-AE9AC2198BCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0F9F347C-F9F2-4A47-929B-AE9AC2198BCF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0F9F347C-F9F2-4A47-929B-AE9AC2198BCF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0F9F347C-F9F2-4A47-929B-AE9AC2198BCF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1DE477AB-4D6A-49BD-B599-68FBC647107C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1DE477AB-4D6A-49BD-B599-68FBC647107C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1DE477AB-4D6A-49BD-B599-68FBC647107C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1DE477AB-4D6A-49BD-B599-68FBC647107C}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/DevOps-Project-41/app/Dockerfile b/DevOps-Project-41/app/Dockerfile
new file mode 100644
index 00000000..c493da56
--- /dev/null
+++ b/DevOps-Project-41/app/Dockerfile
@@ -0,0 +1,57 @@
+# syntax=docker/dockerfile:1
+
+# ─── Build stage ─────────────────────────────────────────────────────────────
+FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
+WORKDIR /src
+
+COPY AiPlatform.sln ./
+COPY src/AiProvider/AiProvider.csproj ./src/AiProvider/
+COPY src/AiApi/AiApi.csproj ./src/AiApi/
+COPY src/AiWorker/AiWorker.csproj ./src/AiWorker/
+COPY tests/AiApi.Tests/AiApi.Tests.csproj ./tests/AiApi.Tests/
+COPY tests/AiWorker.Tests/AiWorker.Tests.csproj ./tests/AiWorker.Tests/
+
+RUN dotnet restore
+
+COPY . .
+
+RUN dotnet build -c Release --no-restore
+RUN dotnet test -c Release --no-build --no-restore
+
+# ─── Publish API ─────────────────────────────────────────────────────────────
+FROM build AS publish-api
+RUN dotnet publish src/AiApi/AiApi.csproj -c Release --no-build -o /app/api
+
+# ─── Publish Worker ──────────────────────────────────────────────────────────
+FROM build AS publish-worker
+RUN dotnet publish src/AiWorker/AiWorker.csproj -c Release --no-build -o /app/worker
+
+# ─── API runtime image ────────────────────────────────────────────────────────
+FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS api
+WORKDIR /app
+
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+USER appuser
+
+COPY --from=publish-api --chown=appuser:appgroup /app/api .
+
+EXPOSE 8080
+ENV ASPNETCORE_URLS=http://+:8080
+
+HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
+ CMD wget -qO- http://localhost:8080/health || exit 1
+
+ENTRYPOINT ["dotnet", "AiApi.dll"]
+
+# ─── Worker runtime image ─────────────────────────────────────────────────────
+FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS worker
+WORKDIR /app
+
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+USER appuser
+
+COPY --from=publish-worker --chown=appuser:appgroup /app/worker .
+
+EXPOSE 9090
+
+ENTRYPOINT ["dotnet", "AiWorker.dll"]
diff --git a/DevOps-Project-41/app/docker-compose.yml b/DevOps-Project-41/app/docker-compose.yml
new file mode 100644
index 00000000..5c64ef87
--- /dev/null
+++ b/DevOps-Project-41/app/docker-compose.yml
@@ -0,0 +1,111 @@
+services:
+
+ ai-api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: api
+ image: ai-native-devsecops/ai-api:local
+ ports:
+ - "8080:8080"
+ environment:
+ ASPNETCORE_ENVIRONMENT: Development
+ ASPNETCORE_URLS: http://+:8080
+ REDIS_CONNECTION_STRING: redis:6379
+ POSTGRES_CONNECTION_STRING: "Host=postgres;Database=aiops;Username=aiops;Password=aiops"
+ AI_PROVIDER: mock
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
+ depends_on:
+ redis:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 15s
+ restart: on-failure
+
+ ai-worker:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: worker
+ image: ai-native-devsecops/ai-worker:local
+ ports:
+ - "9090:9090"
+ environment:
+ REDIS_CONNECTION_STRING: redis:6379
+ POSTGRES_CONNECTION_STRING: "Host=postgres;Database=aiops;Username=aiops;Password=aiops"
+ AI_PROVIDER: mock
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
+ depends_on:
+ redis:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ restart: on-failure
+
+ redis:
+ image: redis:7.2-alpine
+ ports:
+ - "6379:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ restart: unless-stopped
+
+ postgres:
+ image: postgres:16-alpine
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_DB: aiops
+ POSTGRES_USER: aiops
+ POSTGRES_PASSWORD: aiops
+ volumes:
+ - pg-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U aiops -d aiops"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ restart: unless-stopped
+
+ otel-collector:
+ image: otel/opentelemetry-collector-contrib:0.104.0
+ command: ["--config=/etc/otel-collector-config.yaml"]
+ volumes:
+ - ../observability/otel-collector-local.yaml:/etc/otel-collector-config.yaml:ro
+ ports:
+ - "4317:4317"
+ - "4318:4318"
+ - "8889:8889"
+ restart: unless-stopped
+
+ prometheus:
+ image: prom/prometheus:v2.53.0
+ ports:
+ - "9091:9090"
+ volumes:
+ - ../observability/prometheus-local.yml:/etc/prometheus/prometheus.yml:ro
+ restart: unless-stopped
+
+ grafana:
+ image: grafana/grafana:11.1.0
+ ports:
+ - "3000:3000"
+ environment:
+ GF_SECURITY_ADMIN_PASSWORD: admin
+ GF_USERS_ALLOW_SIGN_UP: "false"
+ volumes:
+ - grafana-data:/var/lib/grafana
+ restart: unless-stopped
+
+volumes:
+ pg-data:
+ grafana-data:
diff --git a/DevOps-Project-41/app/src/AiApi/AiApi.csproj b/DevOps-Project-41/app/src/AiApi/AiApi.csproj
new file mode 100644
index 00000000..8a2264d4
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiApi/AiApi.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+ AiApi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DevOps-Project-41/app/src/AiApi/JobRepository.cs b/DevOps-Project-41/app/src/AiApi/JobRepository.cs
new file mode 100644
index 00000000..57794b9f
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiApi/JobRepository.cs
@@ -0,0 +1,86 @@
+using Npgsql;
+
+namespace AiApi;
+
+public class JobRepository
+{
+ private readonly string _connectionString;
+
+ public JobRepository(IConfiguration config)
+ {
+ _connectionString = config.GetConnectionString("Postgres")
+ ?? config["POSTGRES_CONNECTION_STRING"]
+ ?? "Host=localhost;Database=aiops;Username=aiops;Password=aiops";
+ }
+
+ public async Task EnsureSchemaAsync()
+ {
+ await using var conn = new NpgsqlConnection(_connectionString);
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ CREATE TABLE IF NOT EXISTS ai_jobs (
+ job_id TEXT PRIMARY KEY,
+ status TEXT NOT NULL DEFAULT 'queued',
+ prompt TEXT NOT NULL,
+ model TEXT NOT NULL,
+ provider TEXT,
+ result TEXT,
+ error TEXT,
+ duration_ms BIGINT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ completed_at TIMESTAMPTZ
+ );
+ """;
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task InsertJobAsync(string jobId, string prompt, string model)
+ {
+ await using var conn = new NpgsqlConnection(_connectionString);
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "INSERT INTO ai_jobs (job_id, prompt, model, status, created_at) VALUES (@id, @prompt, @model, 'queued', NOW())";
+ cmd.Parameters.AddWithValue("id", jobId);
+ cmd.Parameters.AddWithValue("prompt", prompt);
+ cmd.Parameters.AddWithValue("model", model);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task GetJobAsync(string jobId)
+ {
+ await using var conn = new NpgsqlConnection(_connectionString);
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT job_id, status, result, model, provider, duration_ms, created_at, completed_at, error FROM ai_jobs WHERE job_id = @id";
+ cmd.Parameters.AddWithValue("id", jobId);
+ await using var reader = await cmd.ExecuteReaderAsync();
+ if (!await reader.ReadAsync()) return null;
+
+ return new JobStatusResponse(
+ reader.GetString(0),
+ reader.GetString(1),
+ reader.IsDBNull(2) ? null : reader.GetString(2),
+ reader.IsDBNull(3) ? null : reader.GetString(3),
+ reader.IsDBNull(4) ? null : reader.GetString(4),
+ reader.IsDBNull(5) ? null : reader.GetInt64(5),
+ reader.GetFieldValue(6),
+ reader.IsDBNull(7) ? null : reader.GetFieldValue(7),
+ reader.IsDBNull(8) ? null : reader.GetString(8)
+ );
+ }
+
+ public async Task CanConnectAsync()
+ {
+ try
+ {
+ await using var conn = new NpgsqlConnection(_connectionString);
+ await conn.OpenAsync();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/DevOps-Project-41/app/src/AiApi/Models.cs b/DevOps-Project-41/app/src/AiApi/Models.cs
new file mode 100644
index 00000000..06ac704e
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiApi/Models.cs
@@ -0,0 +1,25 @@
+namespace AiApi;
+
+public record AskRequest(string Prompt, string Model = "mock-devops-model");
+
+public record AskResponse(string JobId, string Status);
+
+public record JobStatusResponse(
+ string JobId,
+ string Status,
+ string? Result,
+ string? Model,
+ string? Provider,
+ long? DurationMs,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? CompletedAt,
+ string? Error
+);
+
+public static class JobStatus
+{
+ public const string Queued = "queued";
+ public const string Processing = "processing";
+ public const string Completed = "completed";
+ public const string Failed = "failed";
+}
diff --git a/DevOps-Project-41/app/src/AiApi/Program.cs b/DevOps-Project-41/app/src/AiApi/Program.cs
new file mode 100644
index 00000000..753cf9a5
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiApi/Program.cs
@@ -0,0 +1,118 @@
+using System.Diagnostics;
+using AiApi;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using Prometheus;
+using StackExchange.Redis;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Redis
+var redisConnection = builder.Configuration["REDIS_CONNECTION_STRING"] ?? "localhost:6379";
+builder.Services.AddSingleton(_ => ConnectionMultiplexer.Connect(redisConnection));
+
+// PostgreSQL repository
+builder.Services.AddSingleton();
+
+// OpenTelemetry
+var otelEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
+builder.Services.AddOpenTelemetry()
+ .ConfigureResource(r => r.AddService("ai-api"))
+ .WithTracing(t =>
+ {
+ t.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation();
+ if (!string.IsNullOrEmpty(otelEndpoint))
+ t.AddOtlpExporter(o => o.Endpoint = new Uri(otelEndpoint));
+ })
+ .WithMetrics(m =>
+ {
+ m.AddAspNetCoreInstrumentation()
+ .AddRuntimeInstrumentation();
+ if (!string.IsNullOrEmpty(otelEndpoint))
+ m.AddOtlpExporter(o => o.Endpoint = new Uri(otelEndpoint));
+ });
+
+var app = builder.Build();
+
+// Ensure DB schema on startup
+using (var scope = app.Services.CreateScope())
+{
+ var repo = scope.ServiceProvider.GetRequiredService();
+ try { await repo.EnsureSchemaAsync(); }
+ catch (Exception ex) { app.Logger.LogWarning(ex, "Could not initialise DB schema — will retry on first request"); }
+}
+
+// Prometheus metrics endpoint
+app.UseHttpMetrics();
+app.MapMetrics("/metrics");
+
+// Custom counters
+var jobsCreated = Metrics.CreateCounter("ai_jobs_created_total", "Total AI jobs created");
+var jobsFailed = Metrics.CreateCounter("ai_jobs_enqueue_failed_total", "Total jobs that failed to enqueue");
+
+var activitySource = new ActivitySource("ai-api");
+
+// GET /health
+app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }));
+
+// GET /ready
+app.MapGet("/ready", async (IConnectionMultiplexer redis, JobRepository repo) =>
+{
+ var redisOk = false;
+ var pgOk = false;
+ try { await redis.GetDatabase().PingAsync(); redisOk = true; } catch { }
+ try { pgOk = await repo.CanConnectAsync(); } catch { }
+
+ if (redisOk && pgOk)
+ return Results.Ok(new { status = "ready", redis = "ok", postgres = "ok" });
+
+ return Results.Json(
+ new { status = "degraded", redis = redisOk ? "ok" : "unavailable", postgres = pgOk ? "ok" : "unavailable" },
+ statusCode: 503);
+});
+
+// POST /ask
+app.MapPost("/ask", async (AskRequest request, IConnectionMultiplexer redis, JobRepository repo, ILogger logger) =>
+{
+ if (string.IsNullOrWhiteSpace(request.Prompt))
+ return Results.BadRequest(new { error = "prompt is required" });
+
+ var jobId = Guid.NewGuid().ToString("N");
+
+ using var activity = activitySource.StartActivity("http.post.ask");
+ activity?.SetTag("job.id", jobId);
+ activity?.SetTag("job.model", request.Model);
+
+ try
+ {
+ await repo.InsertJobAsync(jobId, request.Prompt, request.Model);
+
+ var db = redis.GetDatabase();
+ var payload = System.Text.Json.JsonSerializer.Serialize(new { jobId, prompt = request.Prompt, model = request.Model });
+ await db.ListLeftPushAsync("ai-jobs", payload);
+ activity?.SetTag("queue.enqueued", true);
+
+ jobsCreated.Inc();
+ logger.LogInformation("Job {JobId} enqueued with model {Model}", jobId, request.Model);
+
+ return Results.Accepted($"/jobs/{jobId}", new AskResponse(jobId, JobStatus.Queued));
+ }
+ catch (Exception ex)
+ {
+ jobsFailed.Inc();
+ logger.LogError(ex, "Failed to enqueue job {JobId}", jobId);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ return Results.Problem("Failed to enqueue job", statusCode: 500);
+ }
+});
+
+// GET /jobs/{jobId}
+app.MapGet("/jobs/{jobId}", async (string jobId, JobRepository repo) =>
+{
+ var job = await repo.GetJobAsync(jobId);
+ return job is null ? Results.NotFound(new { error = "job not found" }) : Results.Ok(job);
+});
+
+app.Run();
diff --git a/DevOps-Project-41/app/src/AiApi/appsettings.json b/DevOps-Project-41/app/src/AiApi/appsettings.json
new file mode 100644
index 00000000..6979908b
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiApi/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "Postgres": "Host=localhost;Database=aiops;Username=aiops;Password=aiops"
+ },
+ "REDIS_CONNECTION_STRING": "localhost:6379",
+ "AI_PROVIDER": "mock"
+}
diff --git a/DevOps-Project-41/app/src/AiProvider/AiProvider.csproj b/DevOps-Project-41/app/src/AiProvider/AiProvider.csproj
new file mode 100644
index 00000000..0cd0d9ea
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiProvider/AiProvider.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/DevOps-Project-41/app/src/AiProvider/IAiProvider.cs b/DevOps-Project-41/app/src/AiProvider/IAiProvider.cs
new file mode 100644
index 00000000..c87f0359
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiProvider/IAiProvider.cs
@@ -0,0 +1,11 @@
+namespace AiProvider;
+
+public interface IAiProvider
+{
+ Task CompleteAsync(AiRequest request, CancellationToken cancellationToken = default);
+ string ProviderName { get; }
+}
+
+public record AiRequest(string Prompt, string Model, string JobId);
+
+public record AiResponse(string JobId, string Content, string Model, string Provider, long DurationMs);
diff --git a/DevOps-Project-41/app/src/AiProvider/MockLlmProvider.cs b/DevOps-Project-41/app/src/AiProvider/MockLlmProvider.cs
new file mode 100644
index 00000000..11843416
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiProvider/MockLlmProvider.cs
@@ -0,0 +1,43 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace AiProvider;
+
+public class MockLlmProvider : IAiProvider
+{
+ private readonly ILogger _logger;
+
+ private static readonly Dictionary _responses = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["gitops"] = "GitOps is a practice where Git is the single source of truth for declarative infrastructure and application configuration. Changes are made via pull requests and automatically reconciled by operators like Argo CD.",
+ ["kubernetes"] = "Kubernetes is an open-source container orchestration platform that automates deployment, scaling, and management of containerised applications across clusters.",
+ ["devsecops"] = "DevSecOps integrates security practices into the DevOps pipeline, shifting security left so that vulnerabilities are detected early in development rather than post-deployment.",
+ ["keda"] = "KEDA (Kubernetes Event-Driven Autoscaling) scales workloads based on external event sources such as queue lengths, HTTP requests, or custom metrics.",
+ ["opentelemetry"] = "OpenTelemetry is a vendor-neutral observability framework for generating, collecting, and exporting traces, metrics, and logs from distributed systems.",
+ };
+
+ public string ProviderName => "mock";
+
+ public MockLlmProvider(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async Task CompleteAsync(AiRequest request, CancellationToken cancellationToken = default)
+ {
+ var sw = Stopwatch.StartNew();
+
+ _logger.LogInformation("MockLLM processing job {JobId} with model {Model}", request.JobId, request.Model);
+
+ await Task.Delay(Random.Shared.Next(100, 600), cancellationToken);
+
+ var keyword = _responses.Keys.FirstOrDefault(k => request.Prompt.Contains(k, StringComparison.OrdinalIgnoreCase));
+ var content = keyword is not null
+ ? _responses[keyword]
+ : $"[MockLLM] Received: \"{request.Prompt}\". This is a deterministic mock response for local testing. Configure AI_PROVIDER=ollama or AI_PROVIDER=openai-compatible to use a real model.";
+
+ sw.Stop();
+
+ return new AiResponse(request.JobId, content, request.Model, ProviderName, sw.ElapsedMilliseconds);
+ }
+}
diff --git a/DevOps-Project-41/app/src/AiProvider/OllamaProvider.cs b/DevOps-Project-41/app/src/AiProvider/OllamaProvider.cs
new file mode 100644
index 00000000..991bf755
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiProvider/OllamaProvider.cs
@@ -0,0 +1,37 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using Microsoft.Extensions.Logging;
+
+namespace AiProvider;
+
+public class OllamaProvider : IAiProvider
+{
+ private readonly HttpClient _http;
+ private readonly ILogger _logger;
+
+ public string ProviderName => "ollama";
+
+ public OllamaProvider(HttpClient http, ILogger logger)
+ {
+ _http = http;
+ _logger = logger;
+ }
+
+ public async Task CompleteAsync(AiRequest request, CancellationToken cancellationToken = default)
+ {
+ var sw = Stopwatch.StartNew();
+
+ _logger.LogInformation("Ollama processing job {JobId} with model {Model}", request.JobId, request.Model);
+
+ var payload = new { model = request.Model, prompt = request.Prompt, stream = false };
+ var response = await _http.PostAsJsonAsync("/api/generate", payload, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
+ sw.Stop();
+
+ return new AiResponse(request.JobId, result?.Response ?? string.Empty, request.Model, ProviderName, sw.ElapsedMilliseconds);
+ }
+
+ private record OllamaResponse(string Response);
+}
diff --git a/DevOps-Project-41/app/src/AiProvider/OpenAiCompatibleProvider.cs b/DevOps-Project-41/app/src/AiProvider/OpenAiCompatibleProvider.cs
new file mode 100644
index 00000000..65bf2727
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiProvider/OpenAiCompatibleProvider.cs
@@ -0,0 +1,46 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using Microsoft.Extensions.Logging;
+
+namespace AiProvider;
+
+public class OpenAiCompatibleProvider : IAiProvider
+{
+ private readonly HttpClient _http;
+ private readonly ILogger _logger;
+
+ public string ProviderName => "openai-compatible";
+
+ public OpenAiCompatibleProvider(HttpClient http, ILogger logger)
+ {
+ _http = http;
+ _logger = logger;
+ }
+
+ public async Task CompleteAsync(AiRequest request, CancellationToken cancellationToken = default)
+ {
+ var sw = Stopwatch.StartNew();
+
+ _logger.LogInformation("OpenAI-compatible processing job {JobId} with model {Model}", request.JobId, request.Model);
+
+ var payload = new
+ {
+ model = request.Model,
+ messages = new[] { new { role = "user", content = request.Prompt } }
+ };
+
+ var response = await _http.PostAsJsonAsync("/chat/completions", payload, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
+ var content = result?.Choices?.FirstOrDefault()?.Message?.Content ?? string.Empty;
+
+ sw.Stop();
+
+ return new AiResponse(request.JobId, content, request.Model, ProviderName, sw.ElapsedMilliseconds);
+ }
+
+ private record OpenAiResponse(OpenAiChoice[]? Choices);
+ private record OpenAiChoice(OpenAiMessage Message);
+ private record OpenAiMessage(string Content);
+}
diff --git a/DevOps-Project-41/app/src/AiWorker/AiWorker.csproj b/DevOps-Project-41/app/src/AiWorker/AiWorker.csproj
new file mode 100644
index 00000000..979da42f
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiWorker/AiWorker.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ enable
+ enable
+ AiWorker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DevOps-Project-41/app/src/AiWorker/JobUpdater.cs b/DevOps-Project-41/app/src/AiWorker/JobUpdater.cs
new file mode 100644
index 00000000..817db931
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiWorker/JobUpdater.cs
@@ -0,0 +1,54 @@
+using Npgsql;
+
+namespace AiWorker;
+
+public class JobUpdater
+{
+ private readonly string _connectionString;
+
+ public JobUpdater(IConfiguration config)
+ {
+ _connectionString = config.GetConnectionString("Postgres")
+ ?? config["POSTGRES_CONNECTION_STRING"]
+ ?? "Host=localhost;Database=aiops;Username=aiops;Password=aiops";
+ }
+
+ public async Task MarkProcessingAsync(string jobId)
+ {
+ await using var conn = new NpgsqlConnection(_connectionString);
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE ai_jobs SET status = 'processing' WHERE job_id = @id";
+ cmd.Parameters.AddWithValue("id", jobId);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task MarkCompletedAsync(string jobId, string result, string provider, long durationMs)
+ {
+ await using var conn = new NpgsqlConnection(_connectionString);
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE ai_jobs
+ SET status = 'completed', result = @result, provider = @provider,
+ duration_ms = @duration, completed_at = NOW()
+ WHERE job_id = @id
+ """;
+ cmd.Parameters.AddWithValue("id", jobId);
+ cmd.Parameters.AddWithValue("result", result);
+ cmd.Parameters.AddWithValue("provider", provider);
+ cmd.Parameters.AddWithValue("duration", durationMs);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task MarkFailedAsync(string jobId, string error)
+ {
+ await using var conn = new NpgsqlConnection(_connectionString);
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE ai_jobs SET status = 'failed', error = @error, completed_at = NOW() WHERE job_id = @id";
+ cmd.Parameters.AddWithValue("id", jobId);
+ cmd.Parameters.AddWithValue("error", error);
+ await cmd.ExecuteNonQueryAsync();
+ }
+}
diff --git a/DevOps-Project-41/app/src/AiWorker/Program.cs b/DevOps-Project-41/app/src/AiWorker/Program.cs
new file mode 100644
index 00000000..ddd30a30
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiWorker/Program.cs
@@ -0,0 +1,67 @@
+using AiProvider;
+using AiWorker;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using Prometheus;
+using StackExchange.Redis;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+// Redis
+var redisConnection = builder.Configuration["REDIS_CONNECTION_STRING"] ?? "localhost:6379";
+builder.Services.AddSingleton(_ => ConnectionMultiplexer.Connect(redisConnection));
+
+// Job updater
+builder.Services.AddSingleton();
+
+// AI Provider selection
+var providerName = builder.Configuration["AI_PROVIDER"] ?? "mock";
+builder.Services.AddHttpClient();
+
+builder.Services.AddSingleton(sp =>
+{
+ var loggerFactory = sp.GetRequiredService();
+
+ return providerName switch
+ {
+ "ollama" => new OllamaProvider(
+ CreateHttpClient(sp, builder.Configuration["OLLAMA_BASE_URL"] ?? "http://localhost:11434"),
+ loggerFactory.CreateLogger()),
+
+ "openai-compatible" => new OpenAiCompatibleProvider(
+ CreateHttpClient(sp, builder.Configuration["OPENAI_COMPATIBLE_BASE_URL"] ?? "http://localhost:8000",
+ builder.Configuration["OPENAI_API_KEY"]),
+ loggerFactory.CreateLogger()),
+
+ _ => new MockLlmProvider(loggerFactory.CreateLogger())
+ };
+});
+
+// OpenTelemetry
+var otelEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
+builder.Services.AddOpenTelemetry()
+ .ConfigureResource(r => r.AddService("ai-worker"))
+ .WithTracing(t =>
+ {
+ t.AddHttpClientInstrumentation();
+ if (!string.IsNullOrEmpty(otelEndpoint))
+ t.AddOtlpExporter(o => o.Endpoint = new Uri(otelEndpoint));
+ });
+
+builder.Services.AddHostedService();
+
+var host = builder.Build();
+
+// Expose Prometheus metrics on port 9090
+var metricServer = new MetricServer(port: 9090);
+metricServer.Start();
+
+await host.RunAsync();
+
+static HttpClient CreateHttpClient(IServiceProvider sp, string baseUrl, string? apiKey = null)
+{
+ var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
+ if (!string.IsNullOrEmpty(apiKey))
+ client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
+ return client;
+}
diff --git a/DevOps-Project-41/app/src/AiWorker/Worker.cs b/DevOps-Project-41/app/src/AiWorker/Worker.cs
new file mode 100644
index 00000000..cf4114ae
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiWorker/Worker.cs
@@ -0,0 +1,98 @@
+using System.Diagnostics;
+using System.Text.Json;
+using AiProvider;
+using Prometheus;
+using StackExchange.Redis;
+
+namespace AiWorker;
+
+public class Worker : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly IConnectionMultiplexer _redis;
+ private readonly IAiProvider _aiProvider;
+ private readonly JobUpdater _jobUpdater;
+ private readonly ActivitySource _activitySource = new("ai-worker");
+
+ private static readonly Counter JobsCompleted = Metrics.CreateCounter("ai_jobs_completed_total", "Total jobs completed successfully");
+ private static readonly Counter JobsFailed = Metrics.CreateCounter("ai_jobs_failed_total", "Total jobs failed");
+ private static readonly Histogram JobDuration = Metrics.CreateHistogram("ai_job_duration_seconds", "AI job processing duration in seconds");
+ private static readonly Gauge QueueDepth = Metrics.CreateGauge("ai_queue_depth", "Current Redis queue depth");
+
+ public Worker(ILogger logger, IConnectionMultiplexer redis, IAiProvider aiProvider, JobUpdater jobUpdater)
+ {
+ _logger = logger;
+ _redis = redis;
+ _aiProvider = aiProvider;
+ _jobUpdater = jobUpdater;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("AI Worker started. Provider: {Provider}", _aiProvider.ProviderName);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var db = _redis.GetDatabase();
+ var queueLen = await db.ListLengthAsync("ai-jobs");
+ QueueDepth.Set(queueLen);
+
+ var raw = await db.ListRightPopAsync("ai-jobs");
+ if (raw.IsNullOrEmpty)
+ {
+ await Task.Delay(1000, stoppingToken);
+ continue;
+ }
+
+ var job = JsonSerializer.Deserialize(raw!);
+ if (job is null) continue;
+
+ await ProcessJobAsync(job, stoppingToken);
+ }
+ catch (OperationCanceledException) { break; }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error in worker loop");
+ await Task.Delay(2000, stoppingToken);
+ }
+ }
+
+ _logger.LogInformation("AI Worker stopped");
+ }
+
+ private async Task ProcessJobAsync(JobMessage job, CancellationToken ct)
+ {
+ using var activity = _activitySource.StartActivity("worker.process.job");
+ activity?.SetTag("job.id", job.JobId);
+ activity?.SetTag("job.model", job.Model);
+
+ _logger.LogInformation("Processing job {JobId}", job.JobId);
+
+ await _jobUpdater.MarkProcessingAsync(job.JobId);
+
+ using var timer = JobDuration.NewTimer();
+ try
+ {
+ var response = await _aiProvider.CompleteAsync(
+ new AiRequest(job.Prompt, job.Model, job.JobId), ct);
+
+ await _jobUpdater.MarkCompletedAsync(job.JobId, response.Content, response.Provider, response.DurationMs);
+ JobsCompleted.Inc();
+
+ activity?.SetTag("job.provider", response.Provider);
+ activity?.SetTag("job.duration_ms", response.DurationMs);
+ _logger.LogInformation("Job {JobId} completed in {DurationMs}ms", job.JobId, response.DurationMs);
+ }
+ catch (Exception ex)
+ {
+ await _jobUpdater.MarkFailedAsync(job.JobId, ex.Message);
+ JobsFailed.Inc();
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ _logger.LogError(ex, "Job {JobId} failed", job.JobId);
+ }
+ }
+
+ private record JobMessage(string JobId, string Prompt, string Model);
+}
diff --git a/DevOps-Project-41/app/src/AiWorker/appsettings.json b/DevOps-Project-41/app/src/AiWorker/appsettings.json
new file mode 100644
index 00000000..9ff1f7f1
--- /dev/null
+++ b/DevOps-Project-41/app/src/AiWorker/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "ConnectionStrings": {
+ "Postgres": "Host=localhost;Database=aiops;Username=aiops;Password=aiops"
+ },
+ "REDIS_CONNECTION_STRING": "localhost:6379",
+ "AI_PROVIDER": "mock"
+}
diff --git a/DevOps-Project-41/app/tests/AiApi.Tests/AiApi.Tests.csproj b/DevOps-Project-41/app/tests/AiApi.Tests/AiApi.Tests.csproj
new file mode 100644
index 00000000..e5581b9e
--- /dev/null
+++ b/DevOps-Project-41/app/tests/AiApi.Tests/AiApi.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
diff --git a/DevOps-Project-41/app/tests/AiApi.Tests/MockLlmProviderTests.cs b/DevOps-Project-41/app/tests/AiApi.Tests/MockLlmProviderTests.cs
new file mode 100644
index 00000000..803f2016
--- /dev/null
+++ b/DevOps-Project-41/app/tests/AiApi.Tests/MockLlmProviderTests.cs
@@ -0,0 +1,59 @@
+using AiProvider;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AiApi.Tests;
+
+public class MockLlmProviderTests
+{
+ private readonly MockLlmProvider _provider = new(NullLogger.Instance);
+
+ [Fact]
+ public async Task CompleteAsync_ReturnsResponse_ForKnownKeyword()
+ {
+ var request = new AiRequest("Explain GitOps in simple terms", "mock-devops-model", "job-001");
+
+ var response = await _provider.CompleteAsync(request);
+
+ Assert.Equal("job-001", response.JobId);
+ Assert.NotEmpty(response.Content);
+ Assert.Contains("GitOps", response.Content, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal("mock", response.Provider);
+ }
+
+ [Fact]
+ public async Task CompleteAsync_ReturnsFallback_ForUnknownPrompt()
+ {
+ var request = new AiRequest("Tell me about the weather", "mock-devops-model", "job-002");
+
+ var response = await _provider.CompleteAsync(request);
+
+ Assert.Equal("job-002", response.JobId);
+ Assert.Contains("[MockLLM]", response.Content);
+ }
+
+ [Fact]
+ public async Task CompleteAsync_ReturnsPositiveDuration()
+ {
+ var request = new AiRequest("Kubernetes overview", "mock-devops-model", "job-003");
+
+ var response = await _provider.CompleteAsync(request);
+
+ Assert.True(response.DurationMs >= 0);
+ }
+
+ [Theory]
+ [InlineData("kubernetes")]
+ [InlineData("devsecops")]
+ [InlineData("keda")]
+ [InlineData("opentelemetry")]
+ public async Task CompleteAsync_RecognisesAllKeywords(string keyword)
+ {
+ var request = new AiRequest($"What is {keyword}?", "mock-devops-model", $"job-{keyword}");
+
+ var response = await _provider.CompleteAsync(request);
+
+ Assert.NotEmpty(response.Content);
+ Assert.DoesNotContain("[MockLLM]", response.Content);
+ }
+}
diff --git a/DevOps-Project-41/app/tests/AiWorker.Tests/AiProviderContractTests.cs b/DevOps-Project-41/app/tests/AiWorker.Tests/AiProviderContractTests.cs
new file mode 100644
index 00000000..a95f2598
--- /dev/null
+++ b/DevOps-Project-41/app/tests/AiWorker.Tests/AiProviderContractTests.cs
@@ -0,0 +1,34 @@
+using AiProvider;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AiWorker.Tests;
+
+public class AiProviderContractTests
+{
+ [Fact]
+ public async Task MockProvider_AlwaysReturnsNonEmptyContent()
+ {
+ var provider = new MockLlmProvider(NullLogger.Instance);
+ var request = new AiRequest("How does KEDA work?", "mock-devops-model", "contract-test-001");
+
+ var response = await provider.CompleteAsync(request);
+
+ Assert.NotNull(response);
+ Assert.NotEmpty(response.Content);
+ Assert.Equal("contract-test-001", response.JobId);
+ Assert.Equal("mock", response.Provider);
+ Assert.True(response.DurationMs >= 0);
+ }
+
+ [Fact]
+ public async Task MockProvider_CancellationToken_IsRespected()
+ {
+ var provider = new MockLlmProvider(NullLogger.Instance);
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ () => provider.CompleteAsync(new AiRequest("test", "model", "job-cancel"), cts.Token));
+ }
+}
diff --git a/DevOps-Project-41/app/tests/AiWorker.Tests/AiWorker.Tests.csproj b/DevOps-Project-41/app/tests/AiWorker.Tests/AiWorker.Tests.csproj
new file mode 100644
index 00000000..e5581b9e
--- /dev/null
+++ b/DevOps-Project-41/app/tests/AiWorker.Tests/AiWorker.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
From 12569808c21c92acbd2251c66b63700373b0d0d6 Mon Sep 17 00:00:00 2001
From: oleitao <72361786+oleitao@users.noreply.github.com>
Date: Fri, 22 May 2026 23:27:50 +0100
Subject: [PATCH 2/8] github
---
.github/workflows/project-41-ci.yml | 85 ++++++++++++
.github/workflows/project-41-release.yml | 161 ++++++++++++++++++++++
.github/workflows/project-41-security.yml | 109 +++++++++++++++
3 files changed, 355 insertions(+)
create mode 100644 .github/workflows/project-41-ci.yml
create mode 100644 .github/workflows/project-41-release.yml
create mode 100644 .github/workflows/project-41-security.yml
diff --git a/.github/workflows/project-41-ci.yml b/.github/workflows/project-41-ci.yml
new file mode 100644
index 00000000..1c3380e5
--- /dev/null
+++ b/.github/workflows/project-41-ci.yml
@@ -0,0 +1,85 @@
+name: "[P41] CI"
+
+on:
+ push:
+ branches: [devops-project, master]
+ paths:
+ - "DevOps-Project-41/app/**"
+ - ".github/workflows/project-41-ci.yml"
+ pull_request:
+ branches: [master]
+ paths:
+ - "DevOps-Project-41/app/**"
+ - ".github/workflows/project-41-ci.yml"
+
+permissions:
+ contents: read
+
+env:
+ APP_DIR: DevOps-Project-41/app
+
+jobs:
+ build-test:
+ name: Build & Test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Restore
+ working-directory: ${{ env.APP_DIR }}
+ run: dotnet restore
+
+ - name: Build
+ working-directory: ${{ env.APP_DIR }}
+ run: dotnet build -c Release --no-restore
+
+ - name: Test
+ working-directory: ${{ env.APP_DIR }}
+ run: dotnet test -c Release --no-build --logger trx --results-directory TestResults
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: ${{ env.APP_DIR }}/TestResults
+
+ docker-build:
+ name: Docker Build (smoke test)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build API image
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: api
+ push: false
+ load: true
+ tags: ai-api:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build Worker image
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: worker
+ push: false
+ load: true
+ tags: ai-worker:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/project-41-release.yml b/.github/workflows/project-41-release.yml
new file mode 100644
index 00000000..0a7601ba
--- /dev/null
+++ b/.github/workflows/project-41-release.yml
@@ -0,0 +1,161 @@
+name: "[P41] Release"
+
+on:
+ push:
+ tags:
+ - "p41-v*.*.*"
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Image tag (e.g. 1.0.0)"
+ required: true
+ default: "1.0.0"
+
+permissions:
+ contents: read
+ packages: write
+ id-token: write
+ security-events: write
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_OWNER: ${{ github.repository_owner }}
+ APP_DIR: DevOps-Project-41/app
+
+jobs:
+ build-push-sign:
+ name: Build, Push and Sign
+ runs-on: ubuntu-latest
+ outputs:
+ api-digest: ${{ steps.push-api.outputs.digest }}
+ worker-digest: ${{ steps.push-worker.outputs.digest }}
+ image-tag: ${{ steps.meta.outputs.version }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Extract image metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=sha-
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Run tests before release
+ working-directory: ${{ env.APP_DIR }}
+ run: dotnet test -c Release --logger trx
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push API image
+ id: push-api
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: api
+ push: true
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api:${{ steps.meta.outputs.version }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build and push Worker image
+ id: push-worker
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: worker
+ push: true
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker:${{ steps.meta.outputs.version }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@v3
+
+ - name: Sign API image (keyless)
+ run: |
+ cosign sign --yes \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+
+ - name: Sign Worker image (keyless)
+ run: |
+ cosign sign --yes \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
+
+ - name: Generate SBOM for API (SPDX)
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+ format: spdx-json
+ output: sbom-api.spdx.json
+
+ - name: Generate SBOM for API (CycloneDX)
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+ format: cyclonedx
+ output: sbom-api.cyclonedx.json
+
+ - name: Generate SBOM for Worker (SPDX)
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
+ format: spdx-json
+ output: sbom-worker.spdx.json
+
+ - name: Attest SBOM for API
+ run: |
+ cosign attest --yes \
+ --predicate sbom-api.spdx.json \
+ --type spdxjson \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+
+ - name: Attest SBOM for Worker
+ run: |
+ cosign attest --yes \
+ --predicate sbom-worker.spdx.json \
+ --type spdxjson \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
+
+ - name: Upload SBOMs as artefacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: sbom-${{ steps.meta.outputs.version }}
+ path: "sbom-*.json"
+
+ - name: Verify API signature
+ run: |
+ cosign verify \
+ --certificate-identity-regexp "https://github.com/${{ github.repository }}/.github/workflows/project-41-release.yml.*" \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+
+ - name: Verify Worker signature
+ run: |
+ cosign verify \
+ --certificate-identity-regexp "https://github.com/${{ github.repository }}/.github/workflows/project-41-release.yml.*" \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
diff --git a/.github/workflows/project-41-security.yml b/.github/workflows/project-41-security.yml
new file mode 100644
index 00000000..f277263e
--- /dev/null
+++ b/.github/workflows/project-41-security.yml
@@ -0,0 +1,109 @@
+name: "[P41] Security Scanning"
+
+on:
+ push:
+ branches: [devops-project, master]
+ paths:
+ - "DevOps-Project-41/app/**"
+ - "DevOps-Project-41/k8s/**"
+ - ".github/workflows/project-41-security.yml"
+ schedule:
+ - cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ security-events: write
+
+env:
+ APP_DIR: DevOps-Project-41/app
+
+jobs:
+ trivy-filesystem:
+ name: Trivy Filesystem Scan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Run Trivy filesystem scan
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ scan-type: fs
+ scan-ref: DevOps-Project-41
+ format: sarif
+ output: trivy-fs.sarif
+ severity: HIGH,CRITICAL
+ exit-code: "0"
+
+ - name: Upload SARIF to GitHub Security
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-fs.sarif
+ category: trivy-filesystem
+
+ trivy-config:
+ name: Trivy Kubernetes Manifest Scan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Run Trivy config scan on k8s manifests
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ scan-type: config
+ scan-ref: DevOps-Project-41/k8s
+ format: sarif
+ output: trivy-config.sarif
+ severity: HIGH,CRITICAL
+ exit-code: "0"
+
+ - name: Upload SARIF to GitHub Security
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-config.sarif
+ category: trivy-config
+
+ trivy-image:
+ name: Trivy Image Scan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build API image for scanning
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: api
+ push: false
+ load: true
+ tags: ai-api:scan
+ cache-from: type=gha
+
+ - name: Scan API image with Trivy
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ai-api:scan
+ format: sarif
+ output: trivy-image-api.sarif
+ severity: HIGH,CRITICAL
+ exit-code: "0"
+
+ - name: Upload API image SARIF
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-image-api.sarif
+ category: trivy-image-api
+
+ - name: Upload scan reports as artefacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: trivy-reports
+ path: "*.sarif"
From a15d52dc1dc505baa7a0bfeac6081a3b06d32055 Mon Sep 17 00:00:00 2001
From: oleitao <72361786+oleitao@users.noreply.github.com>
Date: Fri, 22 May 2026 23:30:56 +0100
Subject: [PATCH 3/8] github workflow
---
DevOps-Project-41/.github/workflows/ci.yml | 87 ++++++++++
.../.github/workflows/release.yml | 161 ++++++++++++++++++
.../.github/workflows/security.yml | 110 ++++++++++++
DevOps-Project-41/.gitignore | 6 +
4 files changed, 364 insertions(+)
create mode 100644 DevOps-Project-41/.github/workflows/ci.yml
create mode 100644 DevOps-Project-41/.github/workflows/release.yml
create mode 100644 DevOps-Project-41/.github/workflows/security.yml
create mode 100644 DevOps-Project-41/.gitignore
diff --git a/DevOps-Project-41/.github/workflows/ci.yml b/DevOps-Project-41/.github/workflows/ci.yml
new file mode 100644
index 00000000..0000d4c9
--- /dev/null
+++ b/DevOps-Project-41/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+name: CI
+
+on:
+ push:
+ branches: [devops-project]
+ paths:
+ - "DevOps-Project-41/app/**"
+ - ".github/workflows/ci.yml"
+ pull_request:
+ branches: [devops-project, master]
+ paths:
+ - "DevOps-Project-41/app/**"
+
+permissions:
+ contents: read
+
+env:
+ DOTNET_VERSION: "8.0.x"
+ APP_DIR: DevOps-Project-41/app
+
+jobs:
+ build-and-test:
+ name: Build and Test
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ${{ env.APP_DIR }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build -c Release --no-restore
+
+ - name: Run tests
+ run: dotnet test -c Release --no-build --no-restore --logger trx --results-directory TestResults
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: ${{ env.APP_DIR }}/TestResults/*.trx
+
+ docker-build:
+ name: Docker Build (validation)
+ runs-on: ubuntu-latest
+ needs: build-and-test
+ defaults:
+ run:
+ working-directory: ${{ env.APP_DIR }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build API image (no push)
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: api
+ push: false
+ tags: ai-api:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build Worker image (no push)
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: worker
+ push: false
+ tags: ai-worker:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/DevOps-Project-41/.github/workflows/release.yml b/DevOps-Project-41/.github/workflows/release.yml
new file mode 100644
index 00000000..9867065f
--- /dev/null
+++ b/DevOps-Project-41/.github/workflows/release.yml
@@ -0,0 +1,161 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Image tag (e.g. 1.0.0)"
+ required: true
+ default: "latest"
+
+permissions:
+ contents: read
+ packages: write
+ id-token: write
+ security-events: write
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_OWNER: ${{ github.repository_owner }}
+ APP_DIR: DevOps-Project-41/app
+
+jobs:
+ build-push-sign:
+ name: Build, Push and Sign
+ runs-on: ubuntu-latest
+ outputs:
+ api-digest: ${{ steps.push-api.outputs.digest }}
+ worker-digest: ${{ steps.push-worker.outputs.digest }}
+ image-tag: ${{ steps.meta.outputs.version }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Extract image metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=sha-
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Run tests before release
+ working-directory: ${{ env.APP_DIR }}
+ run: dotnet test -c Release --logger trx
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push API image
+ id: push-api
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: api
+ push: true
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api:${{ steps.meta.outputs.version }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build and push Worker image
+ id: push-worker
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: worker
+ push: true
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker:${{ steps.meta.outputs.version }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@v3
+
+ - name: Sign API image (keyless)
+ run: |
+ cosign sign --yes \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+
+ - name: Sign Worker image (keyless)
+ run: |
+ cosign sign --yes \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
+
+ - name: Generate SBOM for API (SPDX)
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+ format: spdx-json
+ output: sbom-api.spdx.json
+
+ - name: Generate SBOM for API (CycloneDX)
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+ format: cyclonedx
+ output: sbom-api.cyclonedx.json
+
+ - name: Generate SBOM for Worker (SPDX)
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
+ format: spdx-json
+ output: sbom-worker.spdx.json
+
+ - name: Attest SBOM for API
+ run: |
+ cosign attest --yes \
+ --predicate sbom-api.spdx.json \
+ --type spdxjson \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+
+ - name: Attest SBOM for Worker
+ run: |
+ cosign attest --yes \
+ --predicate sbom-worker.spdx.json \
+ --type spdxjson \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
+
+ - name: Upload SBOMs as artefacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: sbom-${{ steps.meta.outputs.version }}
+ path: "sbom-*.json"
+
+ - name: Verify API signature
+ run: |
+ cosign verify \
+ --certificate-identity-regexp "https://github.com/${{ github.repository }}/.github/workflows/release.yml.*" \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-api@${{ steps.push-api.outputs.digest }}
+
+ - name: Verify Worker signature
+ run: |
+ cosign verify \
+ --certificate-identity-regexp "https://github.com/${{ github.repository }}/.github/workflows/release.yml.*" \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ai-worker@${{ steps.push-worker.outputs.digest }}
diff --git a/DevOps-Project-41/.github/workflows/security.yml b/DevOps-Project-41/.github/workflows/security.yml
new file mode 100644
index 00000000..0a37f6b5
--- /dev/null
+++ b/DevOps-Project-41/.github/workflows/security.yml
@@ -0,0 +1,110 @@
+name: Security Scanning
+
+on:
+ push:
+ branches: [devops-project]
+ paths:
+ - "DevOps-Project-41/app/**"
+ - "DevOps-Project-41/k8s/**"
+ - ".github/workflows/security.yml"
+ schedule:
+ - cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ security-events: write
+
+env:
+ APP_DIR: DevOps-Project-41/app
+
+jobs:
+ trivy-filesystem:
+ name: Trivy Filesystem Scan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Run Trivy filesystem scan
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ scan-type: fs
+ scan-ref: DevOps-Project-41
+ format: sarif
+ output: trivy-fs.sarif
+ severity: HIGH,CRITICAL
+ exit-code: "0"
+
+ - name: Upload SARIF to GitHub Security
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-fs.sarif
+ category: trivy-filesystem
+
+ trivy-config:
+ name: Trivy Kubernetes Manifest Scan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Run Trivy config scan on k8s manifests
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ scan-type: config
+ scan-ref: DevOps-Project-41/k8s
+ format: sarif
+ output: trivy-config.sarif
+ severity: HIGH,CRITICAL
+ exit-code: "0"
+
+ - name: Upload SARIF to GitHub Security
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-config.sarif
+ category: trivy-config
+
+ trivy-image:
+ name: Trivy Image Scan
+ runs-on: ubuntu-latest
+ needs: []
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build API image for scanning
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ env.APP_DIR }}
+ file: ${{ env.APP_DIR }}/Dockerfile
+ target: api
+ push: false
+ load: true
+ tags: ai-api:scan
+ cache-from: type=gha
+
+ - name: Scan API image with Trivy
+ uses: aquasecurity/trivy-action@0.24.0
+ with:
+ image-ref: ai-api:scan
+ format: sarif
+ output: trivy-image-api.sarif
+ severity: HIGH,CRITICAL
+ exit-code: "0"
+
+ - name: Upload API image SARIF
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-image-api.sarif
+ category: trivy-image-api
+
+ - name: Upload scan reports as artefacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: trivy-reports
+ path: "*.sarif"
diff --git a/DevOps-Project-41/.gitignore b/DevOps-Project-41/.gitignore
new file mode 100644
index 00000000..ab3f3ee8
--- /dev/null
+++ b/DevOps-Project-41/.gitignore
@@ -0,0 +1,6 @@
+/DevOps-Project-41/app/.idea
+/DevOps-Project-41/app/src/AiApi/.idea
+/DevOps-Project-41/app/src/AiProvider/bin
+/DevOps-Project-41/app/src/AiProvider/obj
+/DevOps-Project-41/app/src/AiWorker/bin
+/DevOps-Project-41/app/src/AiWorker/obj
\ No newline at end of file
From 49d5b2c5deaa85a03a1929460e9b3337f1c1af44 Mon Sep 17 00:00:00 2001
From: oleitao <72361786+oleitao@users.noreply.github.com>
Date: Fri, 22 May 2026 23:31:11 +0100
Subject: [PATCH 4/8] README.md
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index c8a93801..0bb0e100 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,7 @@ For comprehensive AWS-specific projects and hands-on learning experiences, visit
| **38** | Automated Testing in CI/CD Pipeline | Selenium, JUnit, Jest, Jenkins, Docker, Kubernetes | Integrate automated testing (unit, integration, E2E) in CI/CD |
| **39** | Service Mesh Implementation with Istio | Istio, Kubernetes, Prometheus, Grafana, Kiali, Jaeger | Implement service mesh for microservices with observability |
| **40** | Cloud Migration Strategy and Execution | AWS Migration Hub, Database Migration Service, Terraform, Jenkins | Plan and execute cloud migration from on-premises to AWS |
+| 41 | AI-Native DevSecOps Platform with GitOps and Observability | GitHub Actions, Docker, Kubernetes, Argo CD, KEDA, OpenTelemetry, Prometheus, Grafana, Trivy, Cosign, Terraform | End-to-end platform for deploying AI-ready applications with GitOps, autoscaling, observability, SBOM generation, image signing and DevSecOps controls |

From 35b4cdb7fb8299ffc48375b62cb45d50596b91db Mon Sep 17 00:00:00 2001
From: oleitao <72361786+oleitao@users.noreply.github.com>
Date: Fri, 22 May 2026 23:34:58 +0100
Subject: [PATCH 5/8] end-to-end AI devsecops with gitops delivery flow
---
DevOps-Project-41/README.md | 422 ++++++++++++++++++
.../architecture/architecture.md | 106 +++++
.../architecture/diagrams/architecture.mmd | 56 +++
.../architecture/diagrams/diagrams-1.png | Bin 0 -> 1873373 bytes
.../architecture/diagrams/diagrams-2.png | Bin 0 -> 1923407 bytes
DevOps-Project-41/docs/cleanup.md | 62 +++
DevOps-Project-41/docs/gitops.md | 78 ++++
DevOps-Project-41/docs/observability.md | 86 ++++
DevOps-Project-41/docs/security.md | 89 ++++
DevOps-Project-41/docs/setup-cloud.md | 114 +++++
DevOps-Project-41/docs/setup-local.md | 90 ++++
DevOps-Project-41/docs/troubleshooting.md | 196 ++++++++
.../docs/validation-checklist.md | 147 ++++++
DevOps-Project-41/gitops/argocd-app-dev.yaml | 34 ++
DevOps-Project-41/gitops/argocd-app-prod.yaml | 30 ++
.../infra/kind/kind-cluster.yaml | 25 ++
DevOps-Project-41/infra/terraform/README.md | 52 +++
.../k8s/base/api-deployment.yaml | 79 ++++
DevOps-Project-41/k8s/base/api-service.yaml | 18 +
DevOps-Project-41/k8s/base/configmap.yaml | 14 +
.../k8s/base/keda-scaledobject-worker.yaml | 21 +
DevOps-Project-41/k8s/base/kustomization.yaml | 17 +
DevOps-Project-41/k8s/base/namespace.yaml | 6 +
DevOps-Project-41/k8s/base/networkpolicy.yaml | 68 +++
.../k8s/base/postgres-statefulset.yaml | 92 ++++
.../k8s/base/redis-deployment.yaml | 66 +++
.../k8s/base/secret.example.yaml | 16 +
.../k8s/base/serviceaccount.yaml | 8 +
.../k8s/base/worker-deployment.yaml | 77 ++++
.../k8s/base/worker-service.yaml | 18 +
.../overlays/dev/api-deployment-patch.yaml | 18 +
.../k8s/overlays/dev/kustomization.yaml | 38 ++
.../overlays/dev/worker-deployment-patch.yaml | 18 +
.../overlays/prod/api-deployment-patch.yaml | 33 ++
.../k8s/overlays/prod/kustomization.yaml | 34 ++
.../prod/worker-deployment-patch.yaml | 18 +
.../observability/grafana-dashboard.json | 147 ++++++
.../observability/loki-values.yaml | 31 ++
.../observability/otel-collector-local.yaml | 33 ++
.../observability/otel-collector.yaml | 128 ++++++
.../observability/prometheus-local.yml | 22 +
.../observability/prometheus-values.yaml | 40 ++
DevOps-Project-41/security/cosign.md | 40 ++
.../policies/deny-privileged-containers.yaml | 27 ++
.../security/policies/require-labels.yaml | 27 ++
.../security/policies/require-non-root.yaml | 30 ++
.../policies/require-resource-limits.yaml | 32 ++
.../policies/restrict-latest-tag.yaml | 26 ++
DevOps-Project-41/security/sbom.md | 52 +++
DevOps-Project-41/security/trivy.yaml | 22 +
DevOps-Project-41/tests/load/k6-ai-jobs.js | 98 ++++
DevOps-Project-41/tests/smoke/smoke-test.sh | 109 +++++
52 files changed, 3110 insertions(+)
create mode 100644 DevOps-Project-41/README.md
create mode 100644 DevOps-Project-41/architecture/architecture.md
create mode 100644 DevOps-Project-41/architecture/diagrams/architecture.mmd
create mode 100644 DevOps-Project-41/architecture/diagrams/diagrams-1.png
create mode 100644 DevOps-Project-41/architecture/diagrams/diagrams-2.png
create mode 100644 DevOps-Project-41/docs/cleanup.md
create mode 100644 DevOps-Project-41/docs/gitops.md
create mode 100644 DevOps-Project-41/docs/observability.md
create mode 100644 DevOps-Project-41/docs/security.md
create mode 100644 DevOps-Project-41/docs/setup-cloud.md
create mode 100644 DevOps-Project-41/docs/setup-local.md
create mode 100644 DevOps-Project-41/docs/troubleshooting.md
create mode 100644 DevOps-Project-41/docs/validation-checklist.md
create mode 100644 DevOps-Project-41/gitops/argocd-app-dev.yaml
create mode 100644 DevOps-Project-41/gitops/argocd-app-prod.yaml
create mode 100644 DevOps-Project-41/infra/kind/kind-cluster.yaml
create mode 100644 DevOps-Project-41/infra/terraform/README.md
create mode 100644 DevOps-Project-41/k8s/base/api-deployment.yaml
create mode 100644 DevOps-Project-41/k8s/base/api-service.yaml
create mode 100644 DevOps-Project-41/k8s/base/configmap.yaml
create mode 100644 DevOps-Project-41/k8s/base/keda-scaledobject-worker.yaml
create mode 100644 DevOps-Project-41/k8s/base/kustomization.yaml
create mode 100644 DevOps-Project-41/k8s/base/namespace.yaml
create mode 100644 DevOps-Project-41/k8s/base/networkpolicy.yaml
create mode 100644 DevOps-Project-41/k8s/base/postgres-statefulset.yaml
create mode 100644 DevOps-Project-41/k8s/base/redis-deployment.yaml
create mode 100644 DevOps-Project-41/k8s/base/secret.example.yaml
create mode 100644 DevOps-Project-41/k8s/base/serviceaccount.yaml
create mode 100644 DevOps-Project-41/k8s/base/worker-deployment.yaml
create mode 100644 DevOps-Project-41/k8s/base/worker-service.yaml
create mode 100644 DevOps-Project-41/k8s/overlays/dev/api-deployment-patch.yaml
create mode 100644 DevOps-Project-41/k8s/overlays/dev/kustomization.yaml
create mode 100644 DevOps-Project-41/k8s/overlays/dev/worker-deployment-patch.yaml
create mode 100644 DevOps-Project-41/k8s/overlays/prod/api-deployment-patch.yaml
create mode 100644 DevOps-Project-41/k8s/overlays/prod/kustomization.yaml
create mode 100644 DevOps-Project-41/k8s/overlays/prod/worker-deployment-patch.yaml
create mode 100644 DevOps-Project-41/observability/grafana-dashboard.json
create mode 100644 DevOps-Project-41/observability/loki-values.yaml
create mode 100644 DevOps-Project-41/observability/otel-collector-local.yaml
create mode 100644 DevOps-Project-41/observability/otel-collector.yaml
create mode 100644 DevOps-Project-41/observability/prometheus-local.yml
create mode 100644 DevOps-Project-41/observability/prometheus-values.yaml
create mode 100644 DevOps-Project-41/security/cosign.md
create mode 100644 DevOps-Project-41/security/policies/deny-privileged-containers.yaml
create mode 100644 DevOps-Project-41/security/policies/require-labels.yaml
create mode 100644 DevOps-Project-41/security/policies/require-non-root.yaml
create mode 100644 DevOps-Project-41/security/policies/require-resource-limits.yaml
create mode 100644 DevOps-Project-41/security/policies/restrict-latest-tag.yaml
create mode 100644 DevOps-Project-41/security/sbom.md
create mode 100644 DevOps-Project-41/security/trivy.yaml
create mode 100644 DevOps-Project-41/tests/load/k6-ai-jobs.js
create mode 100755 DevOps-Project-41/tests/smoke/smoke-test.sh
diff --git a/DevOps-Project-41/README.md b/DevOps-Project-41/README.md
new file mode 100644
index 00000000..34a04747
--- /dev/null
+++ b/DevOps-Project-41/README.md
@@ -0,0 +1,422 @@
+# DevOps-Project-41: AI-Native DevSecOps Platform
+
+> **End-to-end platform for deploying AI-ready applications with GitOps, event-driven autoscaling, full-stack observability, SBOM generation, image signing and DevSecOps security controls on Kubernetes.**
+
+---
+
+## Overview
+
+This project demonstrates how to build and operate a production-grade AI workload delivery platform using modern DevOps and DevSecOps practices. It combines:
+
+- A **.NET 8 Minimal API** that accepts AI inference requests and queues them asynchronously
+- A **.NET 8 Worker** that consumes jobs from Redis, calls a configurable AI provider (mock, Ollama, or OpenAI-compatible), and persists results in PostgreSQL
+- **GitHub Actions** pipelines for CI, security scanning, SBOM generation, and Cosign image signing
+- **Argo CD** for GitOps-based deployment
+- **KEDA** for event-driven autoscaling based on Redis queue depth (scales to zero)
+- **OpenTelemetry + Prometheus + Grafana + Loki** for traces, metrics and logs
+- **Kyverno** admission policies for Kubernetes security governance
+- **Trivy** for vulnerability and misconfiguration scanning
+
+The platform runs entirely locally using `kind` with no cloud account required.
+
+---
+
+## Architecture
+
+```mermaid
+flowchart TD
+ DEV[Developer] -->|git push| GH[GitHub Repository]
+
+ GH --> CI[GitHub Actions CI\nbuild + test + docker]
+ CI --> SEC[Security Workflow\nTrivy scan + SARIF]
+ CI --> REL[Release Workflow\nGHCR push + SBOM + Cosign sign]
+ REL --> GHCR[GitHub Container Registry]
+ REL -->|update image digest| GITOPS[GitOps Manifests\nk8s/overlays/dev]
+
+ GITOPS --> ARGO[Argo CD\nautomated sync]
+ ARGO --> K8S[Kubernetes Cluster\nkind / EKS / AKS / GKE]
+
+ subgraph K8S [Kubernetes — ai-devsecops namespace]
+ API[ai-api\n.NET 8 Minimal API\n:8080]
+ WORKER[ai-worker\n.NET 8 Worker]
+ REDIS[(Redis\nqueue)]
+ PG[(PostgreSQL\njob results)]
+ KEDA[KEDA\nScaledObject]
+ OTEL[OTel Collector\ntraces + metrics]
+ PROM[Prometheus]
+ GRAF[Grafana\ndashboard]
+ LOKI[Loki\nlogs]
+ end
+
+ API -->|enqueue job| REDIS
+ API -->|insert row| PG
+ KEDA -->|scale based on queue depth| WORKER
+ WORKER -->|dequeue job| REDIS
+ WORKER -->|call AI provider| AI[AI Provider\nmock / Ollama / OpenAI]
+ WORKER -->|update result| PG
+ API -->|traces + metrics| OTEL
+ WORKER -->|traces + metrics| OTEL
+ OTEL --> PROM
+ OTEL --> GRAF
+ OTEL --> LOKI
+```
+
+### Flow summary
+
+| Step | What happens |
+|------|-------------|
+| 1 | Developer pushes code to GitHub |
+| 2 | CI pipeline: restore → build → test → docker build |
+| 3 | Security pipeline: Trivy filesystem + image scan → SARIF upload |
+| 4 | Release pipeline: GHCR push → SBOM → Cosign keyless sign → verify |
+| 5 | Pipeline updates image digest in `k8s/overlays/dev` |
+| 6 | Argo CD detects change → syncs to cluster |
+| 7 | `POST /ask` enqueues job to Redis, inserts row in PostgreSQL |
+| 8 | KEDA detects queue depth → scales `ai-worker` replicas |
+| 9 | Worker dequeues job → calls AI provider → updates PostgreSQL |
+| 10 | OTel exports traces/metrics to Prometheus/Grafana/Loki |
+
+---
+
+## Tools and Technologies
+
+| Tool | Purpose |
+|------|---------|
+| .NET 8 Minimal API | AI inference API service |
+| .NET 8 Worker Service | Async job processor |
+| Redis | Job queue (FIFO via Redis List) |
+| PostgreSQL | Job result persistence |
+| Docker / Docker Compose | Local development stack |
+| kind | Local Kubernetes cluster |
+| Kustomize | Kubernetes manifest management (base + overlays) |
+| Argo CD | GitOps continuous deployment |
+| KEDA | Event-driven autoscaling from Redis queue |
+| OpenTelemetry | Distributed traces, metrics and logs |
+| Prometheus | Metrics collection and alerting |
+| Grafana | Metrics and trace visualisation |
+| Loki | Log aggregation |
+| GitHub Actions | CI/CD automation |
+| GitHub Container Registry | Docker image registry |
+| Trivy | Vulnerability, secret and misconfiguration scanning |
+| Cosign | Keyless container image signing (Sigstore) |
+| Kyverno | Kubernetes admission policies |
+| Terraform | Optional cloud infrastructure (EKS/AKS/GKE) |
+
+---
+
+## Prerequisites
+
+| Tool | Version | Install |
+|------|---------|---------|
+| Docker Desktop | ≥ 24 | [docker.com](https://www.docker.com/products/docker-desktop/) |
+| kind | ≥ 0.23 | `brew install kind` |
+| kubectl | ≥ 1.29 | `brew install kubectl` |
+| Helm | ≥ 3.14 | `brew install helm` |
+| .NET SDK | 8.0 | [dotnet.microsoft.com](https://dotnet.microsoft.com/download) |
+| k6 (optional) | ≥ 0.51 | `brew install k6` |
+| Trivy (optional) | ≥ 0.52 | `brew install trivy` |
+| Cosign (optional) | ≥ 2.2 | `brew install cosign` |
+
+---
+
+## Local Development (Docker Compose)
+
+The fastest way to run the full stack locally — no Kubernetes required.
+
+```bash
+cd DevOps-Project-41/app
+
+# Start all services (api, worker, redis, postgres, otel-collector, prometheus, grafana)
+docker compose up --build
+
+# Test the API
+curl http://localhost:8080/health
+
+# Submit an AI job
+curl -X POST http://localhost:8080/ask \
+ -H "Content-Type: application/json" \
+ -d '{"prompt":"Explain GitOps in simple terms","model":"mock-devops-model"}'
+
+# Check job result (replace JOB_ID)
+curl http://localhost:8080/jobs/JOB_ID
+
+# Run smoke tests
+cd ../tests/smoke && bash smoke-test.sh
+
+# Open Grafana (admin/admin)
+open http://localhost:3000
+
+# Open Prometheus
+open http://localhost:9091
+
+# Tear down
+docker compose down -v
+```
+
+---
+
+## Kubernetes Deployment (kind)
+
+### 1. Create the cluster
+
+```bash
+kind create cluster --config DevOps-Project-41/infra/kind/kind-cluster.yaml
+kubectl cluster-info
+kubectl get nodes
+```
+
+### 2. Create the postgres secret
+
+```bash
+kubectl create namespace ai-devsecops
+kubectl -n ai-devsecops create secret generic postgres-secret \
+ --from-literal=password=aiops-dev-password
+```
+
+### 3. Deploy with Kustomize (without Argo CD)
+
+```bash
+kubectl apply -k DevOps-Project-41/k8s/overlays/dev
+kubectl -n ai-devsecops get pods -w
+```
+
+### 4. Access the API
+
+```bash
+kubectl -n ai-devsecops port-forward svc/ai-api 8080:80
+curl http://localhost:8080/health
+```
+
+---
+
+## GitOps with Argo CD
+
+```bash
+# Install Argo CD
+kubectl create namespace argocd
+kubectl apply -n argocd --server-side --force-conflicts \
+ -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
+
+# Wait for Argo CD to be ready
+kubectl -n argocd wait --for=condition=Available deployment/argocd-server --timeout=120s
+
+# Get initial admin password
+kubectl -n argocd get secret argocd-initial-admin-secret \
+ -o jsonpath="{.data.password}" | base64 -d
+
+# Port-forward the UI
+kubectl -n argocd port-forward svc/argocd-server 8080:443
+
+# Apply Argo CD Application (edit GITHUB_OWNER first)
+kubectl apply -f DevOps-Project-41/gitops/argocd-app-dev.yaml
+
+# Watch sync status
+kubectl -n argocd get applications
+```
+
+> Edit `gitops/argocd-app-dev.yaml` and replace `GITHUB_OWNER` with your GitHub username before applying.
+
+---
+
+## Event-Driven Autoscaling with KEDA
+
+```bash
+# Install KEDA
+helm repo add kedacore https://kedacore.github.io/charts
+helm repo update
+helm install keda kedacore/keda --namespace keda --create-namespace
+
+# Verify KEDA operator is running
+kubectl -n keda get pods
+
+# The ScaledObject is already included in k8s/base/keda-scaledobject-worker.yaml
+# After deployment, watch autoscaling:
+kubectl -n ai-devsecops get scaledobject
+kubectl -n ai-devsecops get hpa
+kubectl -n ai-devsecops get deploy ai-worker -w
+
+# Generate load to trigger scaling
+cd DevOps-Project-41/tests/load
+API_URL=http://localhost:8080 k6 run k6-ai-jobs.js
+```
+
+The worker scales from 0 to up to 10 replicas when the `ai-jobs` Redis list grows, and scales back to 0 when the queue is empty.
+
+---
+
+## Observability
+
+### Install Prometheus + Grafana (Helm)
+
+```bash
+helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
+helm repo update
+helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
+ --namespace monitoring --create-namespace \
+ -f DevOps-Project-41/observability/prometheus-values.yaml
+
+kubectl -n monitoring port-forward svc/kube-prometheus-stack-grafana 3000:80
+```
+
+### Install Loki (Helm)
+
+```bash
+helm repo add grafana https://grafana.github.io/helm-charts
+helm install loki grafana/loki-stack \
+ --namespace monitoring \
+ -f DevOps-Project-41/observability/loki-values.yaml
+```
+
+### Import Grafana Dashboard
+
+1. Open Grafana at `http://localhost:3000` (admin/admin)
+2. Go to **Dashboards → Import**
+3. Upload `DevOps-Project-41/observability/grafana-dashboard.json`
+
+### Dashboard panels
+
+- API Request Rate
+- API P95 Latency
+- Job Queue Depth
+- Worker Replica Count
+- Job Success / Failure Rate
+- AI Provider Duration (p95)
+- Redis Availability
+- PostgreSQL Availability
+
+---
+
+## Supply Chain Security
+
+### Trivy scanning
+
+```bash
+# Filesystem scan (source + configs)
+trivy fs DevOps-Project-41 --severity HIGH,CRITICAL
+
+# Kubernetes manifest scan
+trivy config DevOps-Project-41/k8s
+
+# Image scan
+trivy image ghcr.io/GITHUB_OWNER/ai-api:1.0.0
+```
+
+### SBOM generation
+
+```bash
+trivy image --format spdx-json --output sbom-api.spdx.json \
+ ghcr.io/GITHUB_OWNER/ai-api:1.0.0
+```
+
+See [security/sbom.md](security/sbom.md) for full details.
+
+### Cosign image verification
+
+```bash
+cosign verify \
+ ghcr.io/GITHUB_OWNER/ai-api:1.0.0 \
+ --certificate-identity-regexp "https://github.com/GITHUB_OWNER/DevOps-Projects/.github/workflows/release.yml.*" \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com
+```
+
+See [security/cosign.md](security/cosign.md) for full details.
+
+### Kyverno admission policies
+
+```bash
+# Install Kyverno
+helm repo add kyverno https://kyverno.github.io/kyverno
+helm install kyverno kyverno/kyverno --namespace kyverno --create-namespace
+
+# Apply policies
+kubectl apply -f DevOps-Project-41/security/policies/
+
+# Test — this should be blocked
+kubectl -n ai-devsecops run bad-pod --image=nginx:latest --privileged=true
+```
+
+Policies enforce: no privileged containers, runAsNonRoot, resource limits, no latest tag, required labels.
+
+---
+
+## Validation
+
+```bash
+# 1. API health
+curl http://localhost:8080/health # expects {"status":"healthy"}
+
+# 2. Full job flow
+curl -X POST http://localhost:8080/ask \
+ -H "Content-Type: application/json" \
+ -d '{"prompt":"What is KEDA?","model":"mock-devops-model"}'
+# then GET /jobs/{jobId} until status=completed
+
+# 3. Kubernetes pods
+kubectl -n ai-devsecops get pods # all Running
+
+# 4. Argo CD sync
+kubectl -n argocd get applications # Synced + Healthy
+
+# 5. KEDA scaling
+kubectl -n ai-devsecops get scaledobject # READY=True
+
+# 6. Prometheus targets
+open http://localhost:9091/targets # all UP
+
+# 7. Cosign verify
+cosign verify ghcr.io/GITHUB_OWNER/ai-api:1.0.0 \
+ --certificate-identity-regexp ".*release.yml.*" \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com
+```
+
+See [docs/validation-checklist.md](docs/validation-checklist.md) for the full checklist.
+
+---
+
+## Troubleshooting
+
+| Problem | Cause | Fix |
+|---------|-------|-----|
+| `docker compose up` fails | Port 8080 in use | `lsof -i :8080` and stop conflicting process |
+| API returns 503 on `/ready` | Redis or PostgreSQL not ready | Wait for containers to start; check `docker compose logs redis` |
+| Worker not processing jobs | Wrong Redis connection string | Verify `REDIS_CONNECTION_STRING` in env |
+| KEDA not scaling | Redis address mismatch in ScaledObject | Check `address` field in `keda-scaledobject-worker.yaml` |
+| Argo CD not syncing | Wrong `repoURL` or `path` | Edit `gitops/argocd-app-dev.yaml` with correct values |
+| Cosign verify fails | Wrong workflow identity | Check `--certificate-identity-regexp` matches your repo path |
+| kind cluster not starting | Docker not running | Start Docker Desktop first |
+
+See [docs/troubleshooting.md](docs/troubleshooting.md) for detailed guidance.
+
+---
+
+## Cleanup
+
+```bash
+# Remove Argo CD application
+kubectl delete -f DevOps-Project-41/gitops/argocd-app-dev.yaml --ignore-not-found=true
+
+# Remove namespaces
+kubectl delete namespace ai-devsecops --ignore-not-found=true
+kubectl delete namespace argocd --ignore-not-found=true
+kubectl delete namespace keda --ignore-not-found=true
+kubectl delete namespace monitoring --ignore-not-found=true
+
+# Delete kind cluster
+kind delete cluster --name ai-devsecops
+
+# Stop Docker Compose
+cd DevOps-Project-41/app && docker compose down -v
+```
+
+---
+
+## Future Improvements
+
+- Add AKS / EKS / GKE Terraform modules in `infra/terraform/`
+- Integrate External Secrets Operator with a cloud secret manager
+- Add Istio or Linkerd service mesh for mTLS and traffic management
+- Add canary deployment with Argo Rollouts
+- Define SLOs with Sloth or Pyrra
+- Add a model gateway for multi-provider routing with rate limiting
+- Add Kyverno policy to require signed images at admission time
+- Add cost dashboard for AI workload compute
diff --git a/DevOps-Project-41/architecture/architecture.md b/DevOps-Project-41/architecture/architecture.md
new file mode 100644
index 00000000..18a1dd52
--- /dev/null
+++ b/DevOps-Project-41/architecture/architecture.md
@@ -0,0 +1,106 @@
+# Architecture — AI-Native DevSecOps Platform
+
+## System overview
+
+This platform is designed around three axes:
+
+1. **Developer workflow** — git push triggers automated CI/CD with security gates
+2. **Runtime platform** — Kubernetes with GitOps, event-driven autoscaling and observability
+3. **Security posture** — supply chain security from code to deployment
+
+## Components
+
+### Application layer
+
+| Component | Type | Responsibility |
+|-----------|------|---------------|
+| `ai-api` | .NET 8 Minimal API | Accept HTTP requests, validate, enqueue to Redis, return job status |
+| `ai-worker` | .NET 8 Worker Service | Dequeue jobs, invoke AI provider, persist results, emit telemetry |
+| `AiProvider` | .NET 8 Class Library | Abstraction over MockLLM, Ollama, and OpenAI-compatible providers |
+
+### Infrastructure layer
+
+| Component | Purpose |
+|-----------|---------|
+| Redis 7.2 | FIFO job queue using Redis List (`ai-jobs`) |
+| PostgreSQL 16 | Persistent job state (`ai_jobs` table) |
+| kind (local) | Local Kubernetes cluster with 1 control-plane + 2 workers |
+
+### CI/CD layer
+
+| Workflow | Trigger | Key jobs |
+|----------|---------|----------|
+| `ci.yml` | push / PR | restore → build → test → docker-build |
+| `security.yml` | push / weekly schedule | trivy-fs → trivy-config → trivy-image → upload-sarif |
+| `release.yml` | version tag / manual | build+push GHCR → sign → SBOM → verify |
+
+### GitOps layer
+
+| Component | Configuration |
+|-----------|--------------|
+| Argo CD | Watches `k8s/overlays/dev`, auto-syncs on digest change |
+| Kustomize | `k8s/base` + `k8s/overlays/{dev,prod}` for environment-specific config |
+| KEDA | `ScaledObject` on Redis list `ai-jobs` — scales worker 0→10 replicas |
+
+### Observability layer
+
+```
+Application SDK (OpenTelemetry)
+ → OpenTelemetry Collector (OTLP gRPC)
+ → Prometheus exporter (:8889)
+ → Logging exporter
+ → Tempo/Loki (optional)
+Prometheus scrapes OTel Collector
+Grafana queries Prometheus + Loki
+```
+
+### Security layer
+
+| Control | Where applied |
+|---------|--------------|
+| Trivy scan | GitHub Actions + local CLI |
+| SBOM generation | GitHub Actions release pipeline |
+| Cosign keyless signing | GitHub Actions OIDC → Sigstore Rekor |
+| Kyverno policies | Kubernetes admission webhook |
+| Non-root containers | All Kubernetes workloads |
+| Network policies | Restrict pod-to-pod communication |
+| Secret separation | Kubernetes Secrets (dev) / ESO (prod) |
+
+## Data flow — job lifecycle
+
+```
+POST /ask
+ → API validates request
+ → API inserts row in PostgreSQL (status=queued)
+ → API pushes JSON payload to Redis list ai-jobs
+ → API returns 202 Accepted with jobId
+
+KEDA (every 10s)
+ → reads Redis list length
+ → adjusts HPA target replicas (0–10)
+
+Worker (continuous)
+ → pops job from Redis (blocking)
+ → updates PostgreSQL (status=processing)
+ → calls AI provider
+ → updates PostgreSQL (status=completed/failed, result, duration_ms)
+ → emits OTel span + metrics
+
+GET /jobs/{jobId}
+ → API reads from PostgreSQL
+ → returns current status + result
+```
+
+## Overlay strategy
+
+```
+k8s/base/ → all resources at default scale
+k8s/overlays/dev/ → small resources, mock AI, imagePullPolicy Always
+k8s/overlays/prod/ → 2 replicas, full resource limits, manual Argo CD sync
+```
+
+## Security boundary
+
+Kyverno enforces at admission time so no non-compliant Pod can ever be scheduled, regardless of who applies the manifest.
+
+Network policies restrict lateral movement between Pods. The `ai-api` and `ai-worker` can only talk to Redis, PostgreSQL, and the OTel Collector — not to each other directly.
diff --git a/DevOps-Project-41/architecture/diagrams/architecture.mmd b/DevOps-Project-41/architecture/diagrams/architecture.mmd
new file mode 100644
index 00000000..fc057db0
--- /dev/null
+++ b/DevOps-Project-41/architecture/diagrams/architecture.mmd
@@ -0,0 +1,56 @@
+flowchart TD
+ DEV[Developer] -->|git push| GH[GitHub Repository]
+
+ subgraph CI_CD [CI/CD — GitHub Actions]
+ CI[ci.yml\nbuild + test]
+ SEC[security.yml\nTrivy scan + SARIF]
+ REL[release.yml\nGHCR push + SBOM + Cosign]
+ end
+
+ GH --> CI
+ GH --> SEC
+ GH --> REL
+ REL --> GHCR[GitHub Container Registry\nghcr.io/owner/ai-api\nghcr.io/owner/ai-worker]
+ REL -->|update image digest| MANIFESTS[k8s/overlays/dev\nkustomization.yaml]
+
+ MANIFESTS --> ARGO[Argo CD\nautomated reconciliation]
+
+ subgraph K8S [Kubernetes Cluster — ai-devsecops namespace]
+ direction TB
+ API[ai-api\n.NET 8 Minimal API\nPORT 8080]
+ WORKER[ai-worker\n.NET 8 Worker\nPORT 9090 metrics]
+ REDIS[(Redis 7.2\nJob Queue)]
+ PG[(PostgreSQL 16\nJob Results)]
+ KEDA_OBJ[KEDA ScaledObject\nmin=0 max=10]
+ OTEL[OTel Collector\n:4317 gRPC :4318 HTTP]
+ PROM[Prometheus\n:9090]
+ GRAF[Grafana\n:3000]
+ LOKI[Loki\n:3100]
+ end
+
+ ARGO --> K8S
+
+ API -->|1. enqueue job| REDIS
+ API -->|2. insert row| PG
+ KEDA_OBJ -->|scale replicas| WORKER
+ REDIS -->|3. dequeue job| WORKER
+ WORKER -->|4. call provider| AIPROV[AI Provider\nmock / Ollama /\nOpenAI-compatible]
+ WORKER -->|5. update result| PG
+ API -->|OTLP traces + metrics| OTEL
+ WORKER -->|OTLP traces + metrics| OTEL
+ OTEL -->|scrape| PROM
+ OTEL --> LOKI
+ PROM --> GRAF
+ LOKI --> GRAF
+
+ subgraph SECURITY [Supply Chain Security]
+ TRIVY[Trivy\nfs + image + config]
+ SBOM_BOX[SBOM\nSPDX + CycloneDX]
+ COSIGN[Cosign\nkeyless signing]
+ KYVERNO[Kyverno\nadmission policies]
+ end
+
+ REL --> TRIVY
+ REL --> SBOM_BOX
+ REL --> COSIGN
+ KYVERNO -.->|admission webhook| K8S
diff --git a/DevOps-Project-41/architecture/diagrams/diagrams-1.png b/DevOps-Project-41/architecture/diagrams/diagrams-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..d26e90802cf5580ac263f01b84731f717ea7d032
GIT binary patch
literal 1873373
zcmeFa2UwHM);3D-NSCUHEj6eNmjzf;bxYe^}I<#^o~O6WB4)Trfd$cyYoi{sikQGVs&e?8**H8;`%+
zL}V-R*EVO7{(ld!x2$1v^fTYfxxk#4MRfspW|>a2pokl$6}EXBo<|kz~Ufq1k@akg%goz
zm^lWEg}|X`b2JtPLBg@-2s8wS!P;TXjH$2d5Gh`^xD;RqN6hDDm=;8+L(
zi2%z&U>MLup@9}Ka~Ki}K1P_MaA+bN16GIf#lgWeB+vq7jzmG=Kv_5r4uKJSEgCimlz*A@p1O-Qcc>sAd@CY2J10YB6J%t8O
z7+*d96GdF;=ePOefM@6DK^DY-W#+|V7nea37nTJsnOB3qEMF~v&b*qyir)eU359zt
z29O^WiGc#w12~XK5J_kv5{m|HU_MwE1Qrqp)a3^Z7H*CP)+2FH5I8_C6!gR3jz}2F
z9K{bnFdT{m(_wJX&vz<_Bp6>mtT_q_#Q)U>0m+Yvh4GGjJ@}|1U_cGH7aW5DsG*6!
zy5Sgpqr%biI0FE1=4c#(2uGvL;ZOh|a1;Vq1_v2C
zZwUx{^n9kHL3Z#{lAn4YB@w_9kShQu5bFR%6n_#F4!jN806rKT2%Gl`!Vv-bp@0_n
z=;II=BAlP+AYtd7iw6BTzO(sl02d#=zuFjc3=Zps07?Sb9N{1+p=f{t3Pb^*9XJ{d
zyo~^Dm^ljY!!M%{NHCtS?4pqfknvC$#%sY?*y0lbP?)!d?^nKcNFp4HGY7r^3Lrt4
z0fpd5b1X2B9}hUbA3=1&`Thn9;cz%F1PWvm0B^CIj{`r6;7Gt%aF7LP1V}cZ2VWl$
z%m@H73I}sUAOHga?;^pnpby}~k4_K{AZ(y8050GNB%c<6w_)JPk8&LPR~vX23IjOe
z7RJLJ0mlLDz%ZaS2nb*WfENvh0S3bW)`j6f^dk7Q2?!8~`4_7J4D(5cS_nrX9FQ@F
zzd8zt1vJVJRv4g^dF?R3%K#J{zYTH>APlqzIfnynK%i*8@(9oki~%7Get@zd7=V-i
zYJ(I*01{sqz8KkHI3Ox43hjjifdxb2h`;s#Bp@=deE{Qt58-f-LcsTn#^P`QL$DWM
z5jaO65_}FY0StkH!hsqvkSV|>Fb@Id!O{HZB9rh_c;03pkDr2eiwFQ7Lh*TsFBS!i
z1UnUIBhkP(K<@~!ED{hJe;E`|7Yf9I-4D1Lurr@_`8yR@1?*fXknk|D$shnGFyIRQ
z=Qsq20AL*o@F5Cd27~f>3jqiPXgwcDSU?yc6F}t5V-NTX3FKe^=3rw25e~Hd)dsG_
zVlZAHD}YrvMyvGhy)Rb1{r~v&nW)<1+x$!V-S4e`K!$*G@RFhxv0e_
z+>g3o5KwsDJ{S?j4@o|}a2QBI;5|UVSQtM9!NPpJ00ROS&Ep0b5o88Pbv_X<_Va<@
zZ@x&7Ie-P{Qx*r~w?V4#LmVgsK{G-xk+
z7tj+1h?#GL{eX`rhQHe=NG#Yspuh~EHX!eLGq8N(2HPMOj7I?iNAdjxm<8-1
zi+=peY`8dWH5N)%;?SpK(J^t
zD9Q{D_kcnz=l}Rcabv;~c(iyXp2dt#sA5FZqCn{vn_xCyD6*26@mx@Y(g6dNB*fF$(M$%O
z!-|P#Cxl0_=~O0zO{-ee87u?pSWw1-RsXZrzh)$ZVi!!|0VCt1Xz`IuhIwLiXiQ@C
ze==D>K>E}o`jmf7*nmXOG=!xxI{YxQSu6K`;bEw^mly{dgi2?%7*
zdm%iA&W-h=akv>NqFGc5g>1))A>bW|L;``%W3gO42&~9>0*h=zaHaF`u4FRkBlE}M
zNkl?S9L1YJaC7x`^?~`IT%%yHc&9ix(%vBj7DaaRfSQD}T&NNF6sHJ0j2b~sc1q$pHY5}^jDm6{A{ktIvbRY*9PNV+Cna)-BzpqE
zgTzg7_ja;(4)
zq%9s#_&Yt^XfXt`FDaG6qel4hoIEHP|q5#Ik
zusA$en@q%qyCu5$Iib-BNI2asIx)vBkqY$%Jq!
z(aVdPO!mOL5LixL1U!pMB%?Tt6oOwU!aknrM&^-tz9cVKS343u+}Y2E&hqxgrO;Bz
z9#jH(UIvlHbMUgWhxtWeS?=Db2*(JrCzU{1_>9MMbcK>xZnhja&BrM=6pl=`jllbY
zxB>XFZE-v(f#^#1Cc>S4S6gQciig#O(
z5kn<%7VMyqIf=e0$YdK5-hJUSE|my%0{XkfGNL%CG;drY)h&i+i}$s6i1DL@C()ho
zu8{;ARzifYCjq)(Gb)_sL5`&o>GSL2c|6uHtX)&>U0vM?F0L>qSBi~2hmIg5g+{q?
z=xltSwN;ZdGQ=Y;qqay*jb=$T-GV#LFUWL7f8F_jn}2lI`>L?J9(oXO5^pj6>l
z*r2g4u?%ty($JC$rp!h5s+Q$CaM)cLV)R02K>
zOU6gw@l-r=VI3ZsV8L@G1J1Bxfs9#@=}9J$=5zKJSCNSG@%}q^d6MxgPar48mPm}V
zb*Cb61U!|5hezV6w!TEFD>2Ll>lhXmlL#j>j4YlJC+~RFx32|H^%EmJi2Sui$e0-xRkya_9SNZhn$y=mhGKdG3
z8|80km`@R8Cy!q=?BNKfAmMB_*^Qo(g2EtUXfg0OW|EB))%9=b&+p&UFa^)Tg{HFY
zojD0fPJT(r;q+KPA6{gNiye+Ya3#UKOp?N4TtK42{8&VWgOdx+#)6q(&jd6~b!LZS
z9lY&bqmtYceeEf3s7OBq!54w&CjJ{5_WE};4ErBwnCBD?XgCrdL5PbdgeGG-o@j5I
z#Bf*=j)A88!F^yU_KXxPCDz&18I_s{^C6?)j4&@pT3AY0q8Bcb?vlcG^kw?`MpKDY
za$-b^haG|w!Hh!1d3pFmdH5tJLPHZG(P)qb$sTczaTtpTbeJ`8kDSX@IJe`CBB_1NebNN(Jp$Zx#qkz_1igJdS{KvbRt0O6KsA6Ih;+4Yf9u5~|LTh%5~3siJ8WMR9tFR+
zeGxdnwy#ATa3oI{Ae$(!RJtuL(a+W^g=*)^BjbJT7X41dyZYKVAjxDrkwWri;mN*9
zVLqV_L{dndR@mMaWiqmVUwt-;Z7br
zL_E|sfdu#-OCvZ(lbty<8z;Y*xFi~QuwGc6pSL~C(JL87jpI5n$)q?6JKixhjEDBLGx0_s{gBD_iSePNI2^%|>EYtd
z^K^Hyv&CCkfdagHv2wTm_mz7%jT>&xpe4}$D%k6O)cCkhN%r2#H4O~T|LhMwSjR8e
z4@EGO>6}FV8xA+UiV4d6_yjfWFb*@Cl@P8@3!8r%isr8v28p6Yv%|m;I*r2$&**REn7e{Q?4<)+JO-?aBg;Lb5Bf*d;n$4l-!^&0yb>V9WI
zF8sfe`#-+IF3Nvj41MQoxc6w@gHfR`>#KZIF4G1M?|M!(tEiATge*u}kZ<%~$^R?S
z7R5r1{7u9Jc;3bBS(kjeOUqAf8?C;PSuVH};eVY&yBQJApj#%e6F5xv{NaLmY&7e)
zY!VP44=!fL%Eiq1n`^PZJP4ABT+UC8kt|5=}RUDMJ6DY)QCSrSVm_-3=!^vhY4{-;9&yHq6
zfT)$=+y-R?U9Kt%PQ1bK8)$(aKhVU0Cb$QgZ$fSV*${Q8>Vk^$i%ISrON=pvI64wx
z%b=PI%PRj}{zHACN@C*k00{{$A|)&+E=&**v9BlH7yY@X!+C8lM|~#xM7H+hlqZ9Z
zcZ^hGziQ^Ny7CePcX8huytmjm)m9O=M}dxTGW4S5vDa_I);N4Ojyaei=m*UZ^n$Jc
z>o3z4QG+f`Q|@ln+k-5q^nK>07Fn)3Rd;H<*%7*oKVXT-QW0e-Uf}k-9!i6o-pXO(
z{4Q-N@P*xk%J4r{5)}L?A_NtHF5$Q2_$?7(A+gQ{>H2)>Vp1kzf+B*MPlEWSfWM21
ziVN#O_4yMZBFmv#K+<1B1O0KIXqX
z3N`s_h*0*5Uqk4$zXr_HkQwNp$0tA$Vv@hq6A>|x(SyQpp>!k^VTPhJFlHDu8fQie
zW1`K#y)6vf#$g!Xq7$nAk4eHJziBTl^?O_kvjw1GLNI}!kJ3X?kza=~v(7i=&r0Ri
z$<>J5@QN~f#?l-+m*f4a%=oDIhIPh!{9H?_w!S)a4NPfDLr1cnkVxW1jRS&NXJ7bV
zxwFi7d-4|H!6i%+m9Gg0PWLXWADY|PKHIs~AxQ=6D|`ba-F5KevzTO<+JBDK|D{Q13N;4q2T7;teKh%8fjy
zuE=YJ(
zIxKJi2}<}a>w`en!~dGf=KwvZ&aX*Af=ieFO6gw-Z$Xcx$8!ItFhx8F$zM$I+m7`s
zv&W==7~7p@gb8^Cs2v?!R#zOp;oOtC_ETrA6IL}|_@r({FE%@}U7YzTeY!rv{uv8o
z_H#CB^ky%+&S|*Sbg9wkRK?NdU+b(doi5$-s$8j|+-%=Fq-bI2Y3cMQFJ#+u-yJ{5
z0=umTHvwMBPT(eZ#6&VdnOsRvqH%ncw?i|OxBRuSi3?R^C?|ui1R+5|m>g7AOfn?f
zPLL@qA|xm(08QT_CjDy*lou4KNES(g@<;wc`aBdM4f~f_B9MJg^-7;q`ppHyhK5;2
zBK9lr#^VotT9D4+>*Z*~aXlePf%IDq<}PAx@70#_FE^klI2;D_6tHyHR%z%VVDEe3Yc_EjgaW?KEFs=VH6E;`nR!ir39sj8c7n$SEtM2%jg|ZmRYW
z%$xA5a&u-UGxk6<9Tb+>CAIR3R|dWb{&`WIEaHiH7$JKbyeGz@$?B6#4K>
z*XrV|#9i^lo@4!zSm$`f9bxsBnvV^^*WZjVE=)sZhE`zqI{fOta`Ht*2YeBW$trL{-+A=
zjq>Z}i>e-0y2Q=5FLQ_D)2!NSe~%52LLfHM1t4GbobjEVXNr%(6(RC@kbQP2MduzH
zuZ@S-tUYl^_p`Cxnkm<^6=dt=3#T)k3S2JjH{26`k2?6~gVsn&!>eZ(R!pvaU+gRK
zD0U#{-Ami;^;c$p=5AD;wz;|MQEB_~8Klim+DP)J4OrX*w-=hhVNbZ70`aS6Y8i
zHH{Bqv_G%9w=TfrrsKs3V~0)GhzTL0Y21UChIvP~)O~Mj$%=?RztwoKq_U);syxF^
zTn5uCM0eeL%WZwka@Hlb1Yzvj`>#!!LECrUpOQ#fZLX=58Bu=di9UYEW_iIEGinzq
zEc1Spxw&Y(+H!ZAG2r=G8*=Z2(W?W7(Zi>ZHVyVV&W}S?cz4LUcW7o^Frytk9UD`G
z#2Su!k6xI)woGg~U>%dvtp1$ZP<_$R+NtJv+m37NyN?qOCtVMM(qGo99zm^4U^Mul
z9{5{Wu5ps0jYFx#{Xd<|`?NdOSeioAy`hN_`3#4uA<5yTH!4^L_$byV;n<{heFLx0
zy9^Aw9VpNY(T#xl!WNlNBv0gWU$1oUquV
z8@Vi2Vo=EZ>&`!DPfZRE0}KzY@P85C|5jH1ujnu5%xK5`{z~a{w9|K2Gr~3=K35tg
zf4vSWZm?S9-Bct5cdrQfqOOW5VRs9Y4bRNKDrH~M`4-8k=qw&EIdJIRmGu*H26s1J
zqE@}4`|o=6!MAoq@Qc&u39O0Vl7SGv;`HL$8}5A%)W^;38j0SLPYd7Je&&$3sf3*T
zc*G*83gxsJyP16eSvEwyNwgY3Mt!3fe7hcd+Wtw>lL)6UO2vzBpVZF1Dp5UI%WHgf
z5_`2{)ELo|BsXrYz`OF}VxX#-_4T{kS9hw7TPx%DT`62^xB`qm_Dl+b-gEl@sQQ#FkL4igTCs^37kJuO6m8X^k=b<$qt-->b61vT
z=_y9PTyxd3jV0($(?c1pF2rL~o@dhv`+eP<>=W(=UL^bMKlue(zv{N_nK5(I4y+p8
zG{x`a9nU5!!o97+*XF{1a0N#FPTtiM+PkasG3&?e8mYI3Y!q5uwKgeU%R3tUK&`*s
zy6^0R&*KhpLyW+Oj4p@HAl|UmMgMY@YwNxj7CshqkZ_&+%6QkH@ETP(_u|lO=bK
zl~}plap~T}$d77};T~8iwn~IIwD&~}*|`N_9&GbG^F$qO#hDu!M?R&+bUk3JKp#b|
zwQ!rN4d<>%$Y2ty~*qdqdWU?zF=~A8I6>Tj!@;PQpMYeg`8D9tLe&UL#
z<2SOVzP%pnr|py_<|{>%bR!J!uR`5
zTjI0dy>=DcvRvZqXqyR&BPPGQdR1gZ*{ScHtHeu_x@5-GaGNNT4+^!`xJX3YaJ-*G
zD|_|)T5q>?bZjaXFz_S5z=MkntmLwalau>ePZ(jkI2v;9Ldif%$!$)_4|K_e;-%GVXH3gZ9XjN~ZI$Z!tns#oVv&4yWcJMC
zweR*1YskH+pjDaX=wEgfQ&9b^FJC&+XYnql2d}u~hr#meQK*60)2I;Z+Rd)#KD^vn
zJZF5fELA=-V#0ia{`R4AfKhL@&V_AS;RDaH<#jH5{B?<6u|EmxS6NoNTIgT0yAAv4
z694k``byq_T1-0a?b`FvPewFbY9!Dzk?^lSc(pgt^W0ry7T
zvl*KqEw9eKSXRjscxh>as4Aa5`cw=Sc04TRSWZF3#-b&1+JpE{L9g?l4s>*mAp(ccx;hK4HQknDZ29n2!f9*L`!%nA?l#@WGnf7#cDwjQnWV7g_jekS
z7k32vJ>6(0D{0cUd|Rfb$Qyr^)u!^oM|V|~;ZM4S56dcOsGo1{oOBL3`_icX#g@Fd
zfR(vzeRVg)&ULwmsK}B+Kk4b%I4O5Y_@{DwpO(Vx9++&^ns&G@JvB{pdfgj@yOJC_Jw*y?@vRfgxr2k$nhJ(n~1mibe=_Cn*`
zTbcz8>H|a2BsI%|vcY
zpBUCYp0+wy?fGQsxP9H22h%@t*k@xWWy|yD4#B%+Ru$eT$^KC3J81hF+t}T_NjQNq
z-08NevSr=M$mu2Qy`O~CWLB!KGW>a@TE6@v^Ge0LXB}q?2TAvfTcqs5UwYPQ9}3y=
zMWq8mYmF~_^?Iz9BA%~sboSw`>#;vhklj@_PJ_SO8n)Q1sI6J3G{F<`bbgSPfs+>7Rqd!No+
z>014*k$3XMZO8@3r#Z*uj#+H#-ng4lD$$m8_v)VSIn54j#0?`)o3g*FPnaDzEAX=O
zxUHA&dOMX}OPu6bIgoXs5*IE7dCq+h>vPrhj(N4^oHE2Fb}M!&*ONBt0)&=D^?@4wrAsH!K#Mkf`Tz8AWw
zdc*&rxczoEB$6SRhI)RS>z1*ZIXyl6k$8*vkc8W#@O-byHdrN5!EtS}2KRhS%Am>i
zxTVmj_=0CTZ*@PvEc&7*-D>A~>(Gq{&GuVv4(lI{@4oUbI8`vOxa4%g%f#wQ_34w9
zDZ{Dq)(zQ@Lv~-dJLRMtqn>}^
zo@`|F;=bc1$@fWe%Sv~A?ZIj71#SvO>rH|(3&hLb{P
zHewJ~J(UD`R{8-N;T?`URR6^Bp}ub4{^r}6g(6$ZE|h7vRKs>ny}BEU^ziP>?!2&d
z1N`0^N$hsVg@W0wjVf!=Dw>tebDQz
zmOa)ip9(~st;i4-1f|k<@bek`Y+NiyqsaxS&+ek*)L*V2Z|&ciNjx|^`I`d&{Vq$v
zg$?-khv46D@GnczP&5E_p$Sbt^v?$y|4VP6jG%@K=N^#XZlIir?k*5FCN~y*n}Qkt
z>(ck%-^Jef4&Hy4M%+2IIscooCFk~e&Tb8%?+^RK+%ubw%$eMG8$J0l@Tf~xrlRy!
z<7Z7~O>
z{_oN*3Vky0zKCrn>s>QgS^WasZcHDXRe0d4+4J>_=CV71pXlK2Z28b{6XLk0)G(rgTNmI{vhxNfjfA~EMDi=6}He9md5;Zaw
zxwd7hKTn}+$x@j7DT(p^(*&434W_uNyY>goK})b^xqzU600sP3!Q|4??9W6DQ|dd
z#@4Eecokip%QvktR%ag|%H2KJ-C6sgB&qDJ;`vuAV-6g48oL|$t%>%kUdKg9&2guk
zpy^LhL4hlq1jV|42nb&F`M&c2ak{zm)*$gi!LpODv<6Gpt3hHRtFwGS~qd9EQ)gwpP9H^sLv)
z2-UrkHDyB6;x2Jly??+x=lFWj4%c?{NKWG6)3*=XsF0p1a#b{m>-AGMZj(NE?BHW3
zyWBl3IkI(J?O@RYCIpEf+Q|{f-`Mc^29`@?XIhVy$m*v`Bs_{
z@D$Rd-hZiCJLTb|*DI|v?de~}?39z=y!AgYB}rNR-s>=f8X&1~IX|v~vkWf3t3+-4
z*iQBuH8oU0BhxU0d+^c`+P?DbfInn~*hwb%z5&j+w0L%Z)3r)Hf=+`fFPUv^MCIB?nf
zaiIP>aM-5lY=cN$MzXsNsqejit;-rv@qC
zgWDdBn&+S(8|B?+tFKyQA(Q&I}mL!Au}_qpiZeQd|r)wZgV
z*Of`RCi|Z=YQwC{%TN3WF)1~o%&i|;>)m}oJg4Zuw-D<$DyPPWYBr(M8i&OzP9S}h
zipQ6%67BF*pxQ|ZbYzc)6NEm}%&nrVBAOtV4(~iKB#{!QYE+`*yfvsN%S1HX!dueh
zhYIV{s14!yjDYH{&yLS4(Hd`uE8rhLA;U5pwR>k6z`El97(1Zw#Wvd@v;kmis(-p1K-^HeG(qI(TCS7b?o%HpF8Lsy6UdpHHHEG1Hls7VPDwv8Rd`TbZUiZu6!(v
zRM?iLc2U7r8lK~_=Fq;z%_nzX+)r)PDsLBVJC$G3(Pe0(fj%d6x#=zDK>Cf--~D!S
z4@;u$UWhbZG}5f>3P!j`ksGCbX%S&PLB!G~&z56n2^4N%^{KX_Ds10vUYnv^b)8z4
zUtH7nHOpP|uI}BV7Sb(VCk4~S>85RrIHe71i_RiFY`0As>z~LsG&~{RZ(g?Y?4e*`
zOVgbwJ(Td1oP15HZG{SsdOj$}LpamxfLR9JxHav|^Nm6Jq4z({JrLZkG1pfv>|ea3
zir(n{g!H84uv!3x9uujyYjl(RlcQZ9Qtgdx8YgEDYi!ssI+b=eEh02j$lc6#Q|qfH
zDR$SuskOD={ofoZdV8cW>if?(`#kQLJ!(?>m}T4wN58GZwi+lqSp*A1O?4Y3SwF4A
zw%%TMNw(3duDs(&%du@?bB3+Y+Cp23DQ~)~^V-);_z-W2=$O*ex&qfVJ$R~eHDp;t
z(+`G&bh*WD$4_;-)}IEiPhOs6d&`Iy{5TnKLd-;`cj@hSH=HkRs$Spw*&w%N^A-~!
zr^1g#dI*)CFLgB%*F$^0T)AQ2GV!t5ene!QtlR7ZFHJd=Fm|1`^1f*wMqu8WhCp3N
zY>p(wyWevB*n0hgQq~)KW9v*;-EHbSGS+;2(DX)+P-7`k;QF)VyZN!>or+{D}>dDWb8zG{zphx*|Oc*T(|<9n)(-?%tgBvOZ)
zc_i0N&}zsG9`4mR#;lc})p+dF()`}0sJe9gafK2&xt`0(YWRxg58b?VZhmiDG(G$H(1-q+X{>yAr*=RDAm
zK6O%bswM}U_+<~FuztmdbF!_Kt9Ox1J>}9H%2Ld&XWmH9Y}G7wX|gGNwJRAtu^siq
zC@z@L&=Y}^%@Ehuxhx%GAf{G5dDte0Bs)F*#^hVW4aJG4O0|qtNuswfChlE!jHQa9
z8PZ*AOk=~c^)#USYW2E5hkZ6@70ir|;N&}LJJ~e#b|vAE3g46_A>#CgtL?w2ti>p_dG_BEGriHfiL-sd28CZ*po&I1I(p+WWn4
z{xpDf_^;*;s^se%l5=d*}vA0oXjZ#_u`zjRn?RUp64;azw8~7~S
zHqdrm&bE+iQj#VArkNhlsXWSQRXN@$xUEj)+swA`^o`rpy6&2CGVR*4#$FQ{IaT3Z
zvQIDiZta_WRBt13{Cr6=u6#vy0?ReSaB8&PO=UE?CvTHlyUfV
z?3;e*v`Mj7Aw|nL2sO>T(^f5bnU_LrslDHHy~ZudapmO!c^QG50b?}pL#pSVObtKv
zUVSA1o-)H7mubsT6NNoER}_YduE?Mb1x
zv;)
zb1FNjdAH#fDhFMo1N8qXiKJG`1<|jbpfZ&KQleB+t=}j?1|N|
z^PMqj1N1fG38CKGuBj*vqXl{|=(gEs6_Do`nugUnk2Dk_x|Oe9&B$vw<1BA#Y-0JT
zUbgeC{tC1BHVq&DJ(}K3M^*wZ+>iOTEA(J)%%Qxzd+S}bdl_wmUU{pl1pNxPA43ZD
ze!z~xSAQO>WrUDZ^H*P|(Wfe&+;QzU33@uI`!iGM=fG9Vuuhxki+XZHYtsGSAT766
zy}v)!QLj~VWSWD1$D!baN{p$TDVRlAGHI+~KtVC#(%vRaXWv54JG4|S6(OI|w3Ja^Grb)RZAy8fo#;j6FT@#Jp7
z#?cpU-g;Pck3G6g`}){%A&QB2(?*St520^v!K8m+_GFd>gVXW>Q+Z)z_1{
z+(DkiCodNra*@1}p}kjyIq`7K*fxrY*qP9y
zHDuQe8=^JT(TZ#}IYaqTHgN4Qa`Xam2~^fLB)&B3ZPMnqW#!piTS#VeaztT=&@Dd+
zZP`r5P{ia|NssRyfh$^7nR%SY%^7CB2bD$!0*$_GJrE=2qPmiP?1)Rij!%}Q9OvyL
zkLw@?Bc7*qHY*+qreCrwjP)2tY%rE{JX4jDfBC&E`1Jx&on?Dg#(n#6FUeHPKxq<38iICq?MVMpFV70S+2FuW{>E}H^QR}*jwN)
z1BYGT-G8sF_e>+>6!Gx{K^kZk1;q>%GmyS&rYK$MCc61S&Zwgcf-DuWM
zE85eNxN<93U*_afE5UFml(fVUZkI-X)Tyix&>^aKOWbI10vbjSyA@aEsZ9;gb&0ff
z+}UC`7i@)rE6D~?)?~P5%O@S?wsi!*y|**QIWt&)r~jT8RpYs5R}r^bMi@k_hbnu$
zXT1rG8kHhHJ6>_&Q$d0Cz@=tS*^Gqwwcd03Dq;6ebp
zN%@)-6(eQ;c5MI7UX6jvtJc+Be{%Jkp+tJ)GkK!!wWhOF`KkcTbw!LCpHGza>_=`l
zMqUp-Le9R0umj|=x~mfcbki#kZ7rbOO1`5PqL`HKVz*m{R1qzIV%1iknzLc96_w9RDWTtc=KGgjB
zr+($l8D|ucR^ihYqQ-tV^&S#-UA`YF099w|_&(HjVm=BsGoBfVPl}~o*<9ivFS^14
zEf?RL*)Mk@-NzLi7%zUM6RZGQWRvgt;6MS9MN(RyJ$3Sfd4V((RoOXwfwB+weiMdlpFi4
z8#GJ$4+u6B&QmVb8`=rgeblgfrdTS)C}_B!ocDwPCmKsF*#%#z|0X82;Pe%wREo*U
z5Er;4q~BcfHtThJw~YP$r|EBF4~aeu!+vz}P?HGL
zVm_#OUMG^`Aio86m^pEOQOWJFh+?~?GlqkcxvTBo%!P1W-7D#034%_gs0$C-g8UDpkW
znl>Q{85)$18$M>~;me~Y6y+hcdv6RV9}2ggXg~8!fbm#jmk3EvIwo>sRAR)6wTJE?
zJ$8rIO%G26f2bWD0p=@1oaLT&n#uzC~{w>#kjY|OJ`rxaU}&V9W8z3
zH0XNmy8O6A_GIh9cLwsNyNjz0O>DN0zZSA^8j_0gOdXQ%lpwwJSheTY(@9iXb5#_l
zZMF0F@H=6G>T9h1uiEOmyP96GpX~eT*U~WbO;Pi()!Ii!+C;Yz9Sxg!zp;~Jpo^)mr1lK=
zleRcE-6m{U)wK3TcVzdmEZAuFC;R|VkqoRfv3)|3~)hD`^j4u_s
zrNLFfo?xcEca?O{(rYTIc{4oIdZ!{x`&dq;Nx^9R?!uc>7EO%R(`*PP_sP*2ci5V-UDCuzYN{JGIFdFil`T@Uj8RB!ESH#)x*T)SU(|X`SFqp_LO0qz
zHqiNbW77R6UqfZ;BppKv;?=rW>BhUc&)!U)S=%c}4Cpu}Q7^iSp_^yz)t+;jq7yS%
z=9M%iR&(j6hJ9|Qgt69(Ee{?>9rkZJpN9}q+4J$#yY(T$(nQ*=FLy)e4^uU~g{qY}
zpY>&o3vFaLKljwhw3W>2zw2#kdL$#Z+sbD&>ri5+$-W=Y4DI?adbdrwUGR{SXl-2S
zebea2ozM4AB=1PSG&OhtrkX={i7X2WX&rJ@{OWSBwg!p7>
z7^Dp3R2ltN7krh|)tHyk&yFm;LdzNpAF#S4
zGpaJXUF^cB@0qlfhrHe;c|LFnk~+l}Jmv1FRG_k9Uqo8-k0-M?;xyWBdAeYZ7d&?r
z59BF38<1KAw>_WLmoN^mlp1aeQQGVxlIZpET(}wMnYXr*&ILTneLD6Vb-kX^$f>5m6SsE76m@Vk7uv4a{`|jCrj*_(a@pCuyoDQnaC^T*}
zZB~16Ori6-S$a!HXUHSMhnwA=>K9GD2An^;J46ZvcZwg`x0ytJa74pB#QhE>x7#VR
zgu8koiZWJL)#}neZBalQEh%w4zOF}ZVhG=q|E=WZbJN+~yK;4g4j)b#t-Mq`w(ksN
zti2*m@9JP@waAFVJCXcok{QWS-um%Q3z7a6
zP{K%fjnPW#%&X2r%H+&x(!
zyiM#ufUH~nGWTYtsP?IrmCbXJOP(Q0U(d;+Gy1HkC`XA5wnS?gsxM1VG%8HJ88-8L
zqfqZ!;Z{rX)j4FnZx>yevi?SzU@t}gX4t+$r<9stkO=jNt0T+
zxI~t0s%f~*?IUE3?P4q#ThJcXcF8%QmT*|TtS35ODw?@uMYH6TJ&k_N(l55{gJQAC
z1s8pe-@8l4c?VxUj-BXaCkP&Nr)G52a{P3#&j=>rvTrVr9={}Deol?`;gzHS=BMH;
zBdKlMHJjTg>5Pjj=#=-GyOnrExCi&`b{F-mnC8aA>?3XWm*?f}vcD4ZJ^#wRddSzo
zt3Qe@dwyz2SDGsRj0r=8DZd!5y~ciDRQ+1A|ERH4RpV20|I;M#;^LjXb80=qr9)}C
zmi4}opN?+i5o*`Km${;v&fJQ67`f)hem_NVMdSz0@RJ)!uojd
zqGTpk$yG#>&)o*ARVF|@i@xaYmy^0%R;j5_kTj89ft`oLKQYf|J
zUG?{Rmu&wq+tG1Mq44yws?0`-xH0eA&(E$bca?VCamFTBR(7eCa_9Oe#Ng?!?Q>X)
z^#eS@^CIFyim*NMLC>&(sdF{$i~Hv0xV(xAw6n^k7V{w2P)!qqQuD7WlevV50iq#X
zy=pEYBG65{@zKg`Q};gqp!>FRVy^?Fk4zK(F91+LufMqy!8jJZs~b%SSQ)H|ENeck
z4Rd1v&T;s)F~Z5Ddhr8eUuwvm;IwBR=JW{W10<&3P`_K~D>}$5YsGyps1T~+DIwoHx
zq)!1VbR`wwwTCu$#Ir+zbU4fTMz%2!Ml7V8i0z!I5JFIk1hj+e&+e4d6T_}vg(!FuGI6V-F<>?71=Ct%
zHtySDABzHQQ(ZtGgW=$Uh&h3pI9iy>LACifXtZKr1g*5_Cwq(5#?a}Uakm-VX~_pS
zJG66zB8u_&3PC%BHOoRwwXl1|^S?SLN-v_t)kq8)JP5GG!XQiNU3jElrgRh{$ngLewb`P%!
zz?w24hPkE!3Xw%8pJY;jmK6qRLT&p{rjfDwXlf^WEHrebd@KE#LXrJQNB=f?x6ozNi=O^-nDfJC&4LUvM
zd=XgW3z-QaLKD)2#5WzzBqj|di@SobEuo3nmCd`$C90AccGVMMyFY)eewIF#L`?z9YIw1Q0ocV)3UfPsS!@8dMkAU|8}9kN~}dlIxg2CGiV1#9DT
zymo^45tLmsq4c$L>{A6Lr5AZ}9jwen*NkcoJ-w4Z=vJ-H(={8`(4i$&{-l+7B!cGn
z?E6^iur3HsYg$#3qua!E0Dy@JZmy73AYIo-u$-obsp4hb;)=;GSr?%yh?^)P7^iW~
z*%w9<1pp)`PpvDMTzqaFF(&9~RXb`?V!S4A{adiYMrD?JIvQZD=d4(x#d5H1>0tk-B{dk7N<)=Z
zq0+pG+F9fphN~r-i{jAQtRjFUVswhd1{qbbFJcUiNWp!eliQ!91*D?0h&nt)g&84z
zq^aUmH;#~`R;_kFF%{RNi;k!j8l;<2QF5+R3TlS{?zKHk(}$oy(S`R01q&blGp;7rbDFk
z0>daW!I2=jP~o^?q84HZ{%GG7X-z)T(~n_D`iU55$|q02nT&>;IJ&t
zL>?ZJtIOwZIb@eM4Pas;k7{080mSSC3PF(`Mnr>vDthOt)h!4*3%%i>9SX3ZEIFLE
zM_R4w)dABG0W5Afuz@O0YOfQ9oMx@$)463ry*R{8XCF4OkrfgZ4koG=VX{^9pI&7*
z%DJdQ6hIR!f-kG3M)aGGydp%)xKZs&$@9da0wvfl3G8T0^N9f8?53=dNG5o`W&TaMfrcFe
z_K=%3%mfvLy$!~A)Q(%EPR;RFhj3=d`CGY3!D!3`BHHp(A3)@(f{jhgtj|M)^J|{#
zCT*e%?8bboMV>HDa7Qm>5~akjJA?Ep|V_A3-Qu3mJnAX0?1!6u@pmf
z`;3zZ1Yo$1n=ZG4kU6UyujQ@5(qvIQHgQbU6ZQ!q!Di3g0(uDev^iCS22p5&@oEt<
z0YEq{P!u9%IL!cLQWk~z0rF?eacfmUNY#tXS9IN;nh0R7-u@yCP%OW_{Ig|P3ijRX
zxQV30O~-f+yOaPhieI}XhOJtv|A2M8hmXOZ#vPU73L-dlHo59D2B#SB&F-5mP6nN(
zETK~~&XB`4x&Bl+&GCTf!I(pb*dZ*m&nz9E4sHS`|2~n4o3H^}7+eMna*J2G+{h2M
z82AtPOt#1Ibl&LFZH7lQ?XO3@hD%5fhouJX)X8G6jF6NdPxsZTiVp}=RvTLUEdrA$
zLd}J1MljlWx(3^Ds`8=coY0Y3P5fMW`fG6E31FLhx)-&hQOFqOIUt_GHZe8mb80!p
zY}7l&&UJLLKww)&)*-a>V#v{qK-AFU;gnZ)xx0?cm!nGtM%+87q2DQtGK<5y0Z>es
zniw#Vz{J2B$X$O>G}YRh&R2h0vwFD}qx?7KVRk
zTl)VTGYDwPBtPyrA6n*CLB;FUlS8WG|MM|dVb;Q%{;Vi4O?u;h!pz|e+Y}zh))_;*
zpN!d%;Ktts@fBlOi4;M4=Ao$1yquEnPOE=T5HJ4NU?qZ(f%*)v3fM}ybQPOr`Dk5C
zVWOr66V}9}f;k0KJ$RY7c3Ml=96z-wu+g$>n0T;k;@
z>MMA0pr+M-z_XR>)-xpseAG28`{m|w1jLP6aaJ>)2DL&omY>6b5y6~Jc$CpAR?_SV
zI72g;iso2DAp!8WOFPn*;^*K>X27}z={hnzjk$uG;xtB!wz-7CDO?@_Afkl9sYefU
z+=+MvzIfsYYwg)_ul|&DWW@*Nk~zF!&qVDK4(?+lPr_pRAxk1F`Zar#s{q>tftVjo
zG6zprTmX^KcK$k_AVDQpLt=aytZE7|G0@+D#t5B49kj($|26gi{WEoz>)a+w=+_`I}U}0Fo%syife#AJTQi9bvBTW6_=Rgt1kDM
z+=CbpH8BkvlAOf89?XIRC^S*95pt}fDkZiZW6}|$fR(J4=8;@@3JC{9BKGPcs|^ZB
z0t0OA+STvv@@663L#(`9i$cuP%(6zrs3x|jm9dYb4j+M_vQH-4DCb7+%@y&BU(lu(
zDq9_?*DBpT7MJLx1>Fi%xa
zQ`H&tgiUZ!@}5>U1{qSX_J>&All#+T4_6y<+-|}-E?_S~Y4Tqam0nD(fOOZ3{%XhL
zS=z9R%#yFKdR4$d#U?}8Pg>lp^-#+JOajx4M7xuLRJ9s*@){ZHd6V2FRsuGQK5x!H
zky)G57GN5}8gTaKcbF_If>%i!FvvBA`KCnQMb(h45)aU;Vv7qdlb>4C^^On8j)*mw
z>4v73uHWiTmL~a*PQ5lsGP;}~KogA?=LoRviKmsAtO1O`3u)UAs1Co
ztOR+HWL{zG!A?dLDJzozfZ{^n0I+(&%6AJU=H+DYA?RwG2FwOr%=L)g(;9j?RRbKh
zcZoR#rrV*S@if8>0Vvvoz8}HSESzFjuU;s^j*T;qaRfr$4KTrQ4@Zi4=2_u2Xw#K=
z_lpqDTI6nYfz~M+oNN%$ud)Cg-k3yWQwAoWBdZ7ur3neT9mxHeA69ygg|-==veN-WSs
z`~?8l{kh%&iH{ird3s-iKqc+($Esdw*w98l1f`=F}GE=lp-<-
zhdXMWd;Nug?0lPUVA)u-y*4sMM-q@Kp2dcRgr?~@jct78Tf|FUW>?Nv1~FNZ9fZOX
z$p}%zlas&h^)QDXKll2O<0P$XX#gD5eG?-()Yif(P7Fr0HG!cTa5XkllDKwj2
zThaOAkf$Ifr~s)En`^cg7O$wG#}PV^7w@Jd1vPm*hJ=F&jm-LF#a%^iYy$DkVI+*YRz3aPa?Nvnp}QsSSs10qzkSkDaa
zeukf1Ebud{fQ6wD9wsgf&wDutR-rv&psTbot485)sYOkdmF2PthInv^s-eKI5fb)R
z>q1e3QGO&0+pxUyX;M8VN}hyRVo{CX)^KA26J5DFi>-Tm?5F}YwPGUHHS{zpLlbc%
z2u23mQVJBPiVc)FXAG3Bi%l%nMQ08YLdI&6Y6k#R9hE}Sw1C2Fi=v8hjj=UQ7Rhd>
zbA&V&^bX@FTorpV`LBN^qAgEcAu*VK@r*x&RFjfHfti7`z^%kQ(6k>rp%$wadrV0|
zCUv5zG*H*x&{>;`nUl@Ibni7;OXo!G#efnY--S{gVaWD>;Mvi0l;sM
zpH4&wu-P!KHmQk(r)2P{L+%%9nAR)d3NUE$AEG6%O;equNjzE!7KpCr-N1MwNDR9k
z?nAnbnu&p$1Bl9+U_}sJRL#UhTeE6}U4qRxT(|Drh@uIXq_hQbBkVDRa0*`Um>~5s
zrE=9Qrxv@C(6%9CQjD~zU^HFd$Pcp|hTOh7F{CweFa{0P!(i-e*F2Joo=GeESj$Va
ztEjd~cI4R$WZP*?Wuq@ojd>CXqRrbbzedYW4>!`*jS*p`Lpym4_B~LV6kS1smj6^L
zRv)UX{KikfYFZH}>8*^B_|`C55MYELJ29$IVz|0u|A-L!
z8rC$l1mACI5R`EL4Yp*GB-Kmf)bX?x8@W=vbBt1Qrjydt8kGgbLpKh`CG#j|$l?lo
z>?mXp5C*a}CRQT|NmR7zMhi_+1h+afxiGX?MJsSWS$}o^wFesbJ-L%KEX-)7w{H(5
zSU*JVwP`FwUyG55jCgBnhT>})0t}rnX|qHWS`niyWn-E#LY2a3YROQfri$qppqd*0h81rahUooM)77qYy7!OBA9O&LIXTj1=#+%lH{qYO}I3wCVFe5
zyArFRwf?8=nYvlpHu9$Mb5i_#Hv5Pza2?+b&(W;0Rt1H|0X!SflX#64HL{olIr76c
zJ|V1C#e}4?V7Gx&px)6=Z=M+xwD2&-morzLnxZBDEMH(xD
z{Z&{*rAUZ{&06qa2xR6z)Z)$s)ZprOzbHBGyUF9x`?0J3*O&kF2vOP=S}
zjmHZC#*<0Ra!?N+K@b6)CtLX4YZVpKBqa@>2_A^=V-mOle?Vh+o*JAl7!I(Df-RM-
zODVw(J#eqq6`J2l77+y2el-J+3zRE}0GG+SJZTzRqYMpP!xnDJqHbcOS*DbUsOYDj
z+VtO@uP`6SmpcU?5vWmW2{wo=J}B-$OI*i3w!HENh%E28?;fPsp
zgeMMb?Us{RESTcI4+odqYJn5a;lR2%MhvV5l2M5^ejG4HXW>SPPo%k`&eukN+uj6F
zUDs%a3ce!{nv&YQ(mCpaQt{_!_IF&k}#U1tI;1dZMYf~U601%
zEJo5uucMab53wvVzM~e*-R$QQR5=ZlUil*iTn%Doiqx3m#3j+}qghE8ElCfVRwEX)
z^n+jvDr>66`e->-;-p$x`GhjTxPs<^CMSZdngVuzag(U~R~M}Zor(se@{Dl=aIh^l
z=ZZS09nI$ONkbYERH$Wz!qGaQGqHPshIUojwH$XJ@2pKN#swSQv__(ELyXXtf#oOZp;2LnA5x-1TesP)Fa
z)ms(Oiq$Yyn;9#j3g}5
zPS0;d6IP`R-?`QeO>ZusR`llv^qWTB%8Eq2oF>u@78#Jp6(0gsD}hFCzTpUNi1vc{
zFyBgdT%{F~5J9PWkA|3prB>~QK?NaW)!48Sben{RG#pT4wNZt^Qlz&VRs&K9Dp9R$
zfsH1&CW)W8K~LJV5d~VLSSvAOgc>DsdnP%Ikq*e~$AN>RO*+Z>92bMd>XqIFqd<2x
zoA|(qKw-#hfu@gUtQi-peiVUkU8lqezL{*BxUgPaYrPw2l01tX5Y{Gn)zl^%Ow+m07@__f!9wLOLE8Jm
zlekE6()!&*s7C0!mfFXDnJ{!rqPjAHHIC+@CNz(MKwMFuc79pAoUM@Hwge)g`tCdd
z4w9lH+LKPo^8ZA-H0&g#cY|~9K6whlQ-P9DazGl6;J$WUgfQRxp+DaA#3mxIOnMbO<1FU0h(v@&`@Kl1R
z-o9Gj{9u!Z-3tM=EZDKOHvyJ5on4%C?`p6OA6iF(1}vLF5!=}if}0&odYmVkY2gcv
zA*~G)HmUta0FYBo-E18Cv0sA6)Xis`TSF;h-@9Hou*2|Y%xV52e2EH7Zmq_nK@xdT
zHPekU0hmj5mDWBH&?!K@=2Q)E)t}njrZpNnQ^P@xfUso(o5IY)o`&YeX~VY2S<$FX
zts9+&=wO48EY8_7yCM^ZXIa_^!P?y_?2F)Qm2%ZU^K2*!H&e9NduV{Rto2yzeEH4B
zD%zIW34p5$*t`@~)C6=z43}y!=C+a#c}{Z!L~0@50L
zNCTqW27=b4r+1i{hFRgZS-EtbY82qrie`ebW`PY#OmeW5YxNRK1}r%bg@zk~%@ah+
zg6%MPHea+$8sk*etO|+=K(hTn;8JP^P4Xz2SZoGcwPAx*6AX_n{jpt`JheB3FB*l+
zax@*BiVop{6ADcYT85N21nB5-6!J6&p*D6073oD3-h{4b(*#6~c0GzF*CQ~qI};1!6KPdibV%jj2?TIdP6pkcQYMKVk?eaJ~u8^H+ymv9I#6HWdU1{>goVH%vl+8_a@{`sdkKWsy
zF3FG>)r9&OjAQh#5{l_X5K2pMvQvadNBosI4Yvb>ix@YIU_}Gl#o2Z@R#||<34$ip
z2(59SmNd<&BQQgsHdR41NMXttrV5S(GyrM7u#QwCVO_JvaVM%GY7Xj8LDVrC`K*CX
zg`27lht7sdr&BR^O&g(Jr|z^`69(^YWM|2cXdUK({cF^|TR!!n=}pw^{!
z>rmX{;eQ^Ae(~PN-~5621C9wP=hV$+RMVX>v{+c-x<9n;#GzDodnE=jV;l!A
zWgJHkbI}`DZFABjjh+OcnCnj(b|zviWT=v1aE
z`ynE(Fbd_4{mTRd%pO)FRt!VUk#KG6>A_kf`c3Y$fGMp#xDrl1{6T51VkQvP?WIJC
zK;3MX6V)5a>P(t;un!y>1qomx<}!{W4?`IXw~@4w#Ka91pIXFr$X+Kh{T_wi@D)#^
zspzLxT=mXmLP)vm^5S4`wsqp>2R>-$C@`dFTCQa39@4-imF36xA6B*KXP
zBzeb5gr%VIpuW1`VAUr)!y%hAUaCWSAcJO*Mp6hg246walLVm^oq}8ACC=JvP+1Ak
zs~l@M4u&kwNwSuZa?V{xISZMrL9_5U_FTrgv$$!`MWKZJQ1FBqDW#NB?f@n*7ciG`
z=sh0Uv+7K4L9H?Kaf5PvbNR%8HkC|B6+#NrLV*gg+4JHPP0IUKSb)nmdIPM}K_Es6
zhLLoLsu{b}#&M%T52QrIJdTR<9`V+5lfJ$~LR;MIAG|zJ
zXNax;iYu994Z##MxRL(!7TQqjASQAYCN@*xaNO;|S4aJLOf2m}v+D>|W;3hJ{Wiy`
z|7kr104P}tDKJZ#DKsyt3b|;sp~hSNt-CQ3o968SCCn;JD}FFoqKPmVxC%`rK!JYf
z?9->>!tz+BD0-_h0wS8t!G(ujd&DB9>1HFur3jc~=@NTw>J=x|@S!ejM0`vp&$?mX
zADl{cAwixFT$>~cyOBMms1hi}yGyU$7+wgHVRBKzCVuaRrvrvxbv)mE5_T8bwed44
z@Z+xGkUvZ&(68%jHAfC#8qt^nKvx2qnz}Jp3(cxFvSLT@q7p)mhM{pijsA|*|1kYEz
zdmky5t#|3KV
zWy$dBp1vs|!P7jQu8Xy8-V;+aji$2cdUao6cy8LX$TvHj5I)ULCc5CsFYtJ1Nj?;nMzIb??JQE*2AxL-w<{;m1ezWp
ziuP}YN7`Yr`bpL8TsPfqM`M>iL4QRsArt=Oh$p{-@@NfSRygGRkoP?iav_|Gz$Iwzavl4N9ey
zq3=r(;L*#krUpE-hg%XCfE>DTA0n!w{Nx-Q(QX@&1G)pOVkuB5S|i3l*Wlq2$(
zS3&YR1tlxaDdn!4=lQHZ+#ilE18MuznbRjuUU%OcuD||1r_P+-m~Rxu{=wc|pS<(7
z4}J9Z+i%BsK;7oX_HJPw`la5c3T7teU?$;NmXk;OHDPEHbItdIJ+~cU(kS(Ud7E5K2+A@ZAgqeA9=r_Lo!#}B;&t{w3+x<}b
zemRa~HPyiB1jz$C2oT{W6irUURkKKLnOjKY0-KuCQB?VgD!F#$1nj#mr?fmgcmjB^D|MXA&`yW{z9n3d3%g`4FBbO$Khh$wJ5i5ovSQL%l6f4^H
zzdgoDvFFZWc&h{2)hh{bjDQ*+VfGmlVD)0ZW)egsg0W)@Yzhf?LFPea#!i6QW~qN=
z^9a=pqP9ngQcjfmqeJlEiGiKlZBb5Kiqw8iSnDH1y82kh&)sa!!*V#<0~km^LFy>a
zQK5k~IaKj_K#x^oypon&B89
z9$Ea_dS-o|k7~r
zbPg6zJ>Ljq$G&oK3S*B*xD#u4bvOY5tUOtbKxA|_?$fBu&2JKkj2QV^f=OtgwEu|g
zwRPxHQgJ%uvNJ;ZLtuZ}Gckc!4knn!44^ROjuMrzXgk<=@!=+AKF#JR%;P`_TmY6=
zMx<`W%sh^vLbVs5$J9((X(C-j7UHvozW5t8Oh*@8I9IM
z;L(w$N|wh?3q2^{ZK)E2;M;-yFJ_Xcg%KO|XHIz2y@907VNny?*)
z6t4_Qj%AfgrLjZZ%O)vLTOKPQ36J5
zqEXn`?lXur6?2qm?;<>fjcIyUw{*!xddp2j?*>PlaS(+x!cXyEJ*{Bas;_$4gyqIz
zU2*_t{h^P1
z=r8~LFYf&KZ79px&hEzcPT{gVS`>^GG`Ngn8z9!6T_t+59e{$YO%9%UDBO|9S!no=
z5M~#9aw*sz&|bvl3DP7lfyEA?oYZbeOGgkTY0RZ=HlJ_x2Yda|!Pc4UpZ)yjeD-HO
z?UtMFfA;j5vsaxuxwAE&b&SNs<1j9ki%S;|KK6-E{=qxm@!x;-zklR!-vjD4PMjFQ
z<6;Rg{Q06KQd84V5u*{xMlsoD5|gMJ6D#&B=IE{2L0TcaLr;#eJyzp}OA`+=AXNQdcAR~vU+&7xRNn7ahVF!r~7
z^b_y-%lEwRuitxk@h)K8*giP`rSF^cAdqhqTGj#sCM`cgYs6C;him^cK3tVoSf(H-
zzYL~CfWSEsB_vAS=GMWb3orV-SA5_1{=0`f@aDtg1sJo{-H<;+Z!%A+6ogq_nTMh0`?f{};P4~g-2
z@B5p>gFT|eWvD-bsNQ2D<4L4Vl!%c4%Cj!#+40`xJl}lOD;-G
zefUEP0i%eFfl#MesB5Am8e?msMw2r)zaktDa%f9#%^JxchvY%h+j)e#$3!Z+swt)3
zL?36<6{m3LH6{2Gq+JETUZ#|)iD@++cF;E27YQCVH3pk^D_?qMd^}v^)M!8UH%+WK
zoT;Ua(!;HMT^()x(;TKG)OioAvF6ognYqSFj%3Mz=c)f}eYEiljEVuR>yYBY&yBO`Ztd%G-p?w81!m?`J9F&mGUD9kAGYFEMMYOc0p!$1Q76Llcyw!0vI
zz-6UtIeJR{Poj-YB=GT3oq!9{<|b`yG7+eh{R8wpk|qy2qh?UC-JhtwSGtIcDcP3W
zSRz64vazPys2N?%@l5YNg7l{*ao%Lo1Wipw(a^w)4##}u=
zn5ca9I^OGmVc-i0Tg|Vg6C2e<0Y(`SG#kEAn#(1PxDX;
zn-?56p;{X#VFY%A3CUU1esn;;RFNULwT2%yD5B{`9*ThLreb=5%G`|ESq(wm@zhMh
zVlXr*f(yarQ$^Y=i8tg$1ZUGeoS2}^1{2IL*pG+cVJq!%EiPm&6PEoMI)M`RcN2lo
zY;XeIOxi>RAGTdbGGvQY9{}4SQm<{-^JB)Nm)Vc&%UL%Q^8hg*eOl(ivX_mSlcHqb_lL(v
z_uhNqFW>XG|Le`a_m;Q*-aQ}t$mYotv#srZxmfl+YJS5kDHpSTXG#m$;>Fb0;$&UAS=VO}Bi>mww3$
zp8M>FKlrAzXHF)d0Ht6Y3%HC$p0YuyqfA6t9v|QSiMxLH_uuiKe)1>Z_wIM(&CR^I
zH7t%4rV*2hUIZJOSK;hJvK!9U@Sx0kPbA>`bms#5*3w6
zlPCm%7;*+=1A!7{aF?jt*c^u;&*s1MtH1Kx&w1v(_naHXp_|P{r0cFa{jqy4zU*b6
z|L#9}>umevIQA&Tl&ci!3y4Bcz-ne3NwRc^wBV0HO6aS9h9wHQ)2A{U_c6)uQP(Vi
zuX&3-Rgg+K=R|}|`cANsm%~uTQ5h*p1fr=~q82U9Y&854+4b!(5;{EUNk=}_+x6d=#yZ-PGx{XbgzHlx0s7NYZ?Ug`;M8uS5
zvpnBKDM#n;e%Rwa;|G4=`yTV~TL2s%FE-}0|NdLQ`}(hc-O;k|y2MqpAc>;_4#Ao{
z%d#rQ|3enbR-%kR?&L{Ny1#*RF@q;tHe&TL>ENS)NHz+vAGO?On%6}bkF>?NB*_sS
z0k+#g-L(nsF?Uj-G_PEo^hD|rDLN1UQ=h=0HWDDONqAQC2WOtl@mtV~&M1kRHKMhk
zGjY}P%&Cc}c?6-3kBMqD3-ctQfi=HYCE+#@9m+FHU4RTZhwK8IJPIvF^8f=Ou2slG
zsZDSKWq`EKUtVA(L
zLfweaBo|JcMv4%AgP7X*s1NPZk|9Ez*}Usgc{F{;ZH5%a3AuqEcOGIJ4uI6EE=Fsi
zL^-JFbX~1Lmlg#*>b06StNoM;{vhHqY8*@^0gw)(wiEhjQF(~;WoUe(J{R1R{lu|%M^BFOfzHh>r$-dY!6EldmcR}F8Ju%P1
zw6TqDP8%C(XLr1C5AR<>nxWW^f%vTLAl`74VOyTnlEwB(-7^#^Y!C;F56zg9=-^2~
zRI&XuZEg9P!E9WFZ5~e0Raj3>6ik#f%N05h`BoGFuv6Psu34}rSj{ub6vY}(4uXq`
zs$!50&6=sm2<(}9;8~2tF&_TC&?L*)wua
zrR28>It188_>qj7fGtD>pWMWt_Mvk+G0hz`5p?x$okoe%8LO*NxRj!7G;OtDfWW-i
zOS2cRF&z`0x9M3H_(RoE3RzKYCMp>(1+l9YaM&K{#jU!gyEaFy#Y0m)DwSe9F0gpX
zv`;M(R)7>?rbJwLM12ZU-Mk9x@V
z#>U9#mt#M2sgE=()dUhzeOA&)pq$ezbq~7brdu9(<8z<$^#A%Rzxl&I_(PZP{Mh!X
z(|y0FSW&>G5EMwkfHjY_%cp7;KjC`ou&v4%W=nqQ0i79y1W&|@^jM{nhEAPKLfgk=!V$g3xdo%maBHMoQ$MB$x)5rt6kRL0VkFw!2e
zP73P+2+Bv~gqvIdD5OM02}B9R<1iLpjsr@mcidCnq@2KonDiNJ=BR+~$I%H4U8>ma
zw-zF;!2>T!E8c}tAt4bZPE5I*4TU#Op8fXk{I2`oc*D>Sn(ZJZVxn0}S6_WSQOez{
zj7ViPKvJxX<<1Q_4A)
zluJ)E$YP$<(iA<+g%tKNMgOv94Ky{7U$mgmj89ud;_9wRgXb`zIzkFdS(7FT
zv`VT*gnDmQW@av;nvJw8h|#P{mrf~`tg(PvC*fhC%)x)5J%W#W2J9g7>%N~6b!wEt>J
ztcOz5r{fraEi{4=?MIxw8DB9+n;n9B08^UtM#q~cwjS`H{@%NXJ3a>PK?&n1G2P%$
zn76bi!3z$RI%`H|uB;}a_Nta#4UYrlx&T`iF)_g)0oS_@?)khc*8NCTsn)AK`WoO2
zIvuaP6%N4Hvye_FDMnRU|&lh*REaYF1>DQob8WvUXp&6X^)6-CRgRaJk8
zc=e1TwVFASL{-y7kSghz6SEZB>R1H$4Fn8InJp!5bVyc#KnyfrR
zs@KVz9(806L)W_*2n^=2G)Q8R&a$hTFI2N!9Dz(zM42%xxIf}!;{6Le7NkTN6)wEN
zUPmO$6VxSw0h=1z^hpv%M*~MzIN~+qm@&ASnNsz?aay9)zd}A5x?w~y!E^JrBZx(n
ze_^1DM}rAi8rzmkie!>a)v2l-=t>mn&k?iS3IS==#)_I^ry<;%=4nK9PPI16GeA)g
zZ4gthtHURGODQw-9bVQc3$PTt)P^^h(fq*bL=~->NxP^~1!fnU^T2^7WQ}2M#1v^=
znp%L!O;Z696n_K#-rcD19a%&8m9=TbVgjL17Xa41D$|c8*#-x$^cp#IOtg%s322I-
zLSptbBaqsnO)^oaIceiEST5Apysl&I=dk5Eaa&NOEk3lh@$@jZYF?tLI+2iB
zXKd1)2uI=k=t~a5p-F0C$Xwg)4arg{Hh#@vQJq4;Kj0EI!vs_`?fE4kK&Tg}fl;dR
zDulia0FD;JP$X!(eX`Q0QzSwn?wN_XUow|*KBH5+yHC3HQMW$o;V*jrv)=R*|M_SC
z%TI31y4l8Ne|!v#!3flAL?hC;XIBtBx+E#I@(-S}-fI42*-OTKLkB*(62DsPS`v
z`Dz5_FrJhs7BgYKv3<06`MMi!`qpp#_AmOp&pmVM#PRXs^4>lZfjXqb3}oF&!^DJK
zbMKMCSPtVjE;l-Q&`tMy{p()y_{To_8@}+hz<&
zA_c6nW5kwj3}n(GDJFbj4l(4yL_G%wTirZjQiEz2%+@C+f+
zz?I}qjruc}zbx3N*i5e1;SKUL9jn&5GG*nm!L0hEGXf{xfqDsPgKL>$UUsifI(bnum5
z`)yBn%q{nv-^&NooXMYv8Um$f1**H4@W4Wh_^79#
z2Eq^+3Aur3MbIF`!Q!qet9`|?L^YD)nH-T*#ci5oR<7lO+Z{C|=}(G3vF@_q&UFI;
z5kRRv-OmaJi-SgqxU6LtDp;FN6U8+f+%$%0LTPTGsb)wGOi`^9ne}Ui*@nrGI+7hU
zv{sn6Xjof80-HXd$lr9Qs1F^y*=S#Xi~`UUi;Yta2a3-e#)&v!STCVgZ>2Q(uy%FC
zl2bl$RhexpKY2&lJCC~BR&?&Df+iDZ*cPF2c!%(|lBKO}XL=x8BH30xs;lLUEfBC`h}!?GGCO-ij=3Izz@52|z1vvKr3
zO^Xp?0Gds~A-Ykquc}a`!90{L`*34wJ=r>YzWJv9r!
zOvr-1o~>#1I;RM{*`GicP&NF={Xv6G{i&v-YHB)bs)oQW1{1&T^sWQ8@E>@J+pOwy
zXA?6U875p|&>5RpQWTK-i@zL%0Il>YQmy%D{v_aMN*4!au6+c-T+J`78!`bqB_pKW
zE$3ub2#<&E#wIpcer%gXp^U`!XV;@1iJ4iF?3-+n2XQ&qh*X=cF;6y{T9U1N#eW^n
zEUn?74vkG3RvhpuYnH}n_>ZZlXe&vClN;|#u`_iMTlpbVWn9n;W@E1uSjoS=;S#LF
zqN9yq%?Q$}Bi4BSesQmP2yxd?5$F#F7GR!RyyY24I6l=-`%oh*<`GRQfz=u;b*_
z3lLIRBAb{(;-b{61=cLRb|=?>jdB#c*gw_gIkIC&LsV@rzUWP*u1X@^{%Htq!G_}`
zff_$5hb(z@D5rJ=vLhz6u-igSH#)vAjI}HiQCyW3yR|Q&`Bj2RcsoyJSdW
z;H|Ew0O+Z(6str8B)LDA7%AKzP=;|#;$F<)Tvfv7_{aM?drj
z|IN2N@d;1(=5PMy{_ygw>q=P?m{DRTCI6#vgOXAk+|W%|n>P?=H|Q=L1D>AfyS%-t
z4h<2cP=MfE9HJSdYiXfX{GX7LXdrED9iKmc%Ojugec$ulFL>V5%YgHHN6etq)y){C
z@Yt7uM=&RS3k?XFsOxgh36zOaU&``eF`Lg{^sJ{{`(J+YwXgl^zkKK0cF$f_=$QI3
zf#vodv0=CdJ^K$UqEr{q3p-#3JSI!&poyqZ7bqU9uyruyPBaGqDbYxbL^-9D66H)O*Km>(l`=Ayv7kO_Kauz>5ixS=
z5b^7#rnu1eB^l@Xh`8R*}_viQa5@G-FU}M&$ocl3#vwT?6$8Ng~K+4_d
zPsgtoFSq)z0)C7Zs3`|ZSS_&Hl3W_<4w5&9SR71<189evH_V1L_|{!^aZyv*PN|y>
z!?3lv^_qYA+JEu|FFiP3o;i79HlLk3v3ue2-q&2Z^w!&N%iRu_v5rv+Y09L)Zd@hZ
z)a8IzspgwZA+=rZn4F9v(gN37`J8U-jzCmlu>~lx9T8ghZtpUYB#q
zxiB+jWC5B?2}DTLr9Ae-{-t{{3_GXKe(noj^7&uz1yB8qTOV-q{dvHp{bi~GOq7dQ
zaxJ$&01{Ja;+MFsxGt-+k*{evL(EJm#Xgud3w*3Lu(D<~Y%pv-P_OlbN0T@*L-K@y
zQj(;+jaJa9G?xQP;Zo|(NiUv-<%Doi$P4YC0OU??Of}gO8zsTi9?A#H5gaCK3toyZ
zS#0n~!7$?r_`w|blo+Qs`f&KAlA4HQXOU4t@Y~b$G-^9XV#BguRqhFu({*W|5ku4=BDzF8*83@#ftKdg*Hf5%7bt%wwe0Z9^0vVk~}=d}8-7&Nin6sdY)9fuVw
zG_N-ZW!R0q`XSxAE?xfWpo&p0tq86`l5*H~BI~`|!JMMnT=)Tuqh^b(PjG+(z}jn~
zrf3rE?0b4(5>rudy^IKULy4WvlJjeN<_Vs4WCFjIp{eAdiYDzthe)Ha&Kj%6_(B+w
z7ESM51W*^nL)S^S`HBFhKv}=bu%bgL78~qhrz|32B7J&8B4#i(4|51+*
z&?T}vBTSBK)LJ?4a7uu^i@lA#eqjJ80`tnsTvE+ZVA*V}zH*Rs@FXvjh4wo26TJ%_
z`gIgPPC9IpNC?*+uMPJA0G*Q?yCIW54bf!MS{w+Lao9zP5F-s3LMv2D3wCjX)PiLM
zb{%OQ0$TuwdRpiM!a8;aX|#?7pxOGR1WhG?_Bd(L)C^@o#PFZx5wK7}c_>EBs=|ww
zR7jz7(J&P>%N*ojzS^GQFViuW{Spxw0IjMmS)?F#4e`0?dw9H+rL{(kRFlNS4Kv(Op1+2jz2bmq)w=?qy0Dlw)1G>
z^q>IKnpjySLSVDrDBfs$iV_T>44~Hho=>>eC>TOT_0VKdPXQ_@fK)p+j~byRxV7eG
z^(pcsKA0}aUXmE0qushlox$e)s^Z&0O5FAntz#H`P(|cHNHweCT@j$`QeL1L4Zg0v
zdM+NJPp6xJO#L$D)aLPicY`X*T5<l`((Kz`j64Wf~>-MTU%OP$wL>}!C4X7ld-x{bN|S0Vz6
zJ`xGzIHpco;)!@`p0~Q0KDmQQq*j=D7{=w;4nHTqdB>9&0<+m!n#SZ&=RH8YulU;E0qjuoK@GaV|dQ(
zT9!bWLUU7&5|DtjvAMi_@umko{QJM}-@fQMPuoB2kC$W0nG+GCpbW#NQ{S*MI)5-K)+l%3c}AnrCrYG4hZV
z`gacYEVL(;oBU0UlmH91mfpML5G~<0^(Ml^#OE#_U3c}_ANro}
zdf$8h`h)Lzcixx-J#pQ3p(q#v`h*!1+9cY$Fsnln0(P?_Ot3Do$FLNtMQDUp04V*?
zZEs)tyf<7!S#T`IdL6!u|1P~)vWrqm&V^OQ3$7Q5RgKKrwt@oBd{_GpQ%
zImUvVFyGwR+TI3`a;`VLjw8zm5}o1J-C8?HWc;{4^~uFE6pz7`~X06+!HQkVgzL_~>Fp#)IwGM94Z?3vI0?9Y1q;~x9e
zr#$%)4}ZvwH(Wm!>>u{SP`aE8>hsxT*VkA^E~U1i#iZ1{fso;8z@xCo%@YYt2K4k0
zM-bOO+W4X;XtJc|SVru5sr^LLFP<5aCpk3+3L(Wdu$kFU9m|>2_M>Ng#pV*lS;Gig
zACWE=M3&c>bofauBw#2r#t~qoT>n%D_1o^aA&iw#Vjc;J&CG&~EqH@Ll$gV<$|58~
z>Z;SC6Z+mOW?&oupf<(Y42Fg51Rj|(HJYs0I{=w-(&DFjP63C_UUk&^XVKt5!8R-@
zDt(=fLdja!1teIY#upNoC+7YL0Cic@c?~IbC9(#=BwV>p7$_qsGr+zD34&ZMmMYSY
zGBN5~G;2~oy5AF?hqF)5fTs5eWz@|t)nF2|##7g042QTlVXURT0*|6-^^~xk
zP>tAp#{3)vqQr$M>8{m~WkCVW&SkYmHFqYdcm=N1e$8%_3|;h)j=wy6$CzylNs*yQ
z(3=orh6UJ4yd@{nFriAD?CMpwlhGaL%sc9*Ft|6}c}lG7b5LVq2Ff5C+2D7cYFr9|
z-v0Dq1F0=|Xwk~Bd#fE3x%J>C3bUJ&w`&gy-A+M
zwFQbrq;j7ASZ8p079jK!^k5Mik;#GsYzrnX@a
z14)-QENvpOA@fSYB$cU$SeQdZM)lreer>%Qskj89wS5qiC(SYHcWux^Fo+0VuIP5H
zXcnjsAD9GSvj$fkRDvS%NtWh9W|Np3JdqwY^7IdB&}ev(Ny$83-BCkY{AiM7$4JV}h2vPFv#rHJ0wD1~#!>r*pU#s>opH_5D9F|_AQfaZ
z39>5D0p7^rIIwb|2%oxGLD&z31SEHh3mCZw-qYHRxk!b*D%^h(E
z_&L>lCrCnJ4v+3F!jow>6g&>yReTbm9bqKF4_=~?OXsV9$s@QN|0|9bSraD$QkO3s
zE#80kotb%-b4rv_>T;UTW+zYVoZj8uP%e&+j*pi-o8?TPu4B;A@zCY``Oo`D$8Y-g
zul(m+f^j&G)