mirror of https://github.com/Squidex/squidex.git
75 changed files with 3693 additions and 1157 deletions
@ -0,0 +1,242 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using NodaTime; |
||||
|
using Orleans; |
||||
|
using Orleans.Concurrency; |
||||
|
using Squidex.Domain.Apps.Backup.State; |
||||
|
using Squidex.Domain.Apps.Events.Assets; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Backup |
||||
|
{ |
||||
|
[Reentrant] |
||||
|
public sealed class BackupGrain : Grain, IBackupGrain |
||||
|
{ |
||||
|
private const int MaxBackups = 10; |
||||
|
private readonly IClock clock; |
||||
|
private readonly IAssetStore assetStore; |
||||
|
private readonly IEventDataFormatter eventDataFormatter; |
||||
|
private readonly ISemanticLog log; |
||||
|
private readonly IEventStore eventStore; |
||||
|
private readonly IBackupArchiveLocation backupArchiveLocation; |
||||
|
private readonly IStore<Guid> store; |
||||
|
private CancellationTokenSource currentTask; |
||||
|
private BackupStateJob currentJob; |
||||
|
private Guid appId; |
||||
|
private BackupState state; |
||||
|
private IPersistence<BackupState> persistence; |
||||
|
|
||||
|
public BackupGrain( |
||||
|
IAssetStore assetStore, |
||||
|
IBackupArchiveLocation backupArchiveLocation, |
||||
|
IClock clock, |
||||
|
IEventStore eventStore, |
||||
|
IEventDataFormatter eventDataFormatter, |
||||
|
ISemanticLog log, |
||||
|
IStore<Guid> store) |
||||
|
{ |
||||
|
Guard.NotNull(assetStore, nameof(assetStore)); |
||||
|
Guard.NotNull(backupArchiveLocation, nameof(backupArchiveLocation)); |
||||
|
Guard.NotNull(clock, nameof(clock)); |
||||
|
Guard.NotNull(eventStore, nameof(eventStore)); |
||||
|
Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); |
||||
|
Guard.NotNull(store, nameof(store)); |
||||
|
Guard.NotNull(log, nameof(log)); |
||||
|
|
||||
|
this.assetStore = assetStore; |
||||
|
this.backupArchiveLocation = backupArchiveLocation; |
||||
|
this.clock = clock; |
||||
|
this.eventStore = eventStore; |
||||
|
this.eventDataFormatter = eventDataFormatter; |
||||
|
this.store = store; |
||||
|
this.log = log; |
||||
|
} |
||||
|
|
||||
|
public override Task OnActivateAsync() |
||||
|
{ |
||||
|
return OnActivateAsync(this.GetPrimaryKey()); |
||||
|
} |
||||
|
|
||||
|
public async Task OnActivateAsync(Guid appId) |
||||
|
{ |
||||
|
this.appId = appId; |
||||
|
|
||||
|
persistence = store.WithSnapshots<BackupState, Guid>(GetType(), appId, s => state = s); |
||||
|
|
||||
|
await ReadAsync(); |
||||
|
await CleanupAsync(); |
||||
|
} |
||||
|
|
||||
|
private async Task ReadAsync() |
||||
|
{ |
||||
|
await persistence.ReadAsync(); |
||||
|
} |
||||
|
|
||||
|
private async Task WriteAsync() |
||||
|
{ |
||||
|
await persistence.WriteSnapshotAsync(state); |
||||
|
} |
||||
|
|
||||
|
private async Task CleanupAsync() |
||||
|
{ |
||||
|
var hasUpdated = false; |
||||
|
|
||||
|
foreach (var job in state.Jobs) |
||||
|
{ |
||||
|
if (!job.Stopped.HasValue) |
||||
|
{ |
||||
|
await CleanupAsync(job); |
||||
|
|
||||
|
job.Stopped = clock.GetCurrentInstant(); |
||||
|
job.Failed = true; |
||||
|
|
||||
|
hasUpdated = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (hasUpdated) |
||||
|
{ |
||||
|
await WriteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task CleanupAsync(BackupStateJob job) |
||||
|
{ |
||||
|
await backupArchiveLocation.DeleteArchiveAsync(job.Id); |
||||
|
} |
||||
|
|
||||
|
public async Task StartNewAsync() |
||||
|
{ |
||||
|
if (currentTask != null) |
||||
|
{ |
||||
|
throw new DomainException("Another backup process is already running."); |
||||
|
} |
||||
|
|
||||
|
if (state.Jobs.Count >= MaxBackups) |
||||
|
{ |
||||
|
throw new DomainException($"You cannot have more than {MaxBackups} backups."); |
||||
|
} |
||||
|
|
||||
|
var job = new BackupStateJob { Id = Guid.NewGuid(), Started = clock.GetCurrentInstant() }; |
||||
|
|
||||
|
currentTask = new CancellationTokenSource(); |
||||
|
currentJob = job; |
||||
|
|
||||
|
state.Jobs.Add(job); |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using (var stream = await backupArchiveLocation.OpenStreamAsync(job.Id)) |
||||
|
{ |
||||
|
using (var writer = new EventStreamWriter(stream)) |
||||
|
{ |
||||
|
await eventStore.QueryAsync(async @event => |
||||
|
{ |
||||
|
var eventData = @event.Data; |
||||
|
|
||||
|
if (eventData.Type == nameof(AssetCreated) || |
||||
|
eventData.Type == nameof(AssetUpdated)) |
||||
|
{ |
||||
|
var parsedEvent = eventDataFormatter.Parse(eventData); |
||||
|
|
||||
|
var assetVersion = 0L; |
||||
|
var assetId = Guid.Empty; |
||||
|
|
||||
|
if (parsedEvent.Payload is AssetCreated assetCreated) |
||||
|
{ |
||||
|
assetId = assetCreated.AssetId; |
||||
|
assetVersion = assetCreated.FileVersion; |
||||
|
} |
||||
|
|
||||
|
if (parsedEvent.Payload is AssetUpdated asetUpdated) |
||||
|
{ |
||||
|
assetId = asetUpdated.AssetId; |
||||
|
assetVersion = asetUpdated.FileVersion; |
||||
|
} |
||||
|
|
||||
|
await writer.WriteEventAsync(eventData, async attachmentStream => |
||||
|
{ |
||||
|
await assetStore.DownloadAsync(assetId.ToString(), assetVersion, null, attachmentStream); |
||||
|
}); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
await writer.WriteEventAsync(eventData); |
||||
|
} |
||||
|
}, "AppId", appId, null, currentTask.Token); |
||||
|
} |
||||
|
|
||||
|
stream.Position = 0; |
||||
|
|
||||
|
currentTask.Token.ThrowIfCancellationRequested(); |
||||
|
|
||||
|
await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream); |
||||
|
|
||||
|
currentTask.Token.ThrowIfCancellationRequested(); |
||||
|
} |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
job.Failed = true; |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
job.Stopped = clock.GetCurrentInstant(); |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
|
||||
|
currentTask = null; |
||||
|
currentJob = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task DeleteAsync(Guid id) |
||||
|
{ |
||||
|
var job = state.Jobs.FirstOrDefault(x => x.Id == id); |
||||
|
|
||||
|
if (job == null) |
||||
|
{ |
||||
|
throw new DomainObjectNotFoundException(id.ToString(), typeof(IBackupJob)); |
||||
|
} |
||||
|
|
||||
|
if (currentJob == job) |
||||
|
{ |
||||
|
currentTask?.Cancel(); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
state.Jobs.Remove(job); |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
await CleanupAsync(job); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Task<J<List<IBackupJob>>> GetStateAsync() |
||||
|
{ |
||||
|
return Task.FromResult(new J<List<IBackupJob>>(state.Jobs.OfType<IBackupJob>().ToList())); |
||||
|
} |
||||
|
|
||||
|
private bool IsRunning() |
||||
|
{ |
||||
|
return state.Jobs.Any(x => !x.Stopped.HasValue); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.IO.Compression; |
||||
|
using System.Threading.Tasks; |
||||
|
using Newtonsoft.Json; |
||||
|
using Newtonsoft.Json.Linq; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Backup |
||||
|
{ |
||||
|
public sealed class EventStreamWriter : DisposableObjectBase |
||||
|
{ |
||||
|
private const int MaxItemsPerFolder = 1000; |
||||
|
private readonly StreamWriter streamWriter; |
||||
|
private readonly ZipArchive archive; |
||||
|
private int writtenEvents; |
||||
|
private int writtenAttachments; |
||||
|
|
||||
|
public EventStreamWriter(Stream stream) |
||||
|
{ |
||||
|
archive = new ZipArchive(stream, ZipArchiveMode.Update, true); |
||||
|
} |
||||
|
|
||||
|
public async Task WriteEventAsync(EventData eventData, Func<Stream, Task> attachment = null) |
||||
|
{ |
||||
|
var eventObject = |
||||
|
new JObject( |
||||
|
new JProperty("type", eventData.Type), |
||||
|
new JProperty("payload", eventData.Payload), |
||||
|
new JProperty("metadata", eventData.Metadata)); |
||||
|
|
||||
|
var eventFolder = writtenEvents / MaxItemsPerFolder; |
||||
|
var eventPath = $"events/{eventFolder}/{writtenEvents}.json"; |
||||
|
var eventEntry = archive.GetEntry(eventPath) ?? archive.CreateEntry(eventPath); |
||||
|
|
||||
|
using (var stream = eventEntry.Open()) |
||||
|
{ |
||||
|
using (var textWriter = new StreamWriter(stream)) |
||||
|
{ |
||||
|
using (var jsonWriter = new JsonTextWriter(textWriter)) |
||||
|
{ |
||||
|
await eventObject.WriteToAsync(jsonWriter); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
writtenEvents++; |
||||
|
|
||||
|
if (attachment != null) |
||||
|
{ |
||||
|
var attachmentFolder = writtenAttachments / MaxItemsPerFolder; |
||||
|
var attachmentPath = $"attachments/{attachmentFolder}/{writtenEvents}.blob"; |
||||
|
var attachmentEntry = archive.GetEntry(attachmentPath) ?? archive.CreateEntry(attachmentPath); |
||||
|
|
||||
|
using (var stream = eventEntry.Open()) |
||||
|
{ |
||||
|
await attachment(stream); |
||||
|
} |
||||
|
|
||||
|
writtenAttachments++; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected override void DisposeObject(bool disposing) |
||||
|
{ |
||||
|
if (disposing) |
||||
|
{ |
||||
|
archive.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Backup |
||||
|
{ |
||||
|
public interface IBackupArchiveLocation |
||||
|
{ |
||||
|
Task<Stream> OpenStreamAsync(Guid backupId); |
||||
|
|
||||
|
Task DeleteArchiveAsync(Guid backupId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Orleans; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Backup |
||||
|
{ |
||||
|
public interface IBackupGrain : IGrainWithGuidKey |
||||
|
{ |
||||
|
Task StartNewAsync(); |
||||
|
|
||||
|
Task DeleteAsync(Guid id); |
||||
|
|
||||
|
Task<J<List<IBackupJob>>> GetStateAsync(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using NodaTime; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Backup |
||||
|
{ |
||||
|
public interface IBackupJob |
||||
|
{ |
||||
|
Guid Id { get; } |
||||
|
|
||||
|
Instant Started { get; } |
||||
|
|
||||
|
Instant? Stopped { get; } |
||||
|
|
||||
|
bool Failed { get; } |
||||
|
|
||||
|
string DownloadPath { get; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>netstandard2.0</TargetFramework> |
||||
|
</PropertyGroup> |
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" /> |
||||
|
<ProjectReference Include="..\Squidex.Domain.Apps.Events\Squidex.Domain.Apps.Events.csproj" /> |
||||
|
</ItemGroup> |
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Microsoft.Orleans.OrleansCodeGenerator.Build" Version="2.0.0-rc2" /> |
||||
|
<PackageReference Include="NodaTime" Version="2.2.4" /> |
||||
|
<PackageReference Include="RefactoringEssentials" Version="5.6.0" /> |
||||
|
<PackageReference Include="StyleCop.Analyzers" Version="1.0.2" /> |
||||
|
<PackageReference Include="System.ValueTuple" Version="4.4.0" /> |
||||
|
</ItemGroup> |
||||
|
<PropertyGroup> |
||||
|
<CodeAnalysisRuleSet>..\..\Squidex.ruleset</CodeAnalysisRuleSet> |
||||
|
</PropertyGroup> |
||||
|
<ItemGroup> |
||||
|
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" /> |
||||
|
</ItemGroup> |
||||
|
</Project> |
||||
@ -0,0 +1,18 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using Newtonsoft.Json; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Backup.State |
||||
|
{ |
||||
|
public sealed class BackupState |
||||
|
{ |
||||
|
[JsonProperty] |
||||
|
public List<BackupStateJob> Jobs { get; set; } = new List<BackupStateJob>(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Newtonsoft.Json; |
||||
|
using NodaTime; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Backup.State |
||||
|
{ |
||||
|
public sealed class BackupStateJob : IBackupJob |
||||
|
{ |
||||
|
[JsonProperty] |
||||
|
public Guid Id { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Instant Started { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Instant? Stopped { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public string DownloadPath { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public bool Failed { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,286 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using NodaTime; |
||||
|
using Orleans; |
||||
|
using Orleans.Concurrency; |
||||
|
using Squidex.Domain.Apps.Entities.Backup.State; |
||||
|
using Squidex.Domain.Apps.Events; |
||||
|
using Squidex.Domain.Apps.Events.Assets; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
[Reentrant] |
||||
|
public sealed class BackupGrain : Grain, IBackupGrain |
||||
|
{ |
||||
|
private const int MaxBackups = 10; |
||||
|
private static readonly Duration UpdateDuration = Duration.FromSeconds(1); |
||||
|
private readonly IClock clock; |
||||
|
private readonly IAssetStore assetStore; |
||||
|
private readonly IEventDataFormatter eventDataFormatter; |
||||
|
private readonly ISemanticLog log; |
||||
|
private readonly IEventStore eventStore; |
||||
|
private readonly IBackupArchiveLocation backupArchiveLocation; |
||||
|
private readonly IStore<Guid> store; |
||||
|
private CancellationTokenSource currentTask; |
||||
|
private BackupStateJob currentJob; |
||||
|
private Guid appId; |
||||
|
private BackupState state = new BackupState(); |
||||
|
private IPersistence<BackupState> persistence; |
||||
|
|
||||
|
public BackupGrain( |
||||
|
IAssetStore assetStore, |
||||
|
IBackupArchiveLocation backupArchiveLocation, |
||||
|
IClock clock, |
||||
|
IEventStore eventStore, |
||||
|
IEventDataFormatter eventDataFormatter, |
||||
|
ISemanticLog log, |
||||
|
IStore<Guid> store) |
||||
|
{ |
||||
|
Guard.NotNull(assetStore, nameof(assetStore)); |
||||
|
Guard.NotNull(backupArchiveLocation, nameof(backupArchiveLocation)); |
||||
|
Guard.NotNull(clock, nameof(clock)); |
||||
|
Guard.NotNull(eventStore, nameof(eventStore)); |
||||
|
Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); |
||||
|
Guard.NotNull(store, nameof(store)); |
||||
|
Guard.NotNull(log, nameof(log)); |
||||
|
|
||||
|
this.assetStore = assetStore; |
||||
|
this.backupArchiveLocation = backupArchiveLocation; |
||||
|
this.clock = clock; |
||||
|
this.eventStore = eventStore; |
||||
|
this.eventDataFormatter = eventDataFormatter; |
||||
|
this.store = store; |
||||
|
this.log = log; |
||||
|
} |
||||
|
|
||||
|
public override Task OnActivateAsync() |
||||
|
{ |
||||
|
return OnActivateAsync(this.GetPrimaryKey()); |
||||
|
} |
||||
|
|
||||
|
public async Task OnActivateAsync(Guid appId) |
||||
|
{ |
||||
|
this.appId = appId; |
||||
|
|
||||
|
persistence = store.WithSnapshots<BackupState, Guid>(GetType(), appId, s => state = s); |
||||
|
|
||||
|
await ReadAsync(); |
||||
|
await CleanupAsync(); |
||||
|
} |
||||
|
|
||||
|
private async Task ReadAsync() |
||||
|
{ |
||||
|
await persistence.ReadAsync(); |
||||
|
} |
||||
|
|
||||
|
private async Task WriteAsync() |
||||
|
{ |
||||
|
await persistence.WriteSnapshotAsync(state); |
||||
|
} |
||||
|
|
||||
|
private async Task CleanupAsync() |
||||
|
{ |
||||
|
foreach (var job in state.Jobs) |
||||
|
{ |
||||
|
if (!job.Stopped.HasValue) |
||||
|
{ |
||||
|
job.Stopped = clock.GetCurrentInstant(); |
||||
|
|
||||
|
await CleanupArchiveAsync(job); |
||||
|
await CleanupBackupAsync(job); |
||||
|
|
||||
|
job.IsFailed = true; |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task CleanupBackupAsync(BackupStateJob job) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await assetStore.DeleteAsync(job.Id.ToString(), 0, null); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "deleteBackup") |
||||
|
.WriteProperty("status", "failed") |
||||
|
.WriteProperty("backupId", job.Id.ToString())); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task CleanupArchiveAsync(BackupStateJob job) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await backupArchiveLocation.DeleteArchiveAsync(job.Id); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "deleteArchive") |
||||
|
.WriteProperty("status", "failed") |
||||
|
.WriteProperty("backupId", job.Id.ToString())); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task RunAsync() |
||||
|
{ |
||||
|
if (currentTask != null) |
||||
|
{ |
||||
|
throw new DomainException("Another backup process is already running."); |
||||
|
} |
||||
|
|
||||
|
if (state.Jobs.Count >= MaxBackups) |
||||
|
{ |
||||
|
throw new DomainException($"You cannot have more than {MaxBackups} backups."); |
||||
|
} |
||||
|
|
||||
|
var job = new BackupStateJob { Id = Guid.NewGuid(), Started = clock.GetCurrentInstant() }; |
||||
|
|
||||
|
currentTask = new CancellationTokenSource(); |
||||
|
currentJob = job; |
||||
|
|
||||
|
var lastTimestamp = job.Started; |
||||
|
|
||||
|
state.Jobs.Insert(0, job); |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using (var stream = await backupArchiveLocation.OpenStreamAsync(job.Id)) |
||||
|
{ |
||||
|
using (var writer = new EventStreamWriter(stream)) |
||||
|
{ |
||||
|
await eventStore.QueryAsync(async @event => |
||||
|
{ |
||||
|
var eventData = @event.Data; |
||||
|
|
||||
|
if (eventData.Type == "AssetCreatedEvent" || |
||||
|
eventData.Type == "AssetUpdatedEvent") |
||||
|
{ |
||||
|
var parsedEvent = eventDataFormatter.Parse(eventData); |
||||
|
|
||||
|
var assetVersion = 0L; |
||||
|
var assetId = Guid.Empty; |
||||
|
|
||||
|
if (parsedEvent.Payload is AssetCreated assetCreated) |
||||
|
{ |
||||
|
assetId = assetCreated.AssetId; |
||||
|
assetVersion = assetCreated.FileVersion; |
||||
|
} |
||||
|
|
||||
|
if (parsedEvent.Payload is AssetUpdated asetUpdated) |
||||
|
{ |
||||
|
assetId = asetUpdated.AssetId; |
||||
|
assetVersion = asetUpdated.FileVersion; |
||||
|
} |
||||
|
|
||||
|
await writer.WriteEventAsync(eventData, async attachmentStream => |
||||
|
{ |
||||
|
await assetStore.DownloadAsync(assetId.ToString(), assetVersion, null, attachmentStream); |
||||
|
}); |
||||
|
|
||||
|
job.HandledAssets++; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
await writer.WriteEventAsync(eventData); |
||||
|
} |
||||
|
|
||||
|
job.HandledEvents++; |
||||
|
|
||||
|
var now = clock.GetCurrentInstant(); |
||||
|
|
||||
|
if ((now - lastTimestamp) >= UpdateDuration) |
||||
|
{ |
||||
|
lastTimestamp = now; |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
} |
||||
|
}, SquidexHeaders.AppId, appId.ToString(), null, currentTask.Token); |
||||
|
} |
||||
|
|
||||
|
stream.Position = 0; |
||||
|
|
||||
|
currentTask.Token.ThrowIfCancellationRequested(); |
||||
|
|
||||
|
await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream, currentTask.Token); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "makeBackup") |
||||
|
.WriteProperty("status", "failed") |
||||
|
.WriteProperty("backupId", job.Id.ToString())); |
||||
|
|
||||
|
job.IsFailed = true; |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
await CleanupArchiveAsync(job); |
||||
|
|
||||
|
job.Stopped = clock.GetCurrentInstant(); |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
|
||||
|
currentTask = null; |
||||
|
currentJob = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task DeleteAsync(Guid id) |
||||
|
{ |
||||
|
var job = state.Jobs.FirstOrDefault(x => x.Id == id); |
||||
|
|
||||
|
if (job == null) |
||||
|
{ |
||||
|
throw new DomainObjectNotFoundException(id.ToString(), typeof(IBackupJob)); |
||||
|
} |
||||
|
|
||||
|
if (currentJob == job) |
||||
|
{ |
||||
|
currentTask?.Cancel(); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
await CleanupArchiveAsync(job); |
||||
|
await CleanupBackupAsync(job); |
||||
|
|
||||
|
state.Jobs.Remove(job); |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Task<J<List<IBackupJob>>> GetStateAsync() |
||||
|
{ |
||||
|
return Task.FromResult(new J<List<IBackupJob>>(state.Jobs.OfType<IBackupJob>().ToList())); |
||||
|
} |
||||
|
|
||||
|
private bool IsRunning() |
||||
|
{ |
||||
|
return state.Jobs.Any(x => !x.Stopped.HasValue); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,79 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.IO.Compression; |
||||
|
using System.Threading.Tasks; |
||||
|
using Newtonsoft.Json; |
||||
|
using Newtonsoft.Json.Linq; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public sealed class EventStreamWriter : DisposableObjectBase |
||||
|
{ |
||||
|
private const int MaxItemsPerFolder = 1000; |
||||
|
private readonly ZipArchive archive; |
||||
|
private int writtenEvents; |
||||
|
private int writtenAttachments; |
||||
|
|
||||
|
public EventStreamWriter(Stream stream) |
||||
|
{ |
||||
|
archive = new ZipArchive(stream, ZipArchiveMode.Update, true); |
||||
|
} |
||||
|
|
||||
|
public async Task WriteEventAsync(EventData eventData, Func<Stream, Task> attachment = null) |
||||
|
{ |
||||
|
var eventObject = |
||||
|
new JObject( |
||||
|
new JProperty("type", eventData.Type), |
||||
|
new JProperty("payload", eventData.Payload), |
||||
|
new JProperty("metadata", eventData.Metadata)); |
||||
|
|
||||
|
var eventFolder = writtenEvents / MaxItemsPerFolder; |
||||
|
var eventPath = $"events/{eventFolder}/{writtenEvents}.json"; |
||||
|
var eventEntry = archive.GetEntry(eventPath) ?? archive.CreateEntry(eventPath); |
||||
|
|
||||
|
using (var stream = eventEntry.Open()) |
||||
|
{ |
||||
|
using (var textWriter = new StreamWriter(stream)) |
||||
|
{ |
||||
|
using (var jsonWriter = new JsonTextWriter(textWriter)) |
||||
|
{ |
||||
|
await eventObject.WriteToAsync(jsonWriter); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
writtenEvents++; |
||||
|
|
||||
|
if (attachment != null) |
||||
|
{ |
||||
|
var attachmentFolder = writtenAttachments / MaxItemsPerFolder; |
||||
|
var attachmentPath = $"attachments/{attachmentFolder}/{writtenEvents}.blob"; |
||||
|
var attachmentEntry = archive.GetEntry(attachmentPath) ?? archive.CreateEntry(attachmentPath); |
||||
|
|
||||
|
using (var stream = eventEntry.Open()) |
||||
|
{ |
||||
|
await attachment(stream); |
||||
|
} |
||||
|
|
||||
|
writtenAttachments++; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected override void DisposeObject(bool disposing) |
||||
|
{ |
||||
|
if (disposing) |
||||
|
{ |
||||
|
archive.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public interface IBackupArchiveLocation |
||||
|
{ |
||||
|
Task<Stream> OpenStreamAsync(Guid backupId); |
||||
|
|
||||
|
Task DeleteArchiveAsync(Guid backupId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Orleans; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public interface IBackupGrain : IGrainWithGuidKey |
||||
|
{ |
||||
|
Task RunAsync(); |
||||
|
|
||||
|
Task DeleteAsync(Guid id); |
||||
|
|
||||
|
Task<J<List<IBackupJob>>> GetStateAsync(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using NodaTime; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public interface IBackupJob |
||||
|
{ |
||||
|
Guid Id { get; } |
||||
|
|
||||
|
Instant Started { get; } |
||||
|
|
||||
|
Instant? Stopped { get; } |
||||
|
|
||||
|
int HandledEvents { get; } |
||||
|
|
||||
|
int HandledAssets { get; } |
||||
|
|
||||
|
bool IsFailed { get; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using Newtonsoft.Json; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup.State |
||||
|
{ |
||||
|
public sealed class BackupState |
||||
|
{ |
||||
|
[JsonProperty] |
||||
|
public List<BackupStateJob> Jobs { get; } = new List<BackupStateJob>(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Newtonsoft.Json; |
||||
|
using NodaTime; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup.State |
||||
|
{ |
||||
|
public sealed class BackupStateJob : IBackupJob |
||||
|
{ |
||||
|
[JsonProperty] |
||||
|
public Guid Id { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Instant Started { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Instant? Stopped { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public int HandledEvents { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public int HandledAssets { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public bool IsFailed { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public sealed class TempFolderBackupArchiveLocation : IBackupArchiveLocation |
||||
|
{ |
||||
|
public Task<Stream> OpenStreamAsync(Guid backupId) |
||||
|
{ |
||||
|
var tempFile = GetTempFile(backupId); |
||||
|
|
||||
|
return Task.FromResult<Stream>(new FileStream(tempFile, FileMode.Create, FileAccess.ReadWrite)); |
||||
|
} |
||||
|
|
||||
|
public Task DeleteArchiveAsync(Guid backupId) |
||||
|
{ |
||||
|
var tempFile = GetTempFile(backupId); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
File.Delete(tempFile); |
||||
|
} |
||||
|
catch (IOException) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
private static string GetTempFile(Guid backupId) |
||||
|
{ |
||||
|
return Path.Combine(Path.GetTempPath(), backupId.ToString()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using NodaTime; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents |
||||
|
{ |
||||
|
public interface IContentScheduleItem |
||||
|
{ |
||||
|
Status ScheduledTo { get; } |
||||
|
|
||||
|
Instant ScheduledAt { get; } |
||||
|
|
||||
|
RefToken ScheduledBy { get; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Newtonsoft.Json; |
||||
|
using NodaTime; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.State |
||||
|
{ |
||||
|
public sealed class ContentStateScheduleItem : IContentScheduleItem |
||||
|
{ |
||||
|
[JsonProperty] |
||||
|
public Instant ScheduledAt { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public RefToken ScheduledBy { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Status ScheduledTo { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Squidex.Domain.Apps.Events; |
||||
|
using Squidex.Infrastructure.Commands; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities |
||||
|
{ |
||||
|
public abstract class SquidexDomainObjectGrain<T> : DomainObjectGrain<T> where T : IDomainState, new() |
||||
|
{ |
||||
|
protected SquidexDomainObjectGrain(IStore<Guid> store) |
||||
|
: base(store) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public override void RaiseEvent(Envelope<IEvent> @event) |
||||
|
{ |
||||
|
if (@event.Payload is AppEvent appEvent) |
||||
|
{ |
||||
|
@event.SetAppId(appEvent.AppId.Id); |
||||
|
} |
||||
|
|
||||
|
base.RaiseEvent(@event); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,54 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Microsoft.AspNetCore.Mvc; |
||||
|
using NSwag.Annotations; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Squidex.Infrastructure.Commands; |
||||
|
using Squidex.Pipeline; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Backups |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Manages backups for app.
|
||||
|
/// </summary>
|
||||
|
[ApiExceptionFilter] |
||||
|
[AppApi] |
||||
|
[SwaggerTag(nameof(Backups))] |
||||
|
public class BackupContentController : ApiController |
||||
|
{ |
||||
|
private readonly IAssetStore assetStore; |
||||
|
|
||||
|
public BackupContentController(ICommandBus commandBus, IAssetStore assetStore) |
||||
|
: base(commandBus) |
||||
|
{ |
||||
|
this.assetStore = assetStore; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Get the backup content.
|
||||
|
/// </summary>
|
||||
|
/// <param name="app">The name of the app.</param>
|
||||
|
/// <param name="id">The id of the asset.</param>
|
||||
|
/// <returns>
|
||||
|
/// 200 => Backup found and content returned.
|
||||
|
/// 404 => Backup or app not found.
|
||||
|
/// </returns>
|
||||
|
[HttpGet] |
||||
|
[Route("apps/{app}/backups/{id}")] |
||||
|
[ProducesResponseType(200)] |
||||
|
[ApiCosts(0)] |
||||
|
public IActionResult GetBackupContent(string app, Guid id) |
||||
|
{ |
||||
|
return new FileCallbackResult("application/zip", "Backup.zip", bodyStream => |
||||
|
{ |
||||
|
return assetStore.DownloadAsync(id.ToString(), 0, null, bodyStream); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.AspNetCore.Mvc; |
||||
|
using NSwag.Annotations; |
||||
|
using Orleans; |
||||
|
using Squidex.Areas.Api.Controllers.Backups.Models; |
||||
|
using Squidex.Domain.Apps.Entities.Backup; |
||||
|
using Squidex.Infrastructure.Commands; |
||||
|
using Squidex.Infrastructure.Reflection; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
using Squidex.Pipeline; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Backups |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Manages backups for app.
|
||||
|
/// </summary>
|
||||
|
[ApiAuthorize] |
||||
|
[ApiExceptionFilter] |
||||
|
[AppApi] |
||||
|
[MustBeAppOwner] |
||||
|
[SwaggerTag(nameof(Backups))] |
||||
|
public class BackupsController : ApiController |
||||
|
{ |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
|
||||
|
public BackupsController(ICommandBus commandBus, IGrainFactory grainFactory) |
||||
|
: base(commandBus) |
||||
|
{ |
||||
|
this.grainFactory = grainFactory; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Get all backup jobs.
|
||||
|
/// </summary>
|
||||
|
/// <param name="app">The name of the app.</param>
|
||||
|
/// <returns>
|
||||
|
/// 200 => Backups returned.
|
||||
|
/// 404 => App not found.
|
||||
|
/// </returns>
|
||||
|
[HttpGet] |
||||
|
[Route("apps/{app}/backups/")] |
||||
|
[ProducesResponseType(typeof(List<BackupJobDto>), 200)] |
||||
|
[ApiCosts(0)] |
||||
|
public async Task<IActionResult> GetJobs(string app) |
||||
|
{ |
||||
|
var backupGrain = grainFactory.GetGrain<IBackupGrain>(App.Id); |
||||
|
|
||||
|
var jobs = await backupGrain.GetStateAsync(); |
||||
|
|
||||
|
return Ok(jobs.Value.Select(x => SimpleMapper.Map(x, new BackupJobDto())).ToList()); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Start a new backup.
|
||||
|
/// </summary>
|
||||
|
/// <param name="app">The name of the app.</param>
|
||||
|
/// <returns>
|
||||
|
/// 204 => Backup started.
|
||||
|
/// 404 => App not found.
|
||||
|
/// </returns>
|
||||
|
[HttpPost] |
||||
|
[Route("apps/{app}/backups/")] |
||||
|
[ProducesResponseType(typeof(List<BackupJobDto>), 200)] |
||||
|
[ApiCosts(0)] |
||||
|
public IActionResult PostBackup(string app) |
||||
|
{ |
||||
|
var backupGrain = grainFactory.GetGrain<IBackupGrain>(App.Id); |
||||
|
|
||||
|
backupGrain.RunAsync().Forget(); |
||||
|
|
||||
|
return NoContent(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Delete a backup.
|
||||
|
/// </summary>
|
||||
|
/// <param name="app">The name of the app.</param>
|
||||
|
/// <param name="id">The id of the backup to delete.</param>
|
||||
|
/// <returns>
|
||||
|
/// 204 => Backup started.
|
||||
|
/// 404 => Backup or app not found.
|
||||
|
/// </returns>
|
||||
|
[HttpDelete] |
||||
|
[Route("apps/{app}/backups/{id}")] |
||||
|
[ProducesResponseType(typeof(List<BackupJobDto>), 200)] |
||||
|
[ApiCosts(0)] |
||||
|
public async Task<IActionResult> DeleteBackup(string app, Guid id) |
||||
|
{ |
||||
|
var backupGrain = grainFactory.GetGrain<IBackupGrain>(App.Id); |
||||
|
|
||||
|
await backupGrain.DeleteAsync(id); |
||||
|
|
||||
|
return NoContent(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using NodaTime; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Backups.Models |
||||
|
{ |
||||
|
public sealed class BackupJobDto |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The id of the backup job.
|
||||
|
/// </summary>
|
||||
|
public Guid Id { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The time when the job has been started.
|
||||
|
/// </summary>
|
||||
|
public Instant Started { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The time when the job has been stopped.
|
||||
|
/// </summary>
|
||||
|
public Instant? Stopped { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The number of handled events.
|
||||
|
/// </summary>
|
||||
|
public int HandledEvents { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The number of handled assets.
|
||||
|
/// </summary>
|
||||
|
public int HandledAssets { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Indicates if the job has failed.
|
||||
|
/// </summary>
|
||||
|
public bool IsFailed { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,82 @@ |
|||||
|
<sqx-title message="{app} | Backups | Settings" parameter1="app" [value1]="ctx.appName"></sqx-title> |
||||
|
|
||||
|
<sqx-panel desiredWidth="50rem"> |
||||
|
<div class="panel-header"> |
||||
|
<div class="panel-title-row"> |
||||
|
<div class="float-right"> |
||||
|
<button class="btn btn-success" [disabled]="backups.length === 10" (click)="startBackup()"> |
||||
|
Start Backup |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<h3 class="panel-title">Backups</h3> |
||||
|
</div> |
||||
|
|
||||
|
<a class="panel-close" sqxParentLink> |
||||
|
<i class="icon-close"></i> |
||||
|
</a> |
||||
|
</div> |
||||
|
|
||||
|
<div class="panel-main"> |
||||
|
<div class="panel-content"> |
||||
|
<div class="panel-alert panel-alert-danger" *ngIf="backups.length >= 10"> |
||||
|
Your have reached the maximum number of backups: 10. |
||||
|
</div> |
||||
|
|
||||
|
<div class="table-items-row" *ngFor="let backup of backups; trackBy: trackBy"> |
||||
|
<div class="row no-gutter"> |
||||
|
<div class="col col-auto"> |
||||
|
<div *ngIf="!backup.stopped" class="backup-status backup-status-pending spin"> |
||||
|
<i class="icon-hour-glass"></i> |
||||
|
</div> |
||||
|
<div *ngIf="backup.stopped && backup.isFailed" class="backup-status backup-status-failed"> |
||||
|
<i class="icon-exclamation"></i> |
||||
|
</div> |
||||
|
<div *ngIf="backup.stopped && !backup.isFailed" class="backup-status backup-status-success"> |
||||
|
<i class="icon-checkmark"></i> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col col-auto"> |
||||
|
<div> |
||||
|
Started: |
||||
|
</div> |
||||
|
<div> |
||||
|
Duration: |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col col-auto"> |
||||
|
<div> |
||||
|
{{backup.started.toISOString()}} |
||||
|
</div> |
||||
|
<div *ngIf="backup.stopped"> |
||||
|
{{getDuration(backup) | sqxDuration}} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col"> |
||||
|
<div> |
||||
|
<span title="Archived events"> |
||||
|
Events: <strong class="backup-progress">{{backup.handledEvents | sqxKNumber}}</strong> |
||||
|
</span>, |
||||
|
<span title="Archived assets"> |
||||
|
Assets: <strong class="backup-progress">{{backup.handledAssets | sqxKNumber}}</strong> |
||||
|
</span> |
||||
|
</div> |
||||
|
<div *ngIf="backup.stopped && !backup.isFailed"> |
||||
|
Download: |
||||
|
|
||||
|
<a href="{{getDownloadUrl(backup)}}" target="_blank"> |
||||
|
Ready |
||||
|
</a> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col col-auto"> |
||||
|
<button type="button" class="btn btn-link btn-danger" (sqxConfirmClick)="deleteBackup(backup)" confirmTitle="Delete backup" |
||||
|
confirmText="Do you really want to delete the backup?"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</sqx-panel> |
||||
@ -0,0 +1,29 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
|
|
||||
|
$cicle-size: 2.8rem; |
||||
|
|
||||
|
.backup-status { |
||||
|
& { |
||||
|
@include circle($cicle-size); |
||||
|
line-height: $cicle-size + .1rem; |
||||
|
text-align: center; |
||||
|
font-size: .4 * $cicle-size; |
||||
|
font-weight: normal; |
||||
|
background: $color-border; |
||||
|
color: $color-dark-foreground; |
||||
|
vertical-align: middle; |
||||
|
} |
||||
|
|
||||
|
&-pending { |
||||
|
color: inherit; |
||||
|
} |
||||
|
|
||||
|
&-failed { |
||||
|
background: $color-theme-error; |
||||
|
} |
||||
|
|
||||
|
&-success { |
||||
|
background: $color-theme-green; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Component, OnInit, OnDestroy } from '@angular/core'; |
||||
|
import { Observable, Subscription } from 'rxjs'; |
||||
|
|
||||
|
import { |
||||
|
ApiUrlConfig, |
||||
|
AppContext, |
||||
|
BackupDto, |
||||
|
BackupsService, |
||||
|
Duration, |
||||
|
ImmutableArray |
||||
|
} from 'shared'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-backups-page', |
||||
|
styleUrls: ['./backups-page.component.scss'], |
||||
|
templateUrl: './backups-page.component.html', |
||||
|
providers: [ |
||||
|
AppContext |
||||
|
] |
||||
|
}) |
||||
|
export class BackupsPageComponent implements OnInit, OnDestroy { |
||||
|
private loadSubscription: Subscription; |
||||
|
|
||||
|
public backups = ImmutableArray.empty<BackupDto>(); |
||||
|
|
||||
|
constructor( |
||||
|
public readonly ctx: AppContext, |
||||
|
private readonly apiUrl: ApiUrlConfig, |
||||
|
private readonly backupsService: BackupsService |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.loadSubscription.unsubscribe(); |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.loadSubscription = |
||||
|
Observable.timer(0, 2000) |
||||
|
.switchMap(t => this.backupsService.getBackups(this.ctx.appName)) |
||||
|
.subscribe(dtos => { |
||||
|
this.backups = ImmutableArray.of(dtos); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public startBackup() { |
||||
|
this.backupsService.postBackup(this.ctx.appName) |
||||
|
.subscribe(() => { |
||||
|
this.ctx.notifyInfo('Backup started, it can take several minutes to complete.'); |
||||
|
}, error => { |
||||
|
this.ctx.notifyError(error); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public deleteBackup(backup: BackupDto) { |
||||
|
this.backupsService.deleteBackup(this.ctx.appName, backup.id) |
||||
|
.subscribe(() => { |
||||
|
this.ctx.notifyInfo('Backup is about to be deleted.'); |
||||
|
}, error => { |
||||
|
this.ctx.notifyError(error); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public getDownloadUrl(backup: BackupDto) { |
||||
|
return this.apiUrl.buildUrl(`api/apps/${this.ctx.appName}/backups/${backup.id}`); |
||||
|
} |
||||
|
|
||||
|
public getDuration(backup: BackupDto) { |
||||
|
return Duration.create(backup.started, backup.stopped!); |
||||
|
} |
||||
|
|
||||
|
public trackBy(index: number, item: BackupDto) { |
||||
|
return item.id; |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,101 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; |
||||
|
import { inject, TestBed } from '@angular/core/testing'; |
||||
|
|
||||
|
import { |
||||
|
AnalyticsService, |
||||
|
ApiUrlConfig, |
||||
|
BackupDto, |
||||
|
BackupsService, |
||||
|
DateTime |
||||
|
} from './../'; |
||||
|
|
||||
|
describe('BackupsService', () => { |
||||
|
beforeEach(() => { |
||||
|
TestBed.configureTestingModule({ |
||||
|
imports: [ |
||||
|
HttpClientTestingModule |
||||
|
], |
||||
|
providers: [ |
||||
|
BackupsService, |
||||
|
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, |
||||
|
{ provide: AnalyticsService, useValue: new AnalyticsService() } |
||||
|
] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { |
||||
|
httpMock.verify(); |
||||
|
})); |
||||
|
|
||||
|
it('should make get request to get backups', |
||||
|
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { |
||||
|
|
||||
|
let backups: BackupDto[] | null = null; |
||||
|
|
||||
|
backupsService.getBackups('my-app').subscribe(result => { |
||||
|
backups = result; |
||||
|
}); |
||||
|
|
||||
|
const req = httpMock.expectOne('http://service/p/api/apps/my-app/backups'); |
||||
|
|
||||
|
expect(req.request.method).toEqual('GET'); |
||||
|
expect(req.request.headers.get('If-Match')).toBeNull(); |
||||
|
|
||||
|
req.flush([ |
||||
|
{ |
||||
|
id: '1', |
||||
|
started: '2017-02-03', |
||||
|
stopped: '2017-02-04', |
||||
|
handledEvents: 13, |
||||
|
handledAssets: 17, |
||||
|
isFailed: false |
||||
|
}, |
||||
|
{ |
||||
|
id: '2', |
||||
|
started: '2018-02-03', |
||||
|
stopped: null, |
||||
|
handledEvents: 23, |
||||
|
handledAssets: 27, |
||||
|
isFailed: true |
||||
|
} |
||||
|
]); |
||||
|
|
||||
|
expect(backups).toEqual([ |
||||
|
new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, false), |
||||
|
new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, true) |
||||
|
]); |
||||
|
})); |
||||
|
|
||||
|
it('should make post request to start backup', |
||||
|
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { |
||||
|
|
||||
|
backupsService.postBackup('my-app').subscribe(); |
||||
|
|
||||
|
const req = httpMock.expectOne('http://service/p/api/apps/my-app/backups'); |
||||
|
|
||||
|
expect(req.request.method).toEqual('POST'); |
||||
|
expect(req.request.headers.get('If-Match')).toBeNull(); |
||||
|
|
||||
|
req.flush({}); |
||||
|
})); |
||||
|
|
||||
|
it('should make delete request to remove language', |
||||
|
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => { |
||||
|
|
||||
|
backupsService.deleteBackup('my-app', '1').subscribe(); |
||||
|
|
||||
|
const req = httpMock.expectOne('http://service/p/api/apps/my-app/backups/1'); |
||||
|
|
||||
|
expect(req.request.method).toEqual('DELETE'); |
||||
|
expect(req.request.headers.get('If-Match')).toBeNull(); |
||||
|
|
||||
|
req.flush({}); |
||||
|
})); |
||||
|
}); |
||||
@ -0,0 +1,80 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { HttpClient } from '@angular/common/http'; |
||||
|
import { Injectable } from '@angular/core'; |
||||
|
import { Observable } from 'rxjs'; |
||||
|
|
||||
|
import 'framework/angular/http-extensions'; |
||||
|
|
||||
|
import { |
||||
|
AnalyticsService, |
||||
|
ApiUrlConfig, |
||||
|
DateTime |
||||
|
} from 'framework'; |
||||
|
|
||||
|
export class BackupDto { |
||||
|
constructor( |
||||
|
public readonly id: string, |
||||
|
public readonly started: DateTime, |
||||
|
public readonly stopped: DateTime | null, |
||||
|
public readonly handledEvents: number, |
||||
|
public readonly handledAssets: number, |
||||
|
public readonly isFailed: boolean |
||||
|
) { |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class BackupsService { |
||||
|
constructor( |
||||
|
private readonly http: HttpClient, |
||||
|
private readonly apiUrl: ApiUrlConfig, |
||||
|
private readonly analytics: AnalyticsService |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public getBackups(appName: string): Observable<BackupDto[]> { |
||||
|
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`); |
||||
|
|
||||
|
return this.http.get(url) |
||||
|
.map(response => { |
||||
|
const items: any[] = <any>response; |
||||
|
|
||||
|
return items.map(item => { |
||||
|
return new BackupDto( |
||||
|
item.id, |
||||
|
DateTime.parseISO_UTC(item.started), |
||||
|
item.stopped ? DateTime.parseISO_UTC(item.stopped) : null, |
||||
|
item.handledEvents, |
||||
|
item.handledAssets, |
||||
|
item.isFailed); |
||||
|
}); |
||||
|
}) |
||||
|
.pretifyError('Failed to load backups.'); |
||||
|
} |
||||
|
|
||||
|
public postBackup(appName: string): Observable<any> { |
||||
|
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`); |
||||
|
|
||||
|
return this.http.post(url, {}) |
||||
|
.do(() => { |
||||
|
this.analytics.trackEvent('Backup', 'Started', appName); |
||||
|
}) |
||||
|
.pretifyError('Failed to start backup.'); |
||||
|
} |
||||
|
|
||||
|
public deleteBackup(appName: string, id: string): Observable<any> { |
||||
|
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups/${id}`); |
||||
|
|
||||
|
return this.http.delete(url) |
||||
|
.do(() => { |
||||
|
this.analytics.trackEvent('Backup', 'Deleted', appName); |
||||
|
}) |
||||
|
.pretifyError('Failed to delete backup.'); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
@ -0,0 +1,224 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Security.Claims; |
||||
|
using System.Threading.Tasks; |
||||
|
using FakeItEasy; |
||||
|
using Microsoft.OData.UriParser; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Core.Scripting; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.Edm; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.Repositories; |
||||
|
using Squidex.Domain.Apps.Entities.Schemas; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Security; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents |
||||
|
{ |
||||
|
public class ContentQueryServiceTests |
||||
|
{ |
||||
|
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>(); |
||||
|
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); |
||||
|
private readonly ISchemaEntity schema = A.Fake<ISchemaEntity>(); |
||||
|
private readonly IContentEntity content = A.Fake<IContentEntity>(); |
||||
|
private readonly IAppEntity app = A.Fake<IAppEntity>(); |
||||
|
private readonly IAppProvider appProvider = A.Fake<IAppProvider>(); |
||||
|
private readonly Guid appId = Guid.NewGuid(); |
||||
|
private readonly Guid schemaId = Guid.NewGuid(); |
||||
|
private readonly Guid contentId = Guid.NewGuid(); |
||||
|
private readonly string appName = "my-app"; |
||||
|
private readonly NamedContentData contentData = new NamedContentData(); |
||||
|
private readonly NamedContentData contentTransformed = new NamedContentData(); |
||||
|
private readonly ClaimsPrincipal user; |
||||
|
private readonly ClaimsIdentity identity = new ClaimsIdentity(); |
||||
|
private readonly EdmModelBuilder modelBuilder = A.Fake<EdmModelBuilder>(); |
||||
|
private readonly ContentQueryService sut; |
||||
|
|
||||
|
public ContentQueryServiceTests() |
||||
|
{ |
||||
|
user = new ClaimsPrincipal(identity); |
||||
|
|
||||
|
A.CallTo(() => app.Id).Returns(appId); |
||||
|
A.CallTo(() => app.Name).Returns(appName); |
||||
|
|
||||
|
A.CallTo(() => content.Id).Returns(contentId); |
||||
|
A.CallTo(() => content.Data).Returns(contentData); |
||||
|
A.CallTo(() => content.Status).Returns(Status.Published); |
||||
|
|
||||
|
sut = new ContentQueryService(contentRepository, appProvider, scriptEngine, modelBuilder); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_schema_from_id_if_string_is_guid() |
||||
|
{ |
||||
|
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) |
||||
|
.Returns(schema); |
||||
|
|
||||
|
var result = await sut.FindSchemaAsync(app, schemaId.ToString()); |
||||
|
|
||||
|
Assert.Equal(schema, result); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_schema_from_name_if_string_not_guid() |
||||
|
{ |
||||
|
A.CallTo(() => appProvider.GetSchemaAsync(appId, "my-schema")) |
||||
|
.Returns(schema); |
||||
|
|
||||
|
var result = await sut.FindSchemaAsync(app, "my-schema"); |
||||
|
|
||||
|
Assert.Equal(schema, result); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_throw_if_schema_not_found() |
||||
|
{ |
||||
|
A.CallTo(() => appProvider.GetSchemaAsync(appId, "my-schema")) |
||||
|
.Returns((ISchemaEntity)null); |
||||
|
|
||||
|
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.FindSchemaAsync(app, "my-schema")); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_content_from_repository_and_transform() |
||||
|
{ |
||||
|
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) |
||||
|
.Returns(schema); |
||||
|
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId)) |
||||
|
.Returns(content); |
||||
|
|
||||
|
A.CallTo(() => schema.ScriptQuery) |
||||
|
.Returns("<script-query>"); |
||||
|
|
||||
|
A.CallTo(() => scriptEngine.Transform(A<ScriptContext>.That.Matches(x => x.User == user && x.ContentId == contentId && ReferenceEquals(x.Data, contentData)), "<query-script>")) |
||||
|
.Returns(contentTransformed); |
||||
|
|
||||
|
var result = await sut.FindContentAsync(app, schemaId.ToString(), user, contentId); |
||||
|
|
||||
|
Assert.Equal(schema, result.Schema); |
||||
|
|
||||
|
Assert.Equal(contentTransformed, result.Content.Data); |
||||
|
Assert.Equal(content.Id, result.Content.Id); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_throw_if_content_to_find_does_not_exist() |
||||
|
{ |
||||
|
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) |
||||
|
.Returns(schema); |
||||
|
|
||||
|
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId)) |
||||
|
.Returns((IContentEntity)null); |
||||
|
|
||||
|
await Assert.ThrowsAsync<DomainObjectNotFoundException>(async () => await sut.FindContentAsync(app, schemaId.ToString(), user, contentId)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_contents_with_ids_from_repository_and_transform() |
||||
|
{ |
||||
|
await TestManyIdRequest(true, false, new HashSet<Guid> { Guid.NewGuid() }, Status.Draft, Status.Published); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_non_archived_contents_from_repository_and_transform() |
||||
|
{ |
||||
|
await TestManyRequest(true, false, Status.Draft, Status.Published); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_archived_contents_from_repository_and_transform() |
||||
|
{ |
||||
|
await TestManyRequest(true, true, Status.Archived); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_draft_contents_from_repository_and_transform() |
||||
|
{ |
||||
|
await TestManyRequest(false, false, Status.Published); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_draft_contents_from_repository_and_transform_when_requesting_archive_as_non_frontend() |
||||
|
{ |
||||
|
await TestManyRequest(false, true, Status.Published); |
||||
|
} |
||||
|
|
||||
|
private async Task TestManyRequest(bool isFrontend, bool archive, params Status[] status) |
||||
|
{ |
||||
|
SetupClaims(isFrontend); |
||||
|
|
||||
|
SetupFakeWithOdataQuery(status); |
||||
|
SetupFakeWithScripting(); |
||||
|
|
||||
|
var result = await sut.QueryAsync(app, schemaId.ToString(), user, archive, string.Empty); |
||||
|
|
||||
|
Assert.Equal(schema, result.Schema); |
||||
|
|
||||
|
Assert.Equal(contentData, result.Contents[0].Data); |
||||
|
Assert.Equal(content.Id, result.Contents[0].Id); |
||||
|
|
||||
|
Assert.Equal(123, result.Contents.Total); |
||||
|
} |
||||
|
|
||||
|
private async Task TestManyIdRequest(bool isFrontend, bool archive, HashSet<Guid> ids, params Status[] status) |
||||
|
{ |
||||
|
SetupClaims(isFrontend); |
||||
|
|
||||
|
SetupFakeWithIdQuery(status, ids); |
||||
|
SetupFakeWithScripting(); |
||||
|
|
||||
|
var result = await sut.QueryAsync(app, schemaId.ToString(), user, archive, ids); |
||||
|
|
||||
|
Assert.Equal(schema, result.Schema); |
||||
|
|
||||
|
Assert.Equal(contentData, result.Contents[0].Data); |
||||
|
Assert.Equal(content.Id, result.Contents[0].Id); |
||||
|
|
||||
|
Assert.Equal(123, result.Contents.Total); |
||||
|
} |
||||
|
|
||||
|
private void SetupClaims(bool isFrontend) |
||||
|
{ |
||||
|
if (isFrontend) |
||||
|
{ |
||||
|
identity.AddClaim(new Claim(OpenIdClaims.ClientId, "squidex-frontend")); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void SetupFakeWithIdQuery(Status[] status, HashSet<Guid> ids) |
||||
|
{ |
||||
|
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) |
||||
|
.Returns(schema); |
||||
|
|
||||
|
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), ids)) |
||||
|
.Returns(ResultList.Create(Enumerable.Repeat(content, 1), 123)); |
||||
|
} |
||||
|
|
||||
|
private void SetupFakeWithOdataQuery(Status[] status) |
||||
|
{ |
||||
|
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) |
||||
|
.Returns(schema); |
||||
|
|
||||
|
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<ODataUriParser>.Ignored)) |
||||
|
.Returns(ResultList.Create(Enumerable.Repeat(content, 1), 123)); |
||||
|
} |
||||
|
|
||||
|
private void SetupFakeWithScripting() |
||||
|
{ |
||||
|
A.CallTo(() => schema.ScriptQuery) |
||||
|
.Returns("<script-query>"); |
||||
|
|
||||
|
A.CallTo(() => scriptEngine.Transform(A<ScriptContext>.That.Matches(x => x.User == user && x.ContentId == contentId && ReferenceEquals(x.Data, contentData)), "<query-script>")) |
||||
|
.Returns(contentTransformed); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using MongoDB.Bson; |
||||
|
using MongoDB.Driver; |
||||
|
using Newtonsoft.Json.Linq; |
||||
|
using Squidex.Domain.Apps.Events; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.Migrations; |
||||
|
|
||||
|
namespace Migrate_01.Migrations |
||||
|
{ |
||||
|
public sealed class ConvertEventStoreAppId : IMigration |
||||
|
{ |
||||
|
private readonly IEventStore eventStore; |
||||
|
|
||||
|
public ConvertEventStoreAppId(IEventStore eventStore) |
||||
|
{ |
||||
|
this.eventStore = eventStore; |
||||
|
} |
||||
|
|
||||
|
public async Task UpdateAsync() |
||||
|
{ |
||||
|
if (eventStore is MongoEventStore mongoEventStore) |
||||
|
{ |
||||
|
var collection = mongoEventStore.RawCollection; |
||||
|
|
||||
|
var filterer = Builders<BsonDocument>.Filter; |
||||
|
var updater = Builders<BsonDocument>.Update; |
||||
|
|
||||
|
var writesBatches = new List<WriteModel<BsonDocument>>(); |
||||
|
|
||||
|
async Task WriteAsync(WriteModel<BsonDocument> model, bool force) |
||||
|
{ |
||||
|
if (model != null) |
||||
|
{ |
||||
|
writesBatches.Add(model); |
||||
|
} |
||||
|
|
||||
|
if (writesBatches.Count == 1000 || (force && writesBatches.Count > 0)) |
||||
|
{ |
||||
|
await collection.BulkWriteAsync(writesBatches); |
||||
|
|
||||
|
writesBatches.Clear(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
await collection.Find(new BsonDocument()).ForEachAsync(async commit => |
||||
|
{ |
||||
|
UpdateDefinition<BsonDocument> update = null; |
||||
|
|
||||
|
var index = 0; |
||||
|
|
||||
|
foreach (BsonDocument @event in commit["Events"].AsBsonArray) |
||||
|
{ |
||||
|
var data = JObject.Parse(@event["Payload"].AsString); |
||||
|
|
||||
|
if (data.TryGetValue("appId", out var appIdValue)) |
||||
|
{ |
||||
|
var appId = NamedId<Guid>.Parse(appIdValue.ToString(), Guid.TryParse).Id.ToString(); |
||||
|
|
||||
|
var eventUpdate = updater.Set($"Events.{index}.Metadata.{SquidexHeaders.AppId}", appId); |
||||
|
|
||||
|
if (update != null) |
||||
|
{ |
||||
|
update = updater.Combine(update, eventUpdate); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
update = eventUpdate; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
index++; |
||||
|
} |
||||
|
|
||||
|
if (update != null) |
||||
|
{ |
||||
|
var write = new UpdateOneModel<BsonDocument>(filterer.Eq("_id", commit["_id"].AsString), update); |
||||
|
|
||||
|
await WriteAsync(write, false); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
await WriteAsync(null, true); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue