mirror of https://github.com/Squidex/squidex.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
445 lines
14 KiB
445 lines
14 KiB
// ==========================================================================
|
|
// Squidex Headless CMS
|
|
// ==========================================================================
|
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|
// All rights reserved. Licensed under the MIT license.
|
|
// ==========================================================================
|
|
|
|
using System.Runtime.CompilerServices;
|
|
using Microsoft.Extensions.Logging;
|
|
using NodaTime;
|
|
using Squidex.Domain.Apps.Core.Apps;
|
|
using Squidex.Domain.Apps.Entities.Apps.Commands;
|
|
using Squidex.Domain.Apps.Entities.Backup.State;
|
|
using Squidex.Domain.Apps.Events;
|
|
using Squidex.Domain.Apps.Events.Apps;
|
|
using Squidex.Infrastructure;
|
|
using Squidex.Infrastructure.Commands;
|
|
using Squidex.Infrastructure.EventSourcing;
|
|
using Squidex.Infrastructure.States;
|
|
using Squidex.Infrastructure.Tasks;
|
|
using Squidex.Infrastructure.Translations;
|
|
using Squidex.Shared.Users;
|
|
|
|
namespace Squidex.Domain.Apps.Entities.Backup;
|
|
|
|
public sealed partial class RestoreProcessor
|
|
{
|
|
private readonly IBackupArchiveLocation backupArchiveLocation;
|
|
private readonly IBackupHandlerFactory backupHandlerFactory;
|
|
private readonly ICommandBus commandBus;
|
|
private readonly IEventFormatter eventFormatter;
|
|
private readonly IEventStore eventStore;
|
|
private readonly IEventStreamNames eventStreamNames;
|
|
private readonly IUserResolver userResolver;
|
|
private readonly ILogger<RestoreProcessor> log;
|
|
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1);
|
|
private readonly SimpleState<BackupRestoreState> state;
|
|
private Run? currentRun;
|
|
|
|
public IClock Clock { get; set; } = SystemClock.Instance;
|
|
|
|
public RestoreProcessor(
|
|
IBackupArchiveLocation backupArchiveLocation,
|
|
IBackupHandlerFactory backupHandlerFactory,
|
|
ICommandBus commandBus,
|
|
IEventFormatter eventFormatter,
|
|
IEventStore eventStore,
|
|
IEventStreamNames eventStreamNames,
|
|
IPersistenceFactory<BackupRestoreState> persistenceFactory,
|
|
IUserResolver userResolver,
|
|
ILogger<RestoreProcessor> log)
|
|
{
|
|
this.backupArchiveLocation = backupArchiveLocation;
|
|
this.backupHandlerFactory = backupHandlerFactory;
|
|
this.commandBus = commandBus;
|
|
this.eventFormatter = eventFormatter;
|
|
this.eventStore = eventStore;
|
|
this.eventStreamNames = eventStreamNames;
|
|
this.userResolver = userResolver;
|
|
this.log = log;
|
|
|
|
// Enable locking for the parallel operations that might write stuff.
|
|
state = new SimpleState<BackupRestoreState>(persistenceFactory, GetType(), "Default", true);
|
|
}
|
|
|
|
public async Task LoadAsync(
|
|
CancellationToken ct)
|
|
{
|
|
await state.LoadAsync(ct);
|
|
|
|
if (state.Value.Job?.Status == JobStatus.Started)
|
|
{
|
|
state.Value.Job.Status = JobStatus.Failed;
|
|
|
|
await state.WriteAsync(ct);
|
|
}
|
|
}
|
|
|
|
public Task RestoreAsync(Uri url, RefToken actor, string? newAppName,
|
|
CancellationToken ct)
|
|
{
|
|
Guard.NotNull(url);
|
|
Guard.NotNull(actor);
|
|
|
|
if (!string.IsNullOrWhiteSpace(newAppName))
|
|
{
|
|
Guard.ValidSlug(newAppName);
|
|
}
|
|
|
|
return scheduler.ScheduleAsync(async ct =>
|
|
{
|
|
if (currentRun != null)
|
|
{
|
|
throw new DomainException(T.Get("backups.restoreRunning"));
|
|
}
|
|
|
|
state.Value.Job?.EnsureCanStart();
|
|
|
|
// Set the current run first to indicate that we are running a rule at the moment.
|
|
var run = currentRun = new Run(ct)
|
|
{
|
|
Job = new RestoreJob
|
|
{
|
|
Id = DomainId.NewGuid(),
|
|
NewAppName = newAppName,
|
|
Actor = actor,
|
|
Started = Clock.GetCurrentInstant(),
|
|
Status = JobStatus.Started,
|
|
Url = url
|
|
},
|
|
Handlers = backupHandlerFactory.CreateMany()
|
|
};
|
|
|
|
state.Value.Job = run.Job;
|
|
try
|
|
{
|
|
await ProcessAsync(run, run.CancellationToken);
|
|
}
|
|
finally
|
|
{
|
|
// Unset the run to indicate that we are done.
|
|
currentRun.Dispose();
|
|
currentRun = null;
|
|
}
|
|
}, ct);
|
|
}
|
|
|
|
private async Task ProcessAsync(Run run,
|
|
CancellationToken ct)
|
|
{
|
|
using (Telemetry.Activities.StartActivity("RestoreBackup"))
|
|
{
|
|
try
|
|
{
|
|
await state.WriteAsync(run.CancellationToken);
|
|
|
|
await LogAsync(run, "Started. The restore process has the following steps:");
|
|
await LogAsync(run, " * Download backup");
|
|
await LogAsync(run, " * Restore events and attachments.");
|
|
await LogAsync(run, " * Restore all objects like app, schemas and contents");
|
|
await LogAsync(run, " * Complete the restore operation for all objects");
|
|
await LogFlushAsync(run);
|
|
|
|
log.LogInformation("Backup with job id {backupId} with from URL '{url}' started.", run.Job.Id, run.Job.Url);
|
|
|
|
run.Reader = await DownloadAsync(run, ct);
|
|
|
|
await run.Reader.CheckCompatibilityAsync();
|
|
|
|
using (Telemetry.Activities.StartActivity("ReadEvents"))
|
|
{
|
|
await ReadEventsAsync(run, ct);
|
|
}
|
|
|
|
if (run.Context == null)
|
|
{
|
|
throw new BackupRestoreException("Backup has no event.");
|
|
}
|
|
|
|
foreach (var handler in run.Handlers)
|
|
{
|
|
using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/RestoreAsync"))
|
|
{
|
|
await handler.RestoreAsync(run.Context, ct);
|
|
}
|
|
|
|
await LogAsync(run, $"Restored {handler.Name}");
|
|
}
|
|
|
|
foreach (var handler in run.Handlers)
|
|
{
|
|
using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/CompleteRestoreAsync"))
|
|
{
|
|
await handler.CompleteRestoreAsync(run.Context, run.Job.NewAppName!);
|
|
}
|
|
|
|
await LogAsync(run, $"Completed {handler.Name}");
|
|
}
|
|
|
|
// Add the current user to the app, so that the admin can see it and verify integrity.
|
|
await AssignContributorAsync(run);
|
|
|
|
await SetStatusAsync(run, JobStatus.Completed, "Completed, Yeah!");
|
|
|
|
log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", run.Job.Id, run.Job.Url);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Cleanup as soon as possible.
|
|
await CleanupAsync(run);
|
|
|
|
var message = "Failed with internal error.";
|
|
|
|
switch (ex)
|
|
{
|
|
case BackupRestoreException backupException:
|
|
message = backupException.Message;
|
|
break;
|
|
case FileNotFoundException fileNotFoundException:
|
|
message = fileNotFoundException.Message;
|
|
break;
|
|
}
|
|
|
|
await SetStatusAsync(run, JobStatus.Failed, message);
|
|
|
|
log.LogError(ex, "Backup with job id {backupId} from URL '{url}' failed.", run.Job.Id, run.Job.Url);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task AssignContributorAsync(Run run)
|
|
{
|
|
if (run.Job.Actor?.IsUser != true)
|
|
{
|
|
await LogAsync(run, "Current user not assigned because restore was triggered by client.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Add the current user to the app, so that the admin can see it and verify integrity.
|
|
await PublishAsync(run, new AssignContributor
|
|
{
|
|
ContributorId = run.Job.Actor.Identifier,
|
|
IgnoreActor = true,
|
|
IgnorePlans = true,
|
|
Role = Role.Owner
|
|
});
|
|
|
|
await LogAsync(run, "Assigned current user.");
|
|
}
|
|
catch (DomainException ex)
|
|
{
|
|
await LogAsync(run, $"Failed to assign contributor: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private Task PublishAsync(Run run, AppCommand command)
|
|
{
|
|
command.Actor = run.Job.Actor;
|
|
|
|
if (command is IAppCommand appCommand)
|
|
{
|
|
appCommand.AppId = run.Job.AppId;
|
|
}
|
|
|
|
return commandBus.PublishAsync(command, default);
|
|
}
|
|
|
|
private async Task CleanupAsync(Run run)
|
|
{
|
|
if (run.Job.AppId == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var handler in run.Handlers)
|
|
{
|
|
try
|
|
{
|
|
await handler.CleanupRestoreErrorAsync(run.Job.AppId.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log.LogError(ex, "Failed to clean up restore.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<IBackupReader> DownloadAsync(Run run,
|
|
CancellationToken ct)
|
|
{
|
|
using (Telemetry.Activities.StartActivity("Download"))
|
|
{
|
|
await LogAsync(run, "Downloading Backup");
|
|
|
|
var reader = await backupArchiveLocation.OpenReaderAsync(run.Job.Url, run.Job.Id, ct);
|
|
|
|
await LogAsync(run, "Downloaded Backup");
|
|
|
|
return reader;
|
|
}
|
|
}
|
|
|
|
private async Task ReadEventsAsync(Run run,
|
|
CancellationToken ct)
|
|
{
|
|
// Run batch first, because it is cheaper as it has less items.
|
|
var events = HandleEventsAsync(run, ct).Batch(100, ct).Buffered(2, ct);
|
|
|
|
var handled = 0;
|
|
|
|
await Parallel.ForEachAsync(events, new ParallelOptions
|
|
{
|
|
CancellationToken = ct,
|
|
// The event store cannot insert events in parallel.
|
|
MaxDegreeOfParallelism = 1,
|
|
},
|
|
async (batch, ct) =>
|
|
{
|
|
var commits =
|
|
batch.Select(item =>
|
|
EventCommit.Create(
|
|
item.Stream,
|
|
item.Offset,
|
|
item.Event,
|
|
eventFormatter));
|
|
|
|
await eventStore.AppendUnsafeAsync(commits, ct);
|
|
|
|
// Just in case we use parallel inserts later.
|
|
Interlocked.Increment(ref handled);
|
|
|
|
await LogAsync(run, $"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true);
|
|
});
|
|
}
|
|
|
|
private async IAsyncEnumerable<(string Stream, long Offset, Envelope<IEvent> Event)> HandleEventsAsync(Run run,
|
|
[EnumeratorCancellation] CancellationToken ct)
|
|
{
|
|
var @events = run.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, ct);
|
|
|
|
await foreach (var (stream, @event) in events.WithCancellation(ct))
|
|
{
|
|
var (newStream, handled) = await HandleEventAsync(run, stream, @event, ct);
|
|
|
|
if (handled)
|
|
{
|
|
var offset = run.StreamMapper.GetStreamOffset(newStream);
|
|
|
|
yield return (newStream, offset, @event);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<(string StreamName, bool Handled)> HandleEventAsync(Run run, string stream, Envelope<IEvent> @event,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (@event.Payload is AppCreated appCreated)
|
|
{
|
|
var previousAppId = appCreated.AppId.Id;
|
|
|
|
if (!string.IsNullOrWhiteSpace(run.Job.NewAppName))
|
|
{
|
|
appCreated.Name = run.Job.NewAppName;
|
|
|
|
run.Job.AppId = NamedId.Of(DomainId.NewGuid(), run.Job.NewAppName);
|
|
}
|
|
else
|
|
{
|
|
run.Job.AppId = NamedId.Of(DomainId.NewGuid(), appCreated.Name);
|
|
}
|
|
|
|
await CreateContextAsync(run, previousAppId, ct);
|
|
|
|
run.StreamMapper = new StreamMapper(run.Context);
|
|
}
|
|
|
|
if (@event.Payload is SquidexEvent { Actor: { } } squidexEvent)
|
|
{
|
|
if (run.Context.UserMapping.TryMap(squidexEvent.Actor, out var newUser))
|
|
{
|
|
squidexEvent.Actor = newUser;
|
|
}
|
|
}
|
|
|
|
if (@event.Payload is AppEvent appEvent)
|
|
{
|
|
appEvent.AppId = run.Job.AppId;
|
|
}
|
|
|
|
var (newStream, id) = run.StreamMapper.Map(stream);
|
|
|
|
@event.SetAggregateId(id);
|
|
@event.SetRestored();
|
|
|
|
foreach (var handler in run.Handlers)
|
|
{
|
|
if (!await handler.RestoreEventAsync(@event, run.Context, ct))
|
|
{
|
|
return (newStream, false);
|
|
}
|
|
}
|
|
|
|
return (newStream, true);
|
|
}
|
|
|
|
private async Task CreateContextAsync(Run run, DomainId previousAppId,
|
|
CancellationToken ct)
|
|
{
|
|
var userMapping = new UserMapping(run.Job.Actor);
|
|
|
|
using (Telemetry.Activities.StartActivity("CreateUsers"))
|
|
{
|
|
await LogAsync(run, "Creating Users");
|
|
|
|
await userMapping.RestoreAsync(run.Reader, userResolver, ct);
|
|
|
|
await LogAsync(run, "Created Users");
|
|
}
|
|
|
|
run.Context = new RestoreContext(run.Job.AppId.Id, userMapping, run.Reader, previousAppId);
|
|
}
|
|
|
|
private Task SetStatusAsync(Run run, JobStatus status, string message)
|
|
{
|
|
var now = Clock.GetCurrentInstant();
|
|
|
|
run.Job.Status = status;
|
|
|
|
if (status == JobStatus.Failed || status == JobStatus.Completed)
|
|
{
|
|
run.Job.Stopped = now;
|
|
}
|
|
else if (status == JobStatus.Started)
|
|
{
|
|
run.Job.Started = now;
|
|
}
|
|
|
|
run.Job.Log.Add($"{now}: {message}");
|
|
|
|
return state.WriteAsync(default);
|
|
}
|
|
|
|
private Task LogAsync(Run run, string message, bool replace = false)
|
|
{
|
|
var now = Clock.GetCurrentInstant();
|
|
|
|
if (replace && run.Job.Log.Count > 0)
|
|
{
|
|
run.Job.Log[^1] = $"{now}: {message}";
|
|
}
|
|
else
|
|
{
|
|
run.Job.Log.Add($"{now}: {message}");
|
|
}
|
|
|
|
return state.WriteAsync(100, run.CancellationToken);
|
|
}
|
|
|
|
private Task LogFlushAsync(Run run)
|
|
{
|
|
return state.WriteAsync(run.CancellationToken);
|
|
}
|
|
}
|
|
|