diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e8e9c16..ff7239a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -24,8 +24,8 @@ jobs: name: CI uses: ./.github/workflows/ci.yml - package: - name: Empacotar + migrations: + name: Bundle de migrations needs: ci runs-on: ubuntu-latest timeout-minutes: 15 @@ -49,13 +49,8 @@ jobs: - name: Restaurar ferramentas locais run: dotnet tool restore - - name: Publicar a API - run: > - dotnet publish src/Backend/PolicyDesk.Api/PolicyDesk.Api.csproj - --configuration Release - --output artifacts/api - -p:UseAppHost=false - -p:SourceRevisionId=${{ github.sha }} + - name: Restaurar a infraestrutura para linux-x64 + run: dotnet restore src/Backend/PolicyDesk.Infrastructure/PolicyDesk.Infrastructure.csproj --runtime linux-x64 - name: Gerar o bundle de migrations run: > @@ -67,13 +62,6 @@ jobs: --runtime linux-x64 --output artifacts/migrations/efbundle - - name: Anexar a API - uses: actions/upload-artifact@v4 - with: - name: api - path: artifacts/api - retention-days: 7 - - name: Anexar as migrations uses: actions/upload-artifact@v4 with: @@ -81,9 +69,54 @@ jobs: path: artifacts/migrations retention-days: 7 + image: + name: Imagem Docker + needs: ci + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.name.outputs.image }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Definir o nome da imagem + id: name + run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}-api" >> "$GITHUB_OUTPUT" + + - name: Preparar o Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login no GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Gerar e publicar a imagem + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + push: true + tags: | + ${{ steps.name.outputs.image }}:${{ github.sha }} + ${{ steps.name.outputs.image }}:latest + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=MIT + cache-from: type=gha + cache-to: type=gha,mode=max + deploy: name: Deploy em produção - needs: package + needs: [migrations, image] runs-on: ubuntu-latest timeout-minutes: 20 environment: @@ -94,12 +127,6 @@ jobs: id-token: write steps: - - name: Baixar a API - uses: actions/download-artifact@v4 - with: - name: api - path: api - - name: Baixar as migrations uses: actions/download-artifact@v4 with: @@ -120,16 +147,16 @@ jobs: chmod +x migrations/efbundle migrations/efbundle --connection "$DB_CONNECTION" - - name: Deploy no Azure App Service + - name: Deploy da imagem no Azure App Service uses: azure/webapps-deploy@v3 with: app-name: ${{ vars.AZURE_WEBAPP_NAME }} - package: api + images: ${{ needs.image.outputs.image }}:${{ github.sha }} - name: Smoke test (/health) run: > curl --fail --silent --show-error - --retry 12 --retry-delay 10 --retry-all-errors + --retry 18 --retry-delay 10 --retry-all-errors --max-time 30 "https://${{ vars.AZURE_WEBAPP_NAME }}.azurewebsites.net/health" diff --git a/README.md b/README.md index c2afca7..58dcedb 100644 --- a/README.md +++ b/README.md @@ -328,22 +328,24 @@ O pipeline roda no **GitHub Actions** e publica a API num **Azure App Service Li flowchart LR PR["Pull request
ou push em developer"] --> CI MAIN["Push em main"] --> CI - CI["CI
restore · vulnerabilidades
build Release · testes · cobertura"] --> PKG - PKG["Empacotar
dotnet publish
bundle de migrations"] --> DEP - DEP["Deploy (environment production)
login OIDC · migrations
App Service · smoke test /health"] + CI["CI
restore · vulnerabilidades
build Release · testes · cobertura"] --> MIG + CI --> IMG + MIG["Bundle de migrations"] --> DEP + IMG["Imagem Docker
docker/Dockerfile → ghcr.io"] --> DEP + DEP["Deploy (environment production)
login OIDC · migrations
imagem no App Service · smoke test /health"] ``` | Workflow | Quando roda | O que faz | | --- | --- | --- | | `ci.yml` | PRs para `main`/`developer`, push em `developer` | Build com analisadores como erro, auditoria de pacotes vulneráveis, todos os testes (com Testcontainers) e relatório de cobertura no resumo da execução | -| `cd.yml` | Push em `main` ou manual | Reaproveita o CI, gera os artefatos e faz o deploy no environment `production` | +| `cd.yml` | Push em `main` ou manual | Reaproveita o CI, gera o bundle de migrations e a imagem Docker (publicada no `ghcr.io`) e faz o deploy no environment `production` | | `codeql.yml` | PRs, pushes e semanalmente | Análise estática de segurança do código C# | | `dependabot.yml` | Semanalmente | PRs de atualização de NuGet, Actions e imagens Docker, agrupados | **Decisões:** - **Sem segredos de longa duração**: o login na Azure usa OIDC (federated credentials); a identidade só tem `Website Contributor` no próprio Web App. -- **Build once, deploy many**: o artefato testado é o mesmo que vai para produção. +- **Build once, deploy many**: a imagem testada é a mesma que vai para produção, gerada pelo mesmo `docker/Dockerfile` usado localmente e identificada pelo SHA do commit. - **Migrations fora da inicialização**: um *migrations bundle* do EF Core é aplicado antes do deploy, então a API em produção nunca altera o schema ao subir. - **Infraestrutura como código**: o banco, o plano F1 e o Web App estão descritos em [`infra/main.bicep`](infra/main.bicep). diff --git a/infra/main.bicep b/infra/main.bicep index bd0ca5d..428cc4e 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -18,6 +18,9 @@ param databaseAdminPassword string @description('Nome do banco usado pela API.') param databaseName string = 'policydesk' +@description('Imagem da API. O pipeline de CD troca a tag pela do commit publicado.') +param containerImage string = 'ghcr.io/rodrigosdk/policydesk-api:latest' + @description('Endpoint OTLP opcional para exportar traces, métricas e logs.') param otlpEndpoint string = '' @@ -96,14 +99,13 @@ resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { resource app 'Microsoft.Web/sites@2024-04-01' = { name: appName location: location - kind: 'app,linux' + kind: 'app,linux,container' properties: { serverFarmId: plan.id httpsOnly: true clientAffinityEnabled: false siteConfig: { - linuxFxVersion: 'DOTNETCORE|10.0' - appCommandLine: 'dotnet PolicyDesk.Api.dll' + linuxFxVersion: 'DOCKER|${containerImage}' alwaysOn: false http20Enabled: true minTlsVersion: '1.2' @@ -119,9 +121,17 @@ resource app 'Microsoft.Web/sites@2024-04-01' = { value: 'true' } { - name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + name: 'WEBSITES_PORT' + value: '8080' + } + { + name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' value: 'false' } + { + name: 'DOCKER_REGISTRY_SERVER_URL' + value: 'https://ghcr.io' + } ], otlpSettings) connectionStrings: [ { diff --git a/src/Backend/PolicyDesk.Infrastructure/DataAccess/Repositories/PoliciesRepository.cs b/src/Backend/PolicyDesk.Infrastructure/DataAccess/Repositories/PoliciesRepository.cs index 4106504..36d6672 100644 --- a/src/Backend/PolicyDesk.Infrastructure/DataAccess/Repositories/PoliciesRepository.cs +++ b/src/Backend/PolicyDesk.Infrastructure/DataAccess/Repositories/PoliciesRepository.cs @@ -30,10 +30,10 @@ public Task AddAsync(Policy policy) public async Task> GetPageAsync(Guid? policyholderId, PolicyStatus? status, int page, int pageSize, CancellationToken cancellationToken = default) { var query = dbContext.Policies.AsNoTracking(); - if (policyholderId.HasValue) - query = query.Where(policy => policy.PolicyholderId == policyholderId.Value); - if (status.HasValue) - query = query.Where(policy => policy.Status == status.Value); + if (policyholderId is { } policyholderIdFilter) + query = query.Where(policy => policy.PolicyholderId == policyholderIdFilter); + if (status is { } statusFilter) + query = query.Where(policy => policy.Status == statusFilter); var totalCount = await query.CountAsync(cancellationToken); var items = await query diff --git a/tests/src/PolicyDesk.Domain.Tests/Entities/ClaimsTests.cs b/tests/src/PolicyDesk.Domain.Tests/Entities/ClaimsTests.cs index 3b7b410..c942683 100644 --- a/tests/src/PolicyDesk.Domain.Tests/Entities/ClaimsTests.cs +++ b/tests/src/PolicyDesk.Domain.Tests/Entities/ClaimsTests.cs @@ -71,8 +71,7 @@ public void Deve_AprovarERegistrarData_Quando_SinistroEstiverEmAnalise() var after = DateTime.UtcNow; claim.Status.ShouldBe(ClaimStatus.Approved); - claim.ApprovedAt.ShouldNotBeNull(); - claim.ApprovedAt.Value.ShouldBeInRange(before, after); + claim.ApprovedAt.ShouldNotBeNull().ShouldBeInRange(before, after); claim.UpdatedAt.ShouldBe(claim.ApprovedAt); } @@ -141,8 +140,7 @@ public void Deve_RegistrarPagamento_Quando_SinistroEstiverAprovado() var after = DateTime.UtcNow; claim.Status.ShouldBe(ClaimStatus.Paid); - claim.PaidAt.ShouldNotBeNull(); - claim.PaidAt.Value.ShouldBeInRange(before, after); + claim.PaidAt.ShouldNotBeNull().ShouldBeInRange(before, after); claim.UpdatedAt.ShouldBe(claim.PaidAt); } diff --git a/tests/src/PolicyDesk.Domain.Tests/Entities/PoliciesTests.cs b/tests/src/PolicyDesk.Domain.Tests/Entities/PoliciesTests.cs index 5703a4d..2b8b9eb 100644 --- a/tests/src/PolicyDesk.Domain.Tests/Entities/PoliciesTests.cs +++ b/tests/src/PolicyDesk.Domain.Tests/Entities/PoliciesTests.cs @@ -81,8 +81,7 @@ public void Deve_CancelarERegistrarDatas_Quando_Solicitado() policy.Status.ShouldBe(PolicyStatus.Cancelled); policy.IsCancelled.ShouldBeTrue(); policy.IsInForce(BusinessClockBuilder.DefaultToday).ShouldBeFalse(); - policy.CancelledAt.ShouldNotBeNull(); - policy.CancelledAt.Value.ShouldBeInRange(beforeCancellation, afterCancellation); + policy.CancelledAt.ShouldNotBeNull().ShouldBeInRange(beforeCancellation, afterCancellation); policy.UpdatedAt.ShouldBe(policy.CancelledAt); } @@ -139,8 +138,7 @@ public void Deve_AceitarSinistroEAtualizarApolice_Quando_ValorCouberNoSaldo(deci policy.RegisterClaim(amount, committedAmount); - policy.UpdatedAt.ShouldNotBeNull(); - policy.UpdatedAt.Value.ShouldBeGreaterThanOrEqualTo(beforeRegistration); + policy.UpdatedAt.ShouldNotBeNull().ShouldBeGreaterThanOrEqualTo(beforeRegistration); } [Theory(DisplayName = nameof(Deve_LancarExcecaoSemAtualizar_Quando_ValorExcederSaldo))] diff --git a/tests/src/PolicyDesk.Domain.Tests/Entities/PolicyholdersTests.cs b/tests/src/PolicyDesk.Domain.Tests/Entities/PolicyholdersTests.cs index 3597d7f..32142ac 100644 --- a/tests/src/PolicyDesk.Domain.Tests/Entities/PolicyholdersTests.cs +++ b/tests/src/PolicyDesk.Domain.Tests/Entities/PolicyholdersTests.cs @@ -120,8 +120,7 @@ public void Deve_AtualizarOsDados_Quando_DadosForemValidos() policyholder.BirthDate.ShouldBe(NewBirthDate); policyholder.Email.ShouldBe("jane.doe@example.com"); policyholder.Phone.ShouldBe("+5583912345678"); - policyholder.UpdatedAt.ShouldNotBeNull(); - policyholder.UpdatedAt.Value.ShouldBeInRange(beforeUpdate, afterUpdate); + policyholder.UpdatedAt.ShouldNotBeNull().ShouldBeInRange(beforeUpdate, afterUpdate); } [Fact(DisplayName = nameof(Deve_ManterCpf_Quando_Atualizar))] @@ -256,8 +255,7 @@ public void Deve_RegistrarAlteracao_Quando_ApoliceForCadastrada() policyholder.RegisterPolicy(); - policyholder.UpdatedAt.ShouldNotBeNull(); - policyholder.UpdatedAt.Value.ShouldBeInRange(before, DateTime.UtcNow); + policyholder.UpdatedAt.ShouldNotBeNull().ShouldBeInRange(before, DateTime.UtcNow); policyholder.Active.ShouldBeTrue(); }