diff --git a/docs/admin/00-New-Website-output.md b/docs/plans/admin/00-New-Website-output.md similarity index 100% rename from docs/admin/00-New-Website-output.md rename to docs/plans/admin/00-New-Website-output.md diff --git a/docs/admin/00-New-Website.md b/docs/plans/admin/00-New-Website.md similarity index 100% rename from docs/admin/00-New-Website.md rename to docs/plans/admin/00-New-Website.md diff --git a/docs/admin/01-admin-site_plan.md b/docs/plans/admin/01-admin-site_plan.md similarity index 100% rename from docs/admin/01-admin-site_plan.md rename to docs/plans/admin/01-admin-site_plan.md diff --git a/docs/api/00 - new-aspnet-project.md b/docs/plans/api/00 - new-aspnet-project.md similarity index 100% rename from docs/api/00 - new-aspnet-project.md rename to docs/plans/api/00 - new-aspnet-project.md diff --git a/docs/api/00-output.md b/docs/plans/api/00-output.md similarity index 100% rename from docs/api/00-output.md rename to docs/plans/api/00-output.md diff --git a/docs/api/01_project_setup.md b/docs/plans/api/01_project_setup.md similarity index 100% rename from docs/api/01_project_setup.md rename to docs/plans/api/01_project_setup.md diff --git a/docs/api/01_project_setup_output.md b/docs/plans/api/01_project_setup_output.md similarity index 100% rename from docs/api/01_project_setup_output.md rename to docs/plans/api/01_project_setup_output.md diff --git a/docs/api/02_core_domain_models.md b/docs/plans/api/02_core_domain_models.md similarity index 100% rename from docs/api/02_core_domain_models.md rename to docs/plans/api/02_core_domain_models.md diff --git a/docs/api/02_core_domain_models_output.md b/docs/plans/api/02_core_domain_models_output.md similarity index 100% rename from docs/api/02_core_domain_models_output.md rename to docs/plans/api/02_core_domain_models_output.md diff --git a/docs/api/03_database_and_repositories.md b/docs/plans/api/03_database_and_repositories.md similarity index 100% rename from docs/api/03_database_and_repositories.md rename to docs/plans/api/03_database_and_repositories.md diff --git a/docs/api/03_database_and_repositories_output.md b/docs/plans/api/03_database_and_repositories_output.md similarity index 100% rename from docs/api/03_database_and_repositories_output.md rename to docs/plans/api/03_database_and_repositories_output.md diff --git a/docs/api/04_authentication_and_authorization.md b/docs/plans/api/04_authentication_and_authorization.md similarity index 100% rename from docs/api/04_authentication_and_authorization.md rename to docs/plans/api/04_authentication_and_authorization.md diff --git a/docs/api/04_authentication_and_authorization_output.md b/docs/plans/api/04_authentication_and_authorization_output.md similarity index 100% rename from docs/api/04_authentication_and_authorization_output.md rename to docs/plans/api/04_authentication_and_authorization_output.md diff --git a/docs/api/05_flags_api.md b/docs/plans/api/05_flags_api.md similarity index 100% rename from docs/api/05_flags_api.md rename to docs/plans/api/05_flags_api.md diff --git a/docs/api/05_flags_api_output.md b/docs/plans/api/05_flags_api_output.md similarity index 100% rename from docs/api/05_flags_api_output.md rename to docs/plans/api/05_flags_api_output.md diff --git a/docs/api/06_admin_api.md b/docs/plans/api/06_admin_api.md similarity index 100% rename from docs/api/06_admin_api.md rename to docs/plans/api/06_admin_api.md diff --git a/docs/api/06_admin_api_output.md b/docs/plans/api/06_admin_api_output.md similarity index 100% rename from docs/api/06_admin_api_output.md rename to docs/plans/api/06_admin_api_output.md diff --git a/docs/api/07_audit_and_webhooks.md b/docs/plans/api/07_audit_and_webhooks.md similarity index 100% rename from docs/api/07_audit_and_webhooks.md rename to docs/plans/api/07_audit_and_webhooks.md diff --git a/docs/api/07_audit_and_webhooks_output.md b/docs/plans/api/07_audit_and_webhooks_output.md similarity index 100% rename from docs/api/07_audit_and_webhooks_output.md rename to docs/plans/api/07_audit_and_webhooks_output.md diff --git a/docs/api/08_testing_strategy.md b/docs/plans/api/08_testing_strategy.md similarity index 100% rename from docs/api/08_testing_strategy.md rename to docs/plans/api/08_testing_strategy.md diff --git a/docs/system-design.md b/docs/system-design.md new file mode 100644 index 0000000..970ea73 --- /dev/null +++ b/docs/system-design.md @@ -0,0 +1,59 @@ +# System Design — QA Physical Architecture + +Physical deployment topology for the QA environment. Source: `deploy/qa/docker-compose.qa.yml`, `scripts/ci/*.sh`, `.github/workflows/ci.yml`. + +```mermaid +flowchart TB + subgraph CI["Self-hosted CI runner (qa)"] + Pipeline["deploy-qa.sh / smoke-qa.sh"] + end + + subgraph Registry["Gitea Container Registry"] + ApiImage["miccheck-api:qa"] + AdminImage["miccheck-admin:qa"] + end + + subgraph QAHost["QA Docker host — network: miccheck-qa-net (bridge)"] + subgraph AdminC["admin container
nginx:1.27-alpine"] + Nginx["nginx
serves Vue SPA
proxies /api/, /health"] + end + + subgraph ApiC["api container
aspnet:10.0"] + Api["MicCheck.Api
ASPNETCORE_URLS=http://+:8080"] + end + + subgraph DbC["db container
postgres:16-alpine"] + Db[("miccheck DB")] + end + end + + Browser["Browser / QA tester"] + + Pipeline -->|docker build/push| Registry + Registry -->|pull :qa| AdminC + Registry -->|pull :qa| ApiC + + Browser -->|":3001 (QA_ADMIN_PORT)"| Nginx + Nginx -->|"http://api:8080 (internal)"| Api + Api -->|"Host=db;5432 (internal)"| Db + + Pipeline -.->|"127.0.0.1:55432 (bridge-gateway bound)"| Db +``` + +## Components + +| Component | Image | Exposure | Notes | +|---|---|---|---| +| `admin` | `nginx:1.27-alpine` (built from `src/admin/Dockerfile.ci`, `node:22-alpine` build stage) | `${QA_ADMIN_PORT:-3001}:80` published on host | Serves Vue/Vite SPA; nginx (`src/admin/nginx.conf`) reverse-proxies `/api/` and `/health` to `api:8080` | +| `api` | `mcr.microsoft.com/dotnet/aspnet:10.0` (built from `src/api/MicCheck.Api/Dockerfile.ci`, `sdk:10.0` build stage) | no published host port — internal only, reached via `admin`'s nginx proxy | `ASPNETCORE_URLS=http://+:8080`; JWT config (`Jwt__SecretKey`/`Issuer=MicCheck`/`Audience=MicCheck`) from `JWT_SECRET_KEY` secret; healthcheck `curl localhost:8080/health` | +| `db` | `postgres:16-alpine` | `${DB_BIND_HOST:-127.0.0.1}:55432 → 5432`, bound to docker bridge gateway IP (not `0.0.0.0`) — reachable only from sibling CI containers, not off-box | DB `miccheck`, user `miccheck`, password from `POSTGRES_PASSWORD` secret; volume `miccheck-qa-pgdata` | + +All three services run on an isolated bridge network `miccheck-qa-net` (project `miccheck-qa`), separate from the local dev Aspire stack. + +## Deploy flow + +1. `build-and-push` job builds `api` and `admin` images, tags with git SHA and `qa`, pushes to Gitea registry as `$REGISTRY/$REGISTRY_OWNER/miccheck-api` / `miccheck-admin`. +2. `deploy-qa` job (self-hosted runner, `main` branch only) pulls `:qa` images and runs `docker-compose.qa.yml` via `deploy-qa.sh`. +3. `smoke-qa` job hits the deployed stack (`smoke-qa.sh`) to verify health. + +Secrets used: `REGISTRY`, `REGISTRY_OWNER`, `REGISTRY_USER`, `REGISTRY_TOKEN`, `JWT_SECRET_KEY`, `POSTGRES_PASSWORD`. Vars: `QA_ADMIN_PORT`. diff --git a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs index fb8c2b6..b9e5eeb 100755 --- a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs @@ -1,5 +1,8 @@ using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Segments; using MicCheck.Api.Webhooks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -146,9 +149,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho } [HttpGet("api/v1/environment/{apiKey}/identities")] - public async Task>> ListIdentities( + public async Task>> ListIdentities( string apiKey, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromServices] AdminIdentityService adminIdentityService, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) @@ -158,15 +161,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho if (environment is null) return NotFound(); var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); - var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList(); - return Ok(new PaginatedResponse(result.Total, null, null, results)); + var results = result.Items.Select(AdminIdentityResponse.From).ToList(); + return Ok(new PaginatedResponse(result.Total, null, null, results)); } [HttpPost("api/v1/environment/{apiKey}/identities")] - public async Task> CreateIdentity( + public async Task> CreateIdentity( string apiKey, - [FromBody] Identities.CreateIdentityRequest request, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromBody] CreateIdentityRequest request, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -177,13 +180,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho return Conflict(new { error = "An identity with this identifier already exists in the environment." }); return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id }, - Identities.AdminIdentityResponse.From(identity)); + AdminIdentityResponse.From(identity)); } [HttpGet("api/v1/environment/{apiKey}/identity/{id}")] - public async Task> GetIdentity( + public async Task> GetIdentity( string apiKey, int id, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -191,13 +194,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct); if (identity is null) return NotFound(); - return Ok(Identities.AdminIdentityResponse.From(identity)); + return Ok(AdminIdentityResponse.From(identity)); } [HttpDelete("api/v1/environment/{apiKey}/identity/{id}")] public async Task DeleteIdentity( string apiKey, int id, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -211,10 +214,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho } [HttpPut("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] - public async Task> UpsertIdentityTrait( + public async Task> UpsertIdentityTrait( string apiKey, int id, string key, - [FromBody] Identities.UpsertTraitRequest request, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromBody] UpsertTraitRequest request, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -228,7 +231,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho [HttpDelete("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] public async Task DeleteIdentityTrait( string apiKey, int id, string key, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -240,9 +243,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho } [HttpGet("api/v1/environment/{apiKey}/identity/{id}/featurestates")] - public async Task>> GetIdentityFeatureStates( + public async Task>> GetIdentityFeatureStates( string apiKey, int id, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -252,14 +255,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho if (identity is null) return NotFound(); var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct); - return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); + return Ok(states.Select(FeatureStateResponse.From).ToList()); } [HttpPut("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] - public async Task> SetIdentityFeatureState( + public async Task> SetIdentityFeatureState( string apiKey, int id, int featureId, - Features.UpdateFeatureStateRequest request, - [FromServices] Identities.AdminIdentityService adminIdentityService, + UpdateFeatureStateRequest request, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -269,13 +272,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho if (identity is null) return NotFound(); var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct); - return Ok(Features.FeatureStateResponse.From(state)); + return Ok(FeatureStateResponse.From(state)); } [HttpDelete("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] public async Task DeleteIdentityFeatureState( string apiKey, int id, int featureId, - [FromServices] Identities.AdminIdentityService adminIdentityService, + [FromServices] AdminIdentityService adminIdentityService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -289,22 +292,22 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho } [HttpGet("api/v1/environment/{apiKey}/featurestates")] - public async Task>> ListFeatureStates( + public async Task>> ListFeatureStates( string apiKey, - [FromServices] Features.FeatureStateService featureStateService, + [FromServices] FeatureStateService featureStateService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); if (environment is null) return NotFound(); var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct); - return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); + return Ok(states.Select(FeatureStateResponse.From).ToList()); } [HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")] - public async Task> GetFeatureState( + public async Task> GetFeatureState( string apiKey, int id, - [FromServices] Features.FeatureStateService featureStateService, + [FromServices] FeatureStateService featureStateService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -312,14 +315,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var state = await featureStateService.FindByIdAsync(id, environment.Id, ct); if (state is null) return NotFound(); - return Ok(Features.FeatureStateResponse.From(state)); + return Ok(FeatureStateResponse.From(state)); } [HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")] - public async Task> UpdateFeatureState( + public async Task> UpdateFeatureState( string apiKey, int id, - Features.UpdateFeatureStateRequest request, - [FromServices] Features.FeatureStateService featureStateService, + UpdateFeatureStateRequest request, + [FromServices] FeatureStateService featureStateService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -329,14 +332,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho if (state is null) return NotFound(); var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct); - return Ok(Features.FeatureStateResponse.From(updated)); + return Ok(FeatureStateResponse.From(updated)); } [HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")] - public async Task> PatchFeatureState( + public async Task> PatchFeatureState( string apiKey, int id, - Features.PatchFeatureStateRequest request, - [FromServices] Features.FeatureStateService featureStateService, + PatchFeatureStateRequest request, + [FromServices] FeatureStateService featureStateService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -346,15 +349,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho if (state is null) return NotFound(); var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct); - return Ok(Features.FeatureStateResponse.From(updated)); + return Ok(FeatureStateResponse.From(updated)); } // ─── Feature Segments ──────────────────────────────────────────────────── [HttpGet("api/v1/environment/{apiKey}/feature/{featureId}/segments")] - public async Task>> ListFeatureSegments( + public async Task>> ListFeatureSegments( string apiKey, int featureId, - [FromServices] Features.FeatureSegmentService featureSegmentService, + [FromServices] FeatureSegmentService featureSegmentService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -365,10 +368,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho } [HttpPost("api/v1/environment/{apiKey}/feature/{featureId}/segments")] - public async Task> CreateFeatureSegment( + public async Task> CreateFeatureSegment( string apiKey, int featureId, - Features.CreateFeatureSegmentRequest request, - [FromServices] Features.FeatureSegmentService featureSegmentService, + CreateFeatureSegmentRequest request, + [FromServices] FeatureSegmentService featureSegmentService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -388,10 +391,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho } [HttpPut("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] - public async Task> UpdateFeatureSegment( + public async Task> UpdateFeatureSegment( string apiKey, int featureId, int id, - Features.UpdateFeatureSegmentRequest request, - [FromServices] Features.FeatureSegmentService featureSegmentService, + UpdateFeatureSegmentRequest request, + [FromServices] FeatureSegmentService featureSegmentService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -405,7 +408,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho [HttpDelete("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] public async Task DeleteFeatureSegment( string apiKey, int featureId, int id, - [FromServices] Features.FeatureSegmentService featureSegmentService, + [FromServices] FeatureSegmentService featureSegmentService, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -418,11 +421,11 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho // ─── Identity Segments ─────────────────────────────────────────────────── [HttpGet("api/v1/environment/{apiKey}/identity/{id}/segments")] - public async Task>> GetIdentitySegments( + public async Task>> GetIdentitySegments( string apiKey, int id, - [FromServices] Identities.AdminIdentityService adminIdentityService, - [FromServices] Segments.SegmentService segmentService, - [FromServices] Segments.SegmentEvaluator segmentEvaluator, + [FromServices] AdminIdentityService adminIdentityService, + [FromServices] SegmentService segmentService, + [FromServices] SegmentEvaluator segmentEvaluator, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); @@ -434,7 +437,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct); var matching = segments .Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier)) - .Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name)) + .Select(s => new SegmentSummaryResponse(s.Id, s.Name)) .ToList(); return Ok(matching);