sozsoft-platform/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs
2026-10-05 15:58:59 +03:00

351 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Sozsoft.Platform.Data;
using Sozsoft.Platform.DbStartup;
using Sozsoft.Platform.EntityFrameworkCore;
using Sozsoft.Platform.Permissions;
using Volo.Abp.DistributedLocking;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.MultiTenancy;
using Volo.Abp.TenantManagement;
using Volo.Abp.Uow;
namespace Sozsoft.Platform.Controllers;
/// <summary>
/// Canlı sistemde (tam ABP stack ayaktayken) veritabanı durumunu raporlar ve
/// yetkili host kullanıcısının migration tetiklemesine izin verir.
///
/// Veritabanı hiç yokken bu controller çalışmaz; o durumda
/// <see cref="DbStartup.SetupAppRunner"/> içindeki minimal uygulama devrededir.
/// İki yol da <see cref="SetupMigrationRunner"/> üzerinden aynı migration akışını kullanır.
/// </summary>
[Route("api/setup")]
public class SetupController : ControllerBase
{
/// <summary>
/// Yeni izin verilmemiş kurulumlarda kilitlenmemek için tanınan geriye dönük rol.
/// <see cref="PlatformPermissions.Setup.Migrate"/> rollere atandıktan sonra kaldırılabilir.
/// </summary>
private const string LegacyAdminRoleName = "admin";
private const int DistributedLockTimeoutSeconds = 5;
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _env;
private readonly IHostApplicationLifetime _lifetime;
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentTenant _currentTenant;
private readonly IAbpDistributedLock _distributedLock;
private readonly IUnitOfWorkManager _unitOfWorkManager;
private readonly ITenantRepository _tenantRepository;
private readonly BackgroundWorkerInitializer _backgroundWorkerInitializer;
private readonly AbpBackgroundWorkerRecurringJobInitializer _abpBackgroundWorkerRecurringJobInitializer;
private readonly ILogger<SetupController> _logger;
public SetupController(
IConfiguration configuration,
IHostEnvironment env,
IHostApplicationLifetime lifetime,
IAuthorizationService authorizationService,
ICurrentTenant currentTenant,
IAbpDistributedLock distributedLock,
IUnitOfWorkManager unitOfWorkManager,
ITenantRepository tenantRepository,
BackgroundWorkerInitializer backgroundWorkerInitializer,
AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer,
ILogger<SetupController> logger)
{
_configuration = configuration;
_env = env;
_lifetime = lifetime;
_authorizationService = authorizationService;
_currentTenant = currentTenant;
_distributedLock = distributedLock;
_unitOfWorkManager = unitOfWorkManager;
_tenantRepository = tenantRepository;
_backgroundWorkerInitializer = backgroundWorkerInitializer;
_abpBackgroundWorkerRecurringJobInitializer = abpBackgroundWorkerRecurringJobInitializer;
_logger = logger;
}
/// <summary>
/// Veritabanı durumu. Anonim çağırana yalnızca "hazır mı" bilgisi döner;
/// bekleyen migration sayısı ve hata detayı sadece yetkili host kullanıcısına verilir.
/// </summary>
[HttpGet("application-status")]
[AllowAnonymous]
public async Task<SetupStatusDto> StatusAsync(CancellationToken cancellationToken)
{
var readiness = DatabaseReadinessChecker.GetStatus(_configuration);
var canMigrate = await CanMigrateAsync();
if (canMigrate && readiness.IsReady)
{
readiness = readiness.WithPendingMigrations(
await GetPendingMigrationCountAsync(cancellationToken));
}
return SetupStatusDto.From(readiness, includeDiagnostics: canMigrate);
}
/// <summary>
/// Migration + seed çalıştırır ve logları SSE olarak akıtır.
///
/// Yetki: veritabanı ayaktayken host tarafında <see cref="PlatformPermissions.Setup.Migrate"/>
/// izni (veya geriye dönük olarak admin rolü); veritabanı yokken kurulum parolası.
/// </summary>
[HttpPost("migrate")]
[AllowAnonymous]
public async Task MigrateAsync()
{
var (authorized, denialReason) = await AuthorizeMigrateRequestAsync();
if (!authorized)
{
_logger.LogWarning("Database migration request denied: {Reason}", denialReason);
await WritePlainTextAsync(StatusCodes.Status403Forbidden, denialReason);
return;
}
_logger.LogWarning(
"Database migration triggered via /api/setup/migrate by user {UserId}.",
User?.Identity?.Name ?? "(setup password)");
await RunMigratorAsync(environment: null, reloadWorkers: true);
}
/// <summary>
/// Tenants ekranının Seed sekmesi: yalnızca bu tenant için migration + seed çalıştırır ve
/// logları <see cref="MigrateAsync"/> ile aynı SSE biçiminde akıtır. Yönetici e-posta/parolası
/// DbMigrator'a ortam değişkeniyle geçer (<see cref="TenantSeedOptions"/>).
/// </summary>
[HttpPost("tenants/{id:guid}/seed")]
public async Task SeedTenantAsync(Guid id, [FromBody] SeedTenantInput input)
{
var (authorized, denialReason) = await AuthorizeTenantSeedRequestAsync();
if (!authorized)
{
_logger.LogWarning("Tenant seed request denied: {Reason}", denialReason);
await WritePlainTextAsync(StatusCodes.Status403Forbidden, denialReason);
return;
}
if (string.IsNullOrWhiteSpace(input.AdminEmail) || string.IsNullOrWhiteSpace(input.AdminPassword))
{
await WritePlainTextAsync(StatusCodes.Status400BadRequest, "Admin email and password are required.");
return;
}
var tenant = await _tenantRepository.FindAsync(id, includeDetails: false);
if (tenant is null)
{
await WritePlainTextAsync(StatusCodes.Status404NotFound, $"Tenant not found: {id}");
return;
}
_logger.LogWarning(
"Tenant seed triggered for {TenantName} ({TenantId}) by user {UserId}.",
tenant.Name, tenant.Id, User?.Identity?.Name);
await RunMigratorAsync(new Dictionary<string, string>
{
[TenantSeedOptions.ToEnvironmentVariable(TenantSeedOptions.TenantIdKey)] = tenant.Id.ToString(),
[TenantSeedOptions.ToEnvironmentVariable(TenantSeedOptions.AdminEmailKey)] = input.AdminEmail.Trim(),
[TenantSeedOptions.ToEnvironmentVariable(TenantSeedOptions.AdminPasswordKey)] = input.AdminPassword,
}, reloadWorkers: false);
}
/// <summary>
/// DbMigrator'ü tek kilit altında çalıştırır; migrate ile tenant seed aynı anda yürümez.
/// Worker tanımları host verisidir; tek tenant seed'i onlara dokunmadığı için
/// <paramref name="reloadWorkers"/> yalnızca tam migrate'te açıktır.
/// </summary>
private async Task RunMigratorAsync(IReadOnlyDictionary<string, string>? environment, bool reloadWorkers)
{
var stream = new SetupEventStream(Response);
await stream.StartAsync();
// Çok örnekli (multi-instance) dağıtımlarda aynı anda tek migration çalışsın.
await using var lockHandle = await _distributedLock.TryAcquireAsync(
"Sozsoft.Platform.SetupMigration",
TimeSpan.FromSeconds(DistributedLockTimeoutSeconds),
_lifetime.ApplicationStopping);
if (lockHandle is null)
{
await stream.ErrorAsync("Another migration is already in progress. Please try again later.");
await stream.DoneAsync("Failed.");
return;
}
// ApplicationStopping kullanılıyor: istemci sekmesini kapatsa da migration tamamlanır.
var outcome = await SetupMigrationRunner.ExecuteAsync(
_configuration, _env.ContentRootPath, stream, _lifetime.ApplicationStopping, environment);
if (outcome != MigrationOutcome.Succeeded)
{
_logger.LogWarning("Database migration finished with outcome {Outcome}.", outcome);
return;
}
if (!reloadWorkers)
{
await stream.SuccessAsync("Migration and seed completed successfully.");
await stream.DoneAsync("Completed.");
return;
}
await stream.InfoAsync("Migration and seed completed successfully.");
// Yeni/değişen tanımlar Hangfire'a yansısın diye worker'lar tazelenir.
try
{
await stream.InfoAsync("Loading background workers into Hangfire...");
await _abpBackgroundWorkerRecurringJobInitializer.RunAsync(_lifetime.ApplicationStopping);
await _backgroundWorkerInitializer.RunAsync();
await stream.SuccessAsync("Background workers loaded successfully.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Background workers could not be reloaded after migration.");
await stream.ErrorAsync($"Background workers could not be loaded: {ex.Message}");
await stream.DoneAsync("Failed.");
return;
}
await stream.DoneAsync("Completed.");
}
private async Task WritePlainTextAsync(int statusCode, string message)
{
Response.StatusCode = statusCode;
Response.ContentType = "text/plain; charset=utf-8";
await Response.WriteAsync(message);
}
// Yetkilendirme
/// <summary>
/// İki geçerli kimlik yolu vardır:
///
/// 1. <c>X-Setup-Password</c> header'ı — veritabanına hiç ihtiyaç duymaz, bu yüzden
/// veritabanı düşmüşken de çalışır.
/// 2. Oturum açmış host kullanıcısı + <see cref="PlatformPermissions.Setup.Migrate"/> izni —
/// izin kontrolü veritabanı okuması gerektirdiği için yalnızca veritabanı ayaktayken
/// (örn. yalnızca bekleyen migration varken) kullanılabilir.
///
/// Reddetme nedeni çağırana döndürülür; aksi halde "yetkisiz" hatası teşhis edilemez oluyordu.
/// Nedenler yapılandırma/izin adından fazlasını sızdırmaz.
/// </summary>
private async Task<(bool Authorized, string Reason)> AuthorizeMigrateRequestAsync()
{
if (Request.Headers.ContainsKey(SetupPasswordValidator.HeaderName))
{
if (!SetupPasswordValidator.IsConfigured(_configuration))
{
return (false, "Setup password is not configured on the server (Setup:MigrationPassword).");
}
return SetupPasswordValidator.IsValid(_configuration, Request.Headers)
? (true, string.Empty)
: (false, "Invalid setup password.");
}
if (!DatabaseReadinessChecker.GetStatus(_configuration).IsReady)
{
return (false,
"The database is not available, so permissions cannot be verified. " +
$"Send the {SetupPasswordValidator.HeaderName} header to run migration.");
}
if (User?.Identity?.IsAuthenticated != true)
{
return (false, "Authentication is required to run database migration.");
}
if (_currentTenant.Id is not null)
{
return (false, "Database migration can only be run by a host user.");
}
return await CanMigrateAsync()
? (true, string.Empty)
: (false, $"The '{PlatformPermissions.Setup.Migrate}' permission is required.");
}
/// <summary>
/// Tenant seed'i tenant yönetimi işidir: host kullanıcısı + tenant güncelleme yetkisi.
/// Setup parolası bu uçta geçerli değildir; veritabanı ayakta olmadan tenant da yoktur.
/// </summary>
private async Task<(bool Authorized, string Reason)> AuthorizeTenantSeedRequestAsync()
{
if (User?.Identity?.IsAuthenticated != true)
{
return (false, "Authentication is required to seed tenant data.");
}
if (_currentTenant.Id is not null)
{
return (false, "Tenant data can only be seeded by a host user.");
}
return (await _authorizationService.AuthorizeAsync(User, TenantManagementPermissions.Tenants.Update)).Succeeded
? (true, string.Empty)
: (false, $"The '{TenantManagementPermissions.Tenants.Update}' permission is required.");
}
private async Task<bool> CanMigrateAsync()
{
if (User?.Identity?.IsAuthenticated != true)
{
return false;
}
// Host veritabanına migration uygulanır — tenant kullanıcıları tetikleyemez.
if (_currentTenant.Id is not null)
{
return false;
}
if ((await _authorizationService.AuthorizeAsync(User, PlatformPermissions.Setup.Migrate)).Succeeded)
{
return true;
}
return User.IsInRole(LegacyAdminRoleName);
}
// Bekleyen migration sayısı
private async Task<int?> GetPendingMigrationCountAsync(CancellationToken cancellationToken)
{
try
{
using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
var dbContext = await uow.ServiceProvider
.GetRequiredService<IDbContextProvider<PlatformDbContext>>()
.GetDbContextAsync();
var pending = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken);
return pending.Count();
}
catch (Exception ex)
{
// Bu bilgi opsiyonel — alınamıyorsa durum raporu yine de dönmeli.
_logger.LogDebug(ex, "Pending migration count could not be determined.");
return null;
}
}
}