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();
}