diff --git a/Robust.Cdn/Config/ManifestOptions.cs b/Robust.Cdn/Config/ManifestOptions.cs index ca16d88..b85a5e7 100644 --- a/Robust.Cdn/Config/ManifestOptions.cs +++ b/Robust.Cdn/Config/ManifestOptions.cs @@ -12,6 +12,15 @@ public sealed class ManifestOptions public string FileDiskPath { get; set; } = ""; public Dictionary Forks { get; set; } = new(); + + /// + /// Time, in minutes, before an in-progress publish "times out" and can be deleted. + /// + /// + /// Generally, publishes really should not take longer than more than a few minutes. + /// If a publish takes longer, it likely indicates an error caused it to be aborted. + /// + public int InProgressPublishTimeoutMinutes { get; set; } = 60; } public sealed class ManifestForkOptions diff --git a/Robust.Cdn/Controllers/ForkPublishController.Multi.cs b/Robust.Cdn/Controllers/ForkPublishController.Multi.cs new file mode 100644 index 0000000..1de2cc1 --- /dev/null +++ b/Robust.Cdn/Controllers/ForkPublishController.Multi.cs @@ -0,0 +1,189 @@ +using Dapper; +using Microsoft.AspNetCore.Mvc; +using Robust.Cdn.Helpers; + +namespace Robust.Cdn.Controllers; + +public sealed partial class ForkPublishController +{ + // Code for "multi-request" publishes. + // i.e. start, followed by files, followed by finish call. + + [HttpPost("start")] + public async Task MultiPublishStart( + string fork, + [FromBody] PublishMultiRequest request, + CancellationToken cancel) + { + if (!authHelper.IsAuthValid(fork, out _, out var failureResult)) + return failureResult; + + baseUrlManager.ValidateBaseUrl(); + + if (!ValidVersionRegex.IsMatch(request.Version)) + return BadRequest("Invalid version name"); + + if (VersionAlreadyExists(fork, request.Version)) + return Conflict("Version already exists"); + + var dbCon = manifestDatabase.Connection; + + await using var tx = await dbCon.BeginTransactionAsync(cancel); + + logger.LogInformation("Starting multi publish for fork {Fork} version {Version}", fork, request.Version); + + var hasExistingPublish = dbCon.QuerySingleOrDefault( + "SELECT 1 FROM PublishInProgress WHERE Version = @Version ", + new { request.Version }); + if (hasExistingPublish) + { + // If a publish with this name already exists we abort it and start again. + // We do this so you can "just" retry a mid-way-failed publish without an extra API call required. + + logger.LogWarning("Already had an in-progress publish for this version, aborting it and restarting."); + publishManager.AbortMultiPublish(fork, request.Version, tx, commit: false); + } + + var forkId = dbCon.QuerySingle("SELECT Id FROM Fork WHERE Name = @Name", new { Name = fork }); + + await dbCon.ExecuteAsync(""" + INSERT INTO PublishInProgress (Version, ForkId, StartTime, EngineVersion) + VALUES (@Version, @ForkId, @StartTime, @EngineVersion) + """, + new + { + request.Version, + request.EngineVersion, + ForkId = forkId, + StartTime = DateTime.UtcNow + }); + + var versionDir = buildDirectoryManager.GetBuildVersionPath(fork, request.Version); + Directory.CreateDirectory(versionDir); + + await tx.CommitAsync(cancel); + + logger.LogInformation("Multi publish initiated. Waiting for subsequent API requests..."); + + return NoContent(); + } + + [HttpPost("file")] + [RequestSizeLimit(2048L * 1024 * 1024)] + public async Task MultiPublishFile( + string fork, + [FromHeader(Name = "Robust-Cdn-Publish-File")] + string fileName, + [FromHeader(Name = "Robust-Cdn-Publish-Version")] + string version, + CancellationToken cancel) + { + if (!authHelper.IsAuthValid(fork, out _, out var failureResult)) + return failureResult; + + if (!ValidFileRegex.IsMatch(fileName)) + return BadRequest("Invalid artifact file name"); + + var dbCon = manifestDatabase.Connection; + await using var tx = await dbCon.BeginTransactionAsync(cancel); + + var forkId = dbCon.QuerySingle("SELECT Id FROM Fork WHERE Name = @Name", new { Name = fork }); + var versionId = dbCon.QuerySingleOrDefault(""" + SELECT Id + FROM PublishInProgress + WHERE Version = @Name AND ForkId = @Fork + """, + new { Name = version, Fork = forkId }); + + if (versionId == null) + return NotFound("Unknown in-progress version"); + + var versionDir = buildDirectoryManager.GetBuildVersionPath(fork, version); + var filePath = Path.Combine(versionDir, fileName); + + if (System.IO.File.Exists(filePath)) + return Conflict("File already published"); + + logger.LogDebug("Receiving file {FileName} for multi-publish version {Version}", fileName, version); + + await using var file = System.IO.File.Create(filePath, 4096, FileOptions.Asynchronous); + + await Request.Body.CopyToAsync(file, cancel); + + logger.LogDebug("Successfully Received file {FileName}", fileName); + + return NoContent(); + } + + [HttpPost("finish")] + public async Task MultiPublishFinish( + string fork, + [FromBody] PublishFinishRequest request, + CancellationToken cancel) + { + if (!authHelper.IsAuthValid(fork, out var forkConfig, out var failureResult)) + return failureResult; + + var dbCon = manifestDatabase.Connection; + await using var tx = await dbCon.BeginTransactionAsync(cancel); + + var forkId = dbCon.QuerySingle("SELECT Id FROM Fork WHERE Name = @Name", new { Name = fork }); + var versionMetadata = dbCon.QuerySingleOrDefault(""" + SELECT Version, EngineVersion + FROM PublishInProgress + WHERE Version = @Name AND ForkId = @Fork + """, + new { Name = request.Version, Fork = forkId }); + + if (versionMetadata == null) + return NotFound("Unknown in-progress version"); + + logger.LogInformation("Finishing multi publish {Version} for fork {Fork}", request.Version, fork); + + var versionDir = buildDirectoryManager.GetBuildVersionPath(fork, request.Version); + + logger.LogDebug("Classifying entries..."); + + var artifacts = ClassifyEntries( + forkConfig, + Directory.GetFiles(versionDir), + item => Path.GetRelativePath(versionDir, item)); + + var clientArtifact = artifacts.SingleOrNull(art => art.artifact.Type == ArtifactType.Client); + if (clientArtifact == null) + { + publishManager.AbortMultiPublish(fork, request.Version, tx, commit: true); + return UnprocessableEntity("Publish failed: no client zip was provided"); + } + + var diskFiles = artifacts.ToDictionary(i => i.artifact, i => i.key); + + var buildJson = GenerateBuildJson(diskFiles, clientArtifact.Value.artifact, versionMetadata, fork); + InjectBuildJsonIntoServers(diskFiles, buildJson); + + AddVersionToDatabase(clientArtifact.Value.artifact, diskFiles, fork, versionMetadata); + + dbCon.Execute( + "DELETE FROM PublishInProgress WHERE Version = @Name AND ForkId = @Fork", + new { Name = request.Version, Fork = forkId }); + + tx.Commit(); + + await QueueIngestJobAsync(fork); + + logger.LogInformation("Publish succeeded!"); + + return NoContent(); + } + + public sealed class PublishMultiRequest + { + public required string Version { get; set; } + public required string EngineVersion { get; set; } + } + + public sealed class PublishFinishRequest + { + public required string Version { get; set; } + } +} diff --git a/Robust.Cdn/Controllers/ForkPublishController.OneShot.cs b/Robust.Cdn/Controllers/ForkPublishController.OneShot.cs new file mode 100644 index 0000000..75bcf74 --- /dev/null +++ b/Robust.Cdn/Controllers/ForkPublishController.OneShot.cs @@ -0,0 +1,117 @@ +using System.IO.Compression; +using Microsoft.AspNetCore.Mvc; +using Robust.Cdn.Helpers; + +namespace Robust.Cdn.Controllers; + +public sealed partial class ForkPublishController +{ + // Code for the "one shot" publish workflow where everything is done in a single request. + + [HttpPost] + public async Task PostPublish( + string fork, + [FromBody] PublishRequest request, + CancellationToken cancel) + { + if (!authHelper.IsAuthValid(fork, out var forkConfig, out var failureResult)) + return failureResult; + + baseUrlManager.ValidateBaseUrl(); + + if (string.IsNullOrWhiteSpace(request.Archive)) + return BadRequest("Archive is empty"); + + if (!ValidVersionRegex.IsMatch(request.Version)) + return BadRequest("Invalid version name"); + + if (VersionAlreadyExists(fork, request.Version)) + return Conflict("Version already exists"); + + logger.LogInformation("Starting one-shot publish for fork {Fork} version {Version}", fork, request.Version); + + var httpClient = httpFactory.CreateClient(); + + await using var tmpFile = CreateTempFile(); + + logger.LogDebug("Downloading publish archive {Archive} to temp file", request.Archive); + + await using var response = await httpClient.GetStreamAsync(request.Archive, cancel); + await response.CopyToAsync(tmpFile, cancel); + tmpFile.Seek(0, SeekOrigin.Begin); + + using var archive = new ZipArchive(tmpFile, ZipArchiveMode.Read); + + logger.LogDebug("Classifying archive entries..."); + + var artifacts = ClassifyEntries(forkConfig, archive.Entries, e => e.FullName); + var clientArtifact = artifacts.SingleOrNull(art => art.artifact.Type == ArtifactType.Client); + if (clientArtifact == null) + return BadRequest("Client zip is missing!"); + + var versionDir = buildDirectoryManager.GetBuildVersionPath(fork, request.Version); + + var metadata = new VersionMetadata { Version = request.Version, EngineVersion = request.EngineVersion }; + + try + { + Directory.CreateDirectory(versionDir); + + var diskFiles = ExtractZipToVersionDir(artifacts, versionDir); + var buildJson = GenerateBuildJson(diskFiles, clientArtifact.Value.artifact, metadata, fork); + InjectBuildJsonIntoServers(diskFiles, buildJson); + + using var tx = manifestDatabase.Connection.BeginTransaction(); + + AddVersionToDatabase( + clientArtifact.Value.artifact, + diskFiles, + fork, + metadata); + + tx.Commit(); + + await QueueIngestJobAsync(fork); + + logger.LogInformation("Publish succeeded!"); + + return NoContent(); + } + catch + { + // Clean up after ourselves if something goes wrong. + Directory.Delete(versionDir, true); + + throw; + } + } + + private Dictionary ExtractZipToVersionDir( + List<(ZipArchiveEntry entry, Artifact artifact)> artifacts, + string versionDir) + { + logger.LogDebug("Extracting artifacts to directory {Directory}", versionDir); + + var dict = new Dictionary(); + + foreach (var (entry, artifact) in artifacts) + { + if (!ValidFileRegex.IsMatch(entry.Name)) + { + logger.LogTrace("Skipping artifact {Name}: invalid name", entry.FullName); + continue; + } + + var filePath = Path.Combine(versionDir, entry.Name); + logger.LogTrace("Extracting artifact {Name}", entry.FullName); + + using var entryStream = entry.Open(); + using var file = System.IO.File.Create(filePath); + + entryStream.CopyTo(file); + dict.Add(artifact, filePath); + } + + return dict; + } +} diff --git a/Robust.Cdn/Controllers/ForkPublishController.cs b/Robust.Cdn/Controllers/ForkPublishController.cs index 4cb757d..17bd747 100644 --- a/Robust.Cdn/Controllers/ForkPublishController.cs +++ b/Robust.Cdn/Controllers/ForkPublishController.cs @@ -5,11 +5,11 @@ using System.Text.RegularExpressions; using Dapper; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using Quartz; using Robust.Cdn.Config; using Robust.Cdn.Helpers; using Robust.Cdn.Jobs; +using Robust.Cdn.Services; using SpaceWizards.Sodium; namespace Robust.Cdn.Controllers; @@ -33,7 +33,7 @@ namespace Robust.Cdn.Controllers; /// /// [ApiController] -[Route("/fork/{fork}")] +[Route("/fork/{fork}/publish")] public sealed partial class ForkPublishController( ForkAuthHelper authHelper, IHttpClientFactory httpFactory, @@ -41,81 +41,15 @@ public sealed partial class ForkPublishController( ISchedulerFactory schedulerFactory, BaseUrlManager baseUrlManager, BuildDirectoryManager buildDirectoryManager, + PublishManager publishManager, ILogger logger) : ControllerBase { - private static readonly Regex ValidVersionRegex = MyRegex(); + private static readonly Regex ValidVersionRegex = ValidVersionRegexBuilder(); + private static readonly Regex ValidFileRegex = ValidFileRegexBuilder(); public const string PublishFetchHttpClient = "PublishFetch"; - [HttpPost("publish")] - public async Task PostPublish( - string fork, - [FromBody] PublishRequest request, - CancellationToken cancel) - { - if (!authHelper.IsAuthValid(fork, out var forkConfig, out var failureResult)) - return failureResult; - - baseUrlManager.ValidateBaseUrl(); - - if (string.IsNullOrWhiteSpace(request.Archive)) - return BadRequest("Archive is empty"); - - if (!ValidVersionRegex.IsMatch(request.Version)) - return BadRequest("Invalid version name"); - - if (VersionAlreadyExists(fork, request.Version)) - return Conflict("Version already exists"); - - logger.LogInformation("Starting publish for fork {Fork} version {Version}", fork, request.Version); - - var httpClient = httpFactory.CreateClient(); - - await using var tmpFile = CreateTempFile(); - - logger.LogDebug("Downloading publish archive {Archive} to temp file", request.Archive); - - await using var response = await httpClient.GetStreamAsync(request.Archive, cancel); - await response.CopyToAsync(tmpFile, cancel); - tmpFile.Seek(0, SeekOrigin.Begin); - - using var archive = new ZipArchive(tmpFile, ZipArchiveMode.Read); - - logger.LogDebug("Classifying archive entries..."); - - var artifacts = ClassifyEntries(forkConfig, archive); - var clientArtifact = artifacts.SingleOrDefault(art => art.Type == ArtifactType.Client); - if (clientArtifact == null) - return BadRequest("Client zip is missing!"); - - var versionDir = buildDirectoryManager.GetBuildVersionPath(fork, request.Version); - - try - { - Directory.CreateDirectory(versionDir); - - var diskFiles = ExtractZipToVersionDir(artifacts, versionDir); - var buildJson = GenerateBuildJson(diskFiles, clientArtifact, request, fork); - InjectBuildJsonIntoServers(diskFiles, buildJson); - - AddVersionToDatabase(clientArtifact, diskFiles, fork, request); - - await QueueIngestJobAsync(fork); - - logger.LogInformation("Publish succeeded!"); - - return NoContent(); - } - catch - { - // Clean up after ourselves if something goes wrong. - Directory.Delete(versionDir, true); - - throw; - } - } - private bool VersionAlreadyExists(string fork, string version) { return manifestDatabase.Connection.QuerySingleOrDefault( @@ -132,40 +66,43 @@ SELECT 1 }); } - private List ClassifyEntries(ManifestForkOptions forkConfig, ZipArchive archive) + private List<(T key, Artifact artifact)> ClassifyEntries( + ManifestForkOptions forkConfig, + IEnumerable items, + Func getName) { - var list = new List(); + var list = new List<(T, Artifact)>(); - foreach (var entry in archive.Entries) + foreach (var item in items) { - var artifact = ClassifyEntry(forkConfig, entry); + var name = getName(item); + var artifact = ClassifyEntry(forkConfig, name); if (artifact == null) continue; logger.LogDebug( "Artifact entry {Name}: Type {Type} Platform {Platform}", - entry.FullName, + name, artifact.Type, artifact.Platform); - list.Add(artifact); + list.Add((item, artifact)); } return list; } - private static ZipArtifact? ClassifyEntry(ManifestForkOptions forkConfig, ZipArchiveEntry entry) + private static Artifact? ClassifyEntry(ManifestForkOptions forkConfig, string name) { - if (entry.FullName == $"{forkConfig.ClientZipName}.zip") - return new ZipArtifact { Entry = entry, Type = ArtifactType.Client }; + if (name == $"{forkConfig.ClientZipName}.zip") + return new Artifact { Type = ArtifactType.Client }; - if (entry.FullName.StartsWith(forkConfig.ServerZipName) && entry.FullName.EndsWith(".zip")) + if (name.StartsWith(forkConfig.ServerZipName) && name.EndsWith(".zip")) { - var rid = entry.FullName[forkConfig.ServerZipName.Length..^".zip".Length]; - return new ZipArtifact + var rid = name[forkConfig.ServerZipName.Length..^".zip".Length]; + return new Artifact { - Entry = entry, Platform = rid, Type = ArtifactType.Server }; @@ -174,31 +111,10 @@ private List ClassifyEntries(ManifestForkOptions forkConfig, ZipArc return null; } - private Dictionary ExtractZipToVersionDir(List artifacts, string versionDir) - { - logger.LogDebug("Extracting artifacts to directory {Directory}", versionDir); - - var dict = new Dictionary(); - - foreach (var artifact in artifacts) - { - var filePath = Path.Combine(versionDir, artifact.Entry.Name); - logger.LogTrace("Extracting artifact {Name}", artifact.Entry.FullName); - - using var entry = artifact.Entry.Open(); - using var file = System.IO.File.Create(filePath); - - entry.CopyTo(file); - dict.Add(artifact, filePath); - } - - return dict; - } - private MemoryStream GenerateBuildJson( - Dictionary diskFiles, - ZipArtifact clientArtifact, - PublishRequest request, + Dictionary diskFiles, + Artifact clientArtifact, + VersionMetadata metadata, string forkName) { logger.LogDebug("Generating build.json contents"); @@ -219,10 +135,10 @@ private MemoryStream GenerateBuildJson( var data = new Dictionary { { "download", baseUrlManager.MakeBuildInfoUrl($"fork/{{FORK_ID}}/version/{{FORK_VERSION}}/file/{diskFileName}") }, - { "version", request.Version }, + { "version", metadata.Version }, { "hash", hash }, { "fork_id", forkName }, - { "engine_version", request.EngineVersion }, + { "engine_version", metadata.EngineVersion }, { "manifest_url", baseUrlManager.MakeBuildInfoUrl("fork/{FORK_ID}/version/{FORK_VERSION}/manifest") }, { "manifest_download_url", baseUrlManager.MakeBuildInfoUrl("fork/{FORK_ID}/version/{FORK_VERSION}/download") }, { "manifest_hash", manifestHash } @@ -269,7 +185,7 @@ private static byte[] GetZipEntryBlake2B(ZipArchiveEntry entry) return HashHelper.HashBlake2B(stream); } - private void InjectBuildJsonIntoServers(Dictionary diskFiles, MemoryStream buildJson) + private void InjectBuildJsonIntoServers(Dictionary diskFiles, MemoryStream buildJson) { logger.LogDebug("Adding build.json to server builds"); @@ -298,15 +214,14 @@ private void InjectBuildJsonIntoServers(Dictionary diskFile } private void AddVersionToDatabase( - ZipArtifact clientArtifact, - Dictionary diskFiles, + Artifact clientArtifact, + Dictionary diskFiles, string fork, - PublishRequest request) + VersionMetadata metadata) { logger.LogDebug("Adding new version to database"); var dbCon = manifestDatabase.Connection; - using var tx = dbCon.BeginTransaction(); var forkId = dbCon.QuerySingle("SELECT Id FROM Fork WHERE Name = @Name", new { Name = fork }); @@ -319,11 +234,11 @@ RETURNING Id """, new { - Name = request.Version, + Name = metadata.Version, ForkId = forkId, ClientName = clientName, ClientSha256 = clientSha256, - request.EngineVersion, + metadata.EngineVersion, PublishTime = DateTime.UtcNow }); @@ -347,8 +262,6 @@ INSERT INTO ForkVersionServerBuild (ForkVersionId, Platform, FileName, Sha256, F FileSize = fileSize }); } - - tx.Commit(); } private static (string name, byte[] hash, long size) GetFileNameSha256Pair(string diskPath) @@ -384,13 +297,21 @@ public sealed class PublishRequest public required string Archive { get; set; } } + private sealed class VersionMetadata + { + public required string Version { get; init; } + public required string EngineVersion { get; set; } + } + // File cannot start with a dot but otherwise most shit is fair game. [GeneratedRegex(@"[a-zA-Z0-9\-_][a-zA-Z0-9\-_.]*")] - private static partial Regex MyRegex(); + private static partial Regex ValidVersionRegexBuilder(); + + [GeneratedRegex(@"[a-zA-Z0-9\-_][a-zA-Z0-9\-_.]*")] + private static partial Regex ValidFileRegexBuilder(); - private sealed class ZipArtifact + private sealed class Artifact { - public required ZipArchiveEntry Entry { get; set; } public ArtifactType Type { get; set; } public string? Platform { get; set; } } diff --git a/Robust.Cdn/Helpers/EnumerableHelpers.cs b/Robust.Cdn/Helpers/EnumerableHelpers.cs new file mode 100644 index 0000000..2be1536 --- /dev/null +++ b/Robust.Cdn/Helpers/EnumerableHelpers.cs @@ -0,0 +1,15 @@ +namespace Robust.Cdn.Helpers; + +public static class EnumerableHelpers +{ + public static T? SingleOrNull(this IEnumerable enumerable, Func predicate) where T : struct + { + foreach (var item in enumerable) + { + if (predicate(item)) + return item; + } + + return null; + } +} diff --git a/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs b/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs new file mode 100644 index 0000000..1183fa4 --- /dev/null +++ b/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs @@ -0,0 +1,61 @@ +using Dapper; +using Microsoft.Extensions.Options; +using Quartz; +using Robust.Cdn.Config; +using Robust.Cdn.Services; + +namespace Robust.Cdn.Jobs; + +/// +/// Job that periodically goes through and deletes old in-progress publishes that have "timed out". +/// +/// +/// This deletes old in-progress publishes that have taken too long since being initiated, +/// which likely indicates that the publish encountered an error and will never be completed. +/// +/// +public sealed class DeleteInProgressPublishesJob( + PublishManager publishManager, + ManifestDatabase manifestDatabase, + TimeProvider timeProvider, + IOptions options, + ILogger logger) : IJob +{ + public Task Execute(IJobExecutionContext context) + { + var opts = options.Value; + + logger.LogTrace("Checking for timed out in-progress publishes"); + + var db = manifestDatabase.Connection; + using var tx = db.BeginTransaction(); + + var deleteBefore = timeProvider.GetUtcNow() - TimeSpan.FromMinutes(opts.InProgressPublishTimeoutMinutes); + + var totalDeleted = 0; + + var inProgress = db.Query<(int, string, string, DateTime)>(""" + SELECT PublishInProgress.Id, Version, Fork.Name, StartTime + FROM PublishInProgress + INNER JOIN Fork ON Fork.Id = PublishInProgress.ForkId + """); + + foreach (var (_, name, forkName, startTime) in inProgress) + { + if (startTime >= deleteBefore) + continue; + + logger.LogInformation("Deleting timed out publish for fork {Fork} version {Version}", forkName, name); + + publishManager.AbortMultiPublish(forkName, name, tx, commit: false); + + totalDeleted += 1; + } + + tx.Commit(); + + logger.LogInformation("Deleted {TotalDeleted} timed out publishes", totalDeleted); + + return Task.CompletedTask; + } +} diff --git a/Robust.Cdn/ManifestMigrations/Script0003_AddInProgressPublish.sql b/Robust.Cdn/ManifestMigrations/Script0003_AddInProgressPublish.sql new file mode 100644 index 0000000..b994e28 --- /dev/null +++ b/Robust.Cdn/ManifestMigrations/Script0003_AddInProgressPublish.sql @@ -0,0 +1,19 @@ +-- A publish that is currently in-progress. +-- The publishing job should currently be in the process of uploading the individual files. +CREATE TABLE PublishInProgress( + Id INTEGER PRIMARY KEY, + + -- Version name that is being published. + Version TEXT NOT NULL, + + -- The fork being published to. + ForkId INTEGER NOT NULL REFERENCES Fork(Id) ON DELETE CASCADE, + + -- When the publish was started. + StartTime DATETIME NOT NULL, + + -- The engine version being published. + EngineVersion TEXT NULL, + + UNIQUE (ForkId, Version) +); diff --git a/Robust.Cdn/Program.cs b/Robust.Cdn/Program.cs index f94284b..53815b5 100644 --- a/Robust.Cdn/Program.cs +++ b/Robust.Cdn/Program.cs @@ -21,8 +21,10 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(services => services.GetRequiredService()); -builder.Services.AddTransient(); -builder.Services.AddTransient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddQuartz(q => { q.AddJob(j => j.WithIdentity(IngestNewCdnContentJob.Key).StoreDurably()); @@ -36,6 +38,8 @@ { schedule.RepeatForever().WithIntervalInHours(24); })); + q.ScheduleJob(t => + t.WithSimpleSchedule(s => s.RepeatForever().WithIntervalInHours(24))); }); builder.Services.AddQuartzHostedService(q => diff --git a/Robust.Cdn/Services/PublishManager.cs b/Robust.Cdn/Services/PublishManager.cs new file mode 100644 index 0000000..d417c31 --- /dev/null +++ b/Robust.Cdn/Services/PublishManager.cs @@ -0,0 +1,33 @@ +using System.Data.Common; +using Dapper; + +namespace Robust.Cdn.Services; + +public sealed class PublishManager( + ManifestDatabase manifestDatabase, + BuildDirectoryManager buildDirectoryManager, + ILogger logger) +{ + public void AbortMultiPublish(string fork, string version, DbTransaction tx, bool commit) + { + logger.LogDebug("Aborting publish for fork {Fork}, version {version}", fork, version); + + // Drop record from database. + var dbCon = manifestDatabase.Connection; + dbCon.Execute(""" + DELETE FROM PublishInProgress + WHERE Version = @Version + AND ForkId IN ( + SELECT Id FROM Fork WHERE Name = @Fork + ) + """, new { Version = version, Fork = fork }, tx); + + // Delete directory on disk. + var versionDir = buildDirectoryManager.GetBuildVersionPath(fork, version); + if (Directory.Exists(versionDir)) + Directory.Delete(versionDir, recursive: true); + + if (commit) + tx.Commit(); + } +}