diff --git a/ManagerService.Tests/Controllers/ResourceControllerTests.cs b/ManagerService.Tests/Controllers/ResourceControllerTests.cs index c66b1f3..ad60373 100644 --- a/ManagerService.Tests/Controllers/ResourceControllerTests.cs +++ b/ManagerService.Tests/Controllers/ResourceControllerTests.cs @@ -14,7 +14,10 @@ namespace ManagerService.Tests.Controllers { public class ResourceControllerTests { - private static ResourceController BuildController(MyInfoMateDbContext db) + private static ResourceController BuildController( + MyInfoMateDbContext db, + FakeHttpClientFactory httpClientFactory = null, + FakeBlobService blobService = null) { var cfg = FakeMongoConfig.Create(); var controller = new ResourceController( @@ -22,7 +25,9 @@ namespace ManagerService.Tests.Controllers new ResourceDatabaseService(cfg), new SectionDatabaseService(cfg), new ConfigurationDatabaseService(cfg), - db); + db, + httpClientFactory ?? new FakeHttpClientFactory(), + blobService ?? new FakeBlobService()); FakeUser.SetUser(controller, FakeUser.Create("Manager.contenteditor", "inst-test")); return controller; } @@ -165,7 +170,7 @@ namespace ManagerService.Tests.Controllers { using var db = DbContextFactory.Create(); - var result = BuildController(db).Delete("unknown"); + var result = BuildController(db).Delete("unknown").GetAwaiter().GetResult(); Assert.IsType(result); } @@ -177,11 +182,347 @@ namespace ManagerService.Tests.Controllers db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image }); db.SaveChanges(); - var result = BuildController(db).Delete("r1"); + var result = BuildController(db).Delete("r1").GetAwaiter().GetResult(); var obj = Assert.IsType(result); Assert.Equal(202, obj.StatusCode); Assert.Equal(0, db.Resources.Count()); } + + // ── QUOTA & BLOB (C3) ──────────────────────────────────────────────── + + private static void SeedPlan(MyInfoMateDbContext db, long planQuota, long instanceQuota = 0) + { + db.SubscriptionPlans.Add(new SubscriptionPlan + { + Id = "plan-test", Name = "Test", StorageQuotaBytes = planQuota + }); + db.Instances.Add(new Instance + { + Id = "inst-test", Name = "Test", SubscriptionPlanId = "plan-test", + StorageQuotaBytes = instanceQuota + }); + db.SaveChanges(); + } + + [Fact] + public void Create_OverQuota_Returns413() + { + using var db = DbContextFactory.Create(); + SeedPlan(db, planQuota: 1000); + db.Resources.Add(new Resource + { + Id = "r0", InstanceId = "inst-test", Label = "Déjà là", + Type = ResourceType.Image, SizeBytes = 900 + }); + db.SaveChanges(); + + var result = BuildController(db).Create(new ResourceDTO + { + instanceId = "inst-test", label = "Trop gros", + type = ResourceType.Image, sizeBytes = 200 + }); + + Assert.Equal(413, result.StatusCode); + Assert.Equal(1, db.Resources.Count()); + } + + [Fact] + public void Create_UrlType_IgnoresQuota() + { + using var db = DbContextFactory.Create(); + SeedPlan(db, planQuota: 1000); + db.Resources.Add(new Resource + { + Id = "r0", InstanceId = "inst-test", Label = "Déjà là", + Type = ResourceType.Image, SizeBytes = 1000 + }); + db.SaveChanges(); + + // Un lien externe n'occupe rien dans le bucket : il ne doit pas être refusé + // pour un quota de stockage, même sur une instance pleine. + var result = BuildController(db).Create(new ResourceDTO + { + instanceId = "inst-test", label = "Lien", + type = ResourceType.ImageUrl, url = "https://ailleurs/img.png" + }); + + Assert.Equal(200, result.StatusCode); + } + + /// + /// La divergence que StorageQuota ferme : la surcharge portée par l'instance + /// l'emporte sur le plan. Avant, le pré-vol lisait le plan et bloquait à 1 000 + /// alors que l'écran de quota annonçait 10 000. + /// + [Fact] + public void Create_InstanceOverridesPlanQuota() + { + using var db = DbContextFactory.Create(); + SeedPlan(db, planQuota: 1000, instanceQuota: 10000); + db.Resources.Add(new Resource + { + Id = "r0", InstanceId = "inst-test", Label = "Déjà là", + Type = ResourceType.Image, SizeBytes = 900 + }); + db.SaveChanges(); + + var result = BuildController(db).Create(new ResourceDTO + { + instanceId = "inst-test", label = "Dans le quota surchargé", + type = ResourceType.Image, sizeBytes = 200 + }); + + Assert.Equal(200, result.StatusCode); + } + + [Fact] + public void Delete_RemovesBlobBeforeRow() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, + StoragePath = "pictures/inst-test/r1" + }); + db.SaveChanges(); + + var blob = new FakeBlobService(); + var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult(); + + Assert.Equal(202, result.StatusCode); + Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths); + Assert.Equal(0, db.Resources.Count()); + } + + /// + /// Le cœur du choix : un échec de bucket conserve la ligne. Une ligne encore + /// listée se rattrape ; un blob dont plus aucune ligne ne porte le chemin est + /// facturé sans que rien ne le désigne. + /// + [Fact] + public void Delete_BlobFailure_KeepsRowAndReturns502() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, + StoragePath = "pictures/inst-test/r1" + }); + db.SaveChanges(); + + var blob = new FakeBlobService(BlobDeleteOutcome.Failed); + var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult(); + + Assert.Equal(502, result.StatusCode); + Assert.Equal(1, db.Resources.Count()); + } + + [Fact] + public void Delete_MissingStoragePath_IsRebuiltFromTheDeterministicPath() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "Avant le backfill", Type = ResourceType.Image + }); + db.SaveChanges(); + + var blob = new FakeBlobService(); + BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult(); + + Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths); + } + + [Fact] + public void Update_TypeBecomesFile_FillsStoragePath() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.ImageUrl, + Url = "https://ailleurs/img.png" + }); + db.SaveChanges(); + + BuildController(db).Update(new ResourceDTO + { + id = "r1", type = ResourceType.Image, sizeBytes = 256 + }); + + var resource = db.Resources.Single(); + Assert.Equal("pictures/inst-test/r1", resource.StoragePath); + Assert.Equal(256, resource.SizeBytes); + } + + [Fact] + public void Update_TypeBecomesUrl_ClearsStorageColumns() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, + StoragePath = "pictures/inst-test/r1", SizeBytes = 4096 + }); + db.SaveChanges(); + + BuildController(db).Update(new ResourceDTO { id = "r1", type = ResourceType.ImageUrl }); + + var resource = db.Resources.Single(); + Assert.Null(resource.StoragePath); + Assert.Equal(0, resource.SizeBytes); + } + + // ── BACKFILL STORAGE (C2) ──────────────────────────────────────────── + + private static ResourceBackfillReportDTO RunBackfill( + ResourceController controller, bool dryRun = false, string instanceId = null) + { + var result = controller.BackfillStorage(dryRun, instanceId).GetAwaiter().GetResult(); + var ok = Assert.IsType(result); + return Assert.IsType(ok.Value); + } + + [Fact] + public void Backfill_FillsStoragePathAndSizeBytes() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "A", + Type = ResourceType.Image, Url = "https://bucket/r1" + }); + db.SaveChanges(); + + var http = new FakeHttpClientFactory(new Dictionary { ["https://bucket/r1"] = 2048 }); + var report = RunBackfill(BuildController(db, http)); + + var resource = db.Resources.Single(); + Assert.Equal("pictures/inst-test/r1", resource.StoragePath); + Assert.Equal(2048, resource.SizeBytes); + Assert.Equal(1, report.StoragePathFilled); + Assert.Equal(1, report.SizeBytesFilled); + } + + /// + /// Le point du lien L10 : un backfill qui écrirait quand même laisserait le quota + /// de C3 porter sur des zéros. Une ligne non sondable doit ressortir nommée. + /// + [Fact] + public void Backfill_UnreachableBlob_IsReportedNotCountedAsZero() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "Absente", + Type = ResourceType.Image, Url = "https://bucket/disparue" + }); + db.SaveChanges(); + + var report = RunBackfill(BuildController(db, new FakeHttpClientFactory())); + + Assert.Equal(0, report.SizeBytesFilled); + Assert.Single(report.Unsized); + Assert.Contains("r1", report.Unsized[0]); + // Le chemin, lui, est déterministe : il se calcule même sans blob joignable. + Assert.Equal("pictures/inst-test/r1", db.Resources.Single().StoragePath); + } + + [Fact] + public void Backfill_FileTypeWithoutUrl_IsListedAsOrphan() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "Sans URL", Type = ResourceType.PDF + }); + db.SaveChanges(); + + var report = RunBackfill(BuildController(db)); + + Assert.Single(report.Orphans); + Assert.Empty(report.Unsized); + } + + [Fact] + public void Backfill_UrlType_KeepsNoPathAndNoSize() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "Lien", Type = ResourceType.ImageUrl, + Url = "https://ailleurs/img.png", StoragePath = "pictures/inst-test/r1", SizeBytes = 999 + }); + db.SaveChanges(); + + var report = RunBackfill(BuildController(db)); + + var resource = db.Resources.Single(); + Assert.Null(resource.StoragePath); + Assert.Equal(0, resource.SizeBytes); + Assert.Equal(1, report.UrlTypesNormalised); + } + + [Fact] + public void Backfill_AlreadyCompleteRow_IsLeftAlone() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, + Url = "https://bucket/r1", StoragePath = "pictures/inst-test/r1", SizeBytes = 512 + }); + db.SaveChanges(); + + var http = new FakeHttpClientFactory(new Dictionary { ["https://bucket/r1"] = 999999 }); + var report = RunBackfill(BuildController(db, http)); + + // La taille en base a été écrite par la création, qui connaissait le fichier : + // la resonder n'apporterait rien et écraserait une valeur plus sûre. + Assert.Equal(512, db.Resources.Single().SizeBytes); + Assert.Equal(1, report.AlreadyComplete); + Assert.Equal(0, report.SizeBytesFilled); + } + + [Fact] + public void Backfill_DryRun_WritesNothing() + { + using var db = DbContextFactory.Create(); + db.Resources.Add(new Resource + { + Id = "r1", InstanceId = "inst-test", Label = "A", + Type = ResourceType.Image, Url = "https://bucket/r1" + }); + db.SaveChanges(); + + var http = new FakeHttpClientFactory(new Dictionary { ["https://bucket/r1"] = 2048 }); + var report = RunBackfill(BuildController(db, http), dryRun: true); + + Assert.True(report.DryRun); + Assert.Equal(1, report.StoragePathFilled); + + // Sans vider le ChangeTracker, l'entité modifiée en mémoire répondrait la + // nouvelle valeur alors que rien n'a été enregistré : l'assertion serait creuse. + db.ChangeTracker.Clear(); + var reread = db.Resources.Single(); + Assert.Null(reread.StoragePath); + Assert.Equal(0, reread.SizeBytes); + } + + [Fact] + public void Backfill_ScopedToInstance_IgnoresOthers() + { + using var db = DbContextFactory.Create(); + db.Resources.AddRange( + new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image }, + new Resource { Id = "r2", InstanceId = "other-inst", Label = "B", Type = ResourceType.Image } + ); + db.SaveChanges(); + + var report = RunBackfill(BuildController(db), instanceId: "inst-test"); + + Assert.Equal(1, report.Examined); + Assert.Null(db.Resources.Single(r => r.Id == "r2").StoragePath); + } } } diff --git a/ManagerService.Tests/Infrastructure/FakeBlobService.cs b/ManagerService.Tests/Infrastructure/FakeBlobService.cs new file mode 100644 index 0000000..e57da8b --- /dev/null +++ b/ManagerService.Tests/Infrastructure/FakeBlobService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ManagerService.Services; + +namespace ManagerService.Tests.Infrastructure +{ + /// + /// Bucket en mémoire. Enregistre les chemins qu'on lui demande de supprimer, ce qui + /// permet de vérifier non seulement que la suppression a eu lieu, mais qu'elle a porté + /// sur le bon emplacement — le chemin étant reconstruit quand StoragePath est nul. + /// + public class FakeBlobService : IResourceBlobService + { + private readonly BlobDeleteOutcome _outcome; + + public FakeBlobService(BlobDeleteOutcome outcome = BlobDeleteOutcome.Deleted) + { + _outcome = outcome; + } + + public List DeletedPaths { get; } = new(); + + public bool IsConfigured => _outcome != BlobDeleteOutcome.NotConfigured; + + public Task DeleteAsync(string storagePath) + { + DeletedPaths.Add(storagePath); + return Task.FromResult(_outcome); + } + } +} diff --git a/ManagerService.Tests/Infrastructure/FakeHttpClientFactory.cs b/ManagerService.Tests/Infrastructure/FakeHttpClientFactory.cs new file mode 100644 index 0000000..6f0e8b2 --- /dev/null +++ b/ManagerService.Tests/Infrastructure/FakeHttpClientFactory.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace ManagerService.Tests.Infrastructure +{ + /// + /// Fabrique d'HttpClient sans réseau, pour les chemins qui sondent le poids d'un blob + /// par HEAD (ResourceSizeProbe). Les tailles sont données par URL ; une URL + /// absente de la table répond sans Content-Length, ce qui est exactement le cas + /// « blob introuvable » que le backfill doit inventorier au lieu de le compter pour zéro. + /// + public class FakeHttpClientFactory : IHttpClientFactory + { + private readonly IDictionary _sizesByUrl; + private readonly ISet _throwingUrls; + + public FakeHttpClientFactory(IDictionary sizesByUrl = null, ISet throwingUrls = null) + { + _sizesByUrl = sizesByUrl ?? new Dictionary(); + _throwingUrls = throwingUrls ?? new HashSet(); + } + + public HttpClient CreateClient(string name = "") => + new HttpClient(new StubHandler(_sizesByUrl, _throwingUrls)); + + private class StubHandler : HttpMessageHandler + { + private readonly IDictionary _sizesByUrl; + private readonly ISet _throwingUrls; + + public StubHandler(IDictionary sizesByUrl, ISet throwingUrls) + { + _sizesByUrl = sizesByUrl; + _throwingUrls = throwingUrls; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + + if (_throwingUrls.Contains(url)) + throw new HttpRequestException("hôte injoignable"); + + var response = new HttpResponseMessage(HttpStatusCode.OK); + if (_sizesByUrl.TryGetValue(url, out var size)) + { + // Content-Length ne se pose que sur un contenu : un HEAD réel renvoie + // l'en-tête sans le corps, ce que StringContent + la longueur imite + // suffisamment pour la sonde, qui ne lit que l'en-tête. + response.Content = new ByteArrayContent(Array.Empty()); + response.Content.Headers.ContentLength = size; + } + else + { + response.StatusCode = HttpStatusCode.NotFound; + response.Content = new ByteArrayContent(Array.Empty()); + response.Content.Headers.ContentLength = null; + } + + return Task.FromResult(response); + } + } + } +} diff --git a/ManagerService/Controllers/InstanceController.cs b/ManagerService/Controllers/InstanceController.cs index 107cc21..56c8a9f 100644 --- a/ManagerService/Controllers/InstanceController.cs +++ b/ManagerService/Controllers/InstanceController.cs @@ -383,7 +383,9 @@ namespace ManagerService.Controllers var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId); if (plan != null) { - if (storageQuota == 0) storageQuota = plan.StorageQuotaBytes; + // Même résolveur que le pré-vol de ResourceController : le chiffre + // affiché ici et celui qui bloque un téléversement doivent être le même. + storageQuota = StorageQuota.Resolve(storageQuota, plan.StorageQuotaBytes); if (aiQuota == 0) aiQuota = plan.AiTokensPerMonth; } } diff --git a/ManagerService/Controllers/MigrationController.cs b/ManagerService/Controllers/MigrationController.cs index 5edb933..d3607ef 100644 --- a/ManagerService/Controllers/MigrationController.cs +++ b/ManagerService/Controllers/MigrationController.cs @@ -226,36 +226,18 @@ namespace ManagerService.Controllers : _instanceSvc.GetAll().Select(i => i.Id).ToList(); var source = instanceIds.SelectMany(id => _resourceSvc.GetAll(id)).ToList(); - // Fetch sizes en parallèle par batches de 30 + // Sondage des tailles par HEAD, extrait dans ResourceSizeProbe : le backfill + // des lignes existantes (C2) en a besoin à l'identique, et deux copies + // divergeraient sur ce qui compte — le sort réservé aux échecs. + // L'échec était avalé sans laisser de trace : la ressource arrivait à + // 0 octet et le quota la comptait pour rien, sans que personne puisse + // savoir lesquelles. La sonde les signale, y compris le cas ajouté à + // l'extraction : une réponse sans Content-Length, qu'aucun catch n'attrapait. var httpClient = _httpClientFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(10); - var sizemap = new System.Collections.Concurrent.ConcurrentDictionary(); - var unsized = new System.Collections.Concurrent.ConcurrentDictionary(); - var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList(); - - const int batchSize = 30; - for (int i = 0; i < urlSource.Count; i += batchSize) - { - var batch = urlSource.Skip(i).Take(batchSize); - await Task.WhenAll(batch.Select(async r => - { - try - { - var req = new HttpRequestMessage(HttpMethod.Head, r.Url); - var resp = await httpClient.SendAsync(req); - if (resp.Content.Headers.ContentLength.HasValue) - sizemap[r.Id] = resp.Content.Headers.ContentLength.Value; - } - catch - { - // L'échec était avalé sans laisser de trace : la ressource - // arrivait à 0 octet et le quota de stockage la comptait pour - // rien, sans que personne puisse savoir lesquelles. On le note. - unsized[r.Id] = true; - } - })); - } + var probe = await ResourceSizeProbe.ProbeAsync( + httpClient, source.Select(r => (r.Id, r.Url))); foreach (var old in source) { @@ -281,12 +263,12 @@ namespace ManagerService.Controllers // l'était à la main. Les deux passent par le même calculateur que la // création et le backfill (lien L5) — sinon les trois divergent sur // les types URL, qui n'ont ni blob ni poids à compter dans le quota. - ResourceStorage.Apply(entity, sizemap.TryGetValue(old.Id, out var size) ? size : 0); + ResourceStorage.Apply(entity, probe.TryGetSize(old.Id, out var size) ? size : 0); if (!dryRun) _db.Resources.Add(entity); - if (unsized.ContainsKey(old.Id) && ResourceStorage.HasBlob(entity.Type)) + if (probe.IsUnsized(old.Id) && ResourceStorage.HasBlob(entity.Type)) report.Errors.Add($"Resource {old.Id} ({old.Label}) : taille inconnue (HEAD en échec), SizeBytes à 0 — le quota de stockage la comptera pour rien"); report.Migrated.Resources++; diff --git a/ManagerService/Controllers/ResourceController.cs b/ManagerService/Controllers/ResourceController.cs index 735d5b3..5a9c718 100644 --- a/ManagerService/Controllers/ResourceController.cs +++ b/ManagerService/Controllers/ResourceController.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Drawing; using System.IO; using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; using Manager.DTOs; using Manager.Services; using ManagerService.Data; @@ -30,18 +32,22 @@ namespace ManagerService.Controllers private SectionDatabaseService _sectionService; private ConfigurationDatabaseService _configurationService; private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IResourceBlobService _blobService; IHexIdGeneratorService idService = new HexIdGeneratorService(); private static int MaxWidth = 1024; private static int MaxHeight = 1024; - public ResourceController(ILogger logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext) + public ResourceController(ILogger logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService) { _logger = logger; _resourceService = resourceService; _sectionService = sectionService; _configurationService = configurationService; _myInfoMateDbContext = myInfoMateDbContext; + _httpClientFactory = httpClientFactory; + _blobService = blobService; } /// @@ -234,19 +240,9 @@ namespace ManagerService.Controllers var resourceType = (ResourceType)Enum.Parse(typeof(ResourceType), type); List resources = new List(); - var instance = _myInfoMateDbContext.Instances - .Include(i => i.SubscriptionPlan) - .FirstOrDefault(i => i.Id == instanceId); - var storageQuota = instance?.SubscriptionPlan?.StorageQuotaBytes ?? 0; - if (storageQuota > 0) - { - var storageUsed = _myInfoMateDbContext.Resources - .Where(r => r.InstanceId == instanceId) - .Sum(r => (long?)r.SizeBytes) ?? 0; - var incomingBytes = Request.Form.Files.Sum(f => f.Length); - if (storageUsed + incomingBytes > storageQuota) - return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 }; - } + var incomingBytes = Request.Form.Files.Sum(f => f.Length); + if (ExceedsStorageQuota(instanceId, incomingBytes)) + return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 }; foreach (var file in Request.Form.Files) { @@ -323,7 +319,15 @@ namespace ManagerService.Controllers if (newResource == null) throw new ArgumentNullException("Resource param is null"); - // Todo add some verification ? + // Le pré-vol manquait sur ce chemin : manager-app crée la ligne par JSON + // en annonçant sizeBytes, PUIS téléverse le blob. Sans contrôle ici, tout + // dépassement passait par la porte de service — seul le chemin multipart + // était gardé. + if (ResourceStorage.HasBlob(newResource.type) + && ExceedsStorageQuota(newResource.instanceId, newResource.sizeBytes)) + return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 }; + + // Todo add some verification ? Resource resource = new Resource(); resource.InstanceId = newResource.instanceId; resource.Label = newResource.label; @@ -387,6 +391,19 @@ namespace ManagerService.Controllers if (updatedResource.sizeBytes > 0) resource.SizeBytes = updatedResource.sizeBytes; + // Angle mort laissé ouvert par C1, tranché ici : un Update peut faire passer + // Type d'un type URL à un type fichier, laissant StoragePath nul sur une ligne + // qui a désormais un blob. La crainte d'alors — « recalculer pointerait vers un + // objet qui n'a pas bougé » — ne tient pas : PathFor ne construit qu'un + // pictures/{instanceId}/{resourceId}, le type n'entre pas dans le chemin, il + // décide seulement s'il y en a un. Recalculer est donc sans risque. + ResourceStorage.Apply(resource, resource.SizeBytes); + if (!ResourceStorage.HasBlob(resource.Type)) + { + resource.StoragePath = null; + resource.SizeBytes = 0; + } + _myInfoMateDbContext.SaveChanges(); //OldResource resourceModified = _resourceService.Update(updatedResource.id, resource); @@ -416,8 +433,9 @@ namespace ManagerService.Controllers [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] + [ProducesResponseType(typeof(string), 502)] [HttpDelete("{id}")] - public ObjectResult Delete(string id) + public async Task Delete(string id) { try { @@ -429,6 +447,17 @@ namespace ManagerService.Controllers if (resource == null) throw new KeyNotFoundException("Resource does not exist"); + // Le blob part AVANT la ligne. manager-app faisait l'inverse et avalait + // l'échec : la ligne disparaissait, le blob restait, et n'ayant plus de + // ligne il devenait invisible au quota tout en restant facturé. Ici un + // échec interrompt : mieux vaut une ressource encore listée qu'un orphelin + // que plus rien ne désigne. + var storagePath = resource.StoragePath + ?? ResourceStorage.PathFor(resource.Type, resource.InstanceId, resource.Id); + var outcome = await _blobService.DeleteAsync(storagePath); + if (outcome == BlobDeleteOutcome.Failed) + return new ObjectResult("Le fichier n'a pas pu être supprimé du stockage — la ressource est conservée") { StatusCode = 502 }; + List configurations = _myInfoMateDbContext.Configurations.Where(c => c.InstanceId == resource.InstanceId).ToList(); foreach (var configuration in configurations) @@ -704,6 +733,127 @@ namespace ManagerService.Controllers } } + /// + /// Renseigne StoragePath et SizeBytes sur les ressources déjà en base (C2). + /// + /// Ces colonnes sont nées après le contenu : les lignes créées avant portent un + /// StoragePath nul, et un SizeBytes nul pour celles créées avant que le chemin + /// multipart ne l'écrive. Un quota calculé là-dessus laisserait tout passer, + /// d'où l'ordre imposé — ce backfill précède le contrôle autoritaire (lien L10). + /// + /// dryRun est à true par défaut, à l'inverse de la migration : celle-ci se joue + /// sur une base vide, celui-ci sur des lignes de production. + /// + [Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)] + [ProducesResponseType(typeof(ResourceBackfillReportDTO), 200)] + [ProducesResponseType(typeof(string), 500)] + [HttpPost("backfill-storage")] + public async Task BackfillStorage( + [FromQuery] bool dryRun = true, + [FromQuery] string instanceId = null) + { + try + { + var report = new ResourceBackfillReportDTO { DryRun = dryRun }; + + var query = _myInfoMateDbContext.Resources.AsQueryable(); + if (!string.IsNullOrEmpty(instanceId)) + query = query.Where(r => r.InstanceId == instanceId); + + var resources = await query.ToListAsync(); + report.Examined = resources.Count; + + // Un type URL ne doit porter ni chemin ni poids : il pointe hors du bucket. + // Le cas ne devrait pas exister, mais une ligne mal renseignée gonflerait + // le quota d'octets que personne ne stocke — on la remet dans le contrat + // de ResourceStorage plutôt que de la laisser fausser le calcul de C3. + foreach (var resource in resources.Where(r => !ResourceStorage.HasBlob(r.Type))) + { + if (resource.StoragePath == null && resource.SizeBytes == 0) continue; + resource.StoragePath = null; + resource.SizeBytes = 0; + report.UrlTypesNormalised++; + } + + var blobs = resources.Where(r => ResourceStorage.HasBlob(r.Type)).ToList(); + + // Seules les tailles inconnues sont sondées : une valeur déjà en base a été + // écrite par la création, qui connaissait le fichier réel. La resonder + // coûterait un HEAD par ressource pour un résultat identique. + var toProbe = blobs.Where(r => r.SizeBytes == 0).ToList(); + + var httpClient = _httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(10); + var probe = await ResourceSizeProbe.ProbeAsync( + httpClient, toProbe.Select(r => (r.Id, r.Url))); + + foreach (var resource in blobs) + { + var hadPath = !string.IsNullOrEmpty(resource.StoragePath); + var hadSize = resource.SizeBytes > 0; + + // Le chemin est déterministe, la taille ne l'est pas : on repasse par + // le calculateur commun en lui redonnant la taille connue quand elle + // l'est, plutôt que d'écrire les colonnes à la main ici (lien L5). + var size = hadSize + ? resource.SizeBytes + : (probe.TryGetSize(resource.Id, out var probed) ? probed : 0); + + ResourceStorage.Apply(resource, size); + + if (!hadPath) report.StoragePathFilled++; + if (!hadSize && size > 0) report.SizeBytesFilled++; + if (hadPath && hadSize) report.AlreadyComplete++; + + if (!hadSize && size == 0) + { + // Deux causes distinctes, à ne pas confondre dans un même total : + // une ligne sans URL n'est pas sondable du tout (le blob n'a + // peut-être jamais été téléversé), une ligne sondée sans réponse + // exploitable pointe vers un objet absent ou un bucket muet. + if (string.IsNullOrEmpty(resource.Url)) + report.Orphans.Add($"{resource.Id} ({resource.Label}) — type {resource.Type}, aucune URL : blob jamais téléversé ?"); + else + report.Unsized.Add($"{resource.Id} ({resource.Label}) — HEAD sans Content-Length sur {resource.Url}"); + } + } + + if (!dryRun) + await _myInfoMateDbContext.SaveChangesAsync(); + + return new OkObjectResult(report); + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + + /// + /// Pré-vol du quota, partagé par les deux chemins de création. Passe par + /// pour que le chiffre opposé au client soit celui + /// que son écran de quota lui annonce. + /// + private bool ExceedsStorageQuota(string instanceId, long incomingBytes) + { + if (string.IsNullOrEmpty(instanceId)) return false; + + var instance = _myInfoMateDbContext.Instances + .Include(i => i.SubscriptionPlan) + .FirstOrDefault(i => i.Id == instanceId); + if (instance == null) return false; + + var quota = StorageQuota.Resolve( + instance.StorageQuotaBytes, instance.SubscriptionPlan?.StorageQuotaBytes ?? 0); + if (quota <= 0) return false; + + var used = _myInfoMateDbContext.Resources + .Where(r => r.InstanceId == instanceId) + .Sum(r => (long?)r.SizeBytes) ?? 0; + + return StorageQuota.Exceeds(used, incomingBytes, quota); + } + /// /// Le filigrane est un réglage d'instance depuis le 2026-08-11. Auparavant /// un `instanceId == "633ee379…"` en dur ne l'activait que pour Fort Saint-Héribert. @@ -714,4 +864,21 @@ namespace ManagerService.Controllers .Select(i => i.IsImageWatermark) .FirstOrDefault(); } + + /// + /// Le backfill ne se contente pas de compter : les deux listes sont l'inventaire + /// des lignes qu'il n'a pas pu réparer. C'est la seule sortie qui dise combien + /// d'octets le quota de C3 ne verra jamais. + /// + public class ResourceBackfillReportDTO + { + public bool DryRun { get; set; } + public int Examined { get; set; } + public int StoragePathFilled { get; set; } + public int SizeBytesFilled { get; set; } + public int AlreadyComplete { get; set; } + public int UrlTypesNormalised { get; set; } + public List Orphans { get; set; } = new(); + public List Unsized { get; set; } = new(); + } } diff --git a/ManagerService/Helpers/ResourceSizeProbe.cs b/ManagerService/Helpers/ResourceSizeProbe.cs new file mode 100644 index 0000000..b65b931 --- /dev/null +++ b/ManagerService/Helpers/ResourceSizeProbe.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ManagerService.Helpers +{ + /// + /// Sonde le poids d'un blob par requête HEAD sur son URL publique, par lots. + /// + /// Deux appelants : MigrationController (écart e de la bascule) et le backfill + /// des lignes existantes. Comme , c'est un calculateur + /// unique plutôt que deux implémentations qui divergeraient — ici sur la taille de lot, + /// le délai d'attente et surtout le sort réservé aux échecs. + /// + /// ⚠️ Le serveur n'a aucun client de bucket : ni Firebase Admin ni Google.Cloud.Storage + /// ne sont référencés dans le projet, les blobs étant téléversés depuis manager-app + /// directement. L'URL publique est donc le seul moyen de connaître une taille. + /// + public static class ResourceSizeProbe + { + /// Trente requêtes en vol à la fois, valeur héritée de la migration. + public const int BatchSize = 30; + + public sealed class Result + { + private readonly ConcurrentDictionary _sizes = new(); + private readonly ConcurrentDictionary _unsized = new(); + + public bool TryGetSize(string id, out long size) => _sizes.TryGetValue(id, out size); + + /// + /// Vrai si la sonde n'a pas su donner de taille : HEAD en échec, ou réponse + /// sans Content-Length. Les deux cas laissent la ressource à 0 octet, + /// donc invisible au quota — l'appelant doit le signaler, pas l'avaler. + /// + public bool IsUnsized(string id) => _unsized.ContainsKey(id); + + public int SizedCount => _sizes.Count; + public int UnsizedCount => _unsized.Count; + + internal void Record(string id, long size) => _sizes[id] = size; + internal void RecordUnsized(string id) => _unsized[id] = true; + } + + /// + /// Le est fourni par l'appelant, qui reste maître du + /// délai d'attente : la migration accepte 10 s par ressource, un backfill lancé + /// à la main peut vouloir plus. + /// + public static async Task ProbeAsync(HttpClient client, IEnumerable<(string Id, string Url)> targets) + { + var result = new Result(); + var list = targets.Where(t => !string.IsNullOrEmpty(t.Url)).ToList(); + + for (int i = 0; i < list.Count; i += BatchSize) + { + var batch = list.Skip(i).Take(BatchSize); + await Task.WhenAll(batch.Select(async target => + { + try + { + var request = new HttpRequestMessage(HttpMethod.Head, target.Url); + var response = await client.SendAsync(request); + + // Un 404 ne lève pas : il répond simplement sans Content-Length. + // Le traiter comme un échec est le seul moyen de ne pas confondre + // « blob absent » et « blob de 0 octet ». + if (response.Content.Headers.ContentLength.HasValue) + result.Record(target.Id, response.Content.Headers.ContentLength.Value); + else + result.RecordUnsized(target.Id); + } + catch + { + result.RecordUnsized(target.Id); + } + })); + } + + return result; + } + } +} diff --git a/ManagerService/Helpers/StorageQuota.cs b/ManagerService/Helpers/StorageQuota.cs new file mode 100644 index 0000000..446f5f8 --- /dev/null +++ b/ManagerService/Helpers/StorageQuota.cs @@ -0,0 +1,29 @@ +namespace ManagerService.Helpers +{ + /// + /// Seule source de vérité pour « quel quota de stockage s'applique à cette instance ». + /// + /// Les deux lectures divergeaient : le pré-vol du téléversement lisait le quota du + /// **plan**, l'écran de quota lisait celui de l'**instance** avec le plan en repli. + /// Une instance dont le quota a été surchargé — le mécanisme même de l'add-on — + /// affichait donc un chiffre et se faisait bloquer sur un autre. + /// + public static class StorageQuota + { + /// + /// La surcharge portée par l'instance l'emporte ; 0 veut dire « pas de surcharge » + /// et renvoie au plan. ⚠️ Au niveau du plan, 0 garde son autre sens : illimité. + /// L'asymétrie est volontaire et documentée au §1quinquies — ne pas l'harmoniser + /// sans repasser dans CheckQuota, AiController et SectionIndexingInterceptor. + /// + public static long Resolve(long instanceQuotaBytes, long planQuotaBytes) => + instanceQuotaBytes != 0 ? instanceQuotaBytes : planQuotaBytes; + + /// + /// Un quota résolu à 0 est illimité : rien ne dépasse. C'est le cas d'Enterprise, + /// et c'est aussi ce qui protège une instance sans plan d'être bloquée à zéro octet. + /// + public static bool Exceeds(long usedBytes, long incomingBytes, long quotaBytes) => + quotaBytes > 0 && usedBytes + incomingBytes > quotaBytes; + } +} diff --git a/ManagerService/ManagerService.csproj b/ManagerService/ManagerService.csproj index c5ea77d..3be722d 100644 --- a/ManagerService/ManagerService.csproj +++ b/ManagerService/ManagerService.csproj @@ -7,6 +7,7 @@ + all diff --git a/ManagerService/Services/GoogleEmbeddingService.cs b/ManagerService/Services/GoogleEmbeddingService.cs index 069f40a..d787489 100644 --- a/ManagerService/Services/GoogleEmbeddingService.cs +++ b/ManagerService/Services/GoogleEmbeddingService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration; using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json.Serialization; @@ -26,6 +27,8 @@ namespace ManagerService.Services /// private const int BatchSize = 50; + private const int MaxThrottleRetries = 3; + private readonly IHttpClientFactory _httpClientFactory; private readonly string _apiKey; @@ -74,7 +77,24 @@ namespace ManagerService.Services Dimensions = Dimensions }; - var response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken); + HttpResponseMessage response; + + // Une limite de débit est passagère : la remonter ferait échouer le job Hangfire, + // qui retenterait la section entière — donc ré-embedderait ses morceaux déjà faits. + // Retry-After d'abord, exponentiel s'il est absent. + for (var attempt = 0; ; attempt++) + { + response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken); + + var throttled = response.StatusCode == HttpStatusCode.TooManyRequests + || response.StatusCode == HttpStatusCode.ServiceUnavailable; + + if (!throttled || attempt >= MaxThrottleRetries) + break; + + var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt)); + await Task.Delay(wait, cancellationToken); + } if (!response.IsSuccessStatusCode) { diff --git a/ManagerService/Services/IIngestionService.cs b/ManagerService/Services/IIngestionService.cs index f567b55..068ab13 100644 --- a/ManagerService/Services/IIngestionService.cs +++ b/ManagerService/Services/IIngestionService.cs @@ -12,10 +12,10 @@ namespace ManagerService.Services Task IngestSectionAsync(string sectionId); /// - /// Réindexe toutes les sections d'une instance et rend le nombre de morceaux produits. - /// Sert au rattrapage après un passage à un plan avec IA : sans lui, le client paie un - /// guide qui ne connaît rien de son contenu, puisque rien n'a été indexé tant qu'il - /// était sur un plan sans IA. + /// Met en file un job d'indexation par section de l'instance, et rend le nombre de + /// sections mises en file. Sert au rattrapage après un passage à un plan avec IA : + /// sans lui, le client paie un guide qui ne connaît rien de son contenu, puisque rien + /// n'a été indexé tant qu'il était sur un plan sans IA. /// Task BackfillInstanceAsync(string instanceId); } diff --git a/ManagerService/Services/IngestionService.cs b/ManagerService/Services/IngestionService.cs index 525a099..6bb0aa5 100644 --- a/ManagerService/Services/IngestionService.cs +++ b/ManagerService/Services/IngestionService.cs @@ -32,20 +32,20 @@ namespace ManagerService.Services private readonly IVectorStoreService _vectorStore; private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly IBackgroundJobClient _jobs; public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore, - IConfiguration configuration, ILogger logger) + IConfiguration configuration, ILogger logger, + IBackgroundJobClient jobs) { _db = db; _vectorStore = vectorStore; _configuration = configuration; _logger = logger; + _jobs = jobs; } - public Task IngestSectionAsync(string sectionId) => IngestSectionCountingAsync(sectionId); - - /// Même travail, en rendant le nombre de morceaux — ce dont le backfill a besoin. - private async Task IngestSectionCountingAsync(string sectionId) + public async Task IngestSectionAsync(string sectionId) { var stub = await _db.Sections.AsNoTracking().FirstOrDefaultAsync(s => s.Id == sectionId); @@ -53,7 +53,7 @@ namespace ManagerService.Services if (stub == null) { await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section); - return 0; + return; } // Re-vérifié ici et pas seulement à l'enqueue : le plan a pu changer entre les deux, @@ -68,13 +68,13 @@ namespace ManagerService.Services // de paiement réglé le lendemain ne doit pas imposer de tout ré-embedder. // Voir DOCS/v2/rag-indexing-trigger-decision.md. if (!hasAi) - return 0; + return; // Une section désactivée doit disparaître des réponses du guide, pas seulement de l'app. if (!stub.IsActive) { await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section); - return 0; + return; } var section = await LoadWithChildrenAsync(stub); @@ -84,9 +84,14 @@ namespace ManagerService.Services section.Id, ContentSourceType.Section, chunks); _logger.LogInformation("Section {SectionId} indexée : {ChunkCount} morceaux", sectionId, chunks.Count); - return chunks.Count; } + /// + /// Un job par section plutôt qu'une boucle : l'unité de reprise devient la section. + /// En boucle, un échec d'embedding à la 280ᵉ section faisait retenter le job entier par + /// Hangfire, donc ré-embedder les 279 déjà indexées — des appels facturés pour rien, et + /// autant de chances de retomber sur la même limite de débit. + /// public async Task BackfillInstanceAsync(string instanceId) { var sectionIds = await _db.Sections @@ -94,13 +99,12 @@ namespace ManagerService.Services .Select(s => s.Id) .ToListAsync(); - var chunkCount = 0; foreach (var id in sectionIds) - chunkCount += await IngestSectionCountingAsync(id); + _jobs.Enqueue(s => s.IngestSectionAsync(id)); - _logger.LogInformation("Backfill instance {InstanceId} : {Count} sections, {ChunkCount} morceaux", - instanceId, sectionIds.Count, chunkCount); - return chunkCount; + _logger.LogInformation("Backfill instance {InstanceId} : {Count} sections mises en file", + instanceId, sectionIds.Count); + return sectionIds.Count; } /// diff --git a/ManagerService/Services/ResourceBlobService.cs b/ManagerService/Services/ResourceBlobService.cs new file mode 100644 index 0000000..925f613 --- /dev/null +++ b/ManagerService/Services/ResourceBlobService.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading.Tasks; +using Google.Apis.Auth.OAuth2; +using Google.Cloud.Storage.V1; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ManagerService.Services +{ + public enum BlobDeleteOutcome + { + /// Objet supprimé. + Deleted, + + /// + /// Rien à supprimer : blob jamais téléversé, ou déjà supprimé. Ce n'est pas un + /// échec — c'est même l'état recherché, donc l'appelant peut continuer. + /// + AlreadyAbsent, + + /// + /// Le bucket n'est pas configuré. La suppression est alors sautée, jamais + /// silencieusement réussie : un environnement mal configuré ne doit pas + /// ressembler à un environnement qui nettoie. + /// + NotConfigured, + + /// Le bucket a répondu autre chose. L'appelant ne doit pas supprimer la ligne. + Failed + } + + public interface IResourceBlobService + { + bool IsConfigured { get; } + Task DeleteAsync(string storagePath); + } + + /// + /// Suppression d'un blob dans le bucket, côté serveur. + /// + /// Jusqu'ici seul manager-app supprimait, après avoir supprimé la ligne et en + /// avalant l'échec dans un print : un échec laissait un orphelin définitif, et + /// comme sa ligne n'existait plus, le quota ne le comptait pas — il occupait des + /// octets facturés que personne ne voyait. + /// + /// Aucun secret nouveau : le credential est celui que Startup charge déjà pour les + /// notifications push (Firebase:CredentialsPath). Seul le nom du bucket s'ajoute. + /// + public class ResourceBlobService : IResourceBlobService + { + private readonly ILogger _logger; + private readonly string _bucket; + private readonly Lazy _client; + + public ResourceBlobService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _bucket = configuration["Firebase:StorageBucket"]; + var credentialsPath = configuration["Firebase:CredentialsPath"]; + + // Lazy : ne pas lire le fichier de credentials au démarrage pour un service + // dont la plupart des requêtes n'ont pas besoin, et ne pas faire échouer le + // boot d'un environnement de développement qui n'a pas le fichier. + _client = new Lazy(() => + StorageClient.Create(GoogleCredential.FromFile(credentialsPath))); + + IsConfigured = !string.IsNullOrEmpty(_bucket) + && !string.IsNullOrEmpty(credentialsPath) + && System.IO.File.Exists(credentialsPath); + } + + public bool IsConfigured { get; } + + public async Task DeleteAsync(string storagePath) + { + if (string.IsNullOrEmpty(storagePath)) + return BlobDeleteOutcome.AlreadyAbsent; + + if (!IsConfigured) + return BlobDeleteOutcome.NotConfigured; + + try + { + await _client.Value.DeleteObjectAsync(_bucket, storagePath); + return BlobDeleteOutcome.Deleted; + } + catch (Google.GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) + { + return BlobDeleteOutcome.AlreadyAbsent; + } + catch (Exception ex) + { + _logger.LogError(ex, "Suppression du blob {StoragePath} en échec sur le bucket {Bucket}", storagePath, _bucket); + return BlobDeleteOutcome.Failed; + } + } + } +} diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index 511d06b..af0ce15 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -203,6 +203,10 @@ namespace ManagerService }); } services.AddSingleton(); + // Réutilise le credential Firebase ci-dessus ; seul Firebase:StorageBucket + // s'ajoute à la configuration. Non configuré, le service ne supprime rien + // et le dit — il ne fait jamais semblant d'avoir nettoyé. + services.AddSingleton(); services.AddHttpClient(); services.AddScoped(); services.AddScoped(); diff --git a/ManagerService/appsettings.json b/ManagerService/appsettings.json index e81e19f..01772fa 100644 --- a/ManagerService/appsettings.json +++ b/ManagerService/appsettings.json @@ -42,7 +42,8 @@ "SearchTopK": 5 }, "Firebase": { - "CredentialsPath": "firebase-adminsdk.json" + "CredentialsPath": "firebase-adminsdk.json", + "StorageBucket": "" }, "AppUrls": { "ManagerApp": "https://manager.myinfomate.be",