diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c7b2430 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build + +on: + pull_request: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Tests + run: dotnet test -c Release --logger "trx;LogFileName=test-results.trx" + + # upload test results + - name: Upload dotnet test results + uses: actions/upload-artifact@v4 + with: + name: test-results + path: "**/*.trx" + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} diff --git a/.github/workflows/deploy-azure-acr.yml b/.github/workflows/deploy.yml similarity index 68% rename from .github/workflows/deploy-azure-acr.yml rename to .github/workflows/deploy.yml index 8759135..2ffba09 100644 --- a/.github/workflows/deploy-azure-acr.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Build and deploy a container to an Azure Container Registry +name: Deploy on: push: @@ -14,14 +14,31 @@ env: SCHEMAS: public jobs: - build: + deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - - name: Apply migrations + # run tests + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Tests + run: dotnet test -c Release --logger "trx;LogFileName=test-results.trx" + + # upload test results + - name: Upload dotnet test results + uses: actions/upload-artifact@v4 + with: + name: test-results + path: "**/*.trx" + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} + + # if tests are ok, deploy to production + - name: Apply migrations to production DB run: >- docker run --rm --volume ${{ github.workspace }}/src/migrations:/flyway/sql:ro diff --git a/.github/workflows/test-results.yml b/.github/workflows/test-results.yml new file mode 100644 index 0000000..7c8cdd0 --- /dev/null +++ b/.github/workflows/test-results.yml @@ -0,0 +1,21 @@ +name: Test results + +on: + # Run this workflow after the CI/CD workflow completes + workflow_run: + workflows: [Build, Deploy] + types: + - completed + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Extract the test result files from the artifacts + - uses: dorny/test-reporter@v1 + with: + name: Test results + artifact: test-results + path: "**/*.trx" + reporter: dotnet-trx + fail-on-error: true diff --git a/.gitignore b/.gitignore index b94f593..17c5c37 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ .idea .env flyway.conf +*sln.DotSettings.user +TestResults +*.trx diff --git a/Dockerfile b/Dockerfile index 039349e..ba7cd73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,10 @@ FROM mcr.microsoft.com/dotnet/sdk:7.0.402-jammy as build-env + +### workaround for testcontainers resource reaper issue +ARG RESOURCE_REAPER_SESSION_ID="00000000-0000-0000-0000-000000000000" +LABEL "org.testcontainers.resource-reaper-session"=$RESOURCE_REAPER_SESSION_ID +### end of workaround + WORKDIR /src/VahterBanBot COPY src/VahterBanBot/VahterBanBot.fsproj . RUN dotnet restore diff --git a/README.md b/README.md index 05f3be1..ea43b97 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,5 @@ Database setup - -```postgresql -CREATE ROLE vahter_bot_ban_service WITH LOGIN PASSWORD 'vahter_bot_ban_service'; -GRANT vahter_bot_ban_service TO postgres; -CREATE DATABASE vahter_bot_ban OWNER vahter_bot_ban_service ENCODING 'UTF8'; -GRANT ALL ON DATABASE vahter_bot_ban TO vahter_bot_ban_service; -GRANT USAGE, CREATE ON SCHEMA public TO vahter_bot_ban_service; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -``` +- run init.sql Run migrations diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..c9982b8 --- /dev/null +++ b/init.sql @@ -0,0 +1,6 @@ +CREATE ROLE vahter_bot_ban_service WITH LOGIN PASSWORD 'vahter_bot_ban_service'; +GRANT vahter_bot_ban_service TO postgres; +CREATE DATABASE vahter_bot_ban OWNER vahter_bot_ban_service ENCODING 'UTF8'; +GRANT ALL ON DATABASE vahter_bot_ban TO vahter_bot_ban_service; +GRANT USAGE, CREATE ON SCHEMA public TO vahter_bot_ban_service; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; diff --git a/src/VahterBanBot.Tests/ContainerTestBase.fs b/src/VahterBanBot.Tests/ContainerTestBase.fs new file mode 100644 index 0000000..632833e --- /dev/null +++ b/src/VahterBanBot.Tests/ContainerTestBase.fs @@ -0,0 +1,137 @@ +module VahterBanBot.Tests.ContainerTestBase + +open System +open System.IO +open System.Net.Http +open System.Text +open DotNet.Testcontainers.Builders +open DotNet.Testcontainers.Configurations +open DotNet.Testcontainers.Containers +open Newtonsoft.Json +open Telegram.Bot.Types +open Testcontainers.PostgreSql +open Xunit + +type VahterTestContainers() = + let solutionDir = CommonDirectoryPath.GetSolutionDirectory() + let dbAlias = "vahter-db" + let pgImage = "postgres:15.6" // same as in Azure + + // will be filled in IAsyncLifetime.InitializeAsync + let mutable uri: Uri = null + let mutable httpClient: HttpClient = null + + // base image for the app, we'll build exactly how we build it in Azure + let image = + ImageFromDockerfileBuilder() + .WithDockerfileDirectory(solutionDir, String.Empty) + .WithDockerfile("./Dockerfile") + .WithName("vahter-bot-ban-test") + // workaround for multi-stage builds cleanup + .WithBuildArgument("RESOURCE_REAPER_SESSION_ID", ResourceReaper.DefaultSessionId.ToString("D")) + // it might speed up the process to not clean up the base image + .WithCleanUp(false) + .Build() + + // private network for the containers + let network = + NetworkBuilder() + .Build() + + // PostgreSQL container. Important to have the same image as in Azure + // and assign network alias to it as it will be "host" in DB connection string for the app + let dbContainer = + PostgreSqlBuilder() + .WithImage(pgImage) + .WithNetwork(network) + .WithNetworkAliases(dbAlias) + .Build() + + // Flyway container to run migrations + let flywayContainer = + ContainerBuilder() + .WithImage("redgate/flyway") + .WithNetwork(network) + .WithBindMount(CommonDirectoryPath.GetSolutionDirectory().DirectoryPath + "/src/migrations", "/flyway/sql", AccessMode.ReadOnly) + .WithEnvironment("FLYWAY_URL", "jdbc:postgresql://vahter-db:5432/vahter_bot_ban") + .WithEnvironment("FLYWAY_USER", "vahter_bot_ban_service") + .WithEnvironment("FLYWAY_PASSWORD", "vahter_bot_ban_service") + .WithCommand("migrate", "-schemas=public") + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Successfully applied \d+ migrations")) + .DependsOn(dbContainer) + .Build() + + // the app container + // we'll pass all the necessary environment variables to it + let appContainer = + ContainerBuilder() + .WithImage(image) + .WithNetwork(network) + .WithPortBinding(80, true) + .WithEnvironment("BOT_TELEGRAM_TOKEN", "TELEGRAM_SECRET") + .WithEnvironment("BOT_AUTH_TOKEN", "OUR_SECRET") + .WithEnvironment("LOGS_CHANNEL_ID", "-123") + .WithEnvironment("CHATS_TO_MONITOR", """{"pro.hell": -666, "dotnetru": -42}""") + .WithEnvironment("ALLOWED_USERS", """{"vahter_1": 34, "vahter_2": 69}""") + .WithEnvironment("SHOULD_DELETE_CHANNEL_MESSAGES", "true") + .WithEnvironment("IGNORE_SIDE_EFFECTS", "false") + .WithEnvironment("USE_POLLING", "false") + .WithEnvironment("DATABASE_URL", $"Server={dbAlias};Database=vahter_bot_ban;Port=5432;User Id=vahter_bot_ban_service;Password=vahter_bot_ban_service;Include Error Detail=true;Minimum Pool Size=1;Maximum Pool Size=20;Max Auto Prepare=100;Auto Prepare Min Usages=1;Trust Server Certificate=true;") + .DependsOn(flywayContainer) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(80)) + .Build() + + interface IAsyncLifetime with + member this.InitializeAsync() = task { + // start building the image and spin up db at the same time + let imageTask = image.CreateAsync() + let dbTask = dbContainer.StartAsync() + + // wait for both to finish + do! imageTask + do! dbTask + + // initialize DB with the schema, database and a DB user + let script = File.ReadAllText(CommonDirectoryPath.GetSolutionDirectory().DirectoryPath + "/init.sql") + let! initResult = dbContainer.ExecScriptAsync(script) + if initResult.Stderr <> "" then + failwith initResult.Stderr + + // run migrations + do! flywayContainer.StartAsync() + + // seed some test data + // inserting the only admin users we have + // TODO might be a script in test assembly + let! _ = dbContainer.ExecAsync([|"""INSERT INTO "user"(id, username, banned_by, banned_at, ban_reason) VALUES (34, 'vahter_1', NULL, NULL, NULL), (69, 'vahter_2', NULL, NULL, NULL);"""|]) + + // start the app container + do! appContainer.StartAsync() + + // initialize the http client with correct hostname and port + httpClient <- new HttpClient() + uri <- Uri($"http://{appContainer.Hostname}:{appContainer.GetMappedPublicPort(80)}") + httpClient.BaseAddress <- uri + httpClient.DefaultRequestHeaders.Add("X-Telegram-Bot-Api-Secret-Token", "OUR_SECRET") + } + member this.DisposeAsync() = task { + // stop all the containers, flyway might be dead already + do! flywayContainer.DisposeAsync() + do! appContainer.DisposeAsync() + do! dbContainer.DisposeAsync() + // do! image.DisposeAsync() // might be faster not to dispose base image to cache? + } + + member _.Http = httpClient + member _.Uri = uri + + member this.SendMessage(update: Update) = task { + let json = JsonConvert.SerializeObject(update) + return! this.SendMessage(json) + } + + member _.SendMessage(json: string) = task { + let content = new StringContent(json, Encoding.UTF8, "application/json") + let! resp = httpClient.PostAsync("/bot", content) + return resp + } diff --git a/src/VahterBanBot.Tests/Program.fs b/src/VahterBanBot.Tests/Program.fs new file mode 100644 index 0000000..6bc27f8 --- /dev/null +++ b/src/VahterBanBot.Tests/Program.fs @@ -0,0 +1,7 @@ +open Xunit +open Xunit.Extensions.AssemblyFixture + +[] +do () + +module Program = let [] main _ = 0 diff --git a/src/VahterBanBot.Tests/Tests.fs b/src/VahterBanBot.Tests/Tests.fs new file mode 100644 index 0000000..bca8d56 --- /dev/null +++ b/src/VahterBanBot.Tests/Tests.fs @@ -0,0 +1,37 @@ +module Tests + +open System +open System.Net.Http +open System.Text +open Telegram.Bot.Types +open VahterBanBot.Tests.ContainerTestBase +open Xunit +open Xunit.Extensions.AssemblyFixture + +type Tests(containers: VahterTestContainers) = + [] + let ``Random path returns OK`` () = task { + let! resp = containers.Http.GetAsync("/" + Guid.NewGuid().ToString()) + let! body = resp.Content.ReadAsStringAsync() + Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode) + Assert.Equal("OK", body) + } + + [] + let ``Not possible to interact with the bot without authorization`` () = task { + let http = new HttpClient() + let content = new StringContent("""{"update_id":123}""", Encoding.UTF8, "application/json") + let uri = containers.Uri.ToString() + "bot" + let! resp = http.PostAsync(uri, content) + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, resp.StatusCode) + } + + [] + let ``Should be possible to interact with the bot`` () = task { + let! resp = Update(Id = 123) |> containers.SendMessage + let! body = resp.Content.ReadAsStringAsync() + Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode) + Assert.Equal("null", body) + } + + interface IAssemblyFixture diff --git a/src/VahterBanBot.Tests/VahterBanBot.Tests.fsproj b/src/VahterBanBot.Tests/VahterBanBot.Tests.fsproj new file mode 100644 index 0000000..b119d95 --- /dev/null +++ b/src/VahterBanBot.Tests/VahterBanBot.Tests.fsproj @@ -0,0 +1,33 @@ + + + + net7.0 + false + false + true + true + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/VahterBanBot/Program.fs b/src/VahterBanBot/Program.fs index b796527..a7880bb 100644 --- a/src/VahterBanBot/Program.fs +++ b/src/VahterBanBot/Program.fs @@ -13,7 +13,6 @@ open Telegram.Bot.Types open Giraffe open Microsoft.Extensions.DependencyInjection open Telegram.Bot.Types.Enums -open VahterBanBot open VahterBanBot.Cleanup open VahterBanBot.Utils open VahterBanBot.Bot @@ -136,30 +135,9 @@ let app = builder.Build() app.UseGiraffe(webApp) let server = app.RunAsync() -let telegramClient = app.Services.GetRequiredService() - -let getStartLogMsg() = - let sb = System.Text.StringBuilder() - %sb.AppendLine("Bot started with following configuration") - %sb.AppendLine("AllowedUsers:") - for KeyValue(username, userId) in botConf.AllowedUsers do - %sb.AppendLine($" {prependUsername username} ({userId})") - %sb.AppendLine("ChatsToMonitor:") - for KeyValue(username, chatId) in botConf.ChatsToMonitor do - %sb.AppendLine($" {prependUsername username} ({chatId})") - - let totalStats = (DB.getVahterStats None).Result - %sb.AppendLine (string totalStats) - - sb.ToString() - -if not botConf.IgnoreSideEffects then - let startLogMsg = getStartLogMsg() - app.Logger.LogInformation startLogMsg - telegramClient.SendTextMessageAsync(ChatId(botConf.LogsChannelId), startLogMsg).Wait() - // Dev mode only if botConf.UsePolling then + let telegramClient = app.Services.GetRequiredService() let pollingHandler = { new IUpdateHandler with member x.HandleUpdateAsync (botClient: ITelegramBotClient, update: Update, cancellationToken: CancellationToken) = diff --git a/src/VahterBanBot/StartupMessage.fs b/src/VahterBanBot/StartupMessage.fs new file mode 100644 index 0000000..866169e --- /dev/null +++ b/src/VahterBanBot/StartupMessage.fs @@ -0,0 +1,41 @@ +module VahterBanBot.StartupMessage + +open System.Text +open System.Threading.Tasks +open Microsoft.Extensions.Logging +open Telegram.Bot +open Telegram.Bot.Types +open VahterBanBot.Types +open VahterBanBot.Utils +open Microsoft.Extensions.Hosting + +type StartupMessage( + logger: ILogger, + telegramClient: ITelegramBotClient, + botConf: BotConfiguration +) = + let getStartLogMsg() = + let sb = StringBuilder() + %sb.AppendLine("Bot started with following configuration") + %sb.AppendLine("AllowedUsers:") + for KeyValue(username, userId) in botConf.AllowedUsers do + %sb.AppendLine($" {prependUsername username} ({userId})") + %sb.AppendLine("ChatsToMonitor:") + for KeyValue(username, chatId) in botConf.ChatsToMonitor do + %sb.AppendLine($" {prependUsername username} ({chatId})") + + let totalStats = (DB.getVahterStats None).Result + %sb.AppendLine (string totalStats) + + sb.ToString() + interface IHostedService with + member this.StartAsync _ = task { + if not botConf.IgnoreSideEffects then + let startLogMsg = getStartLogMsg() + logger.LogInformation startLogMsg + do! telegramClient.SendTextMessageAsync(ChatId(botConf.LogsChannelId), startLogMsg) + |> taskIgnore + } + + member this.StopAsync _ = + Task.CompletedTask diff --git a/src/VahterBanBot/VahterBanBot.fsproj b/src/VahterBanBot/VahterBanBot.fsproj index 91e3c1a..2e681da 100644 --- a/src/VahterBanBot/VahterBanBot.fsproj +++ b/src/VahterBanBot/VahterBanBot.fsproj @@ -13,6 +13,7 @@ + diff --git a/vahter-ban-bot.sln b/vahter-ban-bot.sln index 3f3c1c6..0149ac7 100644 --- a/vahter-ban-bot.sln +++ b/vahter-ban-bot.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B17C5EB6-2D8 EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VahterBanBot", "src\VahterBanBot\VahterBanBot.fsproj", "{3CDDAF2F-8555-476B-9CF0-298615ED39A6}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VahterBanBot.Tests", "src\VahterBanBot.Tests\VahterBanBot.Tests.fsproj", "{6AFF7FFB-F313-40FE-BF5D-6A5797CEB311}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,8 +22,13 @@ Global {3CDDAF2F-8555-476B-9CF0-298615ED39A6}.Debug|Any CPU.Build.0 = Debug|Any CPU {3CDDAF2F-8555-476B-9CF0-298615ED39A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {3CDDAF2F-8555-476B-9CF0-298615ED39A6}.Release|Any CPU.Build.0 = Release|Any CPU + {6AFF7FFB-F313-40FE-BF5D-6A5797CEB311}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AFF7FFB-F313-40FE-BF5D-6A5797CEB311}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AFF7FFB-F313-40FE-BF5D-6A5797CEB311}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AFF7FFB-F313-40FE-BF5D-6A5797CEB311}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {3CDDAF2F-8555-476B-9CF0-298615ED39A6} = {B17C5EB6-2D87-4E13-B740-4B220A7E4F66} + {6AFF7FFB-F313-40FE-BF5D-6A5797CEB311} = {B17C5EB6-2D87-4E13-B740-4B220A7E4F66} EndGlobalSection EndGlobal