From 4ad5eefea147d894aab085d3c5c13047c71ece5b Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 13:31:40 +0200 Subject: [PATCH 01/29] Delete automation doc --- docs/.gitkeep | 0 docs/automation.md | 19 ------------------- 2 files changed, 19 deletions(-) create mode 100644 docs/.gitkeep delete mode 100644 docs/automation.md diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/automation.md b/docs/automation.md deleted file mode 100644 index a710a66..0000000 --- a/docs/automation.md +++ /dev/null @@ -1,19 +0,0 @@ -# Automation - -## Platform - -* [GitLab](https://gitlab.com/) is used to run the CI (Continuous Integration) & PKG (Packaging = Continuous Delivery) pipeline, -which is defined in [`.gitlab-ci.yml`](../.gitlab-ci.yml) file. - -## Setup - -### GitLab > Settings > CI/CD > Variables - -* Add the following variables - -Name | Value | Protected | Masked ------------------- | ------------------ | --------- | ------ -SONAR_ORGANIZATION | Sonar Organization | No | No -SONAR_PROJECTKEY | Sonar Project Key | No | No -SONAR_HOSTURL | Sonar Instance URL | No | No -SONAR_TOKEN | Sonar Key | No | Yes From bc06bc09548da01c79c13dd3d018708c3c62ba42 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 13:32:00 +0200 Subject: [PATCH 02/29] Cosmetic code changes to Docker sample --- samples/terraform-docker/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/samples/terraform-docker/README.md b/samples/terraform-docker/README.md index b6d8ea0..631ae8b 100644 --- a/samples/terraform-docker/README.md +++ b/samples/terraform-docker/README.md @@ -1,17 +1,18 @@ # Samples This is a very simple sample to experiment the Terraform backend. + It will create and manage a container in Docker (inspired from [Terraform Get Started](https://learn.hashicorp.com/collections/terraform/docker-get-started)). ## Demonstration -* Make sure docker runtime is running and can be accessed from the command line +Make sure docker runtime is running and can be accessed from the command line: ```bash docker ps ``` -* Run the commands +Run the commands: ```bash cd samples/terraform-docker @@ -31,7 +32,7 @@ docker ps curl localhost:8000 ``` -* Destroy the container +Destroy the container: ```bash terraform destroy From aaeb1c6114234be5c9157a020e54c6258973503f Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 13:32:12 +0200 Subject: [PATCH 03/29] Dump version to 1.1.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 81040e0..bf566de 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 1.0.2 + 1.1.0 From 72a175cff895fd8618863f7559b8939c69b2d3bd Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 13:49:58 +0200 Subject: [PATCH 04/29] Use SUSE Base container images --- src/WebApi/Dockerfile | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/WebApi/Dockerfile b/src/WebApi/Dockerfile index db128dc..de0554e 100644 --- a/src/WebApi/Dockerfile +++ b/src/WebApi/Dockerfile @@ -1,18 +1,21 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM registry.suse.com/bci/dotnet-aspnet:8.0 AS base +USER app WORKDIR /app EXPOSE 8080 -EXPOSE 443 +EXPOSE 8081 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM registry.suse.com/bci/dotnet-sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release WORKDIR /src -COPY ["src/WebApi/WebApi.csproj", "src/WebApi/"] -RUN dotnet restore "src/WebApi/WebApi.csproj" +COPY ["src/", "./"] +RUN dotnet restore "WebApi/WebApi.csproj" COPY . . -WORKDIR "/src/src/WebApi" -RUN dotnet build "WebApi.csproj" -c Release -o /app/build +WORKDIR "/src/WebApi" +RUN dotnet build "WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish -RUN dotnet publish "WebApi.csproj" -c Release -o /app/publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM base AS final WORKDIR /app From 8502f548183326de0006181dd33e94193ab47b24 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 13:59:48 +0200 Subject: [PATCH 05/29] Improve README and update action version in GitLab Actions --- .github/workflows/ci.yaml | 6 ++-- .github/workflows/pkg.yaml | 6 ++-- CONTRIBUTING.md | 25 ++++++++++++++++ README.md | 61 +++++++++++--------------------------- compose.yaml | 2 +- 5 files changed, 49 insertions(+), 51 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 212188d..af14c58 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,11 +24,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Checkout workflow parts - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: devpro/github-workflow-parts ref: feature/sonar-login-deprecation @@ -61,7 +61,7 @@ jobs: uses: ./workflow-parts/docker/build-scan with: docker_file: 'src/WebApi/Dockerfile' - image_tag: 1.0.${{ github.run_id }} + image_tag: 1.1.${{ github.run_id }} image_path: 'docker.io/devprofr' image_name: terraform-backend-mongodb max_high_cves: 16 diff --git a/.github/workflows/pkg.yaml b/.github/workflows/pkg.yaml index 7de3c47..cd9a3b0 100644 --- a/.github/workflows/pkg.yaml +++ b/.github/workflows/pkg.yaml @@ -15,9 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Checkout workflow parts - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: devpro/github-workflow-parts ref: main @@ -29,7 +29,7 @@ jobs: container_username: ${{ secrets.DOCKERHUB_USERNAME }} container_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_file: 'src/WebApi/Dockerfile' - image_tag: 1.0.${{ github.run_id }} + image_tag: 1.1.${{ github.run_id }} image_path: 'docker.io/devprofr' image_name: terraform-backend-mongodb create_latest: ${{ github.ref_name == 'main' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8514e62 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Project development guide + +## Solution design + +This is a .NET 8 / C# codebase (open-source, cross-platform, free, object-oriented technologies). + +### Projects + +Project name | Technology | Project type +------------------------ | ---------- | -------------------------- +`Common.AspNetCore` | .NET 8 | Library +`Common.MongoDb` | .NET 8 | Library +`Common.Runtime` | .NET 8 | Library +`Domain` | .NET 8 | Library +`Infrastructure.MongoDb` | .NET 8 | Library +`WebApi` | ASP.NET 8 | Web application (REST API) + +### Packages (NuGet) + +Name | Description +------------------------ | ---------------------------- +`MongoDB.Bson` | MongoDB BSON +`MongoDB.Driver` | MongoDB .NET Driver +`Swashbuckle.AspNetCore` | OpenAPI / Swagger generation +`System.Text.Json` | JSON support diff --git a/README.md b/README.md index 8386be8..401ef63 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,24 @@ Store [Terraform](https://www.terraform.io) state in [MongoDB](https://www.mongodb.com/), using [HTTP](https://www.terraform.io/language/settings/backends/http) [backend](https://github.com/hashicorp/terraform/tree/main/internal/backend/remote-state). -## How to use +Look at the [project development guide](CONTRIBUTING.md) for more technical details. You're more than welcome to contribute! -* Create a MongoDB database (you can provision a cluster in MongoDB Atlas) +## Quick start + +1. Create a MongoDB database (example with a local container but you can provision a cluster in MongoDB Atlas) ```bash -# example on a MongoDB container running locally -docker run --name mongodb -d -p 27017:27017 mongo:5.0 +# starts the container +docker run --name mongodb -d -p 27017:27017 mongo:8.0 +# (optional) adds indexes for optimal performances +docker run --rm --link mongodb \ + -v "$(pwd)/scripts":/home/scripts mongo:8.0 \ + bash -c "mongo mongodb://mongodb:27017/terraform_backend_dev /home/scripts/mongo-create-index.js" ``` -* Run the web API +2. Run the web API -* Update the Terraform file +3. Update the Terraform file ```tf terraform { @@ -37,48 +43,15 @@ terraform { } ``` -* Execute usual Terraform command lines - -* (Optional) Add MongoDB indexes for optimal performances - -```bash -# example on a MongoDB container running locally -docker run --rm --link mongodb \ - -v "$(pwd)/scripts":/home/scripts mongo:5.0 \ - bash -c "mongo mongodb://mongodb:27017/terraform_backend_dev /home/scripts/mongo-create-index.js" -``` - -## How to demonstrate - -* Run the [terraform-docker sample](samples/terraform-docker/README.md) - -## How to contribute - -This is a .NET 8 / C# codebase (open-source, cross-platform, free, object-oriented technologies) - -### Project structure - -Project name | Technology | Project type ------------------------- | ---------- | -------------------------- -`Common.AspNetCore` | .NET 8 | Library -`Common.MongoDb` | .NET 8 | Library -`Common.Runtime` | .NET 8 | Library -`Domain` | .NET 8 | Library -`Infrastructure.MongoDb` | .NET 8 | Library -`WebApi` | ASP.NET 8 | Web application (REST API) +4. Execute usual Terraform command lines -### Packages (NuGet) +## Samples -Name | Description ------------------------- | ---------------------------- -`MongoDB.Bson` | MongoDB BSON -`MongoDB.Driver` | MongoDB .NET Driver -`Swashbuckle.AspNetCore` | OpenAPI / Swagger generation -`System.Text.Json` | JSON support +* [Docker](samples/terraform-docker/README.md) -## How to compare +## Alternatives & references -### Samples with other solutions +### Terraform backend implementations * [GitLab](https://gitlab.com/gitlab-org/manage/import/gitlab/-/blob/master/doc/user/infrastructure/terraform_state.md) * [lib/api/terraform/state.rb](https://gitlab.com/gitlab-org/manage/import/gitlab/-/blob/master/lib/api/terraform/state.rb) diff --git a/compose.yaml b/compose.yaml index b3e16c6..fe34055 100644 --- a/compose.yaml +++ b/compose.yaml @@ -20,6 +20,6 @@ services: depends_on: - mongodb mongodb: - image: mongo:6.0 + image: mongo:8.0 ports: - "27017:27017" From ba881a9f95c50d9ceb59d633587c928582e76840 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 14:05:14 +0200 Subject: [PATCH 06/29] Update max CVE to 0 in container scan --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af14c58..19933ac 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -64,7 +64,7 @@ jobs: image_tag: 1.1.${{ github.run_id }} image_path: 'docker.io/devprofr' image_name: terraform-backend-mongodb - max_high_cves: 16 - max_medium_cves: 11 + max_high_cves: 0 + max_medium_cves: 0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From c459bd9d07bf293670bd5721eb1cb016ee65327b Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 14:15:39 +0200 Subject: [PATCH 07/29] Update packages --- Devpro.TerraformBackend.sln | 16 +++++++++------- .../Common.AspNetCore.WebApi.csproj | 11 +++++------ src/Common.MongoDb/Common.MongoDb.csproj | 4 ++-- src/Domain/Domain.csproj | 2 +- src/WebApi/WebApi.csproj | 2 +- .../Resources/HealthCheckResourceTest.cs | 7 +------ .../WebApi.IntegrationTests.csproj | 12 ++++++------ 7 files changed, 25 insertions(+), 29 deletions(-) diff --git a/Devpro.TerraformBackend.sln b/Devpro.TerraformBackend.sln index ac2cf0f..554cf57 100644 --- a/Devpro.TerraformBackend.sln +++ b/Devpro.TerraformBackend.sln @@ -7,8 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 - Solution Items", "0 - S ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig .gitignore = .gitignore - .gitlab-ci.yml = .gitlab-ci.yml compose.yaml = compose.yaml + CONTRIBUTING.md = CONTRIBUTING.md Directory.Build.props = Directory.Build.props README.md = README.md EndProjectSection @@ -43,15 +43,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{7F01 scripts\mongo-create-index.js = scripts\mongo-create-index.js EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{4EC46C35-77AA-4C4E-AEAC-63A2D44C9CA6}" - ProjectSection(SolutionItems) = preProject - docs\automation.md = docs\automation.md - EndProjectSection -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.IntegrationTests", "test\WebApi.IntegrationTests\WebApi.IntegrationTests.csproj", "{B055FFAF-8261-43B1-866A-12E289D5D7DC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore.WebApi", "src\Common.AspNetCore.WebApi\Common.AspNetCore.WebApi.csproj", "{19336002-C959-4E76-B112-861F93CF6423}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + .gitlab-ci.yml = .gitlab-ci.yml + .github\workflows\ci.yaml = .github\workflows\ci.yaml + .github\workflows\pkg.yaml = .github\workflows\pkg.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -99,9 +101,9 @@ Global {F23098F5-355B-46F0-BABE-3D6E23D8EED7} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} {001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} {7F01D180-1A34-4377-B4E5-C852D8302CE7} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} - {4EC46C35-77AA-4C4E-AEAC-63A2D44C9CA6} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} {B055FFAF-8261-43B1-866A-12E289D5D7DC} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} {19336002-C959-4E76-B112-861F93CF6423} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC545534-6A10-475B-A0DA-3374CC025D82} diff --git a/src/Common.AspNetCore.WebApi/Common.AspNetCore.WebApi.csproj b/src/Common.AspNetCore.WebApi/Common.AspNetCore.WebApi.csproj index b4e46c2..eebeae5 100644 --- a/src/Common.AspNetCore.WebApi/Common.AspNetCore.WebApi.csproj +++ b/src/Common.AspNetCore.WebApi/Common.AspNetCore.WebApi.csproj @@ -13,14 +13,13 @@ - - - - + + + - - + + diff --git a/src/Common.MongoDb/Common.MongoDb.csproj b/src/Common.MongoDb/Common.MongoDb.csproj index 60e6c6a..9711fa2 100644 --- a/src/Common.MongoDb/Common.MongoDb.csproj +++ b/src/Common.MongoDb/Common.MongoDb.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj index 7d6594a..ae3da51 100644 --- a/src/Domain/Domain.csproj +++ b/src/Domain/Domain.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/WebApi/WebApi.csproj b/src/WebApi/WebApi.csproj index 5bf6a3d..2ea7686 100644 --- a/src/WebApi/WebApi.csproj +++ b/src/WebApi/WebApi.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs index e561721..1ca9f54 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs @@ -6,13 +6,8 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources { [Trait("Category", "IntegrationTests")] - public class HealthCheckResourceTest : IntegrationTestBase + public class HealthCheckResourceTest(WebApplicationFactory factory) : IntegrationTestBase(factory) { - public HealthCheckResourceTest(WebApplicationFactory factory) - : base(factory) - { - } - [Fact] [Trait("Mode", "Readonly")] public async Task HealthCheckResource_Get_ReturnsOk() diff --git a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj index d3b60df..b01e1e9 100644 --- a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj +++ b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj @@ -10,16 +10,16 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all From 8b4cef721bd34d30339d6d83489b01b479e4560c Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 14:18:57 +0200 Subject: [PATCH 08/29] Use ThrowIfNull in RawRequestBodyFormatter CanRead method --- src/Common.AspNetCore/RawRequestBodyFormatter.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Common.AspNetCore/RawRequestBodyFormatter.cs b/src/Common.AspNetCore/RawRequestBodyFormatter.cs index 04b276e..bec0099 100644 --- a/src/Common.AspNetCore/RawRequestBodyFormatter.cs +++ b/src/Common.AspNetCore/RawRequestBodyFormatter.cs @@ -26,10 +26,7 @@ public RawRequestBodyFormatter() public override bool CanRead(InputFormatterContext context) { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } + ArgumentNullException.ThrowIfNull(context); var contentType = context.HttpContext.Request.ContentType; return string.IsNullOrEmpty(contentType) From 22fafe4e69bbcffa2df65ca6a5125ffb873f8287 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 14:27:50 +0200 Subject: [PATCH 09/29] Add CI/CD pipelines section in CONTRIBUTING --- CONTRIBUTING.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8514e62..062ce4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,3 +23,19 @@ Name | Description `MongoDB.Driver` | MongoDB .NET Driver `Swashbuckle.AspNetCore` | OpenAPI / Swagger generation `System.Text.Json` | JSON support + +## CI/CD pipelines + +GitHub Actions are triggered to automate the application lifecycle: + +- [CI](.github/workflows/ci.yaml) (Continuous Integration) +- [PKG](.github/workflows/pkg.yaml) (Continuous Delivery) + +GitHub project has been configured, in General / Security / Secrets and Variables / Actions: + +- DOCKERHUB_TOKEN +- DOCKERHUB_USERNAME +- SONAR_HOST_URL +- SONAR_ORG +- SONAR_PROJECT_KEY +- SONAR_TOKEN From b8e7319598b8530b97d64e2500334d51e6aacc21 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 14:32:00 +0200 Subject: [PATCH 10/29] Update Sonar key in badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 401ef63..876268f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/ci.yaml) [![PKG](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/pkg.yaml/badge.svg?branch=main)](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/pkg.yaml) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=devpro.terraform-backend-mongodb&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=devpro.terraform-backend-mongodb) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=devpro_terraform-backend-mongodb&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=devpro_terraform-backend-mongodb) [![Docker Image Version](https://img.shields.io/docker/v/devprofr/terraform-backend-mongodb?label=Image&logo=docker)](https://hub.docker.com/r/devprofr/terraform-backend-mongodb) Store [Terraform](https://www.terraform.io) state in [MongoDB](https://www.mongodb.com/), using From 232c95649aa824caa5cde131606a03da65ab349c Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 17:33:23 +0200 Subject: [PATCH 11/29] Fix Sonar warning about Dockerfile --- src/WebApi/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebApi/Dockerfile b/src/WebApi/Dockerfile index de0554e..daa926c 100644 --- a/src/WebApi/Dockerfile +++ b/src/WebApi/Dockerfile @@ -11,11 +11,11 @@ COPY ["src/", "./"] RUN dotnet restore "WebApi/WebApi.csproj" COPY . . WORKDIR "/src/WebApi" -RUN dotnet build "WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/build +RUN dotnet build "WebApi.csproj" -c "$BUILD_CONFIGURATION" -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet publish "WebApi.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false FROM base AS final WORKDIR /app From deeba663b6984256332b99b6c5c235f5e2965611 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 17:42:47 +0200 Subject: [PATCH 12/29] Code improvements following update to .NET 8 --- .../DefaultMongoClientFactory.cs | 4 ++-- src/WebApi/ApplicationConfiguration.cs | 7 +----- .../BasicAuthenticationHandler.cs | 10 ++++----- src/WebApi/Controllers/StateController.cs | 22 +++++-------------- ...thenticationServiceCollectionExtensions.cs | 5 ----- .../IntegrationTestBase.cs | 11 ++-------- 6 files changed, 16 insertions(+), 43 deletions(-) diff --git a/src/Common.MongoDb/DefaultMongoClientFactory.cs b/src/Common.MongoDb/DefaultMongoClientFactory.cs index d6a0546..61ff0c2 100644 --- a/src/Common.MongoDb/DefaultMongoClientFactory.cs +++ b/src/Common.MongoDb/DefaultMongoClientFactory.cs @@ -6,7 +6,7 @@ namespace Devpro.Common.MongoDb { public class DefaultMongoClientFactory : IMongoClientFactory { - public DefaultMongoClientFactory() + static DefaultMongoClientFactory() { RegisterConventions(); } @@ -22,7 +22,7 @@ public virtual MongoClient CreateClient(string connectionString) /// /// See https://github.com/mongodb/mongo-csharp-driver/tree/master/src/MongoDB.Bson/Serialization/Conventions /// - protected virtual void RegisterConventions() + protected static void RegisterConventions() { var pack = new ConventionPack { diff --git a/src/WebApi/ApplicationConfiguration.cs b/src/WebApi/ApplicationConfiguration.cs index 7a29909..1e993f4 100644 --- a/src/WebApi/ApplicationConfiguration.cs +++ b/src/WebApi/ApplicationConfiguration.cs @@ -2,13 +2,8 @@ namespace Devpro.TerraformBackend.WebApi { - public class ApplicationConfiguration : WebApiConfiguration + public class ApplicationConfiguration(IConfigurationRoot configurationRoot) : WebApiConfiguration(configurationRoot) { - public ApplicationConfiguration(IConfigurationRoot configurationRoot) - : base(configurationRoot) - { - } - public MongoDbConfiguration MongoDbConfiguration => new() { diff --git a/src/WebApi/Authentication/BasicAuthenticationHandler.cs b/src/WebApi/Authentication/BasicAuthenticationHandler.cs index bd50a90..2841d80 100644 --- a/src/WebApi/Authentication/BasicAuthenticationHandler.cs +++ b/src/WebApi/Authentication/BasicAuthenticationHandler.cs @@ -17,7 +17,7 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header")); } - var authorizationHeader = Request.Headers["Authorization"].ToString(); + var authorizationHeader = Request.Headers.Authorization.ToString(); // raises an error if the authorization header is not Basic if (!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) @@ -27,7 +27,7 @@ protected override Task HandleAuthenticateAsync() // decrypts the authorization header and split out the client id/secret which is separated by the first ':' var authBase64Decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authorizationHeader.Replace("Basic ", "", StringComparison.OrdinalIgnoreCase))); - var authSplit = authBase64Decoded.Split(new[] { ':' }, 2); + var authSplit = authBase64Decoded.Split([':'], 2); // sends an error if no username and password if (authSplit.Length != 2) @@ -55,10 +55,10 @@ protected override Task HandleAuthenticateAsync() }; // set the client ID as the name claim type - var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(client, new[] - { + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(client, + [ new Claim(ClaimTypes.Name, clientId) - })); + ])); // returns a success result return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name))); diff --git a/src/WebApi/Controllers/StateController.cs b/src/WebApi/Controllers/StateController.cs index ab9bc4b..571900c 100644 --- a/src/WebApi/Controllers/StateController.cs +++ b/src/WebApi/Controllers/StateController.cs @@ -9,18 +9,8 @@ namespace Devpro.TerraformBackend.WebApi.Controllers [Authorize] [ApiController] [Route("state")] - public class StateController : ControllerBase + public class StateController(IStateRepository stateRepository, IStateLockRepository stateLockRepository) : ControllerBase { - private readonly IStateRepository _stateRepository; - - private readonly IStateLockRepository _stateLockRepository; - - public StateController(IStateRepository stateRepository, IStateLockRepository stateLockRepository) - { - _stateRepository = stateRepository; - _stateLockRepository = stateLockRepository; - } - /// /// Get Terraform state value. /// @@ -32,7 +22,7 @@ public StateController(IStateRepository stateRepository, IStateLockRepository st public async Task FindOne(string name, [FromQuery(Name = "ID")] string? lockId = "") { //TODO: check lock - return await _stateRepository.FindOneAsync(name); + return await stateRepository.FindOneAsync(name); } /// @@ -48,7 +38,7 @@ public async Task Create(string name, [FromBody] object input, [FromQuery(Name = { //TODO: check lock var jsonInput = JsonSerializer.Serialize(input); - await _stateRepository.CreateAsync(name, jsonInput); + await stateRepository.CreateAsync(name, jsonInput); } [HttpGet("/locks", Name = "GetStateLocks")] @@ -56,7 +46,7 @@ public async Task Create(string name, [FromBody] object input, [FromQuery(Name = public async Task> FindAllLocks([FromQuery] string? name = "") { //TODO: only for admins - return await _stateLockRepository.FindAllAsync(); + return await stateLockRepository.FindAllAsync(); } [HttpPost("{name}/lock", Name = "CreateStateLock")] @@ -66,7 +56,7 @@ public async Task> FindAllLocks([FromQuery] string? name = public async Task Lock(string name, StateLockModel input) { input.Name = name; - await _stateLockRepository.CreateAsync(input); + await stateLockRepository.CreateAsync(input); } [HttpDelete("{name}/lock", Name = "DeleteStateLock")] @@ -76,7 +66,7 @@ public async Task Lock(string name, StateLockModel input) public async Task Unlock(string name, [FromBody] StateLockModel input) { input.Name = name; - await _stateLockRepository.DeleteAsync(input); + await stateLockRepository.DeleteAsync(input); } } } diff --git a/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs index 5870332..5c27025 100644 --- a/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs @@ -8,11 +8,6 @@ public static class AuthenticationServiceCollectionExtensions { public static AuthenticationBuilder AddBasicAuthentication(this IServiceCollection services) { - if (services == null) - { - throw new ArgumentNullException(nameof(services)); - } - return services.AddAuthentication() .AddScheme(BasicAuthenticationDefaults.AuthenticationScheme, null); } diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 88f1225..6defbe9 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -7,18 +7,11 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests /// /// See https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests /// - public abstract class IntegrationTestBase : IClassFixture> + public abstract class IntegrationTestBase(WebApplicationFactory factory) : IClassFixture> { - private readonly WebApplicationFactory _factory; - - protected IntegrationTestBase(WebApplicationFactory factory) - { - _factory = factory; - } - protected HttpClient CreateClient() { - return _factory.CreateClient(); + return factory.CreateClient(); } } } From 0db3475219cdda16e26bb5ab83e086c5be35327a Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 17:58:53 +0200 Subject: [PATCH 13/29] Use file scoped namespace --- .../BasicAuthenticationClient.cs | 13 +- .../BasicAuthenticationDefaults.cs | 9 +- .../BasicAuthorizationAttribute.cs | 11 +- .../Builder/PolicyBuilderExtensions.cs | 17 ++- .../Builder/SwaggerBuilderExtensions.cs | 21 ++-- .../Configuration/WebApiConfiguration.cs | 44 +++---- .../RawRequestBodyFormatter.cs | 83 +++++++------ .../DefaultMongoClientFactory.cs | 51 ++++---- src/Common.MongoDb/IMongoClientFactory.cs | 21 ++-- src/Common.MongoDb/MongoDbConfiguration.cs | 11 +- src/Domain/Models/StateLockModel.cs | 91 +++++++------- src/Domain/Models/StateModel.cs | 19 ++- src/Domain/Models/StateValueModel.cs | 21 ++-- .../Repositories/IStateLockRepository.cs | 15 ++- src/Domain/Repositories/IStateRepository.cs | 11 +- .../BasicAuthenticationHandler.cs | 92 +++++++------- src/WebApi/Controllers/StateController.cs | 115 +++++++++--------- ...thenticationServiceCollectionExtensions.cs | 13 +- .../BehaviorServiceCollectionExtensions.cs | 33 +++-- ...frastructureServiceCollectionExtensions.cs | 21 ++-- .../SwaggerServiceCollectionExtensions.cs | 55 ++++----- 21 files changed, 371 insertions(+), 396 deletions(-) diff --git a/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationClient.cs b/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationClient.cs index 30944bb..cb608c7 100644 --- a/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationClient.cs +++ b/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationClient.cs @@ -1,13 +1,12 @@ using System.Security.Principal; -namespace Devpro.Common.AspNetCore.WebApi.Authentication +namespace Devpro.Common.AspNetCore.WebApi.Authentication; + +public class BasicAuthenticationClient : IIdentity { - public class BasicAuthenticationClient : IIdentity - { - public string? AuthenticationType { get; set; } + public string? AuthenticationType { get; set; } - public bool IsAuthenticated { get; set; } + public bool IsAuthenticated { get; set; } - public string? Name { get; set; } - } + public string? Name { get; set; } } diff --git a/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationDefaults.cs b/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationDefaults.cs index 38694b4..2310fab 100644 --- a/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationDefaults.cs +++ b/src/Common.AspNetCore.WebApi/Authentication/BasicAuthenticationDefaults.cs @@ -1,7 +1,6 @@ -namespace Devpro.Common.AspNetCore.WebApi.Authentication +namespace Devpro.Common.AspNetCore.WebApi.Authentication; + +public class BasicAuthenticationDefaults { - public class BasicAuthenticationDefaults - { - public const string AuthenticationScheme = "Basic"; - } + public const string AuthenticationScheme = "Basic"; } diff --git a/src/Common.AspNetCore.WebApi/Authentication/BasicAuthorizationAttribute.cs b/src/Common.AspNetCore.WebApi/Authentication/BasicAuthorizationAttribute.cs index abef508..55f884e 100644 --- a/src/Common.AspNetCore.WebApi/Authentication/BasicAuthorizationAttribute.cs +++ b/src/Common.AspNetCore.WebApi/Authentication/BasicAuthorizationAttribute.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Authorization; -namespace Devpro.Common.AspNetCore.WebApi.Authentication +namespace Devpro.Common.AspNetCore.WebApi.Authentication; + +public class BasicAuthorizationAttribute : AuthorizeAttribute { - public class BasicAuthorizationAttribute : AuthorizeAttribute + public BasicAuthorizationAttribute() { - public BasicAuthorizationAttribute() - { - AuthenticationSchemes = BasicAuthenticationDefaults.AuthenticationScheme; - } + AuthenticationSchemes = BasicAuthenticationDefaults.AuthenticationScheme; } } diff --git a/src/Common.AspNetCore.WebApi/Builder/PolicyBuilderExtensions.cs b/src/Common.AspNetCore.WebApi/Builder/PolicyBuilderExtensions.cs index 6604a89..b5780c4 100644 --- a/src/Common.AspNetCore.WebApi/Builder/PolicyBuilderExtensions.cs +++ b/src/Common.AspNetCore.WebApi/Builder/PolicyBuilderExtensions.cs @@ -1,18 +1,17 @@ using Devpro.Common.AspNetCore.WebApi.Configuration; using Microsoft.AspNetCore.Builder; -namespace Devpro.Common.AspNetCore.WebApi.Builder +namespace Devpro.Common.AspNetCore.WebApi.Builder; + +public static class PolicyBuilderExtensions { - public static class PolicyBuilderExtensions + public static IApplicationBuilder UseHttpsRedirection(this IApplicationBuilder app, WebApiConfiguration configuration) { - public static IApplicationBuilder UseHttpsRedirection(this IApplicationBuilder app, WebApiConfiguration configuration) + if (configuration.IsHttpsRedirectionEnabled) { - if (configuration.IsHttpsRedirectionEnabled) - { - app.UseHttpsRedirection(); - } - - return app; + app.UseHttpsRedirection(); } + + return app; } } diff --git a/src/Common.AspNetCore.WebApi/Builder/SwaggerBuilderExtensions.cs b/src/Common.AspNetCore.WebApi/Builder/SwaggerBuilderExtensions.cs index fad7302..9bd4b27 100644 --- a/src/Common.AspNetCore.WebApi/Builder/SwaggerBuilderExtensions.cs +++ b/src/Common.AspNetCore.WebApi/Builder/SwaggerBuilderExtensions.cs @@ -1,21 +1,20 @@ using Devpro.Common.AspNetCore.WebApi.Configuration; using Microsoft.AspNetCore.Builder; -namespace Devpro.Common.AspNetCore.WebApi.Builder +namespace Devpro.Common.AspNetCore.WebApi.Builder; + +public static class SwaggerBuilderExtensions { - public static class SwaggerBuilderExtensions + public static IApplicationBuilder UseSwagger(this IApplicationBuilder app, WebApiConfiguration configuration) { - public static IApplicationBuilder UseSwagger(this IApplicationBuilder app, WebApiConfiguration configuration) + if (configuration.IsSwaggerEnabled) { - if (configuration.IsSwaggerEnabled) - { - var openApi = configuration.OpenApi; - - app.UseSwagger(); - app.UseSwaggerUI(c => c.SwaggerEndpoint($"/swagger/{openApi.Version}/swagger.json", $"{openApi.Title} {openApi.Version}")); - } + var openApi = configuration.OpenApi; - return app; + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint($"/swagger/{openApi.Version}/swagger.json", $"{openApi.Title} {openApi.Version}")); } + + return app; } } diff --git a/src/Common.AspNetCore.WebApi/Configuration/WebApiConfiguration.cs b/src/Common.AspNetCore.WebApi/Configuration/WebApiConfiguration.cs index 6920845..eee1be5 100644 --- a/src/Common.AspNetCore.WebApi/Configuration/WebApiConfiguration.cs +++ b/src/Common.AspNetCore.WebApi/Configuration/WebApiConfiguration.cs @@ -1,44 +1,38 @@ using Microsoft.Extensions.Configuration; using Microsoft.OpenApi.Models; -namespace Devpro.Common.AspNetCore.WebApi.Configuration -{ - public class WebApiConfiguration - { - protected IConfigurationRoot ConfigurationRoot { get; } +namespace Devpro.Common.AspNetCore.WebApi.Configuration; - public WebApiConfiguration(IConfigurationRoot configurationRoot) - { - ConfigurationRoot = configurationRoot; - } +public class WebApiConfiguration(IConfigurationRoot configurationRoot) +{ + protected IConfigurationRoot ConfigurationRoot { get; } = configurationRoot; - // flags + // flags - public bool IsOpenTelemetryEnabled => TryGetSection("Application:IsOpenTelemetryEnabled").Get(); + public bool IsOpenTelemetryEnabled => TryGetSection("Application:IsOpenTelemetryEnabled").Get(); - public bool IsHttpsRedirectionEnabled => TryGetSection("Application:IsHttpsRedirectionEnabled").Get(); + public bool IsHttpsRedirectionEnabled => TryGetSection("Application:IsHttpsRedirectionEnabled").Get(); - public bool IsSwaggerEnabled => TryGetSection("Application:IsSwaggerEnabled").Get(); + public bool IsSwaggerEnabled => TryGetSection("Application:IsSwaggerEnabled").Get(); - // definitions + // definitions - public static string HealthCheckEndpoint => "/health"; + public static string HealthCheckEndpoint => "/health"; - public OpenApiInfo OpenApi => TryGetSection("OpenApi").Get() ?? throw new Exception(""); + public OpenApiInfo OpenApi => TryGetSection("OpenApi").Get() ?? throw new Exception(""); - public string OpenTelemetryService => TryGetSection("OpenTelemetry:ServiceName").Get() ?? ""; + public string OpenTelemetryService => TryGetSection("OpenTelemetry:ServiceName").Get() ?? ""; - // infrastructure + // infrastructure - public string OpenTelemetryCollectorEndpoint => TryGetSection("OpenTelemetry:CollectorEndpoint").Get() ?? ""; + public string OpenTelemetryCollectorEndpoint => TryGetSection("OpenTelemetry:CollectorEndpoint").Get() ?? ""; - // protected methods + // protected methods - protected IConfigurationSection TryGetSection(string sectionKey) - { - return ConfigurationRoot.GetSection(sectionKey) - ?? throw new ArgumentException("Missing section \"" + sectionKey + "\" in configuration", nameof(sectionKey)); - } + protected IConfigurationSection TryGetSection(string sectionKey) + { + return ConfigurationRoot.GetSection(sectionKey) + ?? throw new ArgumentException("Missing section \"" + sectionKey + "\" in configuration", nameof(sectionKey)); } } diff --git a/src/Common.AspNetCore/RawRequestBodyFormatter.cs b/src/Common.AspNetCore/RawRequestBodyFormatter.cs index bec0099..686f743 100644 --- a/src/Common.AspNetCore/RawRequestBodyFormatter.cs +++ b/src/Common.AspNetCore/RawRequestBodyFormatter.cs @@ -4,57 +4,56 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Devpro.Common.AspNetCore +namespace Devpro.Common.AspNetCore; + +/// +/// ASP.NET Core input formatter to manage raw request bodies. +/// +/// +/// Solution found at https://weblog.west-wind.com/posts/2017/Sep/14/Accepting-Raw-Request-Body-Content-in-ASPNET-Core-API-Controllers +/// +public class RawRequestBodyFormatter : InputFormatter { - /// - /// ASP.NET Core input formatter to manage raw request bodies. - /// - /// - /// Solution found at https://weblog.west-wind.com/posts/2017/Sep/14/Accepting-Raw-Request-Body-Content-in-ASPNET-Core-API-Controllers - /// - public class RawRequestBodyFormatter : InputFormatter + private const string PlainTextContentType = "text/plain"; + + private const string OctetStreamApplicationContentType = "application/octet-stream"; + + public RawRequestBodyFormatter() { - private const string PlainTextContentType = "text/plain"; + SupportedMediaTypes.Add(new MediaTypeHeaderValue(PlainTextContentType)); + SupportedMediaTypes.Add(new MediaTypeHeaderValue(OctetStreamApplicationContentType)); + } - private const string OctetStreamApplicationContentType = "application/octet-stream"; + public override bool CanRead(InputFormatterContext context) + { + ArgumentNullException.ThrowIfNull(context); - public RawRequestBodyFormatter() - { - SupportedMediaTypes.Add(new MediaTypeHeaderValue(PlainTextContentType)); - SupportedMediaTypes.Add(new MediaTypeHeaderValue(OctetStreamApplicationContentType)); - } + var contentType = context.HttpContext.Request.ContentType; + return string.IsNullOrEmpty(contentType) + || contentType == PlainTextContentType + || contentType == OctetStreamApplicationContentType; + } - public override bool CanRead(InputFormatterContext context) - { - ArgumentNullException.ThrowIfNull(context); + public override async Task ReadRequestBodyAsync(InputFormatterContext context) + { + var request = context.HttpContext.Request; - var contentType = context.HttpContext.Request.ContentType; - return string.IsNullOrEmpty(contentType) - || contentType == PlainTextContentType - || contentType == OctetStreamApplicationContentType; + if (string.IsNullOrEmpty(request.ContentType) + || request.ContentType == PlainTextContentType) + { + using var reader = new StreamReader(request.Body); + var content = await reader.ReadToEndAsync(); + return await InputFormatterResult.SuccessAsync(content); } - public override async Task ReadRequestBodyAsync(InputFormatterContext context) + if (request.ContentType == OctetStreamApplicationContentType) { - var request = context.HttpContext.Request; - - if (string.IsNullOrEmpty(request.ContentType) - || request.ContentType == PlainTextContentType) - { - using var reader = new StreamReader(request.Body); - var content = await reader.ReadToEndAsync(); - return await InputFormatterResult.SuccessAsync(content); - } - - if (request.ContentType == OctetStreamApplicationContentType) - { - using var ms = new MemoryStream(2048); - await request.Body.CopyToAsync(ms); - var content = ms.ToArray(); - return await InputFormatterResult.SuccessAsync(content); - } - - return await InputFormatterResult.FailureAsync(); + using var ms = new MemoryStream(2048); + await request.Body.CopyToAsync(ms); + var content = ms.ToArray(); + return await InputFormatterResult.SuccessAsync(content); } + + return await InputFormatterResult.FailureAsync(); } } diff --git a/src/Common.MongoDb/DefaultMongoClientFactory.cs b/src/Common.MongoDb/DefaultMongoClientFactory.cs index 61ff0c2..5db07c5 100644 --- a/src/Common.MongoDb/DefaultMongoClientFactory.cs +++ b/src/Common.MongoDb/DefaultMongoClientFactory.cs @@ -2,36 +2,35 @@ using MongoDB.Bson.Serialization.Conventions; using MongoDB.Driver; -namespace Devpro.Common.MongoDb +namespace Devpro.Common.MongoDb; + +public class DefaultMongoClientFactory : IMongoClientFactory { - public class DefaultMongoClientFactory : IMongoClientFactory + static DefaultMongoClientFactory() { - static DefaultMongoClientFactory() - { - RegisterConventions(); - } + RegisterConventions(); + } - public virtual MongoClient CreateClient(string connectionString) - { - return new MongoClient(connectionString); - } + public virtual MongoClient CreateClient(string connectionString) + { + return new MongoClient(connectionString); + } - /// - /// Register usual conventions. - /// - /// - /// See https://github.com/mongodb/mongo-csharp-driver/tree/master/src/MongoDB.Bson/Serialization/Conventions - /// - protected static void RegisterConventions() + /// + /// Register usual conventions. + /// + /// + /// See https://github.com/mongodb/mongo-csharp-driver/tree/master/src/MongoDB.Bson/Serialization/Conventions + /// + protected static void RegisterConventions() + { + var pack = new ConventionPack { - var pack = new ConventionPack - { - new CamelCaseElementNameConvention(), - new EnumRepresentationConvention(BsonType.String), - new IgnoreExtraElementsConvention(true), - new IgnoreIfNullConvention(true) - }; - ConventionRegistry.Register("Conventions", pack, t => true); - } + new CamelCaseElementNameConvention(), + new EnumRepresentationConvention(BsonType.String), + new IgnoreExtraElementsConvention(true), + new IgnoreIfNullConvention(true) + }; + ConventionRegistry.Register("Conventions", pack, t => true); } } diff --git a/src/Common.MongoDb/IMongoClientFactory.cs b/src/Common.MongoDb/IMongoClientFactory.cs index fe92060..a6c282d 100644 --- a/src/Common.MongoDb/IMongoClientFactory.cs +++ b/src/Common.MongoDb/IMongoClientFactory.cs @@ -1,17 +1,16 @@ using MongoDB.Driver; -namespace Devpro.Common.MongoDb +namespace Devpro.Common.MongoDb; + +/// +/// Avoids calling "new" in application code. +/// +public interface IMongoClientFactory { /// - /// Avoids calling "new" in application code. + /// Creates MongoDB client from a given connection string. /// - public interface IMongoClientFactory - { - /// - /// Creates MongoDB client from a given connection string. - /// - /// - /// - MongoClient CreateClient(string connectionString); - } + /// + /// + MongoClient CreateClient(string connectionString); } diff --git a/src/Common.MongoDb/MongoDbConfiguration.cs b/src/Common.MongoDb/MongoDbConfiguration.cs index c50ad8e..6236c43 100644 --- a/src/Common.MongoDb/MongoDbConfiguration.cs +++ b/src/Common.MongoDb/MongoDbConfiguration.cs @@ -1,9 +1,8 @@ -namespace Devpro.Common.MongoDb +namespace Devpro.Common.MongoDb; + +public class MongoDbConfiguration { - public class MongoDbConfiguration - { - public string ConnectionString { get; set; } = string.Empty; + public string ConnectionString { get; set; } = string.Empty; - public string DatabaseName { get; set; } = string.Empty; - } + public string DatabaseName { get; set; } = string.Empty; } diff --git a/src/Domain/Models/StateLockModel.cs b/src/Domain/Models/StateLockModel.cs index b036923..4da30cc 100644 --- a/src/Domain/Models/StateLockModel.cs +++ b/src/Domain/Models/StateLockModel.cs @@ -2,51 +2,50 @@ using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; -namespace Devpro.TerraformBackend.Domain.Models +namespace Devpro.TerraformBackend.Domain.Models; + +public class StateLockModel { - public class StateLockModel - { - /// - /// Terraform state lock ID. - /// - [BsonId] - [BsonRepresentation(BsonType.String)] - [JsonPropertyName("ID")] - public string Id { get; set; } = string.Empty; - - /// - /// Name of the Terraform state. - /// - public string Name { get; set; } = string.Empty; - - /// - /// Terraform operation. - /// - public string Operation { get; set; } = string.Empty; - - /// - /// Terraform info. - /// - public string Info { get; set; } = string.Empty; - - /// - /// Terraform state lock owner. - /// - public string Who { get; set; } = string.Empty; - - /// - /// Terraform version. - /// - public string Version { get; set; } = string.Empty; - - /// - /// Terraform state lock timestamp. - /// - public string Created { get; set; } = string.Empty; - - /// - /// Terraform path. - /// - public string Path { get; set; } = string.Empty; - } + /// + /// Terraform state lock ID. + /// + [BsonId] + [BsonRepresentation(BsonType.String)] + [JsonPropertyName("ID")] + public string Id { get; set; } = string.Empty; + + /// + /// Name of the Terraform state. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Terraform operation. + /// + public string Operation { get; set; } = string.Empty; + + /// + /// Terraform info. + /// + public string Info { get; set; } = string.Empty; + + /// + /// Terraform state lock owner. + /// + public string Who { get; set; } = string.Empty; + + /// + /// Terraform version. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Terraform state lock timestamp. + /// + public string Created { get; set; } = string.Empty; + + /// + /// Terraform path. + /// + public string Path { get; set; } = string.Empty; } diff --git a/src/Domain/Models/StateModel.cs b/src/Domain/Models/StateModel.cs index 472bbc5..14d5904 100644 --- a/src/Domain/Models/StateModel.cs +++ b/src/Domain/Models/StateModel.cs @@ -2,18 +2,17 @@ using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; -namespace Devpro.TerraformBackend.Domain.Models +namespace Devpro.TerraformBackend.Domain.Models; + +public class StateModel { - public class StateModel - { - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = string.Empty; + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } = DateTime.MinValue; + public DateTime CreatedAt { get; set; } = DateTime.MinValue; - public StateValueModel Value { get; set; } = new StateValueModel(); - } + public StateValueModel Value { get; set; } = new StateValueModel(); } diff --git a/src/Domain/Models/StateValueModel.cs b/src/Domain/Models/StateValueModel.cs index e633a64..b2eb73d 100644 --- a/src/Domain/Models/StateValueModel.cs +++ b/src/Domain/Models/StateValueModel.cs @@ -1,21 +1,20 @@ using System; using MongoDB.Bson.Serialization.Attributes; -namespace Devpro.TerraformBackend.Domain.Models +namespace Devpro.TerraformBackend.Domain.Models; + +public class StateValueModel { - public class StateValueModel - { - public int Version { get; set; } = 0; + public int Version { get; set; } = 0; - [BsonElement("terraform_version")] - public string TerraformVersion { get; set; } = string.Empty; + [BsonElement("terraform_version")] + public string TerraformVersion { get; set; } = string.Empty; - public int Serial { get; set; } = 0; + public int Serial { get; set; } = 0; - public string Lineage { get; set; } = string.Empty; + public string Lineage { get; set; } = string.Empty; - public object Outputs { get; set; } = new { }; + public object Outputs { get; set; } = new { }; - public object[] Resources { get; set; } = Array.Empty(); - } + public object[] Resources { get; set; } = Array.Empty(); } diff --git a/src/Domain/Repositories/IStateLockRepository.cs b/src/Domain/Repositories/IStateLockRepository.cs index 51a9834..f89590a 100644 --- a/src/Domain/Repositories/IStateLockRepository.cs +++ b/src/Domain/Repositories/IStateLockRepository.cs @@ -2,16 +2,15 @@ using System.Threading.Tasks; using Devpro.TerraformBackend.Domain.Models; -namespace Devpro.TerraformBackend.Domain.Repositories +namespace Devpro.TerraformBackend.Domain.Repositories; + +public interface IStateLockRepository { - public interface IStateLockRepository - { - Task FindOneAsync(string id); + Task FindOneAsync(string id); - Task> FindAllAsync(); + Task> FindAllAsync(); - Task CreateAsync(StateLockModel input); + Task CreateAsync(StateLockModel input); - Task DeleteAsync(StateLockModel input); - } + Task DeleteAsync(StateLockModel input); } diff --git a/src/Domain/Repositories/IStateRepository.cs b/src/Domain/Repositories/IStateRepository.cs index ead46ba..86db223 100644 --- a/src/Domain/Repositories/IStateRepository.cs +++ b/src/Domain/Repositories/IStateRepository.cs @@ -1,11 +1,10 @@ using System.Threading.Tasks; -namespace Devpro.TerraformBackend.Domain.Repositories +namespace Devpro.TerraformBackend.Domain.Repositories; + +public interface IStateRepository { - public interface IStateRepository - { - Task FindOneAsync(string name); + Task FindOneAsync(string name); - Task CreateAsync(string name, string jsonInput); - } + Task CreateAsync(string name, string jsonInput); } diff --git a/src/WebApi/Authentication/BasicAuthenticationHandler.cs b/src/WebApi/Authentication/BasicAuthenticationHandler.cs index 2841d80..8a9c7c0 100644 --- a/src/WebApi/Authentication/BasicAuthenticationHandler.cs +++ b/src/WebApi/Authentication/BasicAuthenticationHandler.cs @@ -5,63 +5,63 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; -namespace Devpro.TerraformBackend.WebApi.Authentication +namespace Devpro.TerraformBackend.WebApi.Authentication; + +public class BasicAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) { - public class BasicAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) + protected override Task HandleAuthenticateAsync() { - protected override Task HandleAuthenticateAsync() + // raises an error if no authorization header + if (!Request.Headers.ContainsKey("Authorization")) { - // raises an error if no authorization header - if (!Request.Headers.ContainsKey("Authorization")) - { - return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header")); - } + return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header")); + } - var authorizationHeader = Request.Headers.Authorization.ToString(); + var authorizationHeader = Request.Headers.Authorization.ToString(); - // raises an error if the authorization header is not Basic - if (!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(AuthenticateResult.Fail("Authorization header does not start with 'Basic'")); - } + // raises an error if the authorization header is not Basic + if (!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(AuthenticateResult.Fail("Authorization header does not start with 'Basic'")); + } - // decrypts the authorization header and split out the client id/secret which is separated by the first ':' - var authBase64Decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authorizationHeader.Replace("Basic ", "", StringComparison.OrdinalIgnoreCase))); - var authSplit = authBase64Decoded.Split([':'], 2); + // decrypts the authorization header and split out the client id/secret which is separated by the first ':' + var authBase64Decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authorizationHeader.Replace("Basic ", "", StringComparison.OrdinalIgnoreCase))); + var authSplit = authBase64Decoded.Split([':'], 2); - // sends an error if no username and password - if (authSplit.Length != 2) - { - return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format")); - } + // sends an error if no username and password + if (authSplit.Length != 2) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format")); + } - // stores the client ID and secret - var clientId = authSplit[0]; - var clientSecret = authSplit[1]; + // stores the client ID and secret + var clientId = authSplit[0]; + var clientSecret = authSplit[1]; - // TODO: store this info in the database & restrict a user to its organization - // checkClient ID and secret are incorrect - if (clientId != "admin" || clientSecret != "admin") - { - return Task.FromResult(AuthenticateResult.Fail(string.Format("The secret is incorrect for the client '{0}'", clientId))); - } + // TODO: store this info in the database & restrict a user to its organization + // checkClient ID and secret are incorrect + if (clientId != "admin" || clientSecret != "admin") + { + return Task.FromResult(AuthenticateResult.Fail(string.Format("The secret is incorrect for the client '{0}'", clientId))); + } - // authenicates the client using basic authentication - var client = new BasicAuthenticationClient - { - AuthenticationType = BasicAuthenticationDefaults.AuthenticationScheme, - IsAuthenticated = true, - Name = clientId - }; + // authenicates the client using basic authentication + var client = new BasicAuthenticationClient + { + AuthenticationType = BasicAuthenticationDefaults.AuthenticationScheme, + IsAuthenticated = true, + Name = clientId + }; - // set the client ID as the name claim type - var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(client, - [ - new Claim(ClaimTypes.Name, clientId) - ])); + // set the client ID as the name claim type + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(client, + [ + new Claim(ClaimTypes.Name, clientId) + ])); - // returns a success result - return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name))); - } + // returns a success result + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name))); } } diff --git a/src/WebApi/Controllers/StateController.cs b/src/WebApi/Controllers/StateController.cs index 571900c..42cd51c 100644 --- a/src/WebApi/Controllers/StateController.cs +++ b/src/WebApi/Controllers/StateController.cs @@ -4,69 +4,68 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Devpro.TerraformBackend.WebApi.Controllers +namespace Devpro.TerraformBackend.WebApi.Controllers; + +[Authorize] +[ApiController] +[Route("state")] +public class StateController(IStateRepository stateRepository, IStateLockRepository stateLockRepository) : ControllerBase { - [Authorize] - [ApiController] - [Route("state")] - public class StateController(IStateRepository stateRepository, IStateLockRepository stateLockRepository) : ControllerBase + /// + /// Get Terraform state value. + /// + /// The name of the Terraform state + /// Terraform state lock ID + /// Raw string + [HttpGet("{name}", Name = "GetState")] + [ProducesResponseType(200)] + public async Task FindOne(string name, [FromQuery(Name = "ID")] string? lockId = "") { - /// - /// Get Terraform state value. - /// - /// The name of the Terraform state - /// Terraform state lock ID - /// Raw string - [HttpGet("{name}", Name = "GetState")] - [ProducesResponseType(200)] - public async Task FindOne(string name, [FromQuery(Name = "ID")] string? lockId = "") - { - //TODO: check lock - return await stateRepository.FindOneAsync(name); - } + //TODO: check lock + return await stateRepository.FindOneAsync(name); + } - /// - /// Get Terraform state value. - /// - /// The name of the Terraform state - /// Terraform state lock ID - /// - [HttpPost("{name}", Name = "CreateState")] - [ProducesResponseType(201)] - [Consumes("application/json", "text/json")] - public async Task Create(string name, [FromBody] object input, [FromQuery(Name = "ID")] string? lockId = "") - { - //TODO: check lock - var jsonInput = JsonSerializer.Serialize(input); - await stateRepository.CreateAsync(name, jsonInput); - } + /// + /// Get Terraform state value. + /// + /// The name of the Terraform state + /// Terraform state lock ID + /// + [HttpPost("{name}", Name = "CreateState")] + [ProducesResponseType(201)] + [Consumes("application/json", "text/json")] + public async Task Create(string name, [FromBody] object input, [FromQuery(Name = "ID")] string? lockId = "") + { + //TODO: check lock + var jsonInput = JsonSerializer.Serialize(input); + await stateRepository.CreateAsync(name, jsonInput); + } - [HttpGet("/locks", Name = "GetStateLocks")] - [ProducesResponseType(200)] - public async Task> FindAllLocks([FromQuery] string? name = "") - { - //TODO: only for admins - return await stateLockRepository.FindAllAsync(); - } + [HttpGet("/locks", Name = "GetStateLocks")] + [ProducesResponseType(200)] + public async Task> FindAllLocks([FromQuery] string? name = "") + { + //TODO: only for admins + return await stateLockRepository.FindAllAsync(); + } - [HttpPost("{name}/lock", Name = "CreateStateLock")] - [ProducesResponseType(201)] - [Consumes("application/json", "text/json")] - [Produces("application/json")] - public async Task Lock(string name, StateLockModel input) - { - input.Name = name; - await stateLockRepository.CreateAsync(input); - } + [HttpPost("{name}/lock", Name = "CreateStateLock")] + [ProducesResponseType(201)] + [Consumes("application/json", "text/json")] + [Produces("application/json")] + public async Task Lock(string name, StateLockModel input) + { + input.Name = name; + await stateLockRepository.CreateAsync(input); + } - [HttpDelete("{name}/lock", Name = "DeleteStateLock")] - [ProducesResponseType(204)] - [Consumes("application/json", "text/json")] - [Produces("application/json")] - public async Task Unlock(string name, [FromBody] StateLockModel input) - { - input.Name = name; - await stateLockRepository.DeleteAsync(input); - } + [HttpDelete("{name}/lock", Name = "DeleteStateLock")] + [ProducesResponseType(204)] + [Consumes("application/json", "text/json")] + [Produces("application/json")] + public async Task Unlock(string name, [FromBody] StateLockModel input) + { + input.Name = name; + await stateLockRepository.DeleteAsync(input); } } diff --git a/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs index 5c27025..97937e8 100644 --- a/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/AuthenticationServiceCollectionExtensions.cs @@ -2,14 +2,13 @@ using Devpro.TerraformBackend.WebApi.Authentication; using Microsoft.AspNetCore.Authentication; -namespace Devpro.TerraformBackend.WebApi.DependencyInjection +namespace Devpro.TerraformBackend.WebApi.DependencyInjection; + +public static class AuthenticationServiceCollectionExtensions { - public static class AuthenticationServiceCollectionExtensions + public static AuthenticationBuilder AddBasicAuthentication(this IServiceCollection services) { - public static AuthenticationBuilder AddBasicAuthentication(this IServiceCollection services) - { - return services.AddAuthentication() - .AddScheme(BasicAuthenticationDefaults.AuthenticationScheme, null); - } + return services.AddAuthentication() + .AddScheme(BasicAuthenticationDefaults.AuthenticationScheme, null); } } diff --git a/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs index 2096f54..4a88667 100644 --- a/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs @@ -1,28 +1,27 @@ using Microsoft.AspNetCore.Mvc; -namespace Devpro.TerraformBackend.WebApi.DependencyInjection +namespace Devpro.TerraformBackend.WebApi.DependencyInjection; + +internal static class BehaviorServiceCollectionExtensions { - internal static class BehaviorServiceCollectionExtensions + internal static IServiceCollection AddBehaviors(this IServiceCollection services) { - internal static IServiceCollection AddBehaviors(this IServiceCollection services) + services.PostConfigure(options => { - services.PostConfigure(options => - { - var builtInFactory = options.InvalidModelStateResponseFactory; + var builtInFactory = options.InvalidModelStateResponseFactory; - options.InvalidModelStateResponseFactory = context => - { - var loggerFactory = context.HttpContext.RequestServices - .GetRequiredService(); - var logger = loggerFactory.CreateLogger("PostConfigure"); + options.InvalidModelStateResponseFactory = context => + { + var loggerFactory = context.HttpContext.RequestServices + .GetRequiredService(); + var logger = loggerFactory.CreateLogger("PostConfigure"); - logger.LogWarning("Invalid model {RequestPath}", context.HttpContext.Request.Path); + logger.LogWarning("Invalid model {RequestPath}", context.HttpContext.Request.Path); - return builtInFactory(context); - }; - }); + return builtInFactory(context); + }; + }); - return services; - } + return services; } } diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index fdb4553..123e93c 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -1,18 +1,17 @@ using Microsoft.Extensions.DependencyInjection.Extensions; -namespace Devpro.TerraformBackend.WebApi.DependencyInjection +namespace Devpro.TerraformBackend.WebApi.DependencyInjection; + +internal static class InfrastructureServiceCollectionExtensions { - internal static class InfrastructureServiceCollectionExtensions + internal static IServiceCollection AddInfrastructure(this IServiceCollection services, ApplicationConfiguration configuration) { - internal static IServiceCollection AddInfrastructure(this IServiceCollection services, ApplicationConfiguration configuration) - { - // MongoDB - services.AddSingleton(configuration.MongoDbConfiguration); - services.TryAddSingleton(); - services.TryAddScoped(); - services.TryAddScoped(); + // MongoDB + services.AddSingleton(configuration.MongoDbConfiguration); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); - return services; - } + return services; } } diff --git a/src/WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs index 3c6aad9..6496d4c 100644 --- a/src/WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs @@ -1,41 +1,40 @@ using Microsoft.OpenApi.Models; -namespace Devpro.TerraformBackend.WebApi.DependencyInjection +namespace Devpro.TerraformBackend.WebApi.DependencyInjection; + +public static class SwaggerServiceCollectionExtensions { - public static class SwaggerServiceCollectionExtensions + public static IServiceCollection AddSwaggerGenWithBasicAuth(this IServiceCollection services, WebApiConfiguration configuration) { - public static IServiceCollection AddSwaggerGenWithBasicAuth(this IServiceCollection services, WebApiConfiguration configuration) - { - var openApi = configuration.OpenApi; + var openApi = configuration.OpenApi; - services.AddSwaggerGen(c => + services.AddSwaggerGen(c => + { + c.SwaggerDoc(openApi.Version, new OpenApiInfo { Title = openApi.Title, Version = openApi.Version }); + c.AddSecurityDefinition("basic", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "basic", + In = ParameterLocation.Header, + Description = "Basic Authorization header using the Bearer scheme." + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement { - c.SwaggerDoc(openApi.Version, new OpenApiInfo { Title = openApi.Title, Version = openApi.Version }); - c.AddSecurityDefinition("basic", new OpenApiSecurityScheme - { - Name = "Authorization", - Type = SecuritySchemeType.Http, - Scheme = "basic", - In = ParameterLocation.Header, - Description = "Basic Authorization header using the Bearer scheme." - }); - c.AddSecurityRequirement(new OpenApiSecurityRequirement { + new OpenApiSecurityScheme { - new OpenApiSecurityScheme + Reference = new OpenApiReference { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "basic" - } - }, - Array.Empty() - } - }); + Type = ReferenceType.SecurityScheme, + Id = "basic" + } + }, + Array.Empty() + } }); + }); - return services; - } + return services; } } From 11ed03a53e04277e43d457f02e426b56811cc48e Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 18:48:56 +0200 Subject: [PATCH 14/29] Add HealthCheckResource_Create_ReturnsOk integration test --- src/WebApi/Authentication/BasicAuthenticationHandler.cs | 2 +- src/WebApi/Controllers/StateController.cs | 3 ++- src/WebApi/Program.cs | 2 +- test/WebApi.IntegrationTests/IntegrationTestBase.cs | 3 +++ test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj | 1 + 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/WebApi/Authentication/BasicAuthenticationHandler.cs b/src/WebApi/Authentication/BasicAuthenticationHandler.cs index 8a9c7c0..14f3262 100644 --- a/src/WebApi/Authentication/BasicAuthenticationHandler.cs +++ b/src/WebApi/Authentication/BasicAuthenticationHandler.cs @@ -47,7 +47,7 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Fail(string.Format("The secret is incorrect for the client '{0}'", clientId))); } - // authenicates the client using basic authentication + // authenticates the client using basic authentication var client = new BasicAuthenticationClient { AuthenticationType = BasicAuthenticationDefaults.AuthenticationScheme, diff --git a/src/WebApi/Controllers/StateController.cs b/src/WebApi/Controllers/StateController.cs index 42cd51c..ba548ae 100644 --- a/src/WebApi/Controllers/StateController.cs +++ b/src/WebApi/Controllers/StateController.cs @@ -34,11 +34,12 @@ public async Task FindOne(string name, [FromQuery(Name = "ID")] string? [HttpPost("{name}", Name = "CreateState")] [ProducesResponseType(201)] [Consumes("application/json", "text/json")] - public async Task Create(string name, [FromBody] object input, [FromQuery(Name = "ID")] string? lockId = "") + public async Task Create(string name, [FromBody] object input, [FromQuery(Name = "ID")] string? lockId = "") { //TODO: check lock var jsonInput = JsonSerializer.Serialize(input); await stateRepository.CreateAsync(name, jsonInput); + return CreatedAtRoute("GetState", new { name }, null); } [HttpGet("/locks", Name = "GetStateLocks")] diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index f95a4e3..32e3384 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -18,7 +18,7 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); -app.MapHealthChecks(WebApiConfiguration.HealthCheckEndpoint); +app.MapHealthChecks(WebApiConfiguration.HealthCheckEndpoint).AllowAnonymous(); // runs the application app.Run(); diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 6defbe9..197cbb7 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -1,4 +1,5 @@ using System.Net.Http; +using Bogus; using Microsoft.AspNetCore.Mvc.Testing; using Xunit; @@ -9,6 +10,8 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests /// public abstract class IntegrationTestBase(WebApplicationFactory factory) : IClassFixture> { + protected Faker Faker { get; } = new("en"); + protected HttpClient CreateClient() { return factory.CreateClient(); diff --git a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj index b01e1e9..f5a7591 100644 --- a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj +++ b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all From 1e53810d68177f282d85ce59fe35c79fc333c0a5 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Sun, 13 Jul 2025 22:55:38 +0200 Subject: [PATCH 15/29] Add HealthCheckResource_Create_ReturnsOk integration test --- .../Resources/HealthCheckResourceTest.cs | 3 +- .../Resources/StateControllerResourceTest.cs | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs diff --git a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs index 1ca9f54..fc1545f 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs @@ -6,7 +6,8 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources { [Trait("Category", "IntegrationTests")] - public class HealthCheckResourceTest(WebApplicationFactory factory) : IntegrationTestBase(factory) + public class HealthCheckResourceTest(WebApplicationFactory factory) + : IntegrationTestBase(factory) { [Fact] [Trait("Mode", "Readonly")] diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs new file mode 100644 index 0000000..30d6203 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -0,0 +1,44 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources; + +[Trait("Category", "IntegrationTests")] +public class StateControllerResourceTest(WebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + public async Task HealthCheckResource_Create_ReturnsOk() + { + // Arrange + var client = CreateClient(); + var authToken = Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "admin", "admin"))); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authToken); + var name = Faker.Random.Word(); + var lockId = Faker.Random.Guid().ToString(); + var payload = new + { + Property1 = "Value1", + Property2 = 123 + }; + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + // Act + var response = await client.PostAsync($"/state/{name}?ID={lockId}", content); + + // Assert + response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + response.Content.Headers.ContentType.Should().BeNull(); + //TODO: test resource URL + var result = await response.Content.ReadAsStringAsync(); + result.Should().NotBeNull(); + result.ToString().Should().BeEmpty(); + } +} From 144c0e227622b75f9edc466c6b8e13592cf3e532 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Mon, 14 Jul 2025 11:18:54 +0200 Subject: [PATCH 16/29] Improve BehaviorServiceCollectionExtensions --- .../BehaviorServiceCollectionExtensions.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs index 4a88667..203f79f 100644 --- a/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs @@ -4,21 +4,30 @@ namespace Devpro.TerraformBackend.WebApi.DependencyInjection; internal static class BehaviorServiceCollectionExtensions { - internal static IServiceCollection AddBehaviors(this IServiceCollection services) + /// + /// Ensures that every time an invalid model state occurs in the API, a warning log is generated with the request path. + /// + /// + /// + internal static IServiceCollection AddInvalidModelStateLog(this IServiceCollection services) { services.PostConfigure(options => { - var builtInFactory = options.InvalidModelStateResponseFactory; + var defaultFactory = options.InvalidModelStateResponseFactory; options.InvalidModelStateResponseFactory = context => { var loggerFactory = context.HttpContext.RequestServices .GetRequiredService(); - var logger = loggerFactory.CreateLogger("PostConfigure"); + var logger = loggerFactory.CreateLogger(nameof(BehaviorServiceCollectionExtensions)); - logger.LogWarning("Invalid model {RequestPath}", context.HttpContext.Request.Path); + var errors = context.ModelState + .Where(m => m.Value?.Errors.Any() == true) + .Select(m => new { Field = m.Key, Errors = m.Value!.Errors.Select(e => e.ErrorMessage) }); - return builtInFactory(context); + logger.LogWarning("Invalid model state for {RequestPath}. Validation errors {@ModelErrors}", context.HttpContext.Request.Path, errors); + + return defaultFactory(context); }; }); From 1d12e044abf5de9e4b3fdfc157f97589e9c1fdf7 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Mon, 14 Jul 2025 11:56:29 +0200 Subject: [PATCH 17/29] Add HealthCheckResource_GetNotExisting_ReturnsOk test Replace FluentAssertions with AwesomeAssertions Move files to libraries when not specific to project --- Devpro.TerraformBackend.sln | 12 +++-- .../SwaggerServiceCollectionExtensions.cs | 6 ++- .../BehaviorServiceCollectionExtensions.cs | 11 +++-- .../RawRequestBodyFormatter.cs | 2 +- .../Repositories/StateRepository.cs | 8 +--- .../BasicAuthenticationHandler.cs | 12 ++--- src/WebApi/ImplicitUsings.cs | 4 +- src/WebApi/Program.cs | 2 +- .../Http/HttpResponseMessageExtensions.cs | 35 ++++++++++++++ .../WebApi.IntegrationTests/ImplicitUsings.cs | 6 +++ .../IntegrationTestBase.cs | 30 +++++++++--- .../Resources/HealthCheckResourceTest.cs | 12 +---- .../Resources/StateControllerResourceTest.cs | 48 +++++++++---------- .../WebApi.IntegrationTests.csproj | 3 +- 14 files changed, 118 insertions(+), 73 deletions(-) rename src/{WebApi => Common.AspNetCore.WebApi}/DependencyInjection/SwaggerServiceCollectionExtensions.cs (86%) rename src/{WebApi => Common.AspNetCore}/DependencyInjection/BehaviorServiceCollectionExtensions.cs (77%) rename src/Common.AspNetCore/{ => Formatters}/RawRequestBodyFormatter.cs (97%) create mode 100644 test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs create mode 100644 test/WebApi.IntegrationTests/ImplicitUsings.cs diff --git a/Devpro.TerraformBackend.sln b/Devpro.TerraformBackend.sln index 554cf57..2338172 100644 --- a/Devpro.TerraformBackend.sln +++ b/Devpro.TerraformBackend.sln @@ -13,9 +13,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 - Solution Items", "0 - S README.md = README.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 - Applications", "2 - Applications", "{6D13F54F-4547-49C7-8136-01BFB4BBEE1E}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 - Applications", "3 - Applications", "{6D13F54F-4547-49C7-8136-01BFB4BBEE1E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Libraries", "1 - Libraries", "{E9839BEC-B050-43E9-8EFD-34659CC92D93}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 - Business", "2 - Business", "{E9839BEC-B050-43E9-8EFD-34659CC92D93}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "src\WebApi\WebApi.csproj", "{5CD7A689-5ADB-4207-972E-6FA881AF1B1C}" EndProject @@ -54,6 +54,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{ .github\workflows\pkg.yaml = .github\workflows\pkg.yaml EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Framework", "1 - Framework", "{0C1E6968-B289-4378-84CF-B64E05E643A5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -96,13 +98,13 @@ Global {5CD7A689-5ADB-4207-972E-6FA881AF1B1C} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} {A5CAD112-C1E6-442B-BE0E-37C697030636} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C} = {0C1E6968-B289-4378-84CF-B64E05E643A5} {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} - {F23098F5-355B-46F0-BABE-3D6E23D8EED7} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {F23098F5-355B-46F0-BABE-3D6E23D8EED7} = {0C1E6968-B289-4378-84CF-B64E05E643A5} {001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} {7F01D180-1A34-4377-B4E5-C852D8302CE7} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} {B055FFAF-8261-43B1-866A-12E289D5D7DC} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} - {19336002-C959-4E76-B112-861F93CF6423} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {19336002-C959-4E76-B112-861F93CF6423} = {0C1E6968-B289-4378-84CF-B64E05E643A5} {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/src/WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs b/src/Common.AspNetCore.WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs similarity index 86% rename from src/WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs rename to src/Common.AspNetCore.WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs index 6496d4c..212d532 100644 --- a/src/WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs +++ b/src/Common.AspNetCore.WebApi/DependencyInjection/SwaggerServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ -using Microsoft.OpenApi.Models; +using Devpro.Common.AspNetCore.WebApi.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; -namespace Devpro.TerraformBackend.WebApi.DependencyInjection; +namespace Devpro.Common.AspNetCore.WebApi.DependencyInjection; public static class SwaggerServiceCollectionExtensions { diff --git a/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs b/src/Common.AspNetCore/DependencyInjection/BehaviorServiceCollectionExtensions.cs similarity index 77% rename from src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs rename to src/Common.AspNetCore/DependencyInjection/BehaviorServiceCollectionExtensions.cs index 203f79f..1c04a40 100644 --- a/src/WebApi/DependencyInjection/BehaviorServiceCollectionExtensions.cs +++ b/src/Common.AspNetCore/DependencyInjection/BehaviorServiceCollectionExtensions.cs @@ -1,15 +1,18 @@ -using Microsoft.AspNetCore.Mvc; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; -namespace Devpro.TerraformBackend.WebApi.DependencyInjection; +namespace Devpro.Common.AspNetCore.DependencyInjection; -internal static class BehaviorServiceCollectionExtensions +public static class BehaviorServiceCollectionExtensions { /// /// Ensures that every time an invalid model state occurs in the API, a warning log is generated with the request path. /// /// /// - internal static IServiceCollection AddInvalidModelStateLog(this IServiceCollection services) + public static IServiceCollection AddInvalidModelStateLog(this IServiceCollection services) { services.PostConfigure(options => { diff --git a/src/Common.AspNetCore/RawRequestBodyFormatter.cs b/src/Common.AspNetCore/Formatters/RawRequestBodyFormatter.cs similarity index 97% rename from src/Common.AspNetCore/RawRequestBodyFormatter.cs rename to src/Common.AspNetCore/Formatters/RawRequestBodyFormatter.cs index 686f743..3e88cce 100644 --- a/src/Common.AspNetCore/RawRequestBodyFormatter.cs +++ b/src/Common.AspNetCore/Formatters/RawRequestBodyFormatter.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Devpro.Common.AspNetCore; +namespace Devpro.Common.AspNetCore.Formatters; /// /// ASP.NET Core input formatter to manage raw request bodies. diff --git a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs index 25214a7..cacaded 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs @@ -8,13 +8,9 @@ namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories { - public class StateRepository : RepositoryBase, IStateRepository + public class StateRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) + : RepositoryBase(mongoClientFactory, logger, configuration), IStateRepository { - public StateRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) - : base(mongoClientFactory, logger, configuration) - { - } - protected override string CollectionName => "tf_state"; public async Task CreateAsync(string name, string jsonInput) diff --git a/src/WebApi/Authentication/BasicAuthenticationHandler.cs b/src/WebApi/Authentication/BasicAuthenticationHandler.cs index 14f3262..4158214 100644 --- a/src/WebApi/Authentication/BasicAuthenticationHandler.cs +++ b/src/WebApi/Authentication/BasicAuthenticationHandler.cs @@ -12,7 +12,7 @@ public class BasicAuthenticationHandler(IOptionsMonitor HandleAuthenticateAsync() { - // raises an error if no authorization header + // checks authorization header if (!Request.Headers.ContainsKey("Authorization")) { return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header")); @@ -20,23 +20,20 @@ protected override Task HandleAuthenticateAsync() var authorizationHeader = Request.Headers.Authorization.ToString(); - // raises an error if the authorization header is not Basic + // checks authorization header starts with Basic if (!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(AuthenticateResult.Fail("Authorization header does not start with 'Basic'")); } - // decrypts the authorization header and split out the client id/secret which is separated by the first ':' + // decrypts the authorization header and split out the client id/secret var authBase64Decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authorizationHeader.Replace("Basic ", "", StringComparison.OrdinalIgnoreCase))); var authSplit = authBase64Decoded.Split([':'], 2); - - // sends an error if no username and password if (authSplit.Length != 2) { return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format")); } - // stores the client ID and secret var clientId = authSplit[0]; var clientSecret = authSplit[1]; @@ -47,7 +44,6 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Fail(string.Format("The secret is incorrect for the client '{0}'", clientId))); } - // authenticates the client using basic authentication var client = new BasicAuthenticationClient { AuthenticationType = BasicAuthenticationDefaults.AuthenticationScheme, @@ -55,13 +51,11 @@ protected override Task HandleAuthenticateAsync() Name = clientId }; - // set the client ID as the name claim type var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(client, [ new Claim(ClaimTypes.Name, clientId) ])); - // returns a success result return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name))); } } diff --git a/src/WebApi/ImplicitUsings.cs b/src/WebApi/ImplicitUsings.cs index 526f685..d116536 100644 --- a/src/WebApi/ImplicitUsings.cs +++ b/src/WebApi/ImplicitUsings.cs @@ -1,5 +1,7 @@ -global using Devpro.Common.AspNetCore; +global using Devpro.Common.AspNetCore.DependencyInjection; +global using Devpro.Common.AspNetCore.Formatters; global using Devpro.Common.AspNetCore.WebApi.Builder; global using Devpro.Common.AspNetCore.WebApi.Configuration; +global using Devpro.Common.AspNetCore.WebApi.DependencyInjection; global using Devpro.TerraformBackend.WebApi; global using Devpro.TerraformBackend.WebApi.DependencyInjection; diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 32e3384..903e3b3 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -9,7 +9,7 @@ builder.Services.AddSwaggerGenWithBasicAuth(configuration); builder.Services.AddBasicAuthentication(); builder.Services.AddHealthChecks(); -builder.Services.AddBehaviors(); +builder.Services.AddInvalidModelStateLog(); // create the application and configures the HTTP request pipeline var app = builder.Build(); diff --git a/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs b/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..7cad05e --- /dev/null +++ b/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs @@ -0,0 +1,35 @@ +using System.Net; + +namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Http; + +internal static class HttpResponseMessageExtensions +{ + public static async Task CheckResponseAndGetContent(this HttpResponseMessage response, HttpStatusCode expectedStatusCode, string? expectedContentType, string? expectedContent) + { + response.StatusCode.Should().Be(expectedStatusCode); + + if (expectedContentType == null) + { + response.Content.Headers.ContentType.Should().BeNull(); + } + else + { + response.Content.Headers.ContentType.Should().NotBeNull(); + response.Content.Headers.ContentType?.ToString().Should().Be(expectedContentType); + } + + var result = await response.Content.ReadAsStringAsync(); + + if (expectedContent == null) + { + result.Should().BeNull(); + } + else + { + result.Should().NotBeNull(); + result.Should().Be(expectedContent); + } + + return result; + } +} diff --git a/test/WebApi.IntegrationTests/ImplicitUsings.cs b/test/WebApi.IntegrationTests/ImplicitUsings.cs new file mode 100644 index 0000000..517e511 --- /dev/null +++ b/test/WebApi.IntegrationTests/ImplicitUsings.cs @@ -0,0 +1,6 @@ +global using System.Text; +global using System.Text.Json; +global using AwesomeAssertions; +global using Bogus; +global using Microsoft.AspNetCore.Mvc.Testing; +global using Xunit; diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 197cbb7..2020058 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -1,20 +1,36 @@ -using System.Net.Http; -using Bogus; -using Microsoft.AspNetCore.Mvc.Testing; -using Xunit; +using System.Net.Http.Headers; namespace Devpro.TerraformBackend.WebApi.IntegrationTests { /// /// See https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests /// - public abstract class IntegrationTestBase(WebApplicationFactory factory) : IClassFixture> + public abstract class IntegrationTestBase(WebApplicationFactory factory) + : IClassFixture> { protected Faker Faker { get; } = new("en"); - protected HttpClient CreateClient() + protected HttpClient CreateClient(bool isAuthorizationNeeded = false) { - return factory.CreateClient(); + var client = factory.CreateClient(); + + if (isAuthorizationNeeded) + { + var authToken = Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "admin", "admin"))); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authToken); + } + + return client; + } + + protected StringContent GeneratePayload() + { + var dummy = new + { + Property1 = Faker.Random.String(), + Property2 = Faker.Random.Int() + }; + return new StringContent(JsonSerializer.Serialize(dummy), Encoding.UTF8, "application/json"); } } } diff --git a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs index fc1545f..56ae381 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs @@ -1,7 +1,4 @@ -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; -using Xunit; +using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources { @@ -20,12 +17,7 @@ public async Task HealthCheckResource_Get_ReturnsOk() var response = await client.GetAsync("/health"); // Assert - response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - response.Content.Headers.ContentType.Should().NotBeNull(); - response.Content.Headers.ContentType?.ToString().Should().Be("text/plain"); - var result = await response.Content.ReadAsStringAsync(); - result.Should().NotBeNull(); - result.Should().Be("Healthy"); + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "text/plain", "Healthy"); } } } diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs index 30d6203..0d928ea 100644 --- a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -1,12 +1,4 @@ -using System; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; -using Xunit; +using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources; @@ -15,30 +7,34 @@ public class StateControllerResourceTest(WebApplicationFactory factory) : IntegrationTestBase(factory) { [Fact] - public async Task HealthCheckResource_Create_ReturnsOk() + public async Task HealthCheckResource_GetNotExisting_ReturnsOk() { // Arrange - var client = CreateClient(); - var authToken = Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "admin", "admin"))); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authToken); + var client = CreateClient(true); var name = Faker.Random.Word(); var lockId = Faker.Random.Guid().ToString(); - var payload = new - { - Property1 = "Value1", - Property2 = 123 - }; - var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); // Act - var response = await client.PostAsync($"/state/{name}?ID={lockId}", content); + var response = await client.GetAsync($"/state/{name}?ID={lockId}"); // Assert - response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - response.Content.Headers.ContentType.Should().BeNull(); - //TODO: test resource URL - var result = await response.Content.ReadAsStringAsync(); - result.Should().NotBeNull(); - result.ToString().Should().BeEmpty(); + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "text/plain; charset=utf-8", string.Empty); + } + + [Fact] + public async Task HealthCheckResource_CreateNew_ReturnsCreated() + { + // Arrange + var client = CreateClient(true); + var name = Faker.Random.Word(); + var lockId = Faker.Random.Guid().ToString(); + var payload = GeneratePayload(); + + // Act + var response = await client.PostAsync($"/state/{name}?ID={lockId}", payload); + + // Assert + //TODO: test resource URL in response + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.Created, null, string.Empty); } } diff --git a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj index f5a7591..420afe4 100644 --- a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj +++ b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj @@ -6,16 +6,17 @@ Devpro.TerraformBackend.WebApi.IntegrationTests {B055FFAF-8261-43B1-866A-12E289D5D7DC} enable + enable false + runtime; build; native; contentfiles; analyzers; buildtransitive all - From 544c2ab6863719ad2576f3ee8631d82fe434d229 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Mon, 14 Jul 2025 16:38:31 +0200 Subject: [PATCH 18/29] Add integration tests and start implementing state lock --- README.md | 2 +- src/Domain/Models/StateValueModel.cs | 5 +- .../Repositories/IStateLockRepository.cs | 8 +- src/Domain/Repositories/IStateRepository.cs | 2 + .../Repositories/StateLockRepository.cs | 20 ++-- .../Repositories/StateRepository.cs | 8 ++ src/WebApi/Controllers/StateController.cs | 97 +++++++++++++++---- .../Http/HttpResponseMessageExtensions.cs | 23 ++--- .../IntegrationTestBase.cs | 64 +++++++----- .../Resources/HealthCheckResourceTest.cs | 29 +++--- .../Resources/StateControllerResourceTest.cs | 25 +++-- .../Resources/SwaggerResourceTest.cs | 22 +++++ 12 files changed, 209 insertions(+), 96 deletions(-) create mode 100644 test/WebApi.IntegrationTests/Resources/SwaggerResourceTest.cs diff --git a/README.md b/README.md index 876268f..033cfb3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Docker Image Version](https://img.shields.io/docker/v/devprofr/terraform-backend-mongodb?label=Image&logo=docker)](https://hub.docker.com/r/devprofr/terraform-backend-mongodb) Store [Terraform](https://www.terraform.io) state in [MongoDB](https://www.mongodb.com/), using -[HTTP](https://www.terraform.io/language/settings/backends/http) [backend](https://github.com/hashicorp/terraform/tree/main/internal/backend/remote-state). +[HTTP](https://developer.hashicorp.com/terraform/language/backend/http) [backend](https://github.com/hashicorp/terraform/tree/main/internal/backend/remote-state). Look at the [project development guide](CONTRIBUTING.md) for more technical details. You're more than welcome to contribute! diff --git a/src/Domain/Models/StateValueModel.cs b/src/Domain/Models/StateValueModel.cs index b2eb73d..cb97ed6 100644 --- a/src/Domain/Models/StateValueModel.cs +++ b/src/Domain/Models/StateValueModel.cs @@ -1,5 +1,4 @@ -using System; -using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.Attributes; namespace Devpro.TerraformBackend.Domain.Models; @@ -16,5 +15,5 @@ public class StateValueModel public object Outputs { get; set; } = new { }; - public object[] Resources { get; set; } = Array.Empty(); + public object[] Resources { get; set; } = []; } diff --git a/src/Domain/Repositories/IStateLockRepository.cs b/src/Domain/Repositories/IStateLockRepository.cs index f89590a..e5dd5ee 100644 --- a/src/Domain/Repositories/IStateLockRepository.cs +++ b/src/Domain/Repositories/IStateLockRepository.cs @@ -6,11 +6,9 @@ namespace Devpro.TerraformBackend.Domain.Repositories; public interface IStateLockRepository { - Task FindOneAsync(string id); + Task FindOneAsync(string name); - Task> FindAllAsync(); + Task CreateAsync(StateLockModel input); - Task CreateAsync(StateLockModel input); - - Task DeleteAsync(StateLockModel input); + Task DeleteAsync(StateLockModel input); } diff --git a/src/Domain/Repositories/IStateRepository.cs b/src/Domain/Repositories/IStateRepository.cs index 86db223..4328b4b 100644 --- a/src/Domain/Repositories/IStateRepository.cs +++ b/src/Domain/Repositories/IStateRepository.cs @@ -7,4 +7,6 @@ public interface IStateRepository Task FindOneAsync(string name); Task CreateAsync(string name, string jsonInput); + + Task DeleteAsync(string name); } diff --git a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs index cc3f696..e107abb 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; +using System.Xml.Linq; using Devpro.Common.MongoDb; using Devpro.TerraformBackend.Domain.Models; using Devpro.TerraformBackend.Domain.Repositories; using Microsoft.Extensions.Logging; +using MongoDB.Bson; using MongoDB.Driver; namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories @@ -11,7 +14,6 @@ namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories public class StateLockRepository : RepositoryBase, IStateLockRepository { //private readonly IMongoCollection _bsonCollection; - private readonly IMongoCollection _modelCollection; public StateLockRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) @@ -23,9 +25,9 @@ public StateLockRepository(IMongoClientFactory mongoClientFactory, ILogger "tf_state_lock"; - public async Task FindOneAsync(string id) + public async Task FindOneAsync(string name) { - return await _modelCollection.Find(x => x.Id == id).FirstOrDefaultAsync(); + return await _modelCollection.Find(x => x.Name == name).FirstOrDefaultAsync(); } public async Task> FindAllAsync() @@ -36,16 +38,18 @@ public async Task> FindAllAsync() return await _modelCollection.Find(_ => true).ToListAsync(); } - public async Task CreateAsync(StateLockModel input) + public async Task CreateAsync(StateLockModel input) { - //TODO: check a lock doesn't exist already + input.Id = ObjectId.GenerateNewId().ToString(); + input.Created = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff+00:00"); await _modelCollection.InsertOneAsync(input); + return input; } - public async Task DeleteAsync(StateLockModel input) + public async Task DeleteAsync(StateLockModel input) { var result = await _modelCollection.DeleteOneAsync(x => x.Id == input.Id && x.Name == input.Name); - return result.DeletedCount; + return result.DeletedCount > 0; } } } diff --git a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs index cacaded..2d975a8 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs @@ -40,5 +40,13 @@ public async Task FindOneAsync(string name) return document["value"].ToJson(); } + + + public async Task DeleteAsync(string name) + { + var collection = GetCollection(); + var deleteResult = await collection.DeleteOneAsync(new BsonDocument("name", name)); + return deleteResult.DeletedCount > 0; + } } } diff --git a/src/WebApi/Controllers/StateController.cs b/src/WebApi/Controllers/StateController.cs index ba548ae..1dd2aa7 100644 --- a/src/WebApi/Controllers/StateController.cs +++ b/src/WebApi/Controllers/StateController.cs @@ -13,60 +13,117 @@ public class StateController(IStateRepository stateRepository, IStateLockReposit { /// /// Get Terraform state value. + /// GET /state/:name?ID=:lockId /// /// The name of the Terraform state - /// Terraform state lock ID /// Raw string - [HttpGet("{name}", Name = "GetState")] + [HttpGet("{name:regex([[a-zA-Z]]+)}", Name = "GetState")] + [Produces("text/plain")] [ProducesResponseType(200)] - public async Task FindOne(string name, [FromQuery(Name = "ID")] string? lockId = "") + [ProducesResponseType(204)] + public async Task FindOne(string name) { - //TODO: check lock - return await stateRepository.FindOneAsync(name); + var state = await stateRepository.FindOneAsync(name); + if (string.IsNullOrEmpty(state)) + { + return NoContent(); + } + + return Ok(state); } /// /// Get Terraform state value. + /// POST /state/:name?ID=:lockId /// /// The name of the Terraform state /// Terraform state lock ID /// - [HttpPost("{name}", Name = "CreateState")] - [ProducesResponseType(201)] + [HttpPost("{name:regex([[a-zA-Z]]+)}", Name = "CreateState")] [Consumes("application/json", "text/json")] + [ProducesResponseType(200)] + [ProducesResponseType(409)] + [ProducesResponseType(423)] public async Task Create(string name, [FromBody] object input, [FromQuery(Name = "ID")] string? lockId = "") { - //TODO: check lock + if (await CheckLock(name, lockId) is { } lockResult) return lockResult; + var jsonInput = JsonSerializer.Serialize(input); await stateRepository.CreateAsync(name, jsonInput); - return CreatedAtRoute("GetState", new { name }, null); + return Ok(); } - [HttpGet("/locks", Name = "GetStateLocks")] + /// + /// DELETE /state/:name?ID=:lockId + /// + /// + /// Terraform state lock ID + /// + [HttpDelete("{name:regex([[a-zA-Z]]+)}", Name = "DeleteState")] [ProducesResponseType(200)] - public async Task> FindAllLocks([FromQuery] string? name = "") + [ProducesResponseType(409)] + [ProducesResponseType(423)] + public async Task Delete(string name, [FromQuery(Name = "ID")] string? lockId = "") { - //TODO: only for admins - return await stateLockRepository.FindAllAsync(); + if (await CheckLock(name, lockId) is { } lockResult) return lockResult; + + await stateRepository.DeleteAsync(name); + return Ok(); } - [HttpPost("{name}/lock", Name = "CreateStateLock")] - [ProducesResponseType(201)] + /// + /// POST /state/:name/lock + /// + /// + /// + /// + [HttpPost("{name:regex([[a-zA-Z]]+)}/lock", Name = "CreateStateLock")] [Consumes("application/json", "text/json")] [Produces("application/json")] - public async Task Lock(string name, StateLockModel input) + [ProducesResponseType(200)] + [ProducesResponseType(409)] + [ProducesResponseType(423)] + public async Task Lock(string name, StateLockModel input) { + if (await CheckLock(name, input.Id) is { } lockResult) return lockResult; + input.Name = name; - await stateLockRepository.CreateAsync(input); + var entry = await stateLockRepository.CreateAsync(input); + return Ok(entry); } - [HttpDelete("{name}/lock", Name = "DeleteStateLock")] - [ProducesResponseType(204)] + /// + /// DELETE /state/:name/lock + /// + /// + /// + /// + [HttpDelete("{name:regex([[a-zA-Z]]+)}/lock", Name = "DeleteStateLock")] + [ProducesResponseType(200)] [Consumes("application/json", "text/json")] [Produces("application/json")] - public async Task Unlock(string name, [FromBody] StateLockModel input) + public async Task Unlock(string name, [FromBody] StateLockModel input) { input.Name = name; await stateLockRepository.DeleteAsync(input); + return Ok(); + } + + private async Task CheckLock(string name, string? lockId = "") + { + var existingLock = await stateLockRepository.FindOneAsync(name); + if (existingLock != null) + { + if (!string.IsNullOrEmpty(lockId)) + { + return StatusCode(423, new { Message = "The state is locked." }); + } + + if (existingLock.Id != lockId) + { + return Conflict("LockId doesn't match with the existing lock"); + } + } + return null; } } diff --git a/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs b/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs index 7cad05e..fdb324b 100644 --- a/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs +++ b/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs @@ -4,9 +4,16 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Http; internal static class HttpResponseMessageExtensions { - public static async Task CheckResponseAndGetContent(this HttpResponseMessage response, HttpStatusCode expectedStatusCode, string? expectedContentType, string? expectedContent) + public static async Task CheckResponseAndGetContent(this HttpResponseMessage response, + HttpStatusCode expectedStatusCode, string? expectedContentType, string? expectedContent = null) { - response.StatusCode.Should().Be(expectedStatusCode); + var result = await response.Content.ReadAsStringAsync(); + + if (expectedContent != null) + { + result.Should().NotBeNull(); + result.Should().Be(expectedContent); + } if (expectedContentType == null) { @@ -18,17 +25,7 @@ internal static class HttpResponseMessageExtensions response.Content.Headers.ContentType?.ToString().Should().Be(expectedContentType); } - var result = await response.Content.ReadAsStringAsync(); - - if (expectedContent == null) - { - result.Should().BeNull(); - } - else - { - result.Should().NotBeNull(); - result.Should().Be(expectedContent); - } + response.StatusCode.Should().Be(expectedStatusCode); return result; } diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 2020058..7cc5089 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -1,36 +1,50 @@ using System.Net.Http.Headers; +using Devpro.TerraformBackend.Domain.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; -namespace Devpro.TerraformBackend.WebApi.IntegrationTests +namespace Devpro.TerraformBackend.WebApi.IntegrationTests; + +/// +/// Ref. https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests +/// +public abstract class IntegrationTestBase(WebApplicationFactory factory) + : IClassFixture> { - /// - /// See https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests - /// - public abstract class IntegrationTestBase(WebApplicationFactory factory) - : IClassFixture> - { - protected Faker Faker { get; } = new("en"); + protected Faker Faker { get; } = new("en"); - protected HttpClient CreateClient(bool isAuthorizationNeeded = false) - { - var client = factory.CreateClient(); + protected Faker StateFaker { get; } = new Faker("en"); - if (isAuthorizationNeeded) - { - var authToken = Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "admin", "admin"))); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authToken); - } + protected Faker StateLockFaker { get; } = new Faker("en"); - return client; - } + protected HttpClient CreateClient(bool isAuthorizationNeeded = false) + { + // ref. https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ + Environment.SetEnvironmentVariable("Application__IsSwaggerEnabled", "true"); + + var client = factory.CreateClient(); - protected StringContent GeneratePayload() + if (isAuthorizationNeeded) { - var dummy = new - { - Property1 = Faker.Random.String(), - Property2 = Faker.Random.Int() - }; - return new StringContent(JsonSerializer.Serialize(dummy), Encoding.UTF8, "application/json"); + var authToken = Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "admin", "admin"))); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authToken); } + + return client; + } + + protected StringContent GeneratePayload() + { + var dummy = new + { + Property1 = Faker.Random.String(), + Property2 = Faker.Random.Int() + }; + return Serialize(dummy); + } + + protected static StringContent Serialize(T value, string mediaType = "application/json") + { + return new StringContent(JsonSerializer.Serialize(value), Encoding.UTF8, mediaType); } } diff --git a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs index 56ae381..7dcd555 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthCheckResourceTest.cs @@ -1,23 +1,22 @@ using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; -namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources +namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources; + +[Trait("Category", "IntegrationTests")] +public class HealthCheckResourceTest(WebApplicationFactory factory) + : IntegrationTestBase(factory) { - [Trait("Category", "IntegrationTests")] - public class HealthCheckResourceTest(WebApplicationFactory factory) - : IntegrationTestBase(factory) + [Fact] + [Trait("Mode", "Readonly")] + public async Task HealthCheckResource_Get_ReturnsOk() { - [Fact] - [Trait("Mode", "Readonly")] - public async Task HealthCheckResource_Get_ReturnsOk() - { - // Arrange - var client = CreateClient(); + // Arrange + var client = CreateClient(); - // Act - var response = await client.GetAsync("/health"); + // Act + var response = await client.GetAsync("/health"); - // Assert - await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "text/plain", "Healthy"); - } + // Assert + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "text/plain", "Healthy"); } } diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs index 0d928ea..caa8a13 100644 --- a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -7,22 +7,22 @@ public class StateControllerResourceTest(WebApplicationFactory factory) : IntegrationTestBase(factory) { [Fact] - public async Task HealthCheckResource_GetNotExisting_ReturnsOk() + [Trait("Mode", "Readonly")] + public async Task StateResource_GetNotExisting_ReturnsNoContent() { // Arrange var client = CreateClient(true); var name = Faker.Random.Word(); - var lockId = Faker.Random.Guid().ToString(); // Act - var response = await client.GetAsync($"/state/{name}?ID={lockId}"); + var response = await client.GetAsync($"/state/{name}"); // Assert - await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "text/plain; charset=utf-8", string.Empty); + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.NoContent, null); } [Fact] - public async Task HealthCheckResource_CreateNew_ReturnsCreated() + public async Task StateResource_CreateNew_ReturnsCreated() { // Arrange var client = CreateClient(true); @@ -35,6 +35,19 @@ public async Task HealthCheckResource_CreateNew_ReturnsCreated() // Assert //TODO: test resource URL in response - await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.Created, null, string.Empty); + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, null, string.Empty); + } + + [Fact] + public async Task StateResource_LockLifeCycle_IsSuccess() + { + // Arrange + var client = CreateClient(true); + var name = Faker.Random.Word(); + var stateLock = StateLockFaker.Generate(); + + // Act & Assert + var createLockResponse = await client.PostAsync($"/state/{name}/lock", Serialize(stateLock)); + await createLockResponse.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "application/json; charset=utf-8", null); } } diff --git a/test/WebApi.IntegrationTests/Resources/SwaggerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/SwaggerResourceTest.cs new file mode 100644 index 0000000..b9980bf --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/SwaggerResourceTest.cs @@ -0,0 +1,22 @@ +using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; + +namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources; + +[Trait("Category", "IntegrationTests")] +public class SwaggerResourceTest(WebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + [Trait("Mode", "Readonly")] + public async Task SwaggerResource_Get_ReturnsOk() + { + // Arrange + var client = CreateClient(); + + // Act + var response = await client.GetAsync("/swagger/index.html"); + + // Assert + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "text/html; charset=utf-8"); + } +} From 494448eb0090a6d07a61b7657efb266e6571b5bd Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Mon, 14 Jul 2025 16:57:02 +0200 Subject: [PATCH 19/29] Add terraform-local-exec --- Devpro.TerraformBackend.sln | 13 ++++++------- samples/terraform-local-exec/README.md | 27 ++++++++++++++++++++++++++ samples/terraform-local-exec/main.tf | 18 +++++++++++++++++ samples/terraform-local-exec/test.txt | 1 + 4 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 samples/terraform-local-exec/README.md create mode 100644 samples/terraform-local-exec/main.tf create mode 100644 samples/terraform-local-exec/test.txt diff --git a/Devpro.TerraformBackend.sln b/Devpro.TerraformBackend.sln index 2338172..b686d82 100644 --- a/Devpro.TerraformBackend.sln +++ b/Devpro.TerraformBackend.sln @@ -47,15 +47,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.IntegrationTests", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore.WebApi", "src\Common.AspNetCore.WebApi\Common.AspNetCore.WebApi.csproj", "{19336002-C959-4E76-B112-861F93CF6423}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Framework", "1 - Framework", "{0C1E6968-B289-4378-84CF-B64E05E643A5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "terraform-local-exec", "terraform-local-exec", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" ProjectSection(SolutionItems) = preProject - .gitlab-ci.yml = .gitlab-ci.yml - .github\workflows\ci.yaml = .github\workflows\ci.yaml - .github\workflows\pkg.yaml = .github\workflows\pkg.yaml + samples\terraform-local-exec\main.tf = samples\terraform-local-exec\main.tf + samples\terraform-local-exec\README.md = samples\terraform-local-exec\README.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Framework", "1 - Framework", "{0C1E6968-B289-4378-84CF-B64E05E643A5}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -105,7 +104,7 @@ Global {7F01D180-1A34-4377-B4E5-C852D8302CE7} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} {B055FFAF-8261-43B1-866A-12E289D5D7DC} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} {19336002-C959-4E76-B112-861F93CF6423} = {0C1E6968-B289-4378-84CF-B64E05E643A5} - {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC545534-6A10-475B-A0DA-3374CC025D82} diff --git a/samples/terraform-local-exec/README.md b/samples/terraform-local-exec/README.md new file mode 100644 index 0000000..1caf388 --- /dev/null +++ b/samples/terraform-local-exec/README.md @@ -0,0 +1,27 @@ +# Terraform with local-exec + +## Run the sample + +Go to the sample directory: + +```bash +cd samples\terraform-local-exec +``` + +Initialize: + +```bash +terraform init +``` + +Apply the change (before confirming you can check from on another terminal to run an apply): + +```bash +terraform apply +``` + +Destroy resources: + +```bash +terraform destroy +``` diff --git a/samples/terraform-local-exec/main.tf b/samples/terraform-local-exec/main.tf new file mode 100644 index 0000000..7fa9e06 --- /dev/null +++ b/samples/terraform-local-exec/main.tf @@ -0,0 +1,18 @@ +terraform { + backend "http" { + address = "http://localhost:5293/state/demo_devpro" + lock_address = "http://localhost:5293/state/demo_devpro/lock" + unlock_address = "http://localhost:5293/state/demo_devpro/lock" + lock_method = "POST" + unlock_method = "DELETE" + username = "admin" + password = "admin" + skip_cert_verification = "true" + } +} + +resource "null_resource" "test_backend" { + provisioner "local-exec" { + command = "echo 'Testing HTTP backend state management' > test.txt" + } +} diff --git a/samples/terraform-local-exec/test.txt b/samples/terraform-local-exec/test.txt new file mode 100644 index 0000000..d353394 --- /dev/null +++ b/samples/terraform-local-exec/test.txt @@ -0,0 +1 @@ +'Testing HTTP backend state management' From c5101669784c48e52bec222e44bec9f401f6ab77 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Mon, 14 Jul 2025 17:51:50 +0200 Subject: [PATCH 20/29] Finalize tests --- samples/terraform-local-exec/main.tf | 15 ++++++++++--- samples/terraform-local-exec/test.txt | 1 - .../Repositories/StateLockRepository.cs | 2 -- src/WebApi/Controllers/StateController.cs | 2 +- .../IntegrationTestBase.cs | 6 ++--- .../Resources/StateControllerResourceTest.cs | 22 +++++++++++++++---- 6 files changed, 34 insertions(+), 14 deletions(-) delete mode 100644 samples/terraform-local-exec/test.txt diff --git a/samples/terraform-local-exec/main.tf b/samples/terraform-local-exec/main.tf index 7fa9e06..d15bf18 100644 --- a/samples/terraform-local-exec/main.tf +++ b/samples/terraform-local-exec/main.tf @@ -1,8 +1,8 @@ terraform { backend "http" { - address = "http://localhost:5293/state/demo_devpro" - lock_address = "http://localhost:5293/state/demo_devpro/lock" - unlock_address = "http://localhost:5293/state/demo_devpro/lock" + address = "http://localhost:5293/state/demo_localexec" + lock_address = "http://localhost:5293/state/demo_localexec/lock" + unlock_address = "http://localhost:5293/state/demo_localexec/lock" lock_method = "POST" unlock_method = "DELETE" username = "admin" @@ -16,3 +16,12 @@ resource "null_resource" "test_backend" { command = "echo 'Testing HTTP backend state management' > test.txt" } } + +resource "local_file" "test" { + content = "Test HTTP backend" + filename = "${path.module}/temp.txt" +} + +resource "random_string" "test" { + length = 16 +} diff --git a/samples/terraform-local-exec/test.txt b/samples/terraform-local-exec/test.txt deleted file mode 100644 index d353394..0000000 --- a/samples/terraform-local-exec/test.txt +++ /dev/null @@ -1 +0,0 @@ -'Testing HTTP backend state management' diff --git a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs index e107abb..276f0fc 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs @@ -40,8 +40,6 @@ public async Task> FindAllAsync() public async Task CreateAsync(StateLockModel input) { - input.Id = ObjectId.GenerateNewId().ToString(); - input.Created = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff+00:00"); await _modelCollection.InsertOneAsync(input); return input; } diff --git a/src/WebApi/Controllers/StateController.cs b/src/WebApi/Controllers/StateController.cs index 1dd2aa7..fb93d79 100644 --- a/src/WebApi/Controllers/StateController.cs +++ b/src/WebApi/Controllers/StateController.cs @@ -114,7 +114,7 @@ public async Task Unlock(string name, [FromBody] StateLockModel i var existingLock = await stateLockRepository.FindOneAsync(name); if (existingLock != null) { - if (!string.IsNullOrEmpty(lockId)) + if (string.IsNullOrEmpty(lockId)) { return StatusCode(423, new { Message = "The state is locked." }); } diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 7cc5089..0a41222 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using Devpro.TerraformBackend.Domain.Models; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; namespace Devpro.TerraformBackend.WebApi.IntegrationTests; @@ -15,7 +13,9 @@ public abstract class IntegrationTestBase(WebApplicationFactory factory protected Faker StateFaker { get; } = new Faker("en"); - protected Faker StateLockFaker { get; } = new Faker("en"); + protected Faker StateLockFaker { get; } = new Faker("en") + .RuleFor(u => u.Id, f => Guid.NewGuid().ToString()) + .RuleFor(o => o.Created, f => DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff+00:00")); protected HttpClient CreateClient(bool isAuthorizationNeeded = false) { diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs index caa8a13..6577b25 100644 --- a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -1,4 +1,5 @@ -using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; +using System.Net; +using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources; @@ -18,7 +19,7 @@ public async Task StateResource_GetNotExisting_ReturnsNoContent() var response = await client.GetAsync($"/state/{name}"); // Assert - await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.NoContent, null); + await response.CheckResponseAndGetContent(HttpStatusCode.NoContent, null); } [Fact] @@ -35,7 +36,7 @@ public async Task StateResource_CreateNew_ReturnsCreated() // Assert //TODO: test resource URL in response - await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, null, string.Empty); + await response.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); } [Fact] @@ -45,9 +46,22 @@ public async Task StateResource_LockLifeCycle_IsSuccess() var client = CreateClient(true); var name = Faker.Random.Word(); var stateLock = StateLockFaker.Generate(); + var payload = GeneratePayload(); // Act & Assert var createLockResponse = await client.PostAsync($"/state/{name}/lock", Serialize(stateLock)); - await createLockResponse.CheckResponseAndGetContent(System.Net.HttpStatusCode.OK, "application/json; charset=utf-8", null); + await createLockResponse.CheckResponseAndGetContent(HttpStatusCode.OK, "application/json; charset=utf-8", null); + var deleteLockRequest = new HttpRequestMessage(HttpMethod.Delete, $"/state/{name}/lock") + { + Content = Serialize(stateLock) + }; + var missingLockIdUpdateResponse = await client.PostAsync($"/state/{name}", payload); + await missingLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Locked, "application/json; charset=utf-8", "{\"message\":\"The state is locked.\"}"); + var wrongLockIdUpdateResponse = await client.PostAsync($"/state/{name}?ID=1234", payload); + await wrongLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Conflict, "text/plain; charset=utf-8", "LockId doesn't match with the existing lock"); + var updateResponse = await client.PostAsync($"/state/{name}?ID={stateLock.Id}", payload); + await updateResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); + var deleteLockResponse = await client.SendAsync(deleteLockRequest); + await deleteLockResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null); } } From 6dcf7d8e6ab81167ad1408f67659c0c3a229d255 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Mon, 14 Jul 2025 18:01:54 +0200 Subject: [PATCH 21/29] Improve test and doc --- samples/terraform-local-exec/README.md | 8 +++++++- .../Resources/StateControllerResourceTest.cs | 15 ++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/samples/terraform-local-exec/README.md b/samples/terraform-local-exec/README.md index 1caf388..c9b0e8e 100644 --- a/samples/terraform-local-exec/README.md +++ b/samples/terraform-local-exec/README.md @@ -1,6 +1,12 @@ # Terraform with local-exec -## Run the sample +## Setup + +Run the application, from the command line or the IDE. + +[Terraform binary](https://developer.hashicorp.com/terraform/install) is the only other tool required on the machine. + +## Workflow Go to the sample directory: diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs index 6577b25..2bc204d 100644 --- a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -23,20 +23,21 @@ public async Task StateResource_GetNotExisting_ReturnsNoContent() } [Fact] - public async Task StateResource_CreateNew_ReturnsCreated() + public async Task StateResource_CreateFindDelete_IsSuccess() { // Arrange var client = CreateClient(true); var name = Faker.Random.Word(); - var lockId = Faker.Random.Guid().ToString(); var payload = GeneratePayload(); - // Act - var response = await client.PostAsync($"/state/{name}?ID={lockId}", payload); - - // Assert + // Act & Assert + var createResponse = await client.PostAsync($"/state/{name}", payload); //TODO: test resource URL in response - await response.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); + await createResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); + var findResponse = await client.GetAsync($"/state/{name}"); + await findResponse.CheckResponseAndGetContent(HttpStatusCode.OK, "text/plain; charset=utf-8"); + var deleteResponse = await client.DeleteAsync($"/state/{name}"); + await deleteResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null); } [Fact] From 5f1286afb7055450609cfef6bacf2920f44b90a8 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Wed, 16 Jul 2025 11:16:30 +0200 Subject: [PATCH 22/29] Small changes --- CONTRIBUTING.md | 75 ++++-- Devpro.TerraformBackend.sln | 224 +++++++++--------- README.md | 34 +-- samples/terraform-docker/README.md | 39 ++- samples/terraform-local-exec/README.md | 33 --- samples/terraform-local/README.md | 40 ++++ .../main.tf | 0 samples/terraform-local/test.txt | 1 + src/Common.MongoDb/RepositoryBase.cs | 36 +++ .../Repositories/RepositoryBase.cs | 38 --- .../Repositories/StateLockRepository.cs | 5 +- .../Repositories/StateRepository.cs | 66 +++--- 12 files changed, 315 insertions(+), 276 deletions(-) delete mode 100644 samples/terraform-local-exec/README.md create mode 100644 samples/terraform-local/README.md rename samples/{terraform-local-exec => terraform-local}/main.tf (100%) create mode 100644 samples/terraform-local/test.txt create mode 100644 src/Common.MongoDb/RepositoryBase.cs delete mode 100644 src/Infrastructure.MongoDb/Repositories/RepositoryBase.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 062ce4d..98d0a00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,37 +1,57 @@ # Project development guide -## Solution design +## Design -This is a .NET 8 / C# codebase (open-source, cross-platform, free, object-oriented technologies). +The application is entirely based on open-source, cross-platform (Linux/Windows), highly performant, free, object-oriented technologies: .NET / C#. ### Projects -Project name | Technology | Project type ------------------------- | ---------- | -------------------------- -`Common.AspNetCore` | .NET 8 | Library -`Common.MongoDb` | .NET 8 | Library -`Common.Runtime` | .NET 8 | Library -`Domain` | .NET 8 | Library -`Infrastructure.MongoDb` | .NET 8 | Library -`WebApi` | ASP.NET 8 | Web application (REST API) +Project name | Technology | Project type +---------------------------|------------|--------------------------- +`Common.AspNetCore` | .NET 8 | Library +`Common.AspNetCore.WebApi` | .NET 8 | Library +`Common.MongoDb` | .NET 8 | Library +`Domain` | .NET 8 | Library +`Infrastructure.MongoDb` | .NET 8 | Library +`WebApi` | ASP.NET 8 | Web application (REST API) ### Packages (NuGet) Name | Description ------------------------- | ---------------------------- +-------------------------|----------------------------- `MongoDB.Bson` | MongoDB BSON `MongoDB.Driver` | MongoDB .NET Driver `Swashbuckle.AspNetCore` | OpenAPI / Swagger generation `System.Text.Json` | JSON support -## CI/CD pipelines +### Documentation -GitHub Actions are triggered to automate the application lifecycle: +* [OpenTofu](https://opentofu.org/) +* [MongoDB](https://www.mongodb.com/) +* [Terraform](https://www.terraform.io) + * [HTTP backend](https://developer.hashicorp.com/terraform/language/backend/http) + * [Remote state backend](https://github.com/hashicorp/terraform/tree/main/internal/backend/remote-state). -- [CI](.github/workflows/ci.yaml) (Continuous Integration) -- [PKG](.github/workflows/pkg.yaml) (Continuous Delivery) +### References of other implementations -GitHub project has been configured, in General / Security / Secrets and Variables / Actions: +* [GitLab](https://gitlab.com/gitlab-org/manage/import/gitlab/-/blob/master/doc/user/infrastructure/terraform_state.md) + * [lib/api/terraform/state.rb](https://gitlab.com/gitlab-org/manage/import/gitlab/-/blob/master/lib/api/terraform/state.rb) +* HTTP + * [akshay/terraform-http-backend-pass](https://git.coop/akshay/terraform-http-backend-pass) + * [bhoriuchi/terraform-backend-http](https://github.com/bhoriuchi/terraform-backend-http) +* git + * [plumber-cd/terraform-backend-git](https://github.com/plumber-cd/terraform-backend-git) + +## Automation + +### Build (CI/CD pipelines) + +GitHub Actions are triggered to automate the integration and delivery of the application: + +- [CI](.github/workflows/ci.yaml) +- [PKG](.github/workflows/pkg.yaml) + +GitHub project has been configured, in **General** / **Security** / **Secrets and Variables** / **Actions**: - DOCKERHUB_TOKEN - DOCKERHUB_USERNAME @@ -39,3 +59,26 @@ GitHub project has been configured, in General / Security / Secrets and Variable - SONAR_ORG - SONAR_PROJECT_KEY - SONAR_TOKEN + +## Procedures + +### Run locally the application + +Create/have a MongoDB database (example with a local container but you can provision a cluster in MongoDB Atlas): + +```bash +# creates a container +docker run --name mongodb -d -p 27017:27017 mongo:8.0 +# (optional) adds indexes for optimal performances +docker run --rm --link mongodb \ + -v "$(pwd)/scripts":/home/scripts mongo:8.0 \ + bash -c "mongo mongodb://mongodb:27017/terraform_backend_dev /home/scripts/mongo-create-index.js" +``` + +Run the web API (example with the command line but an IDE like Visual Studio or Rider would be nice to be able to debug): + +```bash +dotnet run --project src/WebApi +``` + +Open Swagger in a browser: [localhost:5293/swagger](http://localhost:5293/swagger). diff --git a/Devpro.TerraformBackend.sln b/Devpro.TerraformBackend.sln index b686d82..4529a3c 100644 --- a/Devpro.TerraformBackend.sln +++ b/Devpro.TerraformBackend.sln @@ -1,112 +1,112 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32407.343 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 - Solution Items", "0 - Solution Items", "{7B3738E0-6F86-4358-B55C-5AAD42B24F81}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitignore = .gitignore - compose.yaml = compose.yaml - CONTRIBUTING.md = CONTRIBUTING.md - Directory.Build.props = Directory.Build.props - README.md = README.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 - Applications", "3 - Applications", "{6D13F54F-4547-49C7-8136-01BFB4BBEE1E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 - Business", "2 - Business", "{E9839BEC-B050-43E9-8EFD-34659CC92D93}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "src\WebApi\WebApi.csproj", "{5CD7A689-5ADB-4207-972E-6FA881AF1B1C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{A5CAD112-C1E6-442B-BE0E-37C697030636}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.MongoDb", "src\Infrastructure.MongoDb\Infrastructure.MongoDb.csproj", "{0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.MongoDb", "src\Common.MongoDb\Common.MongoDb.csproj", "{49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore", "src\Common.AspNetCore\Common.AspNetCore.csproj", "{F23098F5-355B-46F0-BABE-3D6E23D8EED7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "terraform-docker", "terraform-docker", "{001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF}" - ProjectSection(SolutionItems) = preProject - samples\terraform-docker\main.tf = samples\terraform-docker\main.tf - samples\terraform-docker\outputs.tf = samples\terraform-docker\outputs.tf - samples\terraform-docker\providers.tf = samples\terraform-docker\providers.tf - samples\terraform-docker\README.md = samples\terraform-docker\README.md - samples\terraform-docker\variables.tf = samples\terraform-docker\variables.tf - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{7F01D180-1A34-4377-B4E5-C852D8302CE7}" - ProjectSection(SolutionItems) = preProject - scripts\mongo-create-index.js = scripts\mongo-create-index.js - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.IntegrationTests", "test\WebApi.IntegrationTests\WebApi.IntegrationTests.csproj", "{B055FFAF-8261-43B1-866A-12E289D5D7DC}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore.WebApi", "src\Common.AspNetCore.WebApi\Common.AspNetCore.WebApi.csproj", "{19336002-C959-4E76-B112-861F93CF6423}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Framework", "1 - Framework", "{0C1E6968-B289-4378-84CF-B64E05E643A5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "terraform-local-exec", "terraform-local-exec", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - samples\terraform-local-exec\main.tf = samples\terraform-local-exec\main.tf - samples\terraform-local-exec\README.md = samples\terraform-local-exec\README.md - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.Build.0 = Release|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.Build.0 = Release|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.Build.0 = Release|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.Build.0 = Release|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.Build.0 = Release|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.Build.0 = Release|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.Build.0 = Debug|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.ActiveCfg = Release|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} - {A5CAD112-C1E6-442B-BE0E-37C697030636} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C} = {0C1E6968-B289-4378-84CF-B64E05E643A5} - {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} - {F23098F5-355B-46F0-BABE-3D6E23D8EED7} = {0C1E6968-B289-4378-84CF-B64E05E643A5} - {001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} - {7F01D180-1A34-4377-B4E5-C852D8302CE7} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} - {B055FFAF-8261-43B1-866A-12E289D5D7DC} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} - {19336002-C959-4E76-B112-861F93CF6423} = {0C1E6968-B289-4378-84CF-B64E05E643A5} - {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {DC545534-6A10-475B-A0DA-3374CC025D82} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32407.343 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 - Solution Items", "0 - Solution Items", "{7B3738E0-6F86-4358-B55C-5AAD42B24F81}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + compose.yaml = compose.yaml + CONTRIBUTING.md = CONTRIBUTING.md + Directory.Build.props = Directory.Build.props + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 - Applications", "3 - Applications", "{6D13F54F-4547-49C7-8136-01BFB4BBEE1E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 - Business", "2 - Business", "{E9839BEC-B050-43E9-8EFD-34659CC92D93}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "src\WebApi\WebApi.csproj", "{5CD7A689-5ADB-4207-972E-6FA881AF1B1C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{A5CAD112-C1E6-442B-BE0E-37C697030636}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.MongoDb", "src\Infrastructure.MongoDb\Infrastructure.MongoDb.csproj", "{0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.MongoDb", "src\Common.MongoDb\Common.MongoDb.csproj", "{49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore", "src\Common.AspNetCore\Common.AspNetCore.csproj", "{F23098F5-355B-46F0-BABE-3D6E23D8EED7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "terraform-docker", "terraform-docker", "{001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF}" + ProjectSection(SolutionItems) = preProject + samples\terraform-docker\main.tf = samples\terraform-docker\main.tf + samples\terraform-docker\outputs.tf = samples\terraform-docker\outputs.tf + samples\terraform-docker\providers.tf = samples\terraform-docker\providers.tf + samples\terraform-docker\README.md = samples\terraform-docker\README.md + samples\terraform-docker\variables.tf = samples\terraform-docker\variables.tf + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{7F01D180-1A34-4377-B4E5-C852D8302CE7}" + ProjectSection(SolutionItems) = preProject + scripts\mongo-create-index.js = scripts\mongo-create-index.js + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.IntegrationTests", "test\WebApi.IntegrationTests\WebApi.IntegrationTests.csproj", "{B055FFAF-8261-43B1-866A-12E289D5D7DC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore.WebApi", "src\Common.AspNetCore.WebApi\Common.AspNetCore.WebApi.csproj", "{19336002-C959-4E76-B112-861F93CF6423}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Framework", "1 - Framework", "{0C1E6968-B289-4378-84CF-B64E05E643A5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "terraform-local", "terraform-local", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + samples\terraform-local\main.tf = samples\terraform-local\main.tf + samples\terraform-local\README.md = samples\terraform-local\README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.Build.0 = Release|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.Build.0 = Release|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.Build.0 = Release|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.Build.0 = Release|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.Build.0 = Release|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.Build.0 = Release|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} + {A5CAD112-C1E6-442B-BE0E-37C697030636} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C} = {0C1E6968-B289-4378-84CF-B64E05E643A5} + {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} + {F23098F5-355B-46F0-BABE-3D6E23D8EED7} = {0C1E6968-B289-4378-84CF-B64E05E643A5} + {001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} + {7F01D180-1A34-4377-B4E5-C852D8302CE7} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} + {B055FFAF-8261-43B1-866A-12E289D5D7DC} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} + {19336002-C959-4E76-B112-861F93CF6423} = {0C1E6968-B289-4378-84CF-B64E05E643A5} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DC545534-6A10-475B-A0DA-3374CC025D82} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 033cfb3..08694ef 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,20 @@ -# Terraform backend management in MongoDB +# MongoDB backend for Terraform/OpenTofu state [![CI](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/ci.yaml) [![PKG](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/pkg.yaml/badge.svg?branch=main)](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/pkg.yaml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=devpro_terraform-backend-mongodb&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=devpro_terraform-backend-mongodb) [![Docker Image Version](https://img.shields.io/docker/v/devprofr/terraform-backend-mongodb?label=Image&logo=docker)](https://hub.docker.com/r/devprofr/terraform-backend-mongodb) -Store [Terraform](https://www.terraform.io) state in [MongoDB](https://www.mongodb.com/), using -[HTTP](https://developer.hashicorp.com/terraform/language/backend/http) [backend](https://github.com/hashicorp/terraform/tree/main/internal/backend/remote-state). +Store Terraform/OpenTofu state in a MongoDB database thanks to his HTTP backend. -Look at the [project development guide](CONTRIBUTING.md) for more technical details. You're more than welcome to contribute! +Look at the [project development guide](CONTRIBUTING.md) for more technical details. +You're more than welcome to contribute! ## Quick start -1. Create a MongoDB database (example with a local container but you can provision a cluster in MongoDB Atlas) +1. Make sure a you have access to a MongoDB database -```bash -# starts the container -docker run --name mongodb -d -p 27017:27017 mongo:8.0 -# (optional) adds indexes for optimal performances -docker run --rm --link mongodb \ - -v "$(pwd)/scripts":/home/scripts mongo:8.0 \ - bash -c "mongo mongodb://mongodb:27017/terraform_backend_dev /home/scripts/mongo-create-index.js" -``` +2. Configure the application with the MongoDB database connection information 2. Run the web API @@ -47,16 +40,5 @@ terraform { ## Samples -* [Docker](samples/terraform-docker/README.md) - -## Alternatives & references - -### Terraform backend implementations - -* [GitLab](https://gitlab.com/gitlab-org/manage/import/gitlab/-/blob/master/doc/user/infrastructure/terraform_state.md) - * [lib/api/terraform/state.rb](https://gitlab.com/gitlab-org/manage/import/gitlab/-/blob/master/lib/api/terraform/state.rb) -* HTTP - * [akshay/terraform-http-backend-pass](https://git.coop/akshay/terraform-http-backend-pass) - * [bhoriuchi/terraform-backend-http](https://github.com/bhoriuchi/terraform-backend-http) -* git - * [plumber-cd/terraform-backend-git](https://github.com/plumber-cd/terraform-backend-git) +* [Execute local actions](samples/terraform-local/README.md) +* [Manage Docker images](samples/terraform-docker/README.md) diff --git a/samples/terraform-docker/README.md b/samples/terraform-docker/README.md index 631ae8b..fb2799a 100644 --- a/samples/terraform-docker/README.md +++ b/samples/terraform-docker/README.md @@ -1,38 +1,51 @@ -# Samples +# Terraform Docker sample -This is a very simple sample to experiment the Terraform backend. +This sample will create and manage a container in Docker using Terraform and our MongoDB HTTP backend. +It is inspired from [Terraform Get Started](https://learn.hashicorp.com/collections/terraform/docker-get-started). -It will create and manage a container in Docker (inspired from [Terraform Get Started](https://learn.hashicorp.com/collections/terraform/docker-get-started)). +## Setup -## Demonstration +The following tools must be available from the command line: -Make sure docker runtime is running and can be accessed from the command line: +- [.NET](https://dotnet.microsoft.com/download) or an IDE (Visual Studio or Rider) +- [Terraform](https://developer.hashicorp.com/terraform/install) or [OpenTofy](https://opentofu.org/docs/intro/install/) +- Docker + +## Workflow + +Run the application (example given for information but feel free to start from the IDE): ```bash -docker ps +dotnet run --project src/WebApi ``` -Run the commands: +Go to the sample directory: ```bash cd samples/terraform-docker +``` -SET TF_LOG=TRACE +Initialize (feel free to use `tofu` instead of `terraform`): +```bash +SET TF_LOG=TRACE terraform init +``` -terraform plan +Apply the change (before confirming you can check from on another terminal to run an apply): +```bash terraform apply +``` -# makes sure the container is running -docker ps +Check the running container: -# get nginx container content +```bash +docker ps curl localhost:8000 ``` -Destroy the container: +Destroy resources: ```bash terraform destroy diff --git a/samples/terraform-local-exec/README.md b/samples/terraform-local-exec/README.md deleted file mode 100644 index c9b0e8e..0000000 --- a/samples/terraform-local-exec/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Terraform with local-exec - -## Setup - -Run the application, from the command line or the IDE. - -[Terraform binary](https://developer.hashicorp.com/terraform/install) is the only other tool required on the machine. - -## Workflow - -Go to the sample directory: - -```bash -cd samples\terraform-local-exec -``` - -Initialize: - -```bash -terraform init -``` - -Apply the change (before confirming you can check from on another terminal to run an apply): - -```bash -terraform apply -``` - -Destroy resources: - -```bash -terraform destroy -``` diff --git a/samples/terraform-local/README.md b/samples/terraform-local/README.md new file mode 100644 index 0000000..2ab83eb --- /dev/null +++ b/samples/terraform-local/README.md @@ -0,0 +1,40 @@ +# Sample with local commands + +## Setup + +The following tools must be available from the command line: + +- [.NET](https://dotnet.microsoft.com/download) or an IDE (Visual Studio or Rider) +- [Terraform](https://developer.hashicorp.com/terraform/install) or [OpenTofy](https://opentofu.org/docs/intro/install/) + +## Workflow + +Run the application (example given for information but feel free to start from the IDE): + +```bash +dotnet run --project src/WebApi +``` + +Go to the sample directory: + +```bash +cd samples/terraform-local-exec +``` + +Initialize (feel free to use `tofu` instead of `terraform`): + +```bash +terraform init +``` + +Apply the change (before confirming you can check from on another terminal to run an apply): + +```bash +terraform apply +``` + +Destroy resources: + +```bash +terraform destroy +``` diff --git a/samples/terraform-local-exec/main.tf b/samples/terraform-local/main.tf similarity index 100% rename from samples/terraform-local-exec/main.tf rename to samples/terraform-local/main.tf diff --git a/samples/terraform-local/test.txt b/samples/terraform-local/test.txt new file mode 100644 index 0000000..d353394 --- /dev/null +++ b/samples/terraform-local/test.txt @@ -0,0 +1 @@ +'Testing HTTP backend state management' diff --git a/src/Common.MongoDb/RepositoryBase.cs b/src/Common.MongoDb/RepositoryBase.cs new file mode 100644 index 0000000..66c99a4 --- /dev/null +++ b/src/Common.MongoDb/RepositoryBase.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Devpro.Common.MongoDb; + +public abstract class RepositoryBase +{ + private readonly IMongoClientFactory _mongoClientFactory; + + private readonly ILogger _logger; + + private readonly MongoDbConfiguration _configuration; + + private readonly MongoClient _mongoClient; + + private readonly IMongoDatabase _mongoDatabase; + + protected RepositoryBase(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) + { + _mongoClientFactory = mongoClientFactory; + _logger = logger; + _configuration = configuration; + + _logger.LogDebug("Opening connection to MongoDB"); + _mongoClient = _mongoClientFactory.CreateClient(_configuration.ConnectionString); + _logger.LogDebug("Getting database {DatabaseName}", _configuration.DatabaseName); + _mongoDatabase = _mongoClient.GetDatabase(_configuration.DatabaseName); + } + + protected abstract string CollectionName { get; } + + protected IMongoCollection GetCollection() + { + return _mongoDatabase.GetCollection(CollectionName); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/RepositoryBase.cs b/src/Infrastructure.MongoDb/Repositories/RepositoryBase.cs deleted file mode 100644 index d6b7dd1..0000000 --- a/src/Infrastructure.MongoDb/Repositories/RepositoryBase.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Devpro.Common.MongoDb; -using Microsoft.Extensions.Logging; -using MongoDB.Driver; - -namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories -{ - public abstract class RepositoryBase - { - private readonly IMongoClientFactory _mongoClientFactory; - - private readonly ILogger _logger; - - private readonly MongoDbConfiguration _configuration; - - private readonly MongoClient _mongoClient; - - private readonly IMongoDatabase _mongoDatabase; - - protected RepositoryBase(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) - { - _mongoClientFactory = mongoClientFactory; - _logger = logger; - _configuration = configuration; - - _logger.LogDebug("Opening connection to MongoDB"); - _mongoClient = _mongoClientFactory.CreateClient(_configuration.ConnectionString); - _logger.LogDebug("Getting database {DatabaseName}", _configuration.DatabaseName); - _mongoDatabase = _mongoClient.GetDatabase(_configuration.DatabaseName); - } - - protected abstract string CollectionName { get; } - - protected IMongoCollection GetCollection() - { - return _mongoDatabase.GetCollection(CollectionName); - } - } -} diff --git a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs index 276f0fc..c7b4500 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs @@ -1,12 +1,9 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; -using System.Xml.Linq; using Devpro.Common.MongoDb; using Devpro.TerraformBackend.Domain.Models; using Devpro.TerraformBackend.Domain.Repositories; using Microsoft.Extensions.Logging; -using MongoDB.Bson; using MongoDB.Driver; namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories diff --git a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs index 2d975a8..530b964 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs @@ -6,47 +6,45 @@ using MongoDB.Bson; using MongoDB.Driver; -namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories +namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories; + +public class StateRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) + : RepositoryBase(mongoClientFactory, logger, configuration), IStateRepository { - public class StateRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) - : RepositoryBase(mongoClientFactory, logger, configuration), IStateRepository - { - protected override string CollectionName => "tf_state"; + protected override string CollectionName => "tf_state"; - public async Task CreateAsync(string name, string jsonInput) + public async Task CreateAsync(string name, string jsonInput) + { + //TODO: makes it the latest value + var document = new BsonDocument { - //TODO: makes it the latest value - var document = new BsonDocument - { - ["_id"] = new BsonObjectId(ObjectId.GenerateNewId()), - ["name"] = name, - ["createdAt"] = new BsonDateTime(DateTime.UtcNow), // stored as ToString("yyyy-MM-ddTHH:mm:ss.fff+00:00") - ["value"] = BsonDocument.Parse(jsonInput) - }; - var collection = GetCollection(); - await collection.InsertOneAsync(document); - } + ["_id"] = new BsonObjectId(ObjectId.GenerateNewId()), + ["name"] = name, + ["createdAt"] = new BsonDateTime(DateTime.UtcNow), // stored as ToString("yyyy-MM-ddTHH:mm:ss.fff+00:00") + ["value"] = BsonDocument.Parse(jsonInput) + }; + var collection = GetCollection(); + await collection.InsertOneAsync(document); + } - public async Task FindOneAsync(string name) + public async Task FindOneAsync(string name) + { + var collection = GetCollection(); + var document = await collection.Find(new BsonDocument("name", name)) + .Sort(Builders.Sort.Descending("createdAt")) + .FirstOrDefaultAsync(); + if (document == null) { - var collection = GetCollection(); - var document = await collection.Find(new BsonDocument("name", name)) - .Sort(Builders.Sort.Descending("createdAt")) - .FirstOrDefaultAsync(); - if (document == null) - { - return string.Empty; - } - - return document["value"].ToJson(); + return string.Empty; } + return document["value"].ToJson(); + } - public async Task DeleteAsync(string name) - { - var collection = GetCollection(); - var deleteResult = await collection.DeleteOneAsync(new BsonDocument("name", name)); - return deleteResult.DeletedCount > 0; - } + public async Task DeleteAsync(string name) + { + var collection = GetCollection(); + var deleteResult = await collection.DeleteOneAsync(new BsonDocument("name", name)); + return deleteResult.DeletedCount > 0; } } From 9db6ff0c281cc5a1c727c2d43aa091c1f8d7cd8e Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Wed, 16 Jul 2025 11:22:28 +0200 Subject: [PATCH 23/29] Use StateModel for tests --- test/WebApi.IntegrationTests/IntegrationTestBase.cs | 10 ---------- .../Resources/StateControllerResourceTest.cs | 12 ++++++------ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 0a41222..5430601 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -33,16 +33,6 @@ protected HttpClient CreateClient(bool isAuthorizationNeeded = false) return client; } - protected StringContent GeneratePayload() - { - var dummy = new - { - Property1 = Faker.Random.String(), - Property2 = Faker.Random.Int() - }; - return Serialize(dummy); - } - protected static StringContent Serialize(T value, string mediaType = "application/json") { return new StringContent(JsonSerializer.Serialize(value), Encoding.UTF8, mediaType); diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs index 2bc204d..f5c2131 100644 --- a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -28,10 +28,10 @@ public async Task StateResource_CreateFindDelete_IsSuccess() // Arrange var client = CreateClient(true); var name = Faker.Random.Word(); - var payload = GeneratePayload(); + var state = StateFaker.Generate(); // Act & Assert - var createResponse = await client.PostAsync($"/state/{name}", payload); + var createResponse = await client.PostAsync($"/state/{name}", Serialize(state)); //TODO: test resource URL in response await createResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); var findResponse = await client.GetAsync($"/state/{name}"); @@ -46,8 +46,8 @@ public async Task StateResource_LockLifeCycle_IsSuccess() // Arrange var client = CreateClient(true); var name = Faker.Random.Word(); + var state = StateFaker.Generate(); var stateLock = StateLockFaker.Generate(); - var payload = GeneratePayload(); // Act & Assert var createLockResponse = await client.PostAsync($"/state/{name}/lock", Serialize(stateLock)); @@ -56,11 +56,11 @@ public async Task StateResource_LockLifeCycle_IsSuccess() { Content = Serialize(stateLock) }; - var missingLockIdUpdateResponse = await client.PostAsync($"/state/{name}", payload); + var missingLockIdUpdateResponse = await client.PostAsync($"/state/{name}", Serialize(state)); await missingLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Locked, "application/json; charset=utf-8", "{\"message\":\"The state is locked.\"}"); - var wrongLockIdUpdateResponse = await client.PostAsync($"/state/{name}?ID=1234", payload); + var wrongLockIdUpdateResponse = await client.PostAsync($"/state/{name}?ID=1234", Serialize(state)); await wrongLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Conflict, "text/plain; charset=utf-8", "LockId doesn't match with the existing lock"); - var updateResponse = await client.PostAsync($"/state/{name}?ID={stateLock.Id}", payload); + var updateResponse = await client.PostAsync($"/state/{name}?ID={stateLock.Id}", Serialize(state)); await updateResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); var deleteLockResponse = await client.SendAsync(deleteLockRequest); await deleteLockResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null); From 3261f1da59de5f3593e3a7b73163effb1214bd33 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Wed, 16 Jul 2025 15:50:52 +0200 Subject: [PATCH 24/29] Add tenant --- scripts/mongo-create-index.js | 6 +- src/Domain/Models/StateLockModel.cs | 5 ++ src/Domain/Models/StateModel.cs | 2 + .../Repositories/IStateLockRepository.cs | 2 +- src/Domain/Repositories/IStateRepository.cs | 6 +- .../Repositories/StateLockRepository.cs | 76 +++++++++---------- .../Repositories/StateRepository.cs | 38 +++++++--- src/WebApi/ApplicationConfiguration.cs | 20 ++--- src/WebApi/Controllers/StateController.cs | 41 +++++----- src/WebApi/Program.cs | 6 +- .../Resources/StateControllerResourceTest.cs | 26 ++++--- 11 files changed, 124 insertions(+), 104 deletions(-) diff --git a/scripts/mongo-create-index.js b/scripts/mongo-create-index.js index 73957ed..2cb721d 100644 --- a/scripts/mongo-create-index.js +++ b/scripts/mongo-create-index.js @@ -1,3 +1,3 @@ -db.tf_state.createIndex({ "name": 1 }); -db.tf_state_revisions.createIndex({ "name": 1 }); -db.tf_state_lock.createIndex({ "name": 1 }); +db.tf_state.createIndex({ "tenant": 1, "name": 1 }); +//TODO: use tf_state_revisions +db.tf_state_lock.createIndex({ "tenant": 1, "name": 1 }); diff --git a/src/Domain/Models/StateLockModel.cs b/src/Domain/Models/StateLockModel.cs index 4da30cc..253e83d 100644 --- a/src/Domain/Models/StateLockModel.cs +++ b/src/Domain/Models/StateLockModel.cs @@ -14,6 +14,11 @@ public class StateLockModel [JsonPropertyName("ID")] public string Id { get; set; } = string.Empty; + /// + /// + /// + public string Tenant { get; set; } = string.Empty; + /// /// Name of the Terraform state. /// diff --git a/src/Domain/Models/StateModel.cs b/src/Domain/Models/StateModel.cs index 14d5904..a54015f 100644 --- a/src/Domain/Models/StateModel.cs +++ b/src/Domain/Models/StateModel.cs @@ -10,6 +10,8 @@ public class StateModel [BsonRepresentation(BsonType.ObjectId)] public string Id { get; set; } = string.Empty; + public string Tenant { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.MinValue; diff --git a/src/Domain/Repositories/IStateLockRepository.cs b/src/Domain/Repositories/IStateLockRepository.cs index e5dd5ee..b1bbe8e 100644 --- a/src/Domain/Repositories/IStateLockRepository.cs +++ b/src/Domain/Repositories/IStateLockRepository.cs @@ -6,7 +6,7 @@ namespace Devpro.TerraformBackend.Domain.Repositories; public interface IStateLockRepository { - Task FindOneAsync(string name); + Task FindOneAsync(string tenant, string name); Task CreateAsync(StateLockModel input); diff --git a/src/Domain/Repositories/IStateRepository.cs b/src/Domain/Repositories/IStateRepository.cs index 4328b4b..8a9c668 100644 --- a/src/Domain/Repositories/IStateRepository.cs +++ b/src/Domain/Repositories/IStateRepository.cs @@ -4,9 +4,9 @@ namespace Devpro.TerraformBackend.Domain.Repositories; public interface IStateRepository { - Task FindOneAsync(string name); + Task FindOneAsync(string tenant, string name); - Task CreateAsync(string name, string jsonInput); + Task CreateAsync(string tenant, string name, string jsonInput); - Task DeleteAsync(string name); + Task DeleteAsync(string tenant, string name); } diff --git a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs index c7b4500..ac9347f 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs @@ -1,50 +1,46 @@ -using System.Collections.Generic; -using System.Threading.Tasks; +using System.Threading.Tasks; using Devpro.Common.MongoDb; using Devpro.TerraformBackend.Domain.Models; using Devpro.TerraformBackend.Domain.Repositories; using Microsoft.Extensions.Logging; using MongoDB.Driver; -namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories +namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories; + +public class StateLockRepository : RepositoryBase, IStateLockRepository { - public class StateLockRepository : RepositoryBase, IStateLockRepository + private readonly IMongoCollection _modelCollection; + + public StateLockRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) + : base(mongoClientFactory, logger, configuration) + { + _modelCollection = GetCollection(); + } + + protected override string CollectionName => "tf_state_lock"; + + public async Task FindOneAsync(string tenant, string name) + { + return await _modelCollection.Find(x => x.Name == name).FirstOrDefaultAsync(); + } + + //public async Task> FindAllAsync() + //{ + // //var documents = await _bsonCollection.Find(new BsonDocument()).ToListAsync(); + // //return documents.Select(x => BsonSerializer.Deserialize(x)).ToList(); + + // return await _modelCollection.Find(_ => true).ToListAsync(); + //} + + public async Task CreateAsync(StateLockModel input) + { + await _modelCollection.InsertOneAsync(input); + return input; + } + + public async Task DeleteAsync(StateLockModel input) { - //private readonly IMongoCollection _bsonCollection; - private readonly IMongoCollection _modelCollection; - - public StateLockRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) - : base(mongoClientFactory, logger, configuration) - { - //_bsonCollection = GetCollection(); - _modelCollection = GetCollection(); - } - - protected override string CollectionName => "tf_state_lock"; - - public async Task FindOneAsync(string name) - { - return await _modelCollection.Find(x => x.Name == name).FirstOrDefaultAsync(); - } - - public async Task> FindAllAsync() - { - //var documents = await _bsonCollection.Find(new BsonDocument()).ToListAsync(); - //return documents.Select(x => BsonSerializer.Deserialize(x)).ToList(); - - return await _modelCollection.Find(_ => true).ToListAsync(); - } - - public async Task CreateAsync(StateLockModel input) - { - await _modelCollection.InsertOneAsync(input); - return input; - } - - public async Task DeleteAsync(StateLockModel input) - { - var result = await _modelCollection.DeleteOneAsync(x => x.Id == input.Id && x.Name == input.Name); - return result.DeletedCount > 0; - } + var result = await _modelCollection.DeleteOneAsync(x => x.Tenant == input.Tenant && x.Name == input.Name && x.Id == input.Id); + return result.DeletedCount > 0; } } diff --git a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs index 530b964..513d99e 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Devpro.Common.MongoDb; +using Devpro.TerraformBackend.Domain.Models; using Devpro.TerraformBackend.Domain.Repositories; using Microsoft.Extensions.Logging; using MongoDB.Bson; @@ -8,29 +9,34 @@ namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories; -public class StateRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) - : RepositoryBase(mongoClientFactory, logger, configuration), IStateRepository +public class StateRepository : RepositoryBase, IStateRepository { + private readonly IMongoCollection _bsonCollection; + + public StateRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) + : base(mongoClientFactory, logger, configuration) + { + _bsonCollection = GetCollection(); + } + protected override string CollectionName => "tf_state"; - public async Task CreateAsync(string name, string jsonInput) + public async Task CreateAsync(string tenant, string name, string jsonInput) { - //TODO: makes it the latest value var document = new BsonDocument { ["_id"] = new BsonObjectId(ObjectId.GenerateNewId()), + ["tenant"] = tenant, ["name"] = name, ["createdAt"] = new BsonDateTime(DateTime.UtcNow), // stored as ToString("yyyy-MM-ddTHH:mm:ss.fff+00:00") ["value"] = BsonDocument.Parse(jsonInput) }; - var collection = GetCollection(); - await collection.InsertOneAsync(document); + await _bsonCollection.InsertOneAsync(document); } - public async Task FindOneAsync(string name) + public async Task FindOneAsync(string tenant, string name) { - var collection = GetCollection(); - var document = await collection.Find(new BsonDocument("name", name)) + var document = await _bsonCollection.Find(GetFilter(tenant, name)) .Sort(Builders.Sort.Descending("createdAt")) .FirstOrDefaultAsync(); if (document == null) @@ -41,10 +47,18 @@ public async Task FindOneAsync(string name) return document["value"].ToJson(); } - public async Task DeleteAsync(string name) + public async Task DeleteAsync(string tenant, string name) { - var collection = GetCollection(); - var deleteResult = await collection.DeleteOneAsync(new BsonDocument("name", name)); + var deleteResult = await _bsonCollection.DeleteOneAsync(GetFilter(tenant, name)); return deleteResult.DeletedCount > 0; } + + private static BsonDocument GetFilter(string tenant, string name) + { + return new BsonDocument + { + { "tenant", tenant }, + { "name", name } + }; + } } diff --git a/src/WebApi/ApplicationConfiguration.cs b/src/WebApi/ApplicationConfiguration.cs index 1e993f4..bc58750 100644 --- a/src/WebApi/ApplicationConfiguration.cs +++ b/src/WebApi/ApplicationConfiguration.cs @@ -1,14 +1,14 @@ using Devpro.Common.MongoDb; -namespace Devpro.TerraformBackend.WebApi +namespace Devpro.TerraformBackend.WebApi; + +public class ApplicationConfiguration(IConfigurationRoot configurationRoot) + : WebApiConfiguration(configurationRoot) { - public class ApplicationConfiguration(IConfigurationRoot configurationRoot) : WebApiConfiguration(configurationRoot) - { - public MongoDbConfiguration MongoDbConfiguration => - new() - { - ConnectionString = ConfigurationRoot.GetConnectionString(TryGetSection("MongoDb:ConnectionStringName")?.Get() ?? string.Empty) ?? string.Empty, - DatabaseName = TryGetSection("MongoDb:DatabaseName").Get() ?? string.Empty - }; - } + public MongoDbConfiguration MongoDbConfiguration => + new() + { + ConnectionString = ConfigurationRoot.GetConnectionString(TryGetSection("MongoDb:ConnectionStringName")?.Get() ?? string.Empty) ?? string.Empty, + DatabaseName = TryGetSection("MongoDb:DatabaseName").Get() ?? string.Empty + }; } diff --git a/src/WebApi/Controllers/StateController.cs b/src/WebApi/Controllers/StateController.cs index fb93d79..a04a6f5 100644 --- a/src/WebApi/Controllers/StateController.cs +++ b/src/WebApi/Controllers/StateController.cs @@ -8,22 +8,23 @@ namespace Devpro.TerraformBackend.WebApi.Controllers; [Authorize] [ApiController] -[Route("state")] +[Route("{tenant}/state")] public class StateController(IStateRepository stateRepository, IStateLockRepository stateLockRepository) : ControllerBase { /// /// Get Terraform state value. - /// GET /state/:name?ID=:lockId + /// GET /:tenant/state/:name?ID=:lockId /// + /// /// The name of the Terraform state /// Raw string [HttpGet("{name:regex([[a-zA-Z]]+)}", Name = "GetState")] [Produces("text/plain")] [ProducesResponseType(200)] [ProducesResponseType(204)] - public async Task FindOne(string name) + public async Task FindOne(string tenant, string name) { - var state = await stateRepository.FindOneAsync(name); + var state = await stateRepository.FindOneAsync(tenant, name); if (string.IsNullOrEmpty(state)) { return NoContent(); @@ -34,7 +35,7 @@ public async Task FindOne(string name) /// /// Get Terraform state value. - /// POST /state/:name?ID=:lockId + /// POST /:tenant/state/:name?ID=:lockId /// /// The name of the Terraform state /// Terraform state lock ID @@ -44,17 +45,17 @@ public async Task FindOne(string name) [ProducesResponseType(200)] [ProducesResponseType(409)] [ProducesResponseType(423)] - public async Task Create(string name, [FromBody] object input, [FromQuery(Name = "ID")] string? lockId = "") + public async Task Create(string tenant, string name, [FromBody] object input, [FromQuery(Name = "ID")] string? lockId = "") { - if (await CheckLock(name, lockId) is { } lockResult) return lockResult; + if (await CheckLock(tenant, name, lockId) is { } lockResult) return lockResult; var jsonInput = JsonSerializer.Serialize(input); - await stateRepository.CreateAsync(name, jsonInput); + await stateRepository.CreateAsync(tenant, name, jsonInput); return Ok(); } /// - /// DELETE /state/:name?ID=:lockId + /// DELETE /:tenant/state/:name?ID=:lockId /// /// /// Terraform state lock ID @@ -63,16 +64,16 @@ public async Task Create(string name, [FromBody] object input, [F [ProducesResponseType(200)] [ProducesResponseType(409)] [ProducesResponseType(423)] - public async Task Delete(string name, [FromQuery(Name = "ID")] string? lockId = "") + public async Task Delete(string tenant, string name, [FromQuery(Name = "ID")] string? lockId = "") { - if (await CheckLock(name, lockId) is { } lockResult) return lockResult; + if (await CheckLock(tenant, name, lockId) is { } lockResult) return lockResult; - await stateRepository.DeleteAsync(name); + await stateRepository.DeleteAsync(tenant, name); return Ok(); } /// - /// POST /state/:name/lock + /// POST /:tenant/state/:name/lock /// /// /// @@ -83,17 +84,18 @@ public async Task Delete(string name, [FromQuery(Name = "ID")] st [ProducesResponseType(200)] [ProducesResponseType(409)] [ProducesResponseType(423)] - public async Task Lock(string name, StateLockModel input) + public async Task Lock(string tenant, string name, StateLockModel input) { - if (await CheckLock(name, input.Id) is { } lockResult) return lockResult; + if (await CheckLock(tenant, name, input.Id) is { } lockResult) return lockResult; + input.Tenant = tenant; input.Name = name; var entry = await stateLockRepository.CreateAsync(input); return Ok(entry); } /// - /// DELETE /state/:name/lock + /// DELETE /:tenant/state/:name/lock /// /// /// @@ -102,16 +104,17 @@ public async Task Lock(string name, StateLockModel input) [ProducesResponseType(200)] [Consumes("application/json", "text/json")] [Produces("application/json")] - public async Task Unlock(string name, [FromBody] StateLockModel input) + public async Task Unlock(string tenant, string name, [FromBody] StateLockModel input) { + input.Tenant = tenant; input.Name = name; await stateLockRepository.DeleteAsync(input); return Ok(); } - private async Task CheckLock(string name, string? lockId = "") + private async Task CheckLock(string tenant, string name, string? lockId = "") { - var existingLock = await stateLockRepository.FindOneAsync(name); + var existingLock = await stateLockRepository.FindOneAsync(tenant, name); if (existingLock != null) { if (string.IsNullOrEmpty(lockId)) diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 903e3b3..62ae714 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -1,8 +1,6 @@ -// creates the builder +// creates the web application builder and adds services to the container var builder = WebApplication.CreateBuilder(args); var configuration = new ApplicationConfiguration(builder.Configuration); - -// adds services to the container builder.Services.AddControllers(x => x.InputFormatters.Insert(0, new RawRequestBodyFormatter())); builder.Services.AddInfrastructure(configuration); builder.Services.AddEndpointsApiExplorer(); @@ -11,7 +9,7 @@ builder.Services.AddHealthChecks(); builder.Services.AddInvalidModelStateLog(); -// create the application and configures the HTTP request pipeline +// creates the application and configures the HTTP request pipeline var app = builder.Build(); app.UseSwagger(configuration); app.UseHttpsRedirection(configuration); diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs index f5c2131..075702d 100644 --- a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -7,6 +7,8 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources; public class StateControllerResourceTest(WebApplicationFactory factory) : IntegrationTestBase(factory) { + private string tenant = "dummy"; + [Fact] [Trait("Mode", "Readonly")] public async Task StateResource_GetNotExisting_ReturnsNoContent() @@ -16,7 +18,7 @@ public async Task StateResource_GetNotExisting_ReturnsNoContent() var name = Faker.Random.Word(); // Act - var response = await client.GetAsync($"/state/{name}"); + var response = await client.GetAsync($"/{tenant}/state/{name}"); // Assert await response.CheckResponseAndGetContent(HttpStatusCode.NoContent, null); @@ -31,12 +33,12 @@ public async Task StateResource_CreateFindDelete_IsSuccess() var state = StateFaker.Generate(); // Act & Assert - var createResponse = await client.PostAsync($"/state/{name}", Serialize(state)); + var createResponse = await client.PostAsync($"/{tenant}/state/{name}", Serialize(state)); //TODO: test resource URL in response await createResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); - var findResponse = await client.GetAsync($"/state/{name}"); + var findResponse = await client.GetAsync($"/{tenant}/state/{name}"); await findResponse.CheckResponseAndGetContent(HttpStatusCode.OK, "text/plain; charset=utf-8"); - var deleteResponse = await client.DeleteAsync($"/state/{name}"); + var deleteResponse = await client.DeleteAsync($"/{tenant}/state/{name}"); await deleteResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null); } @@ -50,18 +52,18 @@ public async Task StateResource_LockLifeCycle_IsSuccess() var stateLock = StateLockFaker.Generate(); // Act & Assert - var createLockResponse = await client.PostAsync($"/state/{name}/lock", Serialize(stateLock)); + var createLockResponse = await client.PostAsync($"/{tenant}/state/{name}/lock", Serialize(stateLock)); await createLockResponse.CheckResponseAndGetContent(HttpStatusCode.OK, "application/json; charset=utf-8", null); - var deleteLockRequest = new HttpRequestMessage(HttpMethod.Delete, $"/state/{name}/lock") - { - Content = Serialize(stateLock) - }; - var missingLockIdUpdateResponse = await client.PostAsync($"/state/{name}", Serialize(state)); + var missingLockIdUpdateResponse = await client.PostAsync($"/{tenant}/state/{name}", Serialize(state)); await missingLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Locked, "application/json; charset=utf-8", "{\"message\":\"The state is locked.\"}"); - var wrongLockIdUpdateResponse = await client.PostAsync($"/state/{name}?ID=1234", Serialize(state)); + var wrongLockIdUpdateResponse = await client.PostAsync($"/{tenant}/state/{name}?ID=1234", Serialize(state)); await wrongLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Conflict, "text/plain; charset=utf-8", "LockId doesn't match with the existing lock"); - var updateResponse = await client.PostAsync($"/state/{name}?ID={stateLock.Id}", Serialize(state)); + var updateResponse = await client.PostAsync($"/{tenant}/state/{name}?ID={stateLock.Id}", Serialize(state)); await updateResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); + var deleteLockRequest = new HttpRequestMessage(HttpMethod.Delete, $"/{tenant}/state/{name}/lock") + { + Content = Serialize(stateLock) + }; var deleteLockResponse = await client.SendAsync(deleteLockRequest); await deleteLockResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null); } From 97919f27df55208214ec6b089180100bb8225df3 Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Wed, 16 Jul 2025 16:09:55 +0200 Subject: [PATCH 25/29] Update samples --- Devpro.TerraformBackend.sln | 224 +++++++++--------- README.md | 17 +- .../README.md | 0 .../main.tf | 0 .../outputs.tf | 0 .../providers.tf | 8 +- .../variables.tf | 0 .../README.md | 0 .../{terraform-local => local-files}/main.tf | 6 +- samples/terraform-local/test.txt | 1 - .../Resources/StateControllerResourceTest.cs | 20 +- 11 files changed, 137 insertions(+), 139 deletions(-) rename samples/{terraform-docker => docker-nginx}/README.md (100%) rename samples/{terraform-docker => docker-nginx}/main.tf (100%) rename samples/{terraform-docker => docker-nginx}/outputs.tf (100%) rename samples/{terraform-docker => docker-nginx}/providers.tf (68%) rename samples/{terraform-docker => docker-nginx}/variables.tf (100%) rename samples/{terraform-local => local-files}/README.md (100%) rename samples/{terraform-local => local-files}/main.tf (68%) delete mode 100644 samples/terraform-local/test.txt diff --git a/Devpro.TerraformBackend.sln b/Devpro.TerraformBackend.sln index 4529a3c..1fcc800 100644 --- a/Devpro.TerraformBackend.sln +++ b/Devpro.TerraformBackend.sln @@ -1,112 +1,112 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32407.343 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 - Solution Items", "0 - Solution Items", "{7B3738E0-6F86-4358-B55C-5AAD42B24F81}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitignore = .gitignore - compose.yaml = compose.yaml - CONTRIBUTING.md = CONTRIBUTING.md - Directory.Build.props = Directory.Build.props - README.md = README.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 - Applications", "3 - Applications", "{6D13F54F-4547-49C7-8136-01BFB4BBEE1E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 - Business", "2 - Business", "{E9839BEC-B050-43E9-8EFD-34659CC92D93}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "src\WebApi\WebApi.csproj", "{5CD7A689-5ADB-4207-972E-6FA881AF1B1C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{A5CAD112-C1E6-442B-BE0E-37C697030636}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.MongoDb", "src\Infrastructure.MongoDb\Infrastructure.MongoDb.csproj", "{0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.MongoDb", "src\Common.MongoDb\Common.MongoDb.csproj", "{49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore", "src\Common.AspNetCore\Common.AspNetCore.csproj", "{F23098F5-355B-46F0-BABE-3D6E23D8EED7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "terraform-docker", "terraform-docker", "{001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF}" - ProjectSection(SolutionItems) = preProject - samples\terraform-docker\main.tf = samples\terraform-docker\main.tf - samples\terraform-docker\outputs.tf = samples\terraform-docker\outputs.tf - samples\terraform-docker\providers.tf = samples\terraform-docker\providers.tf - samples\terraform-docker\README.md = samples\terraform-docker\README.md - samples\terraform-docker\variables.tf = samples\terraform-docker\variables.tf - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{7F01D180-1A34-4377-B4E5-C852D8302CE7}" - ProjectSection(SolutionItems) = preProject - scripts\mongo-create-index.js = scripts\mongo-create-index.js - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.IntegrationTests", "test\WebApi.IntegrationTests\WebApi.IntegrationTests.csproj", "{B055FFAF-8261-43B1-866A-12E289D5D7DC}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore.WebApi", "src\Common.AspNetCore.WebApi\Common.AspNetCore.WebApi.csproj", "{19336002-C959-4E76-B112-861F93CF6423}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Framework", "1 - Framework", "{0C1E6968-B289-4378-84CF-B64E05E643A5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "terraform-local", "terraform-local", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - samples\terraform-local\main.tf = samples\terraform-local\main.tf - samples\terraform-local\README.md = samples\terraform-local\README.md - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.Build.0 = Release|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.Build.0 = Release|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.Build.0 = Release|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.Build.0 = Release|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.Build.0 = Release|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.Build.0 = Release|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.Build.0 = Debug|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.ActiveCfg = Release|Any CPU - {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {5CD7A689-5ADB-4207-972E-6FA881AF1B1C} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} - {A5CAD112-C1E6-442B-BE0E-37C697030636} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} - {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} - {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C} = {0C1E6968-B289-4378-84CF-B64E05E643A5} - {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} - {F23098F5-355B-46F0-BABE-3D6E23D8EED7} = {0C1E6968-B289-4378-84CF-B64E05E643A5} - {001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} - {7F01D180-1A34-4377-B4E5-C852D8302CE7} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} - {B055FFAF-8261-43B1-866A-12E289D5D7DC} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} - {19336002-C959-4E76-B112-861F93CF6423} = {0C1E6968-B289-4378-84CF-B64E05E643A5} - {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {DC545534-6A10-475B-A0DA-3374CC025D82} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32407.343 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 - Solution Items", "0 - Solution Items", "{7B3738E0-6F86-4358-B55C-5AAD42B24F81}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + compose.yaml = compose.yaml + CONTRIBUTING.md = CONTRIBUTING.md + Directory.Build.props = Directory.Build.props + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 - Applications", "3 - Applications", "{6D13F54F-4547-49C7-8136-01BFB4BBEE1E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 - Business", "2 - Business", "{E9839BEC-B050-43E9-8EFD-34659CC92D93}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "src\WebApi\WebApi.csproj", "{5CD7A689-5ADB-4207-972E-6FA881AF1B1C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{A5CAD112-C1E6-442B-BE0E-37C697030636}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.MongoDb", "src\Infrastructure.MongoDb\Infrastructure.MongoDb.csproj", "{0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.MongoDb", "src\Common.MongoDb\Common.MongoDb.csproj", "{49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore", "src\Common.AspNetCore\Common.AspNetCore.csproj", "{F23098F5-355B-46F0-BABE-3D6E23D8EED7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker-nginx", "docker-nginx", "{001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF}" + ProjectSection(SolutionItems) = preProject + samples\docker-nginx\main.tf = samples\docker-nginx\main.tf + samples\docker-nginx\outputs.tf = samples\docker-nginx\outputs.tf + samples\docker-nginx\providers.tf = samples\docker-nginx\providers.tf + samples\docker-nginx\README.md = samples\docker-nginx\README.md + samples\docker-nginx\variables.tf = samples\docker-nginx\variables.tf + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{7F01D180-1A34-4377-B4E5-C852D8302CE7}" + ProjectSection(SolutionItems) = preProject + scripts\mongo-create-index.js = scripts\mongo-create-index.js + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.IntegrationTests", "test\WebApi.IntegrationTests\WebApi.IntegrationTests.csproj", "{B055FFAF-8261-43B1-866A-12E289D5D7DC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.AspNetCore.WebApi", "src\Common.AspNetCore.WebApi\Common.AspNetCore.WebApi.csproj", "{19336002-C959-4E76-B112-861F93CF6423}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Framework", "1 - Framework", "{0C1E6968-B289-4378-84CF-B64E05E643A5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "local-files", "local-files", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + samples\local-files\main.tf = samples\local-files\main.tf + samples\local-files\README.md = samples\local-files\README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C}.Release|Any CPU.Build.0 = Release|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5CAD112-C1E6-442B-BE0E-37C697030636}.Release|Any CPU.Build.0 = Release|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C}.Release|Any CPU.Build.0 = Release|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C}.Release|Any CPU.Build.0 = Release|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F23098F5-355B-46F0-BABE-3D6E23D8EED7}.Release|Any CPU.Build.0 = Release|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B055FFAF-8261-43B1-866A-12E289D5D7DC}.Release|Any CPU.Build.0 = Release|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19336002-C959-4E76-B112-861F93CF6423}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5CD7A689-5ADB-4207-972E-6FA881AF1B1C} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} + {A5CAD112-C1E6-442B-BE0E-37C697030636} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {0429A41A-2D3A-42D4-8736-5FC0F6F0FF0C} = {E9839BEC-B050-43E9-8EFD-34659CC92D93} + {49BF313A-4ED3-4BD2-9AEE-E44A5ED19C0C} = {0C1E6968-B289-4378-84CF-B64E05E643A5} + {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} + {F23098F5-355B-46F0-BABE-3D6E23D8EED7} = {0C1E6968-B289-4378-84CF-B64E05E643A5} + {001CB9EB-2F7E-4288-BA9B-1E01ED43B8FF} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} + {7F01D180-1A34-4377-B4E5-C852D8302CE7} = {7B3738E0-6F86-4358-B55C-5AAD42B24F81} + {B055FFAF-8261-43B1-866A-12E289D5D7DC} = {6D13F54F-4547-49C7-8136-01BFB4BBEE1E} + {19336002-C959-4E76-B112-861F93CF6423} = {0C1E6968-B289-4378-84CF-B64E05E643A5} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {3C2E7F7E-1F8E-49D1-AD56-EC60BEB5299D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DC545534-6A10-475B-A0DA-3374CC025D82} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 08694ef..fc24a94 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ -# MongoDB backend for Terraform/OpenTofu state +# MongoDB HTTP backend for Terraform/OpenTofu state [![CI](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/ci.yaml) [![PKG](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/pkg.yaml/badge.svg?branch=main)](https://github.com/devpro/terraform-backend-mongodb/actions/workflows/pkg.yaml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=devpro_terraform-backend-mongodb&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=devpro_terraform-backend-mongodb) [![Docker Image Version](https://img.shields.io/docker/v/devprofr/terraform-backend-mongodb?label=Image&logo=docker)](https://hub.docker.com/r/devprofr/terraform-backend-mongodb) -Store Terraform/OpenTofu state in a MongoDB database thanks to his HTTP backend. +Manage Terraform/OpenTofu state through a secured REST API and take advatange of MongoDB greatness! -Look at the [project development guide](CONTRIBUTING.md) for more technical details. -You're more than welcome to contribute! +The [project development guide](CONTRIBUTING.md) provides the implementation details. ## Quick start @@ -23,9 +22,9 @@ You're more than welcome to contribute! ```tf terraform { backend "http" { - address = "/state/" - lock_address = "/state//lock" - unlock_address = "/state//lock" + address = "//state/" + lock_address = "//state//lock" + unlock_address = "//state//lock" lock_method = "POST" unlock_method = "DELETE" username = "" @@ -40,5 +39,5 @@ terraform { ## Samples -* [Execute local actions](samples/terraform-local/README.md) -* [Manage Docker images](samples/terraform-docker/README.md) +* [Make local actions on files](samples/local-files/README.md) +* [Run NGINX container with Docker](samples/docker-nginx/README.md) diff --git a/samples/terraform-docker/README.md b/samples/docker-nginx/README.md similarity index 100% rename from samples/terraform-docker/README.md rename to samples/docker-nginx/README.md diff --git a/samples/terraform-docker/main.tf b/samples/docker-nginx/main.tf similarity index 100% rename from samples/terraform-docker/main.tf rename to samples/docker-nginx/main.tf diff --git a/samples/terraform-docker/outputs.tf b/samples/docker-nginx/outputs.tf similarity index 100% rename from samples/terraform-docker/outputs.tf rename to samples/docker-nginx/outputs.tf diff --git a/samples/terraform-docker/providers.tf b/samples/docker-nginx/providers.tf similarity index 68% rename from samples/terraform-docker/providers.tf rename to samples/docker-nginx/providers.tf index 1638a7d..3756295 100644 --- a/samples/terraform-docker/providers.tf +++ b/samples/docker-nginx/providers.tf @@ -4,9 +4,9 @@ terraform { # https://developer.hashicorp.com/terraform/language/settings/backends/http#configuration-variables backend "http" { # port can be 5293 (project run from VS) or 9001 (docker compose) - address = "http://localhost:5293/state/demo_devpro" - lock_address = "http://localhost:5293/state/demo_devpro/lock" - unlock_address = "http://localhost:5293/state/demo_devpro/lock" + address = "http://localhost:5293/sample/state/docker-nginx" + lock_address = "http://localhost:5293/sample/state/docker-nginx/lock" + unlock_address = "http://localhost:5293/sample/state/docker-nginx/lock" lock_method = "POST" unlock_method = "DELETE" username = "admin" @@ -18,7 +18,7 @@ terraform { required_providers { docker = { source = "kreuzwerker/docker" - version = "~> 3.0.1" + version = "~> 3.6.2" } } } diff --git a/samples/terraform-docker/variables.tf b/samples/docker-nginx/variables.tf similarity index 100% rename from samples/terraform-docker/variables.tf rename to samples/docker-nginx/variables.tf diff --git a/samples/terraform-local/README.md b/samples/local-files/README.md similarity index 100% rename from samples/terraform-local/README.md rename to samples/local-files/README.md diff --git a/samples/terraform-local/main.tf b/samples/local-files/main.tf similarity index 68% rename from samples/terraform-local/main.tf rename to samples/local-files/main.tf index d15bf18..2498d91 100644 --- a/samples/terraform-local/main.tf +++ b/samples/local-files/main.tf @@ -1,8 +1,8 @@ terraform { backend "http" { - address = "http://localhost:5293/state/demo_localexec" - lock_address = "http://localhost:5293/state/demo_localexec/lock" - unlock_address = "http://localhost:5293/state/demo_localexec/lock" + address = "http://localhost:5293/sample/state/local-files" + lock_address = "http://localhost:5293/sample/state/local-files/lock" + unlock_address = "http://localhost:5293/sample/state/local-files/lock" lock_method = "POST" unlock_method = "DELETE" username = "admin" diff --git a/samples/terraform-local/test.txt b/samples/terraform-local/test.txt deleted file mode 100644 index d353394..0000000 --- a/samples/terraform-local/test.txt +++ /dev/null @@ -1 +0,0 @@ -'Testing HTTP backend state management' diff --git a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs index 075702d..9192c34 100644 --- a/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StateControllerResourceTest.cs @@ -7,7 +7,7 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Resources; public class StateControllerResourceTest(WebApplicationFactory factory) : IntegrationTestBase(factory) { - private string tenant = "dummy"; + private const string Tenant = "dummy"; [Fact] [Trait("Mode", "Readonly")] @@ -18,7 +18,7 @@ public async Task StateResource_GetNotExisting_ReturnsNoContent() var name = Faker.Random.Word(); // Act - var response = await client.GetAsync($"/{tenant}/state/{name}"); + var response = await client.GetAsync($"/{Tenant}/state/{name}"); // Assert await response.CheckResponseAndGetContent(HttpStatusCode.NoContent, null); @@ -33,12 +33,12 @@ public async Task StateResource_CreateFindDelete_IsSuccess() var state = StateFaker.Generate(); // Act & Assert - var createResponse = await client.PostAsync($"/{tenant}/state/{name}", Serialize(state)); + var createResponse = await client.PostAsync($"/{Tenant}/state/{name}", Serialize(state)); //TODO: test resource URL in response await createResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); - var findResponse = await client.GetAsync($"/{tenant}/state/{name}"); + var findResponse = await client.GetAsync($"/{Tenant}/state/{name}"); await findResponse.CheckResponseAndGetContent(HttpStatusCode.OK, "text/plain; charset=utf-8"); - var deleteResponse = await client.DeleteAsync($"/{tenant}/state/{name}"); + var deleteResponse = await client.DeleteAsync($"/{Tenant}/state/{name}"); await deleteResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null); } @@ -52,15 +52,15 @@ public async Task StateResource_LockLifeCycle_IsSuccess() var stateLock = StateLockFaker.Generate(); // Act & Assert - var createLockResponse = await client.PostAsync($"/{tenant}/state/{name}/lock", Serialize(stateLock)); + var createLockResponse = await client.PostAsync($"/{Tenant}/state/{name}/lock", Serialize(stateLock)); await createLockResponse.CheckResponseAndGetContent(HttpStatusCode.OK, "application/json; charset=utf-8", null); - var missingLockIdUpdateResponse = await client.PostAsync($"/{tenant}/state/{name}", Serialize(state)); + var missingLockIdUpdateResponse = await client.PostAsync($"/{Tenant}/state/{name}", Serialize(state)); await missingLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Locked, "application/json; charset=utf-8", "{\"message\":\"The state is locked.\"}"); - var wrongLockIdUpdateResponse = await client.PostAsync($"/{tenant}/state/{name}?ID=1234", Serialize(state)); + var wrongLockIdUpdateResponse = await client.PostAsync($"/{Tenant}/state/{name}?ID=1234", Serialize(state)); await wrongLockIdUpdateResponse.CheckResponseAndGetContent(HttpStatusCode.Conflict, "text/plain; charset=utf-8", "LockId doesn't match with the existing lock"); - var updateResponse = await client.PostAsync($"/{tenant}/state/{name}?ID={stateLock.Id}", Serialize(state)); + var updateResponse = await client.PostAsync($"/{Tenant}/state/{name}?ID={stateLock.Id}", Serialize(state)); await updateResponse.CheckResponseAndGetContent(HttpStatusCode.OK, null, string.Empty); - var deleteLockRequest = new HttpRequestMessage(HttpMethod.Delete, $"/{tenant}/state/{name}/lock") + var deleteLockRequest = new HttpRequestMessage(HttpMethod.Delete, $"/{Tenant}/state/{name}/lock") { Content = Serialize(stateLock) }; From 206342ad3b09386f3e664da3b98de4411de6ffad Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Wed, 16 Jul 2025 17:01:46 +0200 Subject: [PATCH 26/29] Add test on InvalidModelStateBehavior --- src/Domain/Models/StateLockModel.cs | 2 +- ...frastructureServiceCollectionExtensions.cs | 2 +- .../InvalidModelStateBehaviorTest.cs | 51 +++++++++++++++++++ .../Http/HttpResponseMessageExtensions.cs | 11 +++- .../IntegrationTestBase.cs | 10 ++-- .../WebApi.IntegrationTests.csproj | 1 + 6 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs diff --git a/src/Domain/Models/StateLockModel.cs b/src/Domain/Models/StateLockModel.cs index 253e83d..1fd6e35 100644 --- a/src/Domain/Models/StateLockModel.cs +++ b/src/Domain/Models/StateLockModel.cs @@ -12,7 +12,7 @@ public class StateLockModel [BsonId] [BsonRepresentation(BsonType.String)] [JsonPropertyName("ID")] - public string Id { get; set; } = string.Empty; + public string Id { get; set; } = null!; /// /// diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 123e93c..01dbc5c 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Devpro.TerraformBackend.WebApi.DependencyInjection; diff --git a/test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs b/test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs new file mode 100644 index 0000000..7d4b38f --- /dev/null +++ b/test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Net.Http.Json; +using Bogus.DataSets; +using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Behaviors; + +[Trait("Category", "IntegrationTests")] +public class InvalidModelStateBehaviorTest(WebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + [Trait("Mode", "Readonly")] + public async Task InvalidModelStateBehavior_OnInvalidModelState_LogsWarning() + { + // Arrange + var loggerMock = new Mock(); + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(x => x.CreateLogger(It.IsAny())) + .Returns(loggerMock.Object); + var client = CreateClient(isAuthorizationNeeded: true, builderConfiguration: builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(loggerFactoryMock.Object); + }); + }); + var invalidModel = new { Name = (string?)null }; + var name = Faker.Random.Word(); + + // Act + var response = await client.PostAsync($"/dummy/state/{name}/lock", Serialize(invalidModel)); + + // Assert + await response.CheckResponseAndGetContent(System.Net.HttpStatusCode.BadRequest, "application/json; charset=utf-8", + "{\"type\":\"https:\\/\\/tools\\.ietf\\.org\\/html\\/rfc9110#section-15\\.5\\.1\",\"title\":\"One or more validation errors occurred\\.\",\"status\":400,\"errors\":{\"Id\":\\[\"The Id field is required\\.\"\\],\"name\":\\[\"The Name field is required\\.\"\\]},\"traceId\":\".*\"}", + isRegexMatch: true); + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains($"Invalid model state for /dummy/state/{name}/lock")), + It.IsAny(), + It.IsAny>()), + Times.Once()); + } +} diff --git a/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs b/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs index fdb324b..dfa4df1 100644 --- a/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs +++ b/test/WebApi.IntegrationTests/Http/HttpResponseMessageExtensions.cs @@ -5,14 +5,21 @@ namespace Devpro.TerraformBackend.WebApi.IntegrationTests.Http; internal static class HttpResponseMessageExtensions { public static async Task CheckResponseAndGetContent(this HttpResponseMessage response, - HttpStatusCode expectedStatusCode, string? expectedContentType, string? expectedContent = null) + HttpStatusCode expectedStatusCode, string? expectedContentType, string? expectedContent = null, bool isRegexMatch = false) { var result = await response.Content.ReadAsStringAsync(); if (expectedContent != null) { result.Should().NotBeNull(); - result.Should().Be(expectedContent); + if (isRegexMatch) + { + result.Should().MatchRegex(expectedContent); + } + else + { + result.Should().Be(expectedContent); + } } if (expectedContentType == null) diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 5430601..56a573e 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using Devpro.TerraformBackend.Domain.Models; +using Microsoft.AspNetCore.Hosting; namespace Devpro.TerraformBackend.WebApi.IntegrationTests; @@ -17,17 +18,18 @@ public abstract class IntegrationTestBase(WebApplicationFactory factory .RuleFor(u => u.Id, f => Guid.NewGuid().ToString()) .RuleFor(o => o.Created, f => DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff+00:00")); - protected HttpClient CreateClient(bool isAuthorizationNeeded = false) + protected HttpClient CreateClient(bool isAuthorizationNeeded = false, Action? builderConfiguration = null) { // ref. https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ Environment.SetEnvironmentVariable("Application__IsSwaggerEnabled", "true"); - var client = factory.CreateClient(); + var client = (builderConfiguration == null) ? factory.CreateClient() + : factory.WithWebHostBuilder(builderConfiguration).CreateClient(); if (isAuthorizationNeeded) { - var authToken = Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "admin", "admin"))); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "admin", "admin")))); } return client; diff --git a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj index 420afe4..bd05c3f 100644 --- a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj +++ b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj @@ -20,6 +20,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive From 1f1bf4a0f32838203bd0dc85f4dd457050dfadbf Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Wed, 16 Jul 2025 18:12:16 +0200 Subject: [PATCH 27/29] Add user repository in db with password encryption and check --- .gitignore | 3 ++ CONTRIBUTING.md | 7 +++- Devpro.TerraformBackend.sln | 1 + scripts/mongo-create-user.sh | 39 +++++++++++++++++++ src/Domain/Models/StateModel.cs | 1 + src/Domain/Models/UserModel.cs | 18 +++++++++ .../Repositories/IStateLockRepository.cs | 2 +- src/Domain/Repositories/IStateRepository.cs | 2 +- src/Domain/Repositories/IUserRepository.cs | 9 +++++ .../Infrastructure.MongoDb.csproj | 4 ++ .../Repositories/StateLockRepository.cs | 2 +- .../Repositories/StateRepository.cs | 4 +- .../Repositories/UserRepository.cs | 32 +++++++++++++++ .../BasicAuthenticationHandler.cs | 22 ++++++----- ...frastructureServiceCollectionExtensions.cs | 3 +- .../InvalidModelStateBehaviorTest.cs | 6 +-- 16 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 scripts/mongo-create-user.sh create mode 100644 src/Domain/Models/UserModel.cs create mode 100644 src/Domain/Repositories/IUserRepository.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/UserRepository.cs diff --git a/.gitignore b/.gitignore index d7c48f7..056db93 100644 --- a/.gitignore +++ b/.gitignore @@ -356,3 +356,6 @@ appsettings.Development.json .terraform/ .terraform.lock.hcl errored.tfstate + +# temp script files +scripts/add-user.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98d0a00..b703fbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,7 +72,12 @@ docker run --name mongodb -d -p 27017:27017 mongo:8.0 # (optional) adds indexes for optimal performances docker run --rm --link mongodb \ -v "$(pwd)/scripts":/home/scripts mongo:8.0 \ - bash -c "mongo mongodb://mongodb:27017/terraform_backend_dev /home/scripts/mongo-create-index.js" + bash -c "mongosh mongodb://mongodb:27017/terraform_backend_dev /home/scripts/mongo-create-index.js" +# creates one user +./scripts/mongo-create-user.sh admin admin123 dummy +docker run --rm --link mongodb \ + -v "$(pwd)/scripts":/home/scripts mongo:8.0 \ + bash -c "mongosh mongodb://mongodb:27017/terraform_backend_dev /home/scripts/add-user.js" ``` Run the web API (example with the command line but an IDE like Visual Studio or Rider would be nice to be able to debug): diff --git a/Devpro.TerraformBackend.sln b/Devpro.TerraformBackend.sln index 1fcc800..00e5ffc 100644 --- a/Devpro.TerraformBackend.sln +++ b/Devpro.TerraformBackend.sln @@ -41,6 +41,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{7F01D180-1A34-4377-B4E5-C852D8302CE7}" ProjectSection(SolutionItems) = preProject scripts\mongo-create-index.js = scripts\mongo-create-index.js + scripts\mongo-create-user.sh = scripts\mongo-create-user.sh EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.IntegrationTests", "test\WebApi.IntegrationTests\WebApi.IntegrationTests.csproj", "{B055FFAF-8261-43B1-866A-12E289D5D7DC}" diff --git a/scripts/mongo-create-user.sh b/scripts/mongo-create-user.sh new file mode 100644 index 0000000..9f2ca5b --- /dev/null +++ b/scripts/mongo-create-user.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " + exit 1 +fi + +USERNAME="$1" +PASSWORD="$2" +TENANT="$3" + +if ! command -v htpasswd &> /dev/null; then + echo "Error: htpasswd is not installed." + echo "Please install apache2-utils (on Debian/Ubuntu) or httpd-tools (on CentOS/RHEL)." + echo "Installation commands:" + echo " Debian/Ubuntu: sudo apt-get install apache2-utils" + echo " CentOS/RHEL: sudo yum install httpd-tools" + exit 1 +fi + +SCRIPT_DIR=$(dirname "$(realpath "$0")") +JS_FILE="$SCRIPT_DIR/add-user.js" + +HASH=$(htpasswd -bnBC 10 "" "$PASSWORD" | tr -d ':\n') +if [ $? -ne 0 ] || [ -z "$HASH" ]; then + echo "Error: Failed to generate BCrypt hash." + exit 1 +fi + +cat > "$JS_FILE" << EOF +db.user.insertOne({ + username: '$USERNAME', + password_hash: '$HASH', + tenant: '$TENANT' +}); +db.user.createIndex({ "username": 1 }, { unique: true }); +EOF + +echo "Generated "$JS_FILE" successfully" diff --git a/src/Domain/Models/StateModel.cs b/src/Domain/Models/StateModel.cs index a54015f..7ed19b2 100644 --- a/src/Domain/Models/StateModel.cs +++ b/src/Domain/Models/StateModel.cs @@ -14,6 +14,7 @@ public class StateModel public string Name { get; set; } = string.Empty; + [BsonElement("created_at")] public DateTime CreatedAt { get; set; } = DateTime.MinValue; public StateValueModel Value { get; set; } = new StateValueModel(); diff --git a/src/Domain/Models/UserModel.cs b/src/Domain/Models/UserModel.cs new file mode 100644 index 0000000..89a2a5e --- /dev/null +++ b/src/Domain/Models/UserModel.cs @@ -0,0 +1,18 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Devpro.TerraformBackend.Domain.Models; + +public class UserModel +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + public string Username { get; set; } = null!; + + [BsonElement("password_hash")] + public string PasswordHash { get; set; } = null!; + + public string Tenant { get; set; } = null!; +} diff --git a/src/Domain/Repositories/IStateLockRepository.cs b/src/Domain/Repositories/IStateLockRepository.cs index b1bbe8e..1ede34f 100644 --- a/src/Domain/Repositories/IStateLockRepository.cs +++ b/src/Domain/Repositories/IStateLockRepository.cs @@ -6,7 +6,7 @@ namespace Devpro.TerraformBackend.Domain.Repositories; public interface IStateLockRepository { - Task FindOneAsync(string tenant, string name); + Task FindOneAsync(string tenant, string name); Task CreateAsync(StateLockModel input); diff --git a/src/Domain/Repositories/IStateRepository.cs b/src/Domain/Repositories/IStateRepository.cs index 8a9c668..c45f919 100644 --- a/src/Domain/Repositories/IStateRepository.cs +++ b/src/Domain/Repositories/IStateRepository.cs @@ -4,7 +4,7 @@ namespace Devpro.TerraformBackend.Domain.Repositories; public interface IStateRepository { - Task FindOneAsync(string tenant, string name); + Task FindOneAsync(string tenant, string name); Task CreateAsync(string tenant, string name, string jsonInput); diff --git a/src/Domain/Repositories/IUserRepository.cs b/src/Domain/Repositories/IUserRepository.cs new file mode 100644 index 0000000..8cbe7e7 --- /dev/null +++ b/src/Domain/Repositories/IUserRepository.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using Devpro.TerraformBackend.Domain.Models; + +namespace Devpro.TerraformBackend.Domain.Repositories; + +public interface IUserRepository +{ + Task CheckAuthentication(string username, string password); +} diff --git a/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj b/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj index 03a0391..23796c6 100644 --- a/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj +++ b/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj @@ -13,6 +13,10 @@ true + + + + diff --git a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs index ac9347f..a20f433 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateLockRepository.cs @@ -19,7 +19,7 @@ public StateLockRepository(IMongoClientFactory mongoClientFactory, ILogger "tf_state_lock"; - public async Task FindOneAsync(string tenant, string name) + public async Task FindOneAsync(string tenant, string name) { return await _modelCollection.Find(x => x.Name == name).FirstOrDefaultAsync(); } diff --git a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs index 513d99e..9a9a80b 100644 --- a/src/Infrastructure.MongoDb/Repositories/StateRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/StateRepository.cs @@ -34,14 +34,14 @@ public async Task CreateAsync(string tenant, string name, string jsonInput) await _bsonCollection.InsertOneAsync(document); } - public async Task FindOneAsync(string tenant, string name) + public async Task FindOneAsync(string tenant, string name) { var document = await _bsonCollection.Find(GetFilter(tenant, name)) .Sort(Builders.Sort.Descending("createdAt")) .FirstOrDefaultAsync(); if (document == null) { - return string.Empty; + return null; } return document["value"].ToJson(); diff --git a/src/Infrastructure.MongoDb/Repositories/UserRepository.cs b/src/Infrastructure.MongoDb/Repositories/UserRepository.cs new file mode 100644 index 0000000..0066234 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/UserRepository.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Devpro.Common.MongoDb; +using Devpro.TerraformBackend.Domain.Models; +using Devpro.TerraformBackend.Domain.Repositories; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Devpro.TerraformBackend.Infrastructure.MongoDb.Repositories; + +public class UserRepository : RepositoryBase, IUserRepository +{ + private readonly IMongoCollection _modelCollection; + + public UserRepository(IMongoClientFactory mongoClientFactory, ILogger logger, MongoDbConfiguration configuration) + : base(mongoClientFactory, logger, configuration) + { + _modelCollection = GetCollection(); + } + + protected override string CollectionName => "user"; + + public async Task CheckAuthentication(string username, string password) + { + var user = await _modelCollection.Find(x => x.Username == username).FirstOrDefaultAsync(); + if (user == null) + { + return null; + } + + return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? user : null; + } +} diff --git a/src/WebApi/Authentication/BasicAuthenticationHandler.cs b/src/WebApi/Authentication/BasicAuthenticationHandler.cs index 4158214..6621911 100644 --- a/src/WebApi/Authentication/BasicAuthenticationHandler.cs +++ b/src/WebApi/Authentication/BasicAuthenticationHandler.cs @@ -2,20 +2,21 @@ using System.Text; using System.Text.Encodings.Web; using Devpro.Common.AspNetCore.WebApi.Authentication; +using Devpro.TerraformBackend.Domain.Repositories; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; namespace Devpro.TerraformBackend.WebApi.Authentication; -public class BasicAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) +public class BasicAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IUserRepository userRepository) : AuthenticationHandler(options, logger, encoder) { - protected override Task HandleAuthenticateAsync() + protected override async Task HandleAuthenticateAsync() { // checks authorization header if (!Request.Headers.ContainsKey("Authorization")) { - return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header")); + return AuthenticateResult.Fail("Missing Authorization header"); } var authorizationHeader = Request.Headers.Authorization.ToString(); @@ -23,7 +24,7 @@ protected override Task HandleAuthenticateAsync() // checks authorization header starts with Basic if (!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) { - return Task.FromResult(AuthenticateResult.Fail("Authorization header does not start with 'Basic'")); + return AuthenticateResult.Fail("Authorization header does not start with 'Basic'"); } // decrypts the authorization header and split out the client id/secret @@ -31,17 +32,17 @@ protected override Task HandleAuthenticateAsync() var authSplit = authBase64Decoded.Split([':'], 2); if (authSplit.Length != 2) { - return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format")); + return AuthenticateResult.Fail("Invalid Authorization header format"); } var clientId = authSplit[0]; var clientSecret = authSplit[1]; - // TODO: store this info in the database & restrict a user to its organization - // checkClient ID and secret are incorrect - if (clientId != "admin" || clientSecret != "admin") + // credentials + var user = await userRepository.CheckAuthentication(clientId, clientSecret); + if (user == null) { - return Task.FromResult(AuthenticateResult.Fail(string.Format("The secret is incorrect for the client '{0}'", clientId))); + return AuthenticateResult.Fail("Invalid username or password"); } var client = new BasicAuthenticationClient @@ -54,8 +55,9 @@ protected override Task HandleAuthenticateAsync() var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(client, [ new Claim(ClaimTypes.Name, clientId) + //TODO: add tenant (to be checked in state actions) ])); - return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name))); + return AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name)); } } diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 01dbc5c..8105f3a 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Devpro.TerraformBackend.WebApi.DependencyInjection; @@ -11,6 +11,7 @@ internal static IServiceCollection AddInfrastructure(this IServiceCollection ser services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs b/test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs index 7d4b38f..b008b0a 100644 --- a/test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs +++ b/test/WebApi.IntegrationTests/Behaviors/InvalidModelStateBehaviorTest.cs @@ -1,8 +1,4 @@ -using System.Net; -using System.Net.Http.Json; -using Bogus.DataSets; -using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; -using Microsoft.AspNetCore.Mvc; +using Devpro.TerraformBackend.WebApi.IntegrationTests.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; From ee75f19be654ee56ad9157a531e7b79cc10d25fc Mon Sep 17 00:00:00 2001 From: Bertrand Thomas Date: Wed, 16 Jul 2025 23:04:38 +0200 Subject: [PATCH 28/29] Add db scripts in CI --- .github/workflows/ci.yaml | 7 +++++++ test/WebApi.IntegrationTests/IntegrationTestBase.cs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19933ac..50abd6d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,13 @@ jobs: path: workflow-parts - name: Start MongoDB uses: ./workflow-parts/mongodb/start + - name: Initialize database + run: | + mongosh mongodb://localhost:27017/terraform_backend_dev scripts/mongo-create-index.js + sudo apt-get -y install apache2-utils + ./scripts/mongo-create-user.sh admin admin123 dummy + mongosh mongodb://localhost:27017/terraform_backend_dev scripts/add-user.js + shell: bash - name: Build, lint & test uses: ./workflow-parts/dotnet/build-lint-test with: diff --git a/test/WebApi.IntegrationTests/IntegrationTestBase.cs b/test/WebApi.IntegrationTests/IntegrationTestBase.cs index 56a573e..b54caa7 100644 --- a/test/WebApi.IntegrationTests/IntegrationTestBase.cs +++ b/test/WebApi.IntegrationTests/IntegrationTestBase.cs @@ -29,7 +29,7 @@ protected HttpClient CreateClient(bool isAuthorizationNeeded = false, Action Date: Wed, 16 Jul 2025 23:20:35 +0200 Subject: [PATCH 29/29] Make script executable --- scripts/mongo-create-user.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/mongo-create-user.sh diff --git a/scripts/mongo-create-user.sh b/scripts/mongo-create-user.sh old mode 100644 new mode 100755