diff --git a/src/apps/src/Eryph-zero/Program.cs b/src/apps/src/Eryph-zero/Program.cs index 26531bba2..0a9ad6747 100644 --- a/src/apps/src/Eryph-zero/Program.cs +++ b/src/apps/src/Eryph-zero/Program.cs @@ -939,10 +939,25 @@ private static Task ImportAgentSettings( RunAsAdmin( from configString in ReadInput(inFile) from hostSettings in HostSettingsProvider.getHostSettings() - from _ in VmHostAgentConfigurationUpdate.updateConfig( + from _1 in AnsiConsole.writeLine("Updating agent settings...") + from _2 in VmHostAgentConfigurationUpdate.updateConfig( configString, Path.Combine(ZeroConfig.GetVmHostAgentConfigPath(), "agentsettings.yml"), hostSettings) + // Check that the sync service is available (and hence the VM host agent is running). + // When the VM host agent is not running, we do not need to sync the configuration. + from canConnect in use( + Eff(() => new CancellationTokenSource(TimeSpan.FromSeconds(2))), + cts => default(SimpleConsoleRuntime).SyncClientEff + .Bind(sc => sc.CheckRunning(cts.Token)) + .IfFail(_ => false)) + from _3 in canConnect + ? from _1 in AnsiConsole.writeLine( + "eryph is running. Syncing agent settings...") + from _2 in default(SimpleConsoleRuntime).SyncClientEff.Bind( + sc => sc.SendSyncCommand("SYNC_AGENT_SETTINGS", CancellationToken.None)) + select unit + : SuccessAff(unit) select unit, SimpleConsoleRuntime.New()); diff --git a/src/apps/src/Eryph-zero/SimpleConsoleRuntime.cs b/src/apps/src/Eryph-zero/SimpleConsoleRuntime.cs index 005c5d9c7..ae7ff41fb 100644 --- a/src/apps/src/Eryph-zero/SimpleConsoleRuntime.cs +++ b/src/apps/src/Eryph-zero/SimpleConsoleRuntime.cs @@ -7,6 +7,7 @@ using Eryph.AnsiConsole.Sys; using Eryph.Core; using Eryph.Core.Sys; +using Eryph.Modules.VmHostAgent; using Eryph.Modules.VmHostAgent.Inventory; using Eryph.VmManagement.Inventory; using Eryph.VmManagement.Sys; @@ -38,11 +39,13 @@ public static SimpleConsoleRuntime New() => new(new SimpleConsoleRuntimeEnv( new ZeroApplicationInfoProvider(), new HardwareIdProvider(new NullLoggerFactory()), + new SyncClient(), new CancellationTokenSource())); public SimpleConsoleRuntime LocalCancel => new(new SimpleConsoleRuntimeEnv( _env.ApplicationInfoProvider, _env.HardwareIdProvider, + _env.SyncClient, new CancellationTokenSource())); public CancellationToken CancellationToken => _env.CancellationTokenSource.Token; @@ -65,12 +68,16 @@ public static SimpleConsoleRuntime New() => public Eff HardwareIdProviderEff => Eff(rt => rt._env.HardwareIdProvider); + public Eff SyncClientEff => + Eff(rt => rt._env.SyncClient); + public Eff WmiEff => SuccessEff(LiveWmiIO.Default); } public class SimpleConsoleRuntimeEnv( IApplicationInfoProvider applicationInfoProvider, IHardwareIdProvider hardwareIdProvider, + ISyncClient syncClient, CancellationTokenSource cancellationTokenSource) { public IApplicationInfoProvider ApplicationInfoProvider { get; } = applicationInfoProvider; @@ -78,4 +85,6 @@ public class SimpleConsoleRuntimeEnv( public CancellationTokenSource CancellationTokenSource { get; } = cancellationTokenSource; public IHardwareIdProvider HardwareIdProvider { get; } = hardwareIdProvider; + + public ISyncClient SyncClient { get; } = syncClient; } diff --git a/src/core/src/Eryph.Core/VmAgent/VmHostAgentDataStoreConfiguration.cs b/src/core/src/Eryph.Core/VmAgent/VmHostAgentDataStoreConfiguration.cs index 333234bf6..40750127b 100644 --- a/src/core/src/Eryph.Core/VmAgent/VmHostAgentDataStoreConfiguration.cs +++ b/src/core/src/Eryph.Core/VmAgent/VmHostAgentDataStoreConfiguration.cs @@ -5,5 +5,7 @@ public class VmHostAgentDataStoreConfiguration public string Name { get; init; } = string.Empty; public string Path { get; init; } = string.Empty; + + public bool WatchFileSystem { get; init; } = true; } } diff --git a/src/core/src/Eryph.Core/VmAgent/VmHostAgentDefaultsConfiguration.cs b/src/core/src/Eryph.Core/VmAgent/VmHostAgentDefaultsConfiguration.cs index a9187b764..78e9e6f82 100644 --- a/src/core/src/Eryph.Core/VmAgent/VmHostAgentDefaultsConfiguration.cs +++ b/src/core/src/Eryph.Core/VmAgent/VmHostAgentDefaultsConfiguration.cs @@ -11,5 +11,7 @@ public class VmHostAgentDefaultsConfiguration public string Vms { get; init; } public string Volumes { get; init; } + + public bool WatchFileSystem { get; init; } = true; } } diff --git a/src/core/src/Eryph.Messages/Resources/Catlets/Commands/RemoveVirtualDiskCommandResponse.cs b/src/core/src/Eryph.Messages/Resources/Catlets/Commands/RemoveVirtualDiskCommandResponse.cs new file mode 100644 index 000000000..ad4744161 --- /dev/null +++ b/src/core/src/Eryph.Messages/Resources/Catlets/Commands/RemoveVirtualDiskCommandResponse.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Eryph.Messages.Resources.Catlets.Commands; + +public class RemoveVirtualDiskCommandResponse +{ + public DateTimeOffset Timestamp { get; init; } +} diff --git a/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateInventoryCommand.cs b/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateInventoryCommand.cs index fd7158f61..dd68c6ebb 100644 --- a/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateInventoryCommand.cs +++ b/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateInventoryCommand.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Eryph.ConfigModel; using Eryph.Resources.Machines; @@ -11,7 +10,8 @@ public class UpdateInventoryCommand [PrivateIdentifier] public string AgentName { get; set; } - public List Inventory { get; set; } + public VirtualMachineData Inventory { get; set; } + public DateTimeOffset Timestamp { get; set; } } } \ No newline at end of file diff --git a/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateVMHostInventoryCommand.cs b/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateVMHostInventoryCommand.cs index 1a72adbe9..9ecefa8cd 100644 --- a/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateVMHostInventoryCommand.cs +++ b/src/core/src/Eryph.Messages/Resources/Catlets/Commands/UpdateVMHostInventoryCommand.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Eryph.Resources.Disks; using Eryph.Resources.Machines; using JetBrains.Annotations; @@ -13,6 +14,8 @@ public class UpdateVMHostInventoryCommand public List VMInventory { get; set; } + public List DiskInventory { get; set; } + public DateTimeOffset Timestamp { get; set; } } } \ No newline at end of file diff --git a/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesCommand.cs b/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesCommand.cs index 388f5b045..0c44274e3 100644 --- a/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesCommand.cs +++ b/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesCommand.cs @@ -6,6 +6,7 @@ namespace Eryph.Messages.Resources.Commands public class DestroyResourcesCommand : IGenericResourcesCommand, IHasResources, ICommandWithName { public Resource[] Resources { get; set; } + public string GetCommandName() => "Destroy Resources"; } } \ No newline at end of file diff --git a/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesResponse.cs b/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesResponse.cs index b1beae64f..636585f1e 100644 --- a/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesResponse.cs +++ b/src/core/src/Eryph.Messages/Resources/Commands/DestroyResourcesResponse.cs @@ -1,10 +1,11 @@ -using Eryph.Resources; +using System.Collections.Generic; +using Eryph.Resources; -namespace Eryph.Messages.Resources.Commands +namespace Eryph.Messages.Resources.Commands; + +public class DestroyResourcesResponse { - public class DestroyResourcesResponse - { - public Resource[] DestroyedResources { get; set; } - public Resource[] DetachedResources { get; set; } - } -} \ No newline at end of file + public required IReadOnlyList DestroyedResources { get; set; } + + public required IReadOnlyList DetachedResources { get; set; } +} diff --git a/src/core/src/Eryph.Messages/Resources/Disks/RemoveDisksCommand.cs b/src/core/src/Eryph.Messages/Resources/Disks/RemoveDisksCommand.cs index b67c789ac..c529bb703 100644 --- a/src/core/src/Eryph.Messages/Resources/Disks/RemoveDisksCommand.cs +++ b/src/core/src/Eryph.Messages/Resources/Disks/RemoveDisksCommand.cs @@ -1,8 +1,12 @@ -using Eryph.Resources.Disks; +using System; +using System.Collections.Generic; +using Eryph.Resources.Disks; namespace Eryph.Messages.Resources.Disks; public class CheckDisksExistsReply { - public DiskInfo[] MissingDisks { get; set; } -} \ No newline at end of file + public IReadOnlyList MissingDisks { get; set; } + + public DateTimeOffset Timestamp { get; set; } +} diff --git a/src/core/src/Eryph.Messages/Resources/Disks/UpdateDiskInventoryCommand.cs b/src/core/src/Eryph.Messages/Resources/Disks/UpdateDiskInventoryCommand.cs new file mode 100644 index 000000000..acdaa0a2c --- /dev/null +++ b/src/core/src/Eryph.Messages/Resources/Disks/UpdateDiskInventoryCommand.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Eryph.Resources.Disks; + +namespace Eryph.Messages.Resources.Disks; + +public class UpdateDiskInventoryCommand +{ + public string AgentName { get; init; } + + public DateTimeOffset Timestamp { get; init; } + + public IList Inventory { get; init; } +} diff --git a/src/core/src/Eryph.VmManagement/Inventory/DiskStoreInventory.cs b/src/core/src/Eryph.VmManagement/Inventory/DiskStoreInventory.cs new file mode 100644 index 000000000..42c1df99c --- /dev/null +++ b/src/core/src/Eryph.VmManagement/Inventory/DiskStoreInventory.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Eryph.Core; +using Eryph.Core.VmAgent; +using Eryph.Resources.Disks; +using Eryph.VmManagement.Storage; +using LanguageExt; +using LanguageExt.Common; + +using static LanguageExt.Prelude; +using static LanguageExt.Seq; + +namespace Eryph.VmManagement.Inventory; + +public static class DiskStoreInventory +{ + public static Aff>> InventoryStores( + IFileSystemService fileSystemService, + IPowershellEngine powershellEngine, + VmHostAgentConfiguration vmHostAgentConfig) => + from _ in SuccessAff(unit) + let storePaths = append( + vmHostAgentConfig.Environments.ToSeq() + .Bind(e => e.Datastores.ToSeq()) + .Map(ds => ds.Path), + vmHostAgentConfig.Environments.ToSeq() + .Map(e => e.Defaults.Volumes), + vmHostAgentConfig.Datastores.ToSeq() + .Map(ds => ds.Path), + Seq1(vmHostAgentConfig.Defaults.Volumes)) + from diskInfos in storePaths + .Map(storePath => InventoryStore(fileSystemService, powershellEngine, vmHostAgentConfig, storePath)) + .SequenceSerial() + select diskInfos.Flatten(); + + public static Aff>> InventoryStore( + IFileSystemService fileSystemService, + IPowershellEngine powershellEngine, + VmHostAgentConfiguration vmHostAgentConfig, + string path) => + from vhdFiles in Eff(() => fileSystemService.GetFiles(path, "*.vhdx", SearchOption.AllDirectories)) + from diskInfos in vhdFiles.ToSeq() + .Map(vhdFile => InventoryDisk(powershellEngine, vmHostAgentConfig, vhdFile)) + .SequenceParallel() + select diskInfos; + + private static Aff> InventoryDisk( + IPowershellEngine powershellEngine, + VmHostAgentConfiguration vmHostAgentConfig, + string diskPath) => + from diskSettings in DiskStorageSettings.FromVhdPath(powershellEngine, vmHostAgentConfig, diskPath) + .ToAff(identity) + .Map(Right) + | @catch(e => SuccessAff(Left( + Error.New($"Inventory of virtual disk '{diskPath}' failed", e)))) + select diskSettings.Map(s => s.CreateDiskInfo()); +} diff --git a/src/data/src/Eryph.StateDb.MySql/Migrations/20241203163012_InitialCreate.Designer.cs b/src/data/src/Eryph.StateDb.MySql/Migrations/20250122141438_InitialCreate.Designer.cs similarity index 99% rename from src/data/src/Eryph.StateDb.MySql/Migrations/20241203163012_InitialCreate.Designer.cs rename to src/data/src/Eryph.StateDb.MySql/Migrations/20250122141438_InitialCreate.Designer.cs index a8cda906e..175637921 100644 --- a/src/data/src/Eryph.StateDb.MySql/Migrations/20241203163012_InitialCreate.Designer.cs +++ b/src/data/src/Eryph.StateDb.MySql/Migrations/20250122141438_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace Eryph.StateDb.MySql.Migrations { [DbContext(typeof(MySqlStateStoreContext))] - [Migration("20241203163012_InitialCreate")] + [Migration("20250122141438_InitialCreate")] partial class InitialCreate { /// @@ -413,6 +413,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("BeingDeleted") + .HasColumnType("tinyint(1)"); + b.Property("Name") .IsRequired() .HasColumnType("longtext"); @@ -718,6 +721,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("longtext"); + b.Property("LastInventory") + .HasColumnType("datetime(6)"); + b.ToTable("CatletFarms"); }); @@ -729,6 +735,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("longtext"); + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + b.Property("DiskIdentifier") .HasColumnType("char(36)"); diff --git a/src/data/src/Eryph.StateDb.MySql/Migrations/20241203163012_InitialCreate.cs b/src/data/src/Eryph.StateDb.MySql/Migrations/20250122141438_InitialCreate.cs similarity index 99% rename from src/data/src/Eryph.StateDb.MySql/Migrations/20241203163012_InitialCreate.cs rename to src/data/src/Eryph.StateDb.MySql/Migrations/20250122141438_InitialCreate.cs index 9e05b906d..5011ca815 100644 --- a/src/data/src/Eryph.StateDb.MySql/Migrations/20241203163012_InitialCreate.cs +++ b/src/data/src/Eryph.StateDb.MySql/Migrations/20250122141438_InitialCreate.cs @@ -138,6 +138,7 @@ protected override void Up(MigrationBuilder migrationBuilder) Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), Name = table.Column(type: "longtext", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), + BeingDeleted = table.Column(type: "tinyint(1)", nullable: false), TenantId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") }, constraints: table => @@ -164,7 +165,8 @@ protected override void Up(MigrationBuilder migrationBuilder) Environment = table.Column(type: "longtext", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), HardwareId = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4") + .Annotation("MySql:CharSet", "utf8mb4"), + LastInventory = table.Column(type: "datetime(6)", nullable: false) }, constraints: table => { @@ -281,6 +283,7 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("MySql:CharSet", "utf8mb4"), DiskIdentifier = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), Frozen = table.Column(type: "tinyint(1)", nullable: false), + Deleted = table.Column(type: "tinyint(1)", nullable: false), Path = table.Column(type: "longtext", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), FileName = table.Column(type: "longtext", nullable: true) diff --git a/src/data/src/Eryph.StateDb.MySql/Migrations/MySqlStateStoreContextModelSnapshot.cs b/src/data/src/Eryph.StateDb.MySql/Migrations/MySqlStateStoreContextModelSnapshot.cs index 8bfb1707a..76662a633 100644 --- a/src/data/src/Eryph.StateDb.MySql/Migrations/MySqlStateStoreContextModelSnapshot.cs +++ b/src/data/src/Eryph.StateDb.MySql/Migrations/MySqlStateStoreContextModelSnapshot.cs @@ -410,6 +410,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("BeingDeleted") + .HasColumnType("tinyint(1)"); + b.Property("Name") .IsRequired() .HasColumnType("longtext"); @@ -715,6 +718,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("longtext"); + b.Property("LastInventory") + .HasColumnType("datetime(6)"); + b.ToTable("CatletFarms"); }); @@ -726,6 +732,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("longtext"); + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + b.Property("DiskIdentifier") .HasColumnType("char(36)"); diff --git a/src/data/src/Eryph.StateDb.Sqlite/Migrations/20241203163008_InitialCreate.Designer.cs b/src/data/src/Eryph.StateDb.Sqlite/Migrations/20250122141433_InitialCreate.Designer.cs similarity index 99% rename from src/data/src/Eryph.StateDb.Sqlite/Migrations/20241203163008_InitialCreate.Designer.cs rename to src/data/src/Eryph.StateDb.Sqlite/Migrations/20250122141433_InitialCreate.Designer.cs index 7209c22e9..12b785c4f 100644 --- a/src/data/src/Eryph.StateDb.Sqlite/Migrations/20241203163008_InitialCreate.Designer.cs +++ b/src/data/src/Eryph.StateDb.Sqlite/Migrations/20250122141433_InitialCreate.Designer.cs @@ -11,7 +11,7 @@ namespace Eryph.StateDb.Sqlite.Migrations { [DbContext(typeof(SqliteStateStoreContext))] - [Migration("20241203163008_InitialCreate")] + [Migration("20250122141433_InitialCreate")] partial class InitialCreate { /// @@ -408,6 +408,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("BeingDeleted") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT"); @@ -713,6 +716,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("LastInventory") + .HasColumnType("TEXT"); + b.ToTable("CatletFarms"); }); @@ -724,6 +730,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("Deleted") + .HasColumnType("INTEGER"); + b.Property("DiskIdentifier") .HasColumnType("TEXT"); diff --git a/src/data/src/Eryph.StateDb.Sqlite/Migrations/20241203163008_InitialCreate.cs b/src/data/src/Eryph.StateDb.Sqlite/Migrations/20250122141433_InitialCreate.cs similarity index 99% rename from src/data/src/Eryph.StateDb.Sqlite/Migrations/20241203163008_InitialCreate.cs rename to src/data/src/Eryph.StateDb.Sqlite/Migrations/20250122141433_InitialCreate.cs index d601f7830..11a129819 100644 --- a/src/data/src/Eryph.StateDb.Sqlite/Migrations/20241203163008_InitialCreate.cs +++ b/src/data/src/Eryph.StateDb.Sqlite/Migrations/20250122141433_InitialCreate.cs @@ -116,6 +116,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "TEXT", nullable: false), Name = table.Column(type: "TEXT", nullable: false), + BeingDeleted = table.Column(type: "INTEGER", nullable: false), TenantId = table.Column(type: "TEXT", nullable: false) }, constraints: table => @@ -138,7 +139,8 @@ protected override void Up(MigrationBuilder migrationBuilder) ResourceType = table.Column(type: "INTEGER", nullable: false), Name = table.Column(type: "TEXT", nullable: false), Environment = table.Column(type: "TEXT", nullable: false), - HardwareId = table.Column(type: "TEXT", nullable: false) + HardwareId = table.Column(type: "TEXT", nullable: false), + LastInventory = table.Column(type: "TEXT", nullable: false) }, constraints: table => { @@ -242,6 +244,7 @@ protected override void Up(MigrationBuilder migrationBuilder) StorageIdentifier = table.Column(type: "TEXT", nullable: true), DiskIdentifier = table.Column(type: "TEXT", nullable: false), Frozen = table.Column(type: "INTEGER", nullable: false), + Deleted = table.Column(type: "INTEGER", nullable: false), Path = table.Column(type: "TEXT", nullable: true), FileName = table.Column(type: "TEXT", nullable: true), SizeBytes = table.Column(type: "INTEGER", nullable: true), diff --git a/src/data/src/Eryph.StateDb.Sqlite/Migrations/SqliteStateStoreContextModelSnapshot.cs b/src/data/src/Eryph.StateDb.Sqlite/Migrations/SqliteStateStoreContextModelSnapshot.cs index cd1bea7a7..1405c9eef 100644 --- a/src/data/src/Eryph.StateDb.Sqlite/Migrations/SqliteStateStoreContextModelSnapshot.cs +++ b/src/data/src/Eryph.StateDb.Sqlite/Migrations/SqliteStateStoreContextModelSnapshot.cs @@ -405,6 +405,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("BeingDeleted") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT"); @@ -710,6 +713,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("LastInventory") + .HasColumnType("TEXT"); + b.ToTable("CatletFarms"); }); @@ -721,6 +727,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("Deleted") + .HasColumnType("INTEGER"); + b.Property("DiskIdentifier") .HasColumnType("TEXT"); diff --git a/src/data/src/Eryph.StateDb/GeneInventoryQueries.cs b/src/data/src/Eryph.StateDb/GeneInventoryQueries.cs index b461e6a26..a861584d5 100644 --- a/src/data/src/Eryph.StateDb/GeneInventoryQueries.cs +++ b/src/data/src/Eryph.StateDb/GeneInventoryQueries.cs @@ -48,8 +48,10 @@ public Task> GetDisksUsingGene( CancellationToken cancellationToken = default) => dbContext.VirtualDisks .Where(d => d.UniqueGeneIndex == uniqueGeneId.ToUniqueGeneIndex() - && d.LastSeenAgent == agentName) + && d.LastSeenAgent == agentName + && !d.Deleted) .SelectMany(d => d.Children) + .Where(d => !d.Deleted) .Select(c => c.Id) .Distinct() .ToListAsync(cancellationToken); @@ -59,7 +61,8 @@ from gene in dbContext.Genes where gene.GeneType != GeneType.Volume || !dbContext.VirtualDisks.Any(d => d.UniqueGeneIndex == gene.UniqueGeneIndex && d.LastSeenAgent == gene.LastSeenAgent - && (d.AttachedDrives.Count != 0 || d.Children.Count != 0)) + && !d.Deleted + && (d.AttachedDrives.Count != 0 || d.Children.Any(c => !c.Deleted))) where gene.GeneType != GeneType.Fodder || !dbContext.MetadataGenes.Any(mg => mg.UniqueGeneIndex == gene.UniqueGeneIndex) select gene; diff --git a/src/data/src/Eryph.StateDb/Model/CatletFarm.cs b/src/data/src/Eryph.StateDb/Model/CatletFarm.cs index d0c14e180..573184c2c 100644 --- a/src/data/src/Eryph.StateDb/Model/CatletFarm.cs +++ b/src/data/src/Eryph.StateDb/Model/CatletFarm.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Eryph.Resources; namespace Eryph.StateDb.Model; @@ -13,4 +14,6 @@ public CatletFarm() public ICollection Catlets { get; set; } = null!; public required string HardwareId { get; set; } + + public DateTimeOffset LastInventory { get; set; } } diff --git a/src/data/src/Eryph.StateDb/Model/Project.cs b/src/data/src/Eryph.StateDb/Model/Project.cs index 077dbdee9..9c0f6c4fe 100644 --- a/src/data/src/Eryph.StateDb/Model/Project.cs +++ b/src/data/src/Eryph.StateDb/Model/Project.cs @@ -10,6 +10,13 @@ public class Project public required string Name { get; set; } + /// + /// Indicates that the project is being deleted. The flag is only + /// used temporarily while eryph cleans up the resources of the project. + /// In the end, the project will be removed from the database. + /// + public bool BeingDeleted { get; set; } + public Guid TenantId { get; set; } public Tenant Tenant { get; set; } = null!; diff --git a/src/data/src/Eryph.StateDb/Model/VirtualDisk.cs b/src/data/src/Eryph.StateDb/Model/VirtualDisk.cs index c35a99799..3c1427409 100644 --- a/src/data/src/Eryph.StateDb/Model/VirtualDisk.cs +++ b/src/data/src/Eryph.StateDb/Model/VirtualDisk.cs @@ -17,6 +17,14 @@ public VirtualDisk() public bool Frozen { get; set; } + /// + /// Indicates that the disk has been deleted. Disks are not + /// directly removed from the database but are marked as deleted. + /// Otherwise, the inventory might add deleted disks again in + /// some corner cases. + /// + public bool Deleted { get; set; } + public string? Path { get; set; } public string? FileName { get; set; } diff --git a/src/data/src/Eryph.StateDb/Specifications/VirtualDiskSpecs.cs b/src/data/src/Eryph.StateDb/Specifications/VirtualDiskSpecs.cs index 0b2934605..a3046fa66 100644 --- a/src/data/src/Eryph.StateDb/Specifications/VirtualDiskSpecs.cs +++ b/src/data/src/Eryph.StateDb/Specifications/VirtualDiskSpecs.cs @@ -40,13 +40,30 @@ public GetByName(Guid projectId, string dataStore, string environment, } } + public sealed class FindDeleted : Specification + { + public FindDeleted(DateTimeOffset cutoff) + { + Query.Where(x => x.Deleted && x.LastSeen < cutoff); + } + + } + + public sealed class FindDeletedInProject : Specification + { + public FindDeletedInProject(Guid projectId) + { + Query.Where(x => x.Deleted && x.ProjectId == projectId ); + } + } + public sealed class FindOutdated : Specification { - public FindOutdated(DateTimeOffset lastSeenBefore, [CanBeNull] string agentName) + public FindOutdated(DateTimeOffset lastSeenBefore, string? agentName) { Query.Where(x => x.LastSeen < lastSeenBefore); - if(!string.IsNullOrEmpty(agentName)) + if (!string.IsNullOrEmpty(agentName)) Query.Where(x => x.LastSeenAgent == agentName); Query.Include(x => x.Project); diff --git a/src/modules/src/Eryph.Modules.ComputeApi/Handlers/GetCatletConfigurationHandler.cs b/src/modules/src/Eryph.Modules.ComputeApi/Handlers/GetCatletConfigurationHandler.cs index 299db365c..a1bf169fc 100644 --- a/src/modules/src/Eryph.Modules.ComputeApi/Handlers/GetCatletConfigurationHandler.cs +++ b/src/modules/src/Eryph.Modules.ComputeApi/Handlers/GetCatletConfigurationHandler.cs @@ -66,7 +66,7 @@ public async Task> HandleGetRequest( var driveConfig = new CatletDriveConfig(); - if (drive.AttachedDisk != null) + if (drive.AttachedDisk is { Deleted: false }) { driveConfig.Name = drive.AttachedDisk.Name; diff --git a/src/modules/src/Eryph.Modules.ComputeApi/Model/CatletSpecBuilder.cs b/src/modules/src/Eryph.Modules.ComputeApi/Model/CatletSpecBuilder.cs index 754216dc7..7c54a1c36 100644 --- a/src/modules/src/Eryph.Modules.ComputeApi/Model/CatletSpecBuilder.cs +++ b/src/modules/src/Eryph.Modules.ComputeApi/Model/CatletSpecBuilder.cs @@ -10,6 +10,7 @@ public class CatletSpecBuilder( { protected override void CustomizeQuery(ISpecificationBuilder query) { + query.Include(x => x.Drives).ThenInclude(d => d.AttachedDisk); query.Include(x => x.ReportedNetworks); } } diff --git a/src/modules/src/Eryph.Modules.ComputeApi/Model/V1/MapperProfile.cs b/src/modules/src/Eryph.Modules.ComputeApi/Model/V1/MapperProfile.cs index 1c93000bf..41c02864f 100644 --- a/src/modules/src/Eryph.Modules.ComputeApi/Model/V1/MapperProfile.cs +++ b/src/modules/src/Eryph.Modules.ComputeApi/Model/V1/MapperProfile.cs @@ -23,7 +23,13 @@ public MapperProfile() .ForMember(x => x.ProviderName, x => x.MapFrom(y => y.NetworkProvider)); CreateMap(); - CreateMap(); + CreateMap() + .ForMember( + d => d.AttachedDiskId, + o => o.MapFrom(drive => Optional(drive.AttachedDisk) + .Filter(disk => !disk.Deleted) + .Map(disk => disk.Id) + .ToNullable())); CreateMap(); CreateMap<(StateDb.Model.Catlet Catlet, CatletNetworkPort Port), CatletNetwork>() diff --git a/src/modules/src/Eryph.Modules.ComputeApi/Model/VirtualDiskSpecBuilder.cs b/src/modules/src/Eryph.Modules.ComputeApi/Model/VirtualDiskSpecBuilder.cs index f24256c1e..47a526f95 100644 --- a/src/modules/src/Eryph.Modules.ComputeApi/Model/VirtualDiskSpecBuilder.cs +++ b/src/modules/src/Eryph.Modules.ComputeApi/Model/VirtualDiskSpecBuilder.cs @@ -10,6 +10,7 @@ public class VirtualDiskSpecBuilder( { protected override void CustomizeQuery(ISpecificationBuilder query) { + query.Where(x => !x.Deleted); query.Include(x => x.AttachedDrives); query.Include(x => x.Children); } diff --git a/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeInterceptorBase.cs b/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeInterceptorBase.cs index 104ba5b0c..d81054fe0 100644 --- a/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeInterceptorBase.cs +++ b/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeInterceptorBase.cs @@ -9,11 +9,22 @@ namespace Eryph.Modules.Controller.ChangeTracking; +/// +/// Base class for change interceptors that detect changes in the database. +/// +/// +/// We use both +/// and +/// to detect changes. EF Core will implicitly create a savepoint when +/// SaveChangesAsync() is called. We must detect changes when a savepoint +/// is created. Otherwise, we would miss deleted entities as EF Core seems +/// to remove them from the change tracker after the SaveChangesAsync(). +/// internal abstract class ChangeInterceptorBase : DbTransactionInterceptor { private readonly IChangeTrackingQueue _queue; private readonly ILogger _logger; - private Seq> _currentItem = Prelude.Empty; + private HashSet> _changes = HashSet>.Empty; protected ChangeInterceptorBase( IChangeTrackingQueue queue, @@ -27,6 +38,25 @@ protected abstract Task> DetectChanges( DbContext dbContext, CancellationToken cancellationToken = default); + public override async Task CreatedSavepointAsync( + DbTransaction transaction, + TransactionEventData eventData, + CancellationToken cancellationToken = default) + { + if (eventData.Context is null) + { + await base.CreatedSavepointAsync(transaction, eventData, cancellationToken); + return; + } + + var currentChanges = await DetectChanges(eventData.Context, cancellationToken) + .MapT(changes => new ChangeTrackingQueueItem(eventData.TransactionId, changes)); + + _changes = _changes.Union(currentChanges); + + await base.CreatedSavepointAsync(transaction, eventData, cancellationToken); + } + public override async ValueTask TransactionCommittingAsync( DbTransaction transaction, TransactionEventData eventData, @@ -36,9 +66,11 @@ public override async ValueTask TransactionCommittingAsync( if (eventData.Context is null) return await base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken); - _currentItem = await DetectChanges(eventData.Context, cancellationToken) + var currentChanges = await DetectChanges(eventData.Context, cancellationToken) .MapT(changes => new ChangeTrackingQueueItem(eventData.TransactionId, changes)); + _changes = _changes.Union(currentChanges); + return await base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken); } @@ -47,7 +79,7 @@ public override async Task TransactionCommittedAsync( TransactionEndEventData eventData, CancellationToken cancellationToken = default) { - foreach (var item in _currentItem) + foreach (var item in _changes) { _logger.LogDebug("Detected relevant changes in transaction {TransactionId}: {Changes}", item.TransactionId, item.Changes); @@ -55,6 +87,13 @@ public override async Task TransactionCommittedAsync( } } + public override void CreatedSavepoint( + DbTransaction transaction, + TransactionEventData eventData) + { + throw new NotSupportedException(); + } + public override InterceptionResult TransactionCommitting( DbTransaction transaction, TransactionEventData eventData, diff --git a/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeTrackingQueue.cs b/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeTrackingQueue.cs index f5583f41b..25280aa2a 100644 --- a/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeTrackingQueue.cs +++ b/src/modules/src/Eryph.Modules.Controller/ChangeTracking/ChangeTrackingQueue.cs @@ -51,15 +51,4 @@ public async Task EnqueueAsync( } } -internal class ChangeTrackingQueueItem -{ - public ChangeTrackingQueueItem(Guid transactionId, TChange changes) - { - TransactionId = transactionId; - Changes = changes; - } - - public Guid TransactionId { get; } - - public TChange Changes { get; } -} \ No newline at end of file +internal record ChangeTrackingQueueItem(Guid TransactionId, TChange Changes); diff --git a/src/modules/src/Eryph.Modules.Controller/Compute/CreateVirtualDiskSaga.cs b/src/modules/src/Eryph.Modules.Controller/Compute/CreateVirtualDiskSaga.cs index bbac488ee..238db82a1 100644 --- a/src/modules/src/Eryph.Modules.Controller/Compute/CreateVirtualDiskSaga.cs +++ b/src/modules/src/Eryph.Modules.Controller/Compute/CreateVirtualDiskSaga.cs @@ -29,8 +29,7 @@ internal class CreateVirtualDiskSaga( IWorkflow workflow, IStateStore stateStore, IStorageManagementAgentLocator agentLocator, - IInventoryLockManager lockManager, - IVirtualDiskDataService dataService) + IInventoryLockManager lockManager) : OperationTaskWorkflowSaga>(workflow), IHandleMessages> { @@ -64,7 +63,7 @@ public Task Handle(OperationTaskStatusEvent message) { await lockManager.AcquireVhdLock(response.DiskInfo.DiskIdentifier); - await dataService.AddNewVHD(new VirtualDisk() + await stateStore.For().AddAsync(new VirtualDisk { ProjectId = Data.Data.ProjectId, Id = Data.Data.DiskId, diff --git a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourceState.cs b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourceState.cs index 82813cd92..ff1fba6d3 100644 --- a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourceState.cs +++ b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourceState.cs @@ -3,6 +3,7 @@ public enum DestroyResourceState { Initiated = 0, - ResourcesDestroyed = 5, - ResourcesReleased = 10 -} \ No newline at end of file + CatletsDestroyed = 10, + DisksDestroyed = 20, + NetworksDestroyed = 30, +} diff --git a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSaga.cs b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSaga.cs index d8d0fbac7..5b2be995a 100644 --- a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSaga.cs +++ b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSaga.cs @@ -1,158 +1,183 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using Dbosoft.Rebus.Operations.Events; using Dbosoft.Rebus.Operations.Workflow; using Eryph.Messages.Resources.Catlets.Commands; using Eryph.Messages.Resources.Commands; +using Eryph.ModuleCore; using Eryph.Resources; using JetBrains.Annotations; using Rebus.Handlers; using Rebus.Sagas; -using Resource = Eryph.Resources.Resource; -namespace Eryph.Modules.Controller.Compute +namespace Eryph.Modules.Controller.Compute; + +[UsedImplicitly] +internal class DestroyResourcesSaga(IWorkflow workflow) : + OperationTaskWorkflowSaga>(workflow), + IHandleMessages>, + IHandleMessages>, + IHandleMessages> { - [UsedImplicitly] - internal class DestroyResourcesSaga : - OperationTaskWorkflowSaga, - IHandleMessages>, - IHandleMessages>, - IHandleMessages> + protected override void CorrelateMessages( + ICorrelationConfig> config) { + base.CorrelateMessages(config); + config.Correlate>( + m => m.InitiatingTaskId, d => d.SagaTaskId); + config.Correlate>( + m => m.InitiatingTaskId, d => d.SagaTaskId); + config.Correlate>( + m => m.InitiatingTaskId, d => d.SagaTaskId); + } - public DestroyResourcesSaga(IWorkflow workflow) : base(workflow) - { - } - - protected override void CorrelateMessages(ICorrelationConfig config) - { - base.CorrelateMessages(config); - config.Correlate>(m => m.InitiatingTaskId, d => d.SagaTaskId); - config.Correlate>(m => m.InitiatingTaskId, d => d.SagaTaskId); - config.Correlate>(m => m.InitiatingTaskId, d => d.SagaTaskId); + protected override async Task Initiated(DestroyResourcesCommand message) + { + Data.Data.State = DestroyResourceState.Initiated; - } + Data.Data.PendingCatlets = message.Resources + .Where(r => r.Type == ResourceType.Catlet) + .Select(r => r.Id) + .ToHashSet(); - protected override Task Initiated(DestroyResourcesCommand message) - { - Data.State = DestroyResourceState.Initiated; - Data.Resources = message.Resources; + Data.Data.PendingDisks = message.Resources + .Where(r => r.Type == ResourceType.VirtualDisk) + .Select(r => r.Id) + .ToHashSet(); - var firstGroup = new List(); - var secondGroup = new List(); + Data.Data.PendingNetworks = message.Resources + .Where(r => r.Type == ResourceType.VirtualNetwork) + .Select(r => r.Id) + .ToHashSet(); + await StartNextTask(); + } - foreach (var resource in Data.Resources?? Array.Empty()) - switch(resource.Type) - { - case ResourceType.Catlet: - firstGroup.Add(resource); - break; - - case ResourceType.VirtualDisk: - secondGroup.Add(resource); - break; - case ResourceType.VirtualNetwork: - secondGroup.Add(resource); - break; - default: - throw new ArgumentOutOfRangeException(); - }; - - Data.DestroyGroups = new List>(); + public Task Handle(OperationTaskStatusEvent message) => + FailOrRun(message, async (DestroyResourcesResponse response) => + { + Collect(response); + var removedCatlets = response.DestroyedResources.ToSeq() + .Concat(response.DetachedResources.ToSeq()) + .Filter(r => r.Type is ResourceType.Catlet) + .Map(r => r.Id); + Data.Data.PendingCatlets = Data.Data.PendingCatlets + .Except(removedCatlets) + .ToHashSet(); + + if (Data.Data.PendingCatlets.Count > 0) + return; - if(firstGroup.Count>0) - Data.DestroyGroups.Add(firstGroup); - - if (secondGroup.Count > 0) - Data.DestroyGroups.Add(secondGroup); - - return DestroyNextGroup(); + await StartNextTask(); + }); + + public Task Handle(OperationTaskStatusEvent message) => + FailOrRun(message, async (DestroyResourcesResponse response) => + { + Collect(response); + var removedDisks = response.DestroyedResources.ToSeq() + .Concat(response.DetachedResources.ToSeq()) + .Filter(r => r.Type is ResourceType.VirtualDisk) + .Map(r => r.Id); + Data.Data.PendingDisks = Data.Data.PendingDisks + .Except(removedDisks) + .ToHashSet(); + + if (Data.Data.PendingDisks.Count > 0) + return; + await StartNextTask(); + }); - } - - private async Task DestroyNextGroup() + public Task Handle(OperationTaskStatusEvent message) => + FailOrRun(message, async (DestroyResourcesResponse response) => { - if (Data.DestroyGroups.Count == 0) + Collect(response); + var removedNetworks = response.DestroyedResources.ToSeq() + .Concat(response.DetachedResources.ToSeq()) + .Filter(r => r.Type is ResourceType.VirtualNetwork) + .Map(r => r.Id); + Data.Data.PendingNetworks = Data.Data.PendingNetworks + .Except(removedNetworks) + .ToHashSet(); + + if (Data.Data.PendingNetworks.Count > 0) { - await Complete(new DestroyResourcesResponse - { - DestroyedResources = Data.DestroyedResources.ToArray(), - DetachedResources = Data.DetachedResources.ToArray() - }); - return; + await Fail($"Some networks were not removed: {string.Join(", ", Data.Data.PendingNetworks)}"); } + + await Complete(new DestroyResourcesResponse + { + DestroyedResources = Data.Data.DestroyedResources.ToList(), + DetachedResources = Data.Data.DetachedResources.ToList() + }); + }); - Data.Resources = Data.DestroyGroups[0].ToArray(); - Data.DestroyGroups.RemoveAt(0); - - var networks = new List(); - foreach (var resource in Data.Resources) - switch(resource.Type) + private async Task StartNextTask() + { + if (Data.Data.State < DestroyResourceState.CatletsDestroyed) + { + if (Data.Data.PendingCatlets.Count > 0) + { + foreach (var catletId in Data.Data.PendingCatlets) { + await StartNewTask(new DestroyCatletCommand { CatletId = catletId }); + } - case ResourceType.Catlet: - await StartNewTask(new DestroyCatletCommand{ CatletId = resource.Id }); - break; - case ResourceType.VirtualDisk: - await StartNewTask(new DestroyVirtualDiskCommand{DiskId = resource.Id}); - break; - case ResourceType.VirtualNetwork: - networks.Add(resource.Id); - - break; - default: - throw new ArgumentOutOfRangeException(); - }; - - if(networks.Count > 0) - await StartNewTask(new DestroyVirtualNetworksCommand { NetworkIds = networks.ToArray() }); + return; + } + Data.Data.State = DestroyResourceState.CatletsDestroyed; } - - public Task Handle(OperationTaskStatusEvent message) + if (Data.Data.State < DestroyResourceState.DisksDestroyed) { - return FailOrRun(message, - (response) => CollectAndCheckCompleted(response.DestroyedResources, response.DetachedResources)); - } + if (Data.Data.PendingDisks.Count > 0) + { + foreach (var diskId in Data.Data.PendingDisks) + { + await StartNewTask(new DestroyVirtualDiskCommand { DiskId = diskId }); + } - public Task Handle(OperationTaskStatusEvent message) - { - return FailOrRun(message, - (response) => CollectAndCheckCompleted(response.DestroyedResources, response.DetachedResources)); - } + return; + } - public Task Handle(OperationTaskStatusEvent message) - { - return FailOrRun(message, - (response) => CollectAndCheckCompleted(response.DestroyedResources, response.DetachedResources)); + Data.Data.State = DestroyResourceState.DisksDestroyed; } - private Task CollectAndCheckCompleted(Resource[]? destroyedResources, Resource[]? detachedResources) + if (Data.Data.State < DestroyResourceState.NetworksDestroyed) { - if (destroyedResources != null) Data.DestroyedResources.AddRange(destroyedResources); - - if (detachedResources != null) Data.DetachedResources.AddRange(detachedResources); - - var pendingResources = (Data.Resources ?? Array.Empty()).ToList(); - - foreach (var resource in Data.DestroyedResources.Concat(Data.DetachedResources)) + if (Data.Data.PendingNetworks.Count > 0) { - if (pendingResources.Contains(resource)) - pendingResources.Remove(resource); - } + await StartNewTask(new DestroyVirtualNetworksCommand + { + NetworkIds = Data.Data.PendingNetworks.ToArray() + }); - if (pendingResources.Count == 0) - { - return DestroyNextGroup(); + return; } - return Task.CompletedTask; + Data.Data.State = DestroyResourceState.NetworksDestroyed; } + + await Complete(new DestroyResourcesResponse + { + DestroyedResources = Data.Data.DestroyedResources.ToList(), + DetachedResources = Data.Data.DetachedResources.ToList() + }); + } + + private void Collect(DestroyResourcesResponse response) + { + Data.Data.DestroyedResources = Data.Data.DestroyedResources + .Union(response.DestroyedResources.ToSeq()) + .ToHashSet(); + Data.Data.DetachedResources = Data.Data.DetachedResources + .Union(response.DetachedResources.ToSeq()) + .ToHashSet(); } -} \ No newline at end of file +} diff --git a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSagaData.cs b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSagaData.cs index f243cb0ee..a9b9aece4 100644 --- a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSagaData.cs +++ b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyResourcesSagaData.cs @@ -1,18 +1,22 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Dbosoft.Rebus.Operations.Workflow; using Eryph.Modules.Controller.Operations; using Eryph.Resources; -namespace Eryph.Modules.Controller.Compute +namespace Eryph.Modules.Controller.Compute; + +public class DestroyResourcesSagaData : TaskWorkflowSagaData { - public class DestroyResourcesSagaData : TaskWorkflowSagaData - { - public DestroyResourceState State { get; set; } + public DestroyResourceState State { get; set; } + + public ISet PendingCatlets { get; set; } = new HashSet(); + + public ISet PendingDisks { get; set; } = new HashSet(); - public Resource[]? Resources { get; set; } - public List DestroyedResources { get; set; } = new(); - public List DetachedResources { get; set; } = new(); + public ISet PendingNetworks { get; set; } = new HashSet(); - public List> DestroyGroups { get; set; } - } -} \ No newline at end of file + public ISet DestroyedResources { get; set; } = new HashSet(); + + public ISet DetachedResources { get; set; } = new HashSet(); +} diff --git a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyVirtualDiskSaga.cs b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyVirtualDiskSaga.cs index ec5e35c68..1a3c5c973 100644 --- a/src/modules/src/Eryph.Modules.Controller/Compute/DestroyVirtualDiskSaga.cs +++ b/src/modules/src/Eryph.Modules.Controller/Compute/DestroyVirtualDiskSaga.cs @@ -1,16 +1,13 @@ using System.Threading.Tasks; using Dbosoft.Rebus.Operations.Events; using Dbosoft.Rebus.Operations.Workflow; -using Eryph.ConfigModel; using Eryph.Messages.Resources.Catlets.Commands; using Eryph.Messages.Resources.Commands; -using Eryph.Modules.Controller.DataServices; using Eryph.Modules.Controller.Inventory; using Eryph.Resources; using Eryph.StateDb; using Eryph.StateDb.Model; using JetBrains.Annotations; -using LanguageExt; using Rebus.Handlers; using Rebus.Sagas; using Resource = Eryph.Resources.Resource; @@ -20,7 +17,6 @@ namespace Eryph.Modules.Controller.Compute; [UsedImplicitly] internal class DestroyVirtualDiskSaga( IWorkflow workflow, - IVirtualDiskDataService virtualDiskDataService, IStateStore stateStore, IStorageManagementAgentLocator agentLocator, IInventoryLockManager lockManager) @@ -29,19 +25,19 @@ internal class DestroyVirtualDiskSaga( { public Task Handle(OperationTaskStatusEvent message) { - return FailOrRun(message, async () => + return FailOrRun(message, async (RemoveVirtualDiskCommandResponse response) => { - var virtualDisk = await virtualDiskDataService.GetVHD(Data.DiskId) - .Map(d => d.IfNoneUnsafe((VirtualDisk?)null)); - + var virtualDisk = await stateStore.For().GetByIdAsync(Data.DiskId); if (virtualDisk is not null) { await lockManager.AcquireVhdLock(virtualDisk.DiskIdentifier); - await virtualDiskDataService.DeleteVHD(Data.DiskId); + virtualDisk.Deleted = true; + virtualDisk.LastSeen = response.Timestamp; } await Complete(new DestroyResourcesResponse { + DetachedResources = [], DestroyedResources = [ new Resource(ResourceType.VirtualDisk, Data.DiskId) ], }); }); @@ -56,13 +52,13 @@ protected override void CorrelateMessages(ICorrelationConfig d.IfNoneUnsafe((VirtualDisk?)null)); + var virtualDisk = await stateStore.For().GetByIdAsync(Data.DiskId); if (virtualDisk is null) { await Complete(new DestroyResourcesResponse { + DetachedResources = [], DestroyedResources = [ new Resource(ResourceType.VirtualDisk, Data.DiskId) ], }); return; @@ -79,6 +75,7 @@ await Complete(new DestroyResourcesResponse await Complete(new DestroyResourcesResponse { DetachedResources = [new Resource(ResourceType.VirtualDisk, Data.DiskId)], + DestroyedResources = [], }); return; } diff --git a/src/modules/src/Eryph.Modules.Controller/Compute/UpdateCatletSaga.cs b/src/modules/src/Eryph.Modules.Controller/Compute/UpdateCatletSaga.cs index f5b390279..bf7a39299 100644 --- a/src/modules/src/Eryph.Modules.Controller/Compute/UpdateCatletSaga.cs +++ b/src/modules/src/Eryph.Modules.Controller/Compute/UpdateCatletSaga.cs @@ -305,7 +305,7 @@ public Task Handle(OperationTaskStatusEvent message) await bus.SendLocal(new UpdateInventoryCommand { AgentName = Data.Data.AgentName, - Inventory = [response.Inventory], + Inventory = response.Inventory, Timestamp = response.Timestamp, }); diff --git a/src/modules/src/Eryph.Modules.Controller/ControllerModule.cs b/src/modules/src/Eryph.Modules.Controller/ControllerModule.cs index e63960f31..8bd1ac352 100644 --- a/src/modules/src/Eryph.Modules.Controller/ControllerModule.cs +++ b/src/modules/src/Eryph.Modules.Controller/ControllerModule.cs @@ -44,6 +44,7 @@ namespace Eryph.Modules.Controller public class ControllerModule { private readonly ChangeTrackingConfig _changeTrackingConfig = new(); + private readonly InventoryConfig _inventoryConfig = new(); public string Name => "Eryph.Controller"; @@ -51,6 +52,9 @@ public ControllerModule(IConfiguration configuration) { configuration.GetSection("ChangeTracking") .Bind(_changeTrackingConfig); + + configuration.GetSection("Inventory") + .Bind(_inventoryConfig); } [UsedImplicitly] @@ -64,18 +68,28 @@ public void ConfigureServices(IServiceProvider serviceProvider, IServiceCollecti q.AddJob( job => job.WithIdentity(InventoryTimerJob.Key) .DisallowConcurrentExecution()); + q.AddJob( + job => job.WithIdentity(VirtualDiskCleanupJob.Key) + .DisallowConcurrentExecution()); q.AddTrigger(trigger => trigger.WithIdentity("InventoryTimerJobTrigger") .ForJob(InventoryTimerJob.Key) .StartNow() - .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromMinutes(10)).RepeatForever())); + .WithSimpleSchedule(s => s.WithInterval(_inventoryConfig.InventoryInterval).RepeatForever())); + q.AddTrigger(trigger => trigger.WithIdentity("VirtualDiskCleanupJobTrigger") + .ForJob(VirtualDiskCleanupJob.Key) + .StartNow() + .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromHours(1)))); - // The scheduled trigger will only fire the first time after 10 minutes. - // We add another trigger without a schedule to trigger the job immediately - // when the scheduler starts. + // The scheduled trigger will only fire the first time after waiting for one interval. + // We add another trigger without a schedule to trigger the job immediately when + // the scheduler starts. q.AddTrigger(trigger => trigger.WithIdentity("InventoryTimerJobStartupTrigger") .ForJob(InventoryTimerJob.Key) .StartNow()); + q.AddTrigger(trigger => trigger.WithIdentity("VirtualDiskCleanupJobStartupTrigger") + .ForJob(VirtualDiskCleanupJob.Key) + .StartNow()); }); services.AddQuartzHostedService(); } @@ -101,7 +115,6 @@ public void ConfigureContainer(IServiceProvider serviceProvider, Container conta container.Register(Lifestyle.Scoped); container.Register(Lifestyle.Scoped); container.Register(Lifestyle.Scoped); - container.Register(Lifestyle.Scoped); container.Register(Lifestyle.Scoped); container.Register(Lifestyle.Scoped); diff --git a/src/modules/src/Eryph.Modules.Controller/DataServices/IVirtualDiskDataService.cs b/src/modules/src/Eryph.Modules.Controller/DataServices/IVirtualDiskDataService.cs deleted file mode 100644 index 371ddac84..000000000 --- a/src/modules/src/Eryph.Modules.Controller/DataServices/IVirtualDiskDataService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Eryph.StateDb.Model; -using LanguageExt; - -namespace Eryph.Modules.Controller.DataServices; - -public interface IVirtualDiskDataService -{ - Task> GetVHD(Guid id); - Task AddNewVHD(VirtualDisk virtualDisk); - - Task> FindVHDByLocation( - Guid projectId, string dataStore, string environment, string storageIdentifier, - string name, Guid diskIdentifier); - Task> FindOutdated(DateTimeOffset lastSeenBefore, string agentName); - - Task UpdateVhd(VirtualDisk virtualDisk); - Task DeleteVHD(Guid id); -} \ No newline at end of file diff --git a/src/modules/src/Eryph.Modules.Controller/DataServices/VirtualDiskDataService.cs b/src/modules/src/Eryph.Modules.Controller/DataServices/VirtualDiskDataService.cs deleted file mode 100644 index ab74573c8..000000000 --- a/src/modules/src/Eryph.Modules.Controller/DataServices/VirtualDiskDataService.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Eryph.StateDb; -using Eryph.StateDb.Model; -using Eryph.StateDb.Specifications; -using JetBrains.Annotations; -using LanguageExt; - -namespace Eryph.Modules.Controller.DataServices -{ - internal class VirtualDiskDataService : IVirtualDiskDataService - { - private readonly IStateStore _stateStore; - - public VirtualDiskDataService(IStateStore stateStore) - { - _stateStore = stateStore; - } - - public async Task> GetVHD(Guid id) - { - var res = await _stateStore.For().GetByIdAsync(id); - return res; - } - - public async Task AddNewVHD([NotNull] VirtualDisk virtualDisk) - { - if (virtualDisk.Id == Guid.Empty) - throw new ArgumentException($"{nameof(VirtualDisk.Id)} is missing", nameof(virtualDisk)); - - - var res = await _stateStore.For().AddAsync(virtualDisk); - return res; - } - - public async Task> FindVHDByLocation( - Guid projectId, string dataStore, string environment, string storageIdentifier, - string name, Guid diskIdentifier) - { - return await _stateStore.For().ListAsync( - new VirtualDiskSpecs.GetByLocation(projectId,dataStore, - environment, storageIdentifier, name, diskIdentifier)); - } - - public async Task> FindOutdated(DateTimeOffset lastSeenBefore - , string agentName) - { - return await _stateStore.For().ListAsync( - new VirtualDiskSpecs.FindOutdated(lastSeenBefore, agentName)); - } - public async Task UpdateVhd(VirtualDisk virtualDisk) - { - await _stateStore.For().UpdateAsync(virtualDisk); - return virtualDisk; - } - - public async Task DeleteVHD(Guid id) - { - var res = await _stateStore.For().GetByIdAsync(id); - - if(res!= null) - await _stateStore.For().DeleteAsync(res); - - return Unit.Default; - } - } -} \ No newline at end of file diff --git a/src/modules/src/Eryph.Modules.Controller/Inventory/CheckDisksExistsReplyHandler.cs b/src/modules/src/Eryph.Modules.Controller/Inventory/CheckDisksExistsReplyHandler.cs index 79249daec..04a246362 100644 --- a/src/modules/src/Eryph.Modules.Controller/Inventory/CheckDisksExistsReplyHandler.cs +++ b/src/modules/src/Eryph.Modules.Controller/Inventory/CheckDisksExistsReplyHandler.cs @@ -8,6 +8,8 @@ using Dbosoft.Rebus.Operations.Workflow; using Eryph.Messages.Resources.Disks; using Eryph.Modules.Controller.DataServices; +using Eryph.StateDb; +using Eryph.StateDb.Model; using JetBrains.Annotations; namespace Eryph.Modules.Controller.Inventory; @@ -15,7 +17,7 @@ namespace Eryph.Modules.Controller.Inventory; [UsedImplicitly] internal class CheckDisksExistsReplyHandler( IWorkflow workflow, - IVirtualDiskDataService dataService, + IStateStoreRepository repository, IInventoryLockManager lockManager) : IHandleMessages> { @@ -27,7 +29,7 @@ public async Task Handle(OperationTaskStatusEvent messa if (message.GetMessage(workflow.WorkflowOptions.JsonSerializerOptions) is not CheckDisksExistsReply reply) return; - if (reply.MissingDisks is not { Length: > 0 }) + if (reply.MissingDisks is not { Count: > 0 }) return; // Acquire all necessary locks in the beginning to minimize the potential for deadlocks. @@ -36,9 +38,14 @@ public async Task Handle(OperationTaskStatusEvent messa await lockManager.AcquireVhdLock(diskIdentifier); } - foreach (var disk in reply.MissingDisks) + foreach (var diskInfo in reply.MissingDisks) { - await dataService.DeleteVHD(disk.Id); + var disk = await repository.GetByIdAsync(diskInfo.Id); + if (disk is not null) + { + disk.Deleted = true; + disk.LastSeen = reply.Timestamp; + } } } } diff --git a/src/modules/src/Eryph.Modules.Controller/Inventory/InventoryConfig.cs b/src/modules/src/Eryph.Modules.Controller/Inventory/InventoryConfig.cs new file mode 100644 index 000000000..ad53a6846 --- /dev/null +++ b/src/modules/src/Eryph.Modules.Controller/Inventory/InventoryConfig.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Eryph.Modules.Controller.Inventory; + +internal class InventoryConfig +{ + public TimeSpan InventoryInterval { get; init; } = TimeSpan.FromMinutes(10); +} \ No newline at end of file diff --git a/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateDiskInventoryCommandHandler.cs b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateDiskInventoryCommandHandler.cs new file mode 100644 index 000000000..7835e811d --- /dev/null +++ b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateDiskInventoryCommandHandler.cs @@ -0,0 +1,55 @@ +using System.Threading.Tasks; +using Dbosoft.Rebus.Operations; +using Eryph.Messages.Resources.Disks; +using Eryph.Modules.Controller.DataServices; +using Eryph.StateDb; +using JetBrains.Annotations; +using LanguageExt.UnsafeValueAccess; +using Microsoft.Extensions.Logging; +using Rebus.Handlers; +using Rebus.Pipeline; + +namespace Eryph.Modules.Controller.Inventory; + +[UsedImplicitly] +internal class UpdateDiskInventoryCommandHandler( + IInventoryLockManager lockManager, + IVirtualMachineMetadataService metadataService, + IOperationDispatcher dispatcher, + IMessageContext messageContext, + IVirtualMachineDataService vmDataService, + IVMHostMachineDataService vmHostDataService, + IStateStore stateStore, + ILogger logger) + : UpdateInventoryCommandHandlerBase( + lockManager, + metadataService, + dispatcher, + vmDataService, + stateStore, + messageContext, + logger), + IHandleMessages +{ + private readonly IInventoryLockManager _lockManager = lockManager; + + public async Task Handle(UpdateDiskInventoryCommand message) + { + var vmHost = await vmHostDataService.GetVMHostByAgentName(message.AgentName); + if (vmHost.IsNone || IsUpdateOutdated(vmHost.ValueUnsafe(), message.Timestamp)) + return; + + var diskIdentifiers = CollectDiskIdentifiers(message.Inventory.ToSeq()); + foreach (var diskIdentifier in diskIdentifiers) + { + await _lockManager.AcquireVhdLock(diskIdentifier); + } + + foreach (var diskInfo in message.Inventory) + { + await AddOrUpdateDisk(message.AgentName, message.Timestamp, diskInfo); + } + + await CheckDisks(message.Timestamp, message.AgentName); + } +} diff --git a/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateInventoryCommandHandlerBase.cs b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateInventoryCommandHandlerBase.cs index 1065a3e24..b39a0c89b 100644 --- a/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateInventoryCommandHandlerBase.cs +++ b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateInventoryCommandHandlerBase.cs @@ -21,17 +21,17 @@ using Microsoft.Extensions.Logging; using Rebus.Pipeline; +using static LanguageExt.Prelude; + namespace Eryph.Modules.Controller.Inventory { internal class UpdateInventoryCommandHandlerBase { private readonly IOperationDispatcher _dispatcher; private readonly IVirtualMachineDataService _vmDataService; - private readonly IVirtualDiskDataService _vhdDataService; - private readonly IInventoryLockManager _lockManager; - protected readonly IVirtualMachineMetadataService MetadataService; - protected readonly IStateStore StateStore; + private readonly IVirtualMachineMetadataService _metadataService; + private readonly IStateStore _stateStore; private readonly IMessageContext _messageContext; private readonly ILogger _logger; @@ -40,17 +40,15 @@ protected UpdateInventoryCommandHandlerBase( IVirtualMachineMetadataService metadataService, IOperationDispatcher dispatcher, IVirtualMachineDataService vmDataService, - IVirtualDiskDataService vhdDataService, IStateStore stateStore, IMessageContext messageContext, ILogger logger) { _lockManager = lockManager; - MetadataService = metadataService; + _metadataService = metadataService; _dispatcher = dispatcher; _vmDataService = vmDataService; - _vhdDataService = vhdDataService; - StateStore = stateStore; + _stateStore = stateStore; _messageContext = messageContext; _logger = logger; } @@ -64,10 +62,10 @@ protected async Task UpdateVMs( var diskInfos = vms.SelectMany(x => x.Drives) .Select(d => d.Disk) .Where(d => d != null) - .ToList(); + .ToSeq(); // Acquire all necessary locks in the beginning to minimize the potential for deadlocks. - foreach (var vhdId in diskInfos.Map(d => d.DiskIdentifier).Order()) + foreach (var vhdId in CollectDiskIdentifiers(diskInfos)) { await _lockManager.AcquireVhdLock(vhdId); } @@ -77,19 +75,22 @@ protected async Task UpdateVMs( await _lockManager.AcquireVmLock(vmId); } - var addedDisks = await ResolveAndUpdateDisks(diskInfos, timestamp, hostMachine) - .ConfigureAwait(false); - foreach (var vmInfo in vms) { //get known metadata for VM, if metadata is unknown skip this VM as it is not in Eryph management - var optionalMetadata = await MetadataService.GetMetadata(vmInfo.MetadataId).ConfigureAwait(false); + var optionalMetadata = await _metadataService.GetMetadata(vmInfo.MetadataId); //TODO: add logging that entry has been skipped due to missing metadata await optionalMetadata.IfSomeAsync(async metadata => { - var optionalMachine = (await _vmDataService.GetVM(metadata.MachineId).ConfigureAwait(false)); - var project = await FindRequiredProject(vmInfo.ProjectName, vmInfo.ProjectId).ConfigureAwait(false); + var optionalMachine = (await _vmDataService.GetVM(metadata.MachineId)); + var project = await FindRequiredProject(vmInfo.ProjectName, vmInfo.ProjectId); + if (project.BeingDeleted) + { + _logger.LogDebug("Skipping inventory update for VM {VmId}. The project {ProjectName}({ProjectId}) is marked as deleted.", + vmInfo.VMId, project.Name, project.Id); + return; + } //machine not found or metadata is assigned to new VM - a new VM resource will be created if (optionalMachine.IsNone || metadata.VMId != vmInfo.VMId) @@ -112,17 +113,16 @@ await _dispatcher.StartNew( NewMetadataId = metadata.Id, CatletId = metadata.MachineId, VMId = vmInfo.VMId, - }).ConfigureAwait(false); + }); } if (metadata.MachineId == Guid.Empty) metadata.MachineId = Guid.NewGuid(); - var catlet = await VirtualMachineInfoToCatlet(vmInfo, - hostMachine, metadata.MachineId, project, addedDisks) - .ConfigureAwait(false); - await _vmDataService.AddNewVM(catlet, metadata).ConfigureAwait(false); + var catlet = await VirtualMachineInfoToCatlet( + vmInfo, hostMachine, timestamp, metadata.MachineId, project); + await _vmDataService.AddNewVM(catlet, metadata); return; } @@ -138,17 +138,17 @@ await optionalMachine.IfSomeAsync(async existingMachine => existingMachine.LastSeen = timestamp; - await StateStore.LoadPropertyAsync(existingMachine, x=> x.Project).ConfigureAwait(false); + await _stateStore.LoadPropertyAsync(existingMachine, x => x.Project); - Debug.Assert(existingMachine.Project != null); + Debug.Assert(existingMachine.Project != null); - await StateStore.LoadCollectionAsync(existingMachine, x => x.ReportedNetworks).ConfigureAwait(false); - await StateStore.LoadCollectionAsync(existingMachine, x => x.NetworkAdapters).ConfigureAwait(false); + await _stateStore.LoadCollectionAsync(existingMachine, x => x.ReportedNetworks); + await _stateStore.LoadCollectionAsync(existingMachine, x => x.NetworkAdapters); // update data for existing machine var newMachine = await VirtualMachineInfoToCatlet(vmInfo, - hostMachine, existingMachine.Id, existingMachine.Project, addedDisks).ConfigureAwait(false); + hostMachine, timestamp, existingMachine.Id, existingMachine.Project); existingMachine.Name = newMachine.Name; existingMachine.Host = hostMachine; existingMachine.AgentName = newMachine.AgentName; @@ -177,170 +177,23 @@ await optionalMachine.IfSomeAsync(async existingMachine => existingMachine.LastSeenState = timestamp; existingMachine.Status = newMachine.Status; existingMachine.UpTime = newMachine.UpTime; - }).ConfigureAwait(false); - }).ConfigureAwait(false); - } - - - var outdatedDisks = (await _vhdDataService.FindOutdated(timestamp, hostMachine.Name)).ToArray(); - if (outdatedDisks.Length == 0) - return; - await _dispatcher.StartNew( - EryphConstants.DefaultTenantId, - Guid.NewGuid().ToString(), - new CheckDisksExistsCommand - { - AgentName = hostMachine.Name, - Disks = outdatedDisks.Select(d => new DiskInfo - { - Id = d.Id, - ProjectId = d.Project.Id, - ProjectName = d.Project.Name, - DataStore = d.DataStore, - Environment = d.Environment, - StorageIdentifier = d.StorageIdentifier, - Name = d.Name, - FileName = d.FileName, - Path = d.Path, - DiskIdentifier = d.DiskIdentifier, - Gene = d.ToUniqueGeneId(GeneType.Volume) - .IfNoneUnsafe((UniqueGeneIdentifier?)null), - }).ToArray() + }); }); - } - - private async Task> ResolveAndUpdateDisks( - List diskInfos, - DateTimeOffset timestamp, - CatletFarm hostMachine) - { - var allDisks = diskInfos - .ToSeq() - .Map(SelectAllParentDisks) - .Flatten() - .Distinct((x, y) => string.Equals(x.Path, y.Path, StringComparison.OrdinalIgnoreCase) - && string.Equals(x.FileName, y.FileName, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - var addedDisks = new List(); - - foreach (var diskInfo in allDisks) - { - var project = await FindProject(diskInfo.ProjectName, diskInfo.ProjectId) - .IfNoneAsync(() => FindRequiredProject(EryphConstants.DefaultProjectName, null)) - .ConfigureAwait(false); - - var disk = await LookupVirtualDisk(diskInfo, project, addedDisks) - .IfNoneAsync(async () => - { - var d = new VirtualDisk - { - Id = diskInfo.Id, - Name = diskInfo.Name, - DiskIdentifier = diskInfo.DiskIdentifier, - DataStore = diskInfo.DataStore, - Environment = diskInfo.Environment, - StorageIdentifier = diskInfo.StorageIdentifier, - Project = project, - FileName = diskInfo.FileName, - Path = diskInfo.Path.ToLowerInvariant(), - GeneSet = diskInfo.Gene?.Id.GeneSet.Value, - GeneName = diskInfo.Gene?.Id.GeneName.Value, - GeneArchitecture = diskInfo.Gene?.Architecture.Value - }; - d = await _vhdDataService.AddNewVHD(d).ConfigureAwait(false); - addedDisks.Add(d); - return d; - }).ConfigureAwait(false); - - disk.SizeBytes = diskInfo.SizeBytes; - disk.UsedSizeBytes = diskInfo.UsedSizeBytes; - disk.Frozen = diskInfo.Frozen; - disk.LastSeen = timestamp; - disk.LastSeenAgent = hostMachine.Name; - await _vhdDataService.UpdateVhd(disk).ConfigureAwait(false); - - } - - //second loop to assign parents and to update state db - foreach (var diskInfo in diskInfos) - { - var project = await FindProject(diskInfo.ProjectName, diskInfo.ProjectId) - .IfNoneAsync(() => FindRequiredProject(EryphConstants.DefaultProjectName, null)) - .ConfigureAwait(false); - - await LookupVirtualDisk(diskInfo, project, addedDisks).IfSomeAsync(async currentDisk => - { - if (diskInfo.Parent == null) - { - currentDisk.Parent = null; - return; - } - - // The parent disk might be located in a different project. Most commonly, - // this happens for parent disks which are located in the gene pool as the - // gene pool is part of the default project. - var parentProject = await FindProject(diskInfo.Parent.ProjectName, diskInfo.Parent.ProjectId) - .IfNoneAsync(() => FindRequiredProject(EryphConstants.DefaultProjectName, null)) - .ConfigureAwait(false); - await LookupVirtualDisk(diskInfo.Parent, parentProject, addedDisks) - .IfSomeAsync(parentDisk => - { - currentDisk.Parent = parentDisk; - - }).ConfigureAwait(false); - await _vhdDataService.UpdateVhd(currentDisk).ConfigureAwait(false); - - }).ConfigureAwait(false); } - - return addedDisks; - } - - private static Seq SelectAllParentDisks(DiskInfo diskInfo) => - diskInfo.Parent != null - ? diskInfo.Cons(SelectAllParentDisks(diskInfo.Parent)) - : diskInfo.Cons(); - - private async Task> LookupVirtualDisk( - DiskInfo diskInfo, - Project project, - IReadOnlyCollection addedDisks) - { - return await _vhdDataService.FindVHDByLocation( - project.Id, - diskInfo.DataStore, - diskInfo.Environment, - diskInfo.StorageIdentifier, - diskInfo.Name, - diskInfo.DiskIdentifier) - .Map(l => addedDisks.Append(l)) - .Map(l => l.Filter( - x => x.DataStore == diskInfo.DataStore && - x.Project.Name == diskInfo.ProjectName && - x.Environment == diskInfo.Environment && - x.StorageIdentifier == diskInfo.StorageIdentifier && - x.Name == diskInfo.Name)) - .Map(x => x.ToArray()) - .Map(candidates => candidates.Length <= 1 - ? candidates.HeadOrNone() - : candidates.Find(x => - string.Equals(x.Path, diskInfo.Path, StringComparison.OrdinalIgnoreCase) && - string.Equals(x.FileName, diskInfo.FileName, StringComparison.OrdinalIgnoreCase))).ConfigureAwait(false); } protected async Task> FindProject( string projectName, Guid? optionalProjectId) { if (optionalProjectId.GetValueOrDefault() != Guid.Empty) - return await StateStore.For().GetByIdAsync(optionalProjectId.GetValueOrDefault()).ConfigureAwait(false); + return await _stateStore.For().GetByIdAsync(optionalProjectId.GetValueOrDefault()); if (string.IsNullOrWhiteSpace(projectName)) projectName = EryphConstants.DefaultProjectName; - return await StateStore.For() + return await _stateStore.For() .GetBySpecAsync(new ProjectSpecs.GetByName( - EryphConstants.DefaultTenantId, projectName)).ConfigureAwait(false); + EryphConstants.DefaultTenantId, projectName)); } protected async Task FindRequiredProject(string projectName, @@ -349,68 +202,73 @@ protected async Task FindRequiredProject(string projectName, if (string.IsNullOrWhiteSpace(projectName)) projectName = EryphConstants.DefaultProjectName; - var foundProject = await FindProject(projectName, projectId).ConfigureAwait(false); + var foundProject = await FindProject(projectName, projectId); return foundProject.IfNone( () => throw new NotFoundException( $"Project '{(projectId.HasValue ? projectId : projectName)}' not found.")); } - private Task VirtualMachineInfoToCatlet(VirtualMachineData vmInfo, CatletFarm hostMachine, - Guid machineId, Project project, IReadOnlyCollection addedDisks) - { - return - from drivesAndDisks in Task.FromResult(vmInfo.Drives) - .MapAsync( drives=> drives.Map(d => - d.Disk != null - ? LookupVirtualDisk(d.Disk, project, addedDisks) - .Map(disk=>(Drive: d, Disk: disk )) - : Task.FromResult( (Drive: d, Disk: Option.None)) - ).TraverseSerial(l=>l)) - - let drives = drivesAndDisks.Map(d => new CatletDrive - { - Id = d.Drive.Id, - CatletId = machineId, - Type = d.Drive.Type ?? CatletDriveType.VHD, - AttachedDisk = d.Disk.IfNoneUnsafe(() => null) - - }).ToList() - - select new Catlet + private Task VirtualMachineInfoToCatlet( + VirtualMachineData vmInfo, + CatletFarm hostMachine, + DateTimeOffset timestamp, + Guid machineId, + Project project) => + from _ in Task.FromResult(unit) + from drives in vmInfo.Drives.ToSeq() + .Map(d => VirtualMachineDriveDataToCatletDrive(d, hostMachine.Name, timestamp)) + .SequenceSerial() + select new Catlet + { + Id = machineId, + Project = project, + ProjectId = project.Id, + VMId = vmInfo.VMId, + Name = vmInfo.Name, + Status = vmInfo.Status.ToCatletStatus(), + Host = hostMachine, + AgentName = hostMachine.Name, + DataStore = vmInfo.DataStore, + Environment = vmInfo.Environment, + Path = vmInfo.VMPath, + Frozen = vmInfo.Frozen, + StorageIdentifier = vmInfo.StorageIdentifier, + MetadataId = vmInfo.MetadataId, + UpTime = vmInfo.Status is VmStatus.Stopped ? TimeSpan.Zero : vmInfo.UpTime, + CpuCount = vmInfo.Cpu?.Count ?? 0, + StartupMemory = vmInfo.Memory?.Startup ?? 0, + MinimumMemory = vmInfo.Memory?.Minimum ?? 0, + MaximumMemory = vmInfo.Memory?.Maximum ?? 0, + Features = MapFeatures(vmInfo), + SecureBootTemplate = vmInfo.Firmware?.SecureBootTemplate, + NetworkAdapters = vmInfo.NetworkAdapters.Select(a => new CatletNetworkAdapter { - Id = machineId, - Project = project, - ProjectId = project.Id, - VMId = vmInfo.VMId, - Name = vmInfo.Name, - Status = vmInfo.Status.ToCatletStatus(), - Host = hostMachine, - AgentName = hostMachine.Name, - DataStore = vmInfo.DataStore, - Environment = vmInfo.Environment, - Path = vmInfo.VMPath, - Frozen = vmInfo.Frozen, - StorageIdentifier = vmInfo.StorageIdentifier, - MetadataId = vmInfo.MetadataId, - UpTime = vmInfo.Status is VmStatus.Stopped ? TimeSpan.Zero : vmInfo.UpTime, - CpuCount = vmInfo.Cpu?.Count ?? 0, - StartupMemory = vmInfo.Memory?.Startup ?? 0, - MinimumMemory = vmInfo.Memory?.Minimum ?? 0, - MaximumMemory = vmInfo.Memory?.Maximum ?? 0, - Features = MapFeatures(vmInfo), - SecureBootTemplate = vmInfo.Firmware?.SecureBootTemplate, - NetworkAdapters = vmInfo.NetworkAdapters.Select(a => new CatletNetworkAdapter - { - Id = a.Id, - CatletId = machineId, - Name = a.AdapterName, - SwitchName = a.VirtualSwitchName, - MacAddress = a.MacAddress, - }).ToList(), - Drives = drives, - ReportedNetworks = (vmInfo.Networks?.ToReportedNetwork(machineId) ?? Array.Empty()).ToList() - }; + Id = a.Id, + CatletId = machineId, + Name = a.AdapterName, + SwitchName = a.VirtualSwitchName, + MacAddress = a.MacAddress, + }).ToList(), + Drives = drives.ToList(), + ReportedNetworks = (vmInfo.Networks?.ToReportedNetwork(machineId) ?? []).ToList() + }; + + private async Task VirtualMachineDriveDataToCatletDrive( + VirtualMachineDriveData driveData, + string agentName, + DateTimeOffset timestamp) + { + var disk = await Optional(driveData.Disk) + .BindAsync(d => AddOrUpdateDisk(agentName, timestamp, d).ToAsync()) + .ToOption(); + + return new CatletDrive + { + Id = driveData.Id, + Type = driveData.Type ?? CatletDriveType.VHD, + AttachedDisk = disk.IfNoneUnsafe(() => null) + }; } private static ISet MapFeatures(VirtualMachineData vmInfo) @@ -431,5 +289,143 @@ private static ISet MapFeatures(VirtualMachineData vmInfo) return features; } + + protected async Task> AddOrUpdateDisk( + string agentName, + DateTimeOffset timestamp, + DiskInfo diskInfo) + { + var disk = await GetDisk(agentName, diskInfo); + if (disk is not null && (disk.LastSeen >= timestamp || disk.Project.BeingDeleted)) + return disk; + + Option parentDisk = null; + if (diskInfo.Parent is not null) + { + parentDisk = await AddOrUpdateDisk(agentName, timestamp, diskInfo.Parent); + } + + if (disk is not null) + { + // We do not attempt to update the project of an existing disks. + // Disks are looked up per project so we are always creating a + // new disk entry in the database. + + disk.Parent = parentDisk.IfNoneUnsafe(() => null); + disk.SizeBytes = diskInfo.SizeBytes; + disk.UsedSizeBytes = diskInfo.UsedSizeBytes; + disk.Frozen = diskInfo.Frozen; + disk.Deleted = false; + disk.LastSeen = timestamp; + disk.LastSeenAgent = agentName; + await _stateStore.SaveChangesAsync(); + return disk; + } + + var project = await FindProject(diskInfo.ProjectName, diskInfo.ProjectId) + .IfNoneAsync(() => FindRequiredProject(EryphConstants.DefaultProjectName, null)); + if (project.BeingDeleted) + return None; + + disk = new VirtualDisk + { + Id = diskInfo.Id, + Name = diskInfo.Name, + DiskIdentifier = diskInfo.DiskIdentifier, + DataStore = diskInfo.DataStore, + Environment = diskInfo.Environment, + StorageIdentifier = diskInfo.StorageIdentifier, + Project = project, + FileName = diskInfo.FileName, + Path = diskInfo.Path.ToLowerInvariant(), + GeneSet = diskInfo.Gene?.Id.GeneSet.Value, + GeneName = diskInfo.Gene?.Id.GeneName.Value, + GeneArchitecture = diskInfo.Gene?.Architecture.Value, + SizeBytes = diskInfo.SizeBytes, + UsedSizeBytes = diskInfo.UsedSizeBytes, + Frozen = diskInfo.Frozen, + LastSeen = timestamp, + LastSeenAgent = agentName, + Parent = parentDisk.IfNoneUnsafe(() => null), + }; + await _stateStore.For().AddAsync(disk); + await _stateStore.SaveChangesAsync(); + return disk; + } + + protected async Task CheckDisks( + DateTimeOffset timestamp, + string agentName) + { + var outdatedDisks = await _stateStore.For().ListAsync( + new VirtualDiskSpecs.FindOutdated(timestamp, agentName)); + if (outdatedDisks.Count == 0) + return; + + await _dispatcher.StartNew( + EryphConstants.DefaultTenantId, + Guid.NewGuid().ToString(), + new CheckDisksExistsCommand + { + AgentName = agentName, + Disks = outdatedDisks.Select(d => new DiskInfo + { + Id = d.Id, + ProjectId = d.Project.Id, + ProjectName = d.Project.Name, + DataStore = d.DataStore, + Environment = d.Environment, + StorageIdentifier = d.StorageIdentifier, + Name = d.Name, + FileName = d.FileName, + Path = d.Path, + DiskIdentifier = d.DiskIdentifier, + Gene = d.ToUniqueGeneId(GeneType.Volume) + .IfNoneUnsafe((UniqueGeneIdentifier?)null), + }).ToArray() + }); + } + + protected async Task GetDisk( + string agentName, DiskInfo diskInfo) + { + var project = await FindProject(diskInfo.ProjectName, diskInfo.ProjectId) + .IfNoneAsync(() => FindRequiredProject(EryphConstants.DefaultProjectName, null)); + + var virtualDisks = await _stateStore.For().ListAsync( + new VirtualDiskSpecs.GetByLocation( + project.Id, + diskInfo.DataStore, + diskInfo.Environment, + diskInfo.StorageIdentifier, + diskInfo.Name, + diskInfo.DiskIdentifier)); + + return virtualDisks.Length() > 1 + ? virtualDisks.FirstOrDefault(d => + string.Equals(d.Path, diskInfo.Path, StringComparison.OrdinalIgnoreCase) + && string.Equals(d.FileName, diskInfo.FileName, StringComparison.OrdinalIgnoreCase)) + : virtualDisks.FirstOrDefault(); + } + + protected Seq CollectDiskIdentifiers(Seq diskInfos) => + diskInfos.Map(d => Optional(d.Parent)).Somes() + .Match(Empty: Seq, Seq: CollectDiskIdentifiers) + .Append(diskInfos.Map(d => d.DiskIdentifier)) + .Distinct() + .Order() + .ToSeq(); + + protected bool IsUpdateOutdated(CatletFarm vmHost, DateTimeOffset timestamp) + { + if (vmHost.LastInventory >= timestamp) + { + _logger.LogInformation( + "Skipping inventory update for host {Hostname} with timestamp {Timestamp:O}. Most recent information is dated {LastInventory:O}.", + vmHost.Name, timestamp, vmHost.LastInventory); + return true; + } + return false; + } } -} \ No newline at end of file +} diff --git a/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMHostInventoryCommandHandler.cs b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMHostInventoryCommandHandler.cs index e648180cd..190d483a2 100644 --- a/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMHostInventoryCommandHandler.cs +++ b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMHostInventoryCommandHandler.cs @@ -21,7 +21,6 @@ internal class UpdateVMHostInventoryCommandHandler( IOperationDispatcher dispatcher, IMessageContext messageContext, IVirtualMachineDataService vmDataService, - IVirtualDiskDataService vhdDataService, IVMHostMachineDataService vmHostDataService, IStateStore stateStore, ILogger logger) @@ -30,28 +29,43 @@ internal class UpdateVMHostInventoryCommandHandler( metadataService, dispatcher, vmDataService, - vhdDataService, stateStore, messageContext, logger), IHandleMessages { + private readonly IInventoryLockManager _lockManager = lockManager; + public async Task Handle(UpdateVMHostInventoryCommand message) { - var newMachineState = await - vmHostDataService.GetVMHostByHardwareId(message.HostInventory.HardwareId).IfNoneAsync( - async () => new CatletFarm - { - Id = Guid.NewGuid(), - Name = message.HostInventory.Name, - HardwareId = message.HostInventory.HardwareId, - Project = await FindRequiredProject(EryphConstants.DefaultProjectName, null), - Environment = EryphConstants.DefaultEnvironmentName, - }); - - var existingMachine = await vmHostDataService.GetVMHostByHardwareId(message.HostInventory.HardwareId) - .IfNoneAsync(() => vmHostDataService.AddNewVMHost(newMachineState)); - - await UpdateVMs(message.Timestamp, message.VMInventory, existingMachine); + var vmHost = await vmHostDataService.GetVMHostByHardwareId(message.HostInventory.HardwareId) + .IfNoneAsync(async () => await vmHostDataService.AddNewVMHost(new CatletFarm + { + Id = Guid.NewGuid(), + Name = message.HostInventory.Name, + HardwareId = message.HostInventory.HardwareId, + Project = await FindRequiredProject(EryphConstants.DefaultProjectName, null), + Environment = EryphConstants.DefaultEnvironmentName, + })); + + if (IsUpdateOutdated(vmHost, message.Timestamp)) + return; + + var diskIdentifiers = CollectDiskIdentifiers(message.DiskInventory.ToSeq()); + foreach (var diskIdentifier in diskIdentifiers) + { + await _lockManager.AcquireVhdLock(diskIdentifier); + } + + foreach (var diskInfo in message.DiskInventory) + { + await AddOrUpdateDisk(vmHost.Name, message.Timestamp, diskInfo); + } + + await UpdateVMs(message.Timestamp, message.VMInventory, vmHost); + + await CheckDisks(message.Timestamp, vmHost.Name); + + vmHost.LastInventory = message.Timestamp; } } diff --git a/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMInventoryCommandHandler.cs b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMInventoryCommandHandler.cs index 5ebdd6e7c..015ca68c4 100644 --- a/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMInventoryCommandHandler.cs +++ b/src/modules/src/Eryph.Modules.Controller/Inventory/UpdateVMInventoryCommandHandler.cs @@ -4,6 +4,7 @@ using Eryph.Modules.Controller.DataServices; using Eryph.StateDb; using JetBrains.Annotations; +using LanguageExt.UnsafeValueAccess; using Microsoft.Extensions.Logging; using Rebus.Handlers; using Rebus.Pipeline; @@ -11,38 +12,31 @@ namespace Eryph.Modules.Controller.Inventory; [UsedImplicitly] -internal class UpdateVMInventoryCommandHandler - : UpdateInventoryCommandHandlerBase, - IHandleMessages -{ - private readonly IVMHostMachineDataService _vmHostDataService; - - public UpdateVMInventoryCommandHandler( - IInventoryLockManager lockManager, - IVirtualMachineMetadataService metadataService, - IOperationDispatcher dispatcher, - IMessageContext messageContext, - IVirtualMachineDataService vmDataService, - IVirtualDiskDataService vhdDataService, - IVMHostMachineDataService vmHostDataService, - IStateStore stateStore, - ILogger logger) : - base(lockManager, +internal class UpdateVMInventoryCommandHandler( + IInventoryLockManager lockManager, + IVirtualMachineMetadataService metadataService, + IOperationDispatcher dispatcher, + IMessageContext messageContext, + IVirtualMachineDataService vmDataService, + IVMHostMachineDataService vmHostDataService, + IStateStore stateStore, + ILogger logger) + : UpdateInventoryCommandHandlerBase( + lockManager, metadataService, dispatcher, vmDataService, - vhdDataService, stateStore, messageContext, - logger) - { - _vmHostDataService = vmHostDataService; - } - - - public Task Handle(UpdateInventoryCommand message) + logger), + IHandleMessages +{ + public async Task Handle(UpdateInventoryCommand message) { - return _vmHostDataService.GetVMHostByAgentName(message.AgentName) - .IfSomeAsync(hostMachine => UpdateVMs(message.Timestamp, message.Inventory, hostMachine)); + var vmHost = await vmHostDataService.GetVMHostByAgentName(message.AgentName); + if (vmHost.IsNone || IsUpdateOutdated(vmHost.ValueUnsafe(), message.Timestamp)) + return; + + await UpdateVMs(message.Timestamp, [message.Inventory], vmHost.ValueUnsafe()); } -} \ No newline at end of file +} diff --git a/src/modules/src/Eryph.Modules.Controller/Inventory/VirtualDiskCleanupJob.cs b/src/modules/src/Eryph.Modules.Controller/Inventory/VirtualDiskCleanupJob.cs new file mode 100644 index 000000000..782138ede --- /dev/null +++ b/src/modules/src/Eryph.Modules.Controller/Inventory/VirtualDiskCleanupJob.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Eryph.StateDb; +using Eryph.StateDb.Model; +using Eryph.StateDb.Specifications; +using Microsoft.Extensions.Logging; +using Quartz; +using SimpleInjector; +using SimpleInjector.Lifestyles; + +namespace Eryph.Modules.Controller.Inventory; + +internal class VirtualDiskCleanupJob(Container container) : IJob +{ + public static readonly JobKey Key = new(nameof(VirtualDiskCleanupJob)); + + public async Task Execute(IJobExecutionContext context) + { + await using var scope = AsyncScopedLifestyle.BeginScope(container); + var stateStore = container.GetInstance(); + var lockManager = container.GetInstance(); + + var disks = await stateStore.For().ListAsync( + new VirtualDiskSpecs.FindDeleted(DateTimeOffset.UtcNow.AddHours(-1))); + var diskIdentifiers = disks.Select(d => d.DiskIdentifier).Distinct().Order(); + foreach (var diskIdentifier in diskIdentifiers) + { + await lockManager.AcquireVhdLock(diskIdentifier); + } + + await stateStore.For().DeleteRangeAsync(disks); + + await stateStore.SaveChangesAsync(); + } +} diff --git a/src/modules/src/Eryph.Modules.Controller/Networks/DestroyVirtualNetworksSaga.cs b/src/modules/src/Eryph.Modules.Controller/Networks/DestroyVirtualNetworksSaga.cs index d6e876f5a..4e972689b 100644 --- a/src/modules/src/Eryph.Modules.Controller/Networks/DestroyVirtualNetworksSaga.cs +++ b/src/modules/src/Eryph.Modules.Controller/Networks/DestroyVirtualNetworksSaga.cs @@ -59,6 +59,7 @@ protected override async Task Initiated(DestroyVirtualNetworksCommand message) { await Complete(new DestroyResourcesResponse { + DetachedResources = [], DestroyedResources = destroyedNetworks .Select(x => new Resource(ResourceType.VirtualNetwork, x)).ToArray() }); @@ -83,6 +84,7 @@ public Task Handle(OperationTaskStatusEvent message) { return Complete(new DestroyResourcesResponse { + DetachedResources = [], DestroyedResources = (Data.DestroyedNetworks ?? Array.Empty()) .Select(x => new Resource(ResourceType.VirtualNetwork, x)).ToArray() }); diff --git a/src/modules/src/Eryph.Modules.Controller/Projects/DestroyProjectCommandFailedHandler.cs b/src/modules/src/Eryph.Modules.Controller/Projects/DestroyProjectCommandFailedHandler.cs new file mode 100644 index 000000000..9d887cec5 --- /dev/null +++ b/src/modules/src/Eryph.Modules.Controller/Projects/DestroyProjectCommandFailedHandler.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Dbosoft.Rebus.Operations; +using Dbosoft.Rebus.Operations.Events; +using Eryph.Messages.Projects; +using Eryph.StateDb; +using Eryph.StateDb.Model; +using Microsoft.Extensions.Logging; +using Rebus.Handlers; + +namespace Eryph.Modules.Controller.Projects; + +/// +/// The handler removes the flag +/// in case the fails. +/// +/// +/// The project is in a partially deleted state in case the saga fails. +/// Most likely, the project is not usable anymore. We remove the flag +/// just in case. +/// +internal class DestroyProjectCommandFailedHandler( + ILogger logger, + IStateStore stateStore, + WorkflowOptions workflowOptions) + : IHandleMessages> +{ + public async Task Handle(OperationTaskStatusEvent message) + { + if (!message.OperationFailed) + return; + + if (message.GetMessage(workflowOptions.JsonSerializerOptions) is not DestroyProjectCommand command) + return; + + var project = await stateStore.For().GetByIdAsync(command.ProjectId); + if (project is null) + return; + + logger.LogInformation("Could not delete the project {ProjectId}. Removing the BeingDeleted flag.", + command.ProjectId); + + project.BeingDeleted = false; + await stateStore.SaveChangesAsync(); + } +} diff --git a/src/modules/src/Eryph.Modules.Controller/Projects/DestroyProjectSaga.cs b/src/modules/src/Eryph.Modules.Controller/Projects/DestroyProjectSaga.cs index a23d4f7a4..bd11de9cf 100644 --- a/src/modules/src/Eryph.Modules.Controller/Projects/DestroyProjectSaga.cs +++ b/src/modules/src/Eryph.Modules.Controller/Projects/DestroyProjectSaga.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; using Dbosoft.Rebus.Operations; using Dbosoft.Rebus.Operations.Events; @@ -6,94 +7,96 @@ using Eryph.Core; using Eryph.Messages.Projects; using Eryph.Messages.Resources.Commands; +using Eryph.Modules.Controller.Inventory; using Eryph.StateDb; using Eryph.StateDb.Model; using Eryph.StateDb.Specifications; using JetBrains.Annotations; +using Microsoft.Extensions.Logging; using Rebus.Handlers; using Rebus.Sagas; using Resource = Eryph.Resources.Resource; -namespace Eryph.Modules.Controller.Projects -{ - [UsedImplicitly] - internal class DestroyProjectSaga : OperationTaskWorkflowSaga, +namespace Eryph.Modules.Controller.Projects; + +[UsedImplicitly] +internal class DestroyProjectSaga( + ILogger logger, + IInventoryLockManager lockManager, + IWorkflow workflow, + IStateStore stateStore) + : OperationTaskWorkflowSaga(workflow), IHandleMessages> +{ + protected override void CorrelateMessages( + ICorrelationConfig config) { - private readonly IStateStore _stateStore; - - public DestroyProjectSaga(IWorkflow workflow, IStateStore stateStore) : base(workflow) - { - _stateStore = stateStore; - } + base.CorrelateMessages(config); + config.Correlate>( + m => m.InitiatingTaskId, d => d.SagaTaskId); + } + protected override async Task Initiated(DestroyProjectCommand message) + { + Data.ProjectId = message.ProjectId; - protected override void CorrelateMessages(ICorrelationConfig config) + if (Data.ProjectId == EryphConstants.DefaultProjectId) { - base.CorrelateMessages(config); - config.Correlate>(m => m.InitiatingTaskId, d => d.SagaTaskId); + await Fail(new ErrorData { ErrorMessage = "Default project cannot be deleted" }); + return; } - - protected override async Task Initiated(DestroyProjectCommand message) + var project = await stateStore.For().GetByIdAsync(Data.ProjectId); + if (project == null) { - Data.ProjectId = message.ProjectId; - - if (Data.ProjectId == EryphConstants.DefaultProjectId) - { - await Fail(new ErrorData { ErrorMessage = "Default project cannot be deleted" }); - return; - } - - var project = await _stateStore.For().GetByIdAsync(Data.ProjectId); - - if (project == null) - { - await Complete(); - return; - } - - await _stateStore.LoadCollectionAsync(project, x => x.Resources); - - if (project.Resources.Count == 0) - { - await DeleteProject(); - await Complete(); - return; - } - - await StartNewTask(new DestroyResourcesCommand - { - Resources = project.Resources.Select(x=> new Resource(x.ResourceType, x.Id)).ToArray() - }); - - + await Complete(); + return; } - private async Task DeleteProject() + await stateStore.LoadCollectionAsync(project, x => x.Resources); + if (project.Resources.Count == 0) { - var project = await _stateStore.For().GetByIdAsync(Data.ProjectId); + await DeleteProject(); + await Complete(); + return; + } - if (project != null) - { - var roleAssignments = await _stateStore.For() - .ListAsync(new ProjectRoleAssignmentSpecs.GetByProject(project.Id)) - .ConfigureAwait(false); + project.BeingDeleted = true; - await _stateStore.For().DeleteRangeAsync(roleAssignments).ConfigureAwait(false); - await _stateStore.For().DeleteAsync(project).ConfigureAwait(false); - } - } + await StartNewTask(new DestroyResourcesCommand + { + Resources = project.Resources.Select(x=> new Resource(x.ResourceType, x.Id)).ToArray() + }); + } - public Task Handle(OperationTaskStatusEvent message) + private async Task DeleteProject() + { + var project = await stateStore.For().GetByIdAsync(Data.ProjectId); + if (project is null) + return; + + var disks = await stateStore.For() + .ListAsync(new VirtualDiskSpecs.FindDeletedInProject(project.Id)); + var diskIdentifiers = disks.Select(d => d.DiskIdentifier).Distinct().Order(); + foreach (var diskIdentifier in diskIdentifiers) { - return FailOrRun(message, - async (response) => - { - await DeleteProject(); - await Complete(response); - }); + await lockManager.AcquireVhdLock(diskIdentifier); } + await stateStore.For().DeleteRangeAsync(disks); + + var roleAssignments = await stateStore.For() + .ListAsync(new ProjectRoleAssignmentSpecs.GetByProject(project.Id)); + await stateStore.For().DeleteRangeAsync(roleAssignments); + + await stateStore.For().DeleteAsync(project); + } + public Task Handle(OperationTaskStatusEvent message) + { + return FailOrRun(message, async (DestroyResourcesResponse response) => + { + await DeleteProject(); + await Complete(response); + }); } -} \ No newline at end of file +} diff --git a/src/modules/src/Eryph.Modules.Controller/StateStoreDbUnitOfWork.cs b/src/modules/src/Eryph.Modules.Controller/StateStoreDbUnitOfWork.cs index 27c0f59b3..4618fe40c 100644 --- a/src/modules/src/Eryph.Modules.Controller/StateStoreDbUnitOfWork.cs +++ b/src/modules/src/Eryph.Modules.Controller/StateStoreDbUnitOfWork.cs @@ -1,43 +1,44 @@ using System.Threading.Tasks; using Dbosoft.Rebus; -using Eryph.Rebus; using Eryph.StateDb; using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore.Storage; -namespace Eryph.Modules.Controller -{ - [UsedImplicitly] - public sealed class StateStoreDbUnitOfWork : IRebusUnitOfWork - { - private readonly StateStoreContext _dbContext; +namespace Eryph.Modules.Controller; - public StateStoreDbUnitOfWork(StateStoreContext dbContext) - { - _dbContext = dbContext; - } +[UsedImplicitly] +public sealed class StateStoreDbUnitOfWork( + StateStoreContext dbContext) + : IRebusUnitOfWork +{ + private IDbContextTransaction? _dbTransaction; - public ValueTask DisposeAsync() - { - return default; - } + public async Task Initialize() + { + _dbTransaction = await dbContext.Database.BeginTransactionAsync(); + } - public Task Initialize() - { - return Task.CompletedTask; - } + public async Task Commit() + { + await dbContext.SaveChangesAsync(); + if(_dbTransaction is not null) + await _dbTransaction.CommitAsync(); + } - public Task Commit() - { - return _dbContext.SaveChangesAsync(); - } + public async Task Rollback() + { + if (_dbTransaction is not null) + await _dbTransaction.RollbackAsync(); + } - public Task Rollback() - { - return Task.CompletedTask; - } + public async ValueTask DisposeAsync() + { + if(_dbTransaction is not null) + await _dbTransaction.DisposeAsync(); + } - public void Dispose() - { - } + public void Dispose() + { + _dbTransaction?.Dispose(); } -} \ No newline at end of file +} diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfiguration.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfiguration.cs index 4e7073d0d..dbeffb6f4 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfiguration.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfiguration.cs @@ -33,7 +33,7 @@ public static Aff readConfig( from fileExists in File.exists(configPath) from config in fileExists ? from yaml in File.readAllText(configPath) - from config in parseConfigYaml(yaml) + from config in parseConfigYaml(yaml, false) select config : from config in SuccessEff(new VmHostAgentConfiguration()) from _ in saveConfig(config, configPath, hostSettings) @@ -53,14 +53,23 @@ from yaml in serialize(configToSave) from __ in File.writeAllText(configPath, yaml) select unit; - public static Eff parseConfigYaml(string yaml) => + public static Eff parseConfigYaml( + string yaml, + bool strict) => from y in Optional(yaml).Filter(notEmpty) .ToEff(Error.New("The configuration must not be empty.")) from config in Eff(() => { - var yamlDeserializer = new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .Build(); + var builder = new DeserializerBuilder() + .WithCaseInsensitivePropertyMatching() + .WithNamingConvention(UnderscoredNamingConvention.Instance); + + if (!strict) + { + builder = builder.IgnoreUnmatchedProperties(); + } + + var yamlDeserializer = builder.Build(); return yamlDeserializer.Deserialize(yaml); }).MapFail(error => Error.New("The configuration is malformed.", error)) @@ -150,5 +159,6 @@ private static VmHostAgentDefaultsConfiguration applyHostDefaults( { Vms = Optional(defaults.Vms).Filter(notEmpty).IfNone(hostSettings.DefaultDataPath), Volumes = Optional(defaults.Volumes).Filter(notEmpty).IfNone(hostSettings.DefaultVirtualHardDiskPath), + WatchFileSystem = defaults.WatchFileSystem, }; } diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfigurationUpdate.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfigurationUpdate.cs index cbd82969b..3a14d1597 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfigurationUpdate.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Configuration/VmHostAgentConfigurationUpdate.cs @@ -15,7 +15,7 @@ public static Aff updateConfig( string configYaml, string configPath, HostSettings hostSettings) => - from newConfig in VmHostAgentConfiguration.parseConfigYaml(configYaml) + from newConfig in VmHostAgentConfiguration.parseConfigYaml(configYaml, true) from _ in VmHostAgentConfigurationValidations.ValidateVmHostAgentConfig(newConfig) .ToAff(issues => Error.New("The new configuration is invalid.", Error.Many(issues.Map(i => i.ToError())))) diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Eryph.Modules.VmHostAgent.csproj b/src/modules/src/Eryph.Modules.VmHostAgent/Eryph.Modules.VmHostAgent.csproj index 50d5f382b..885cf7679 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/Eryph.Modules.VmHostAgent.csproj +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Eryph.Modules.VmHostAgent.csproj @@ -24,6 +24,7 @@ + diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/CheckDisksExistsCommandHandler.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/CheckDisksExistsCommandHandler.cs index d465881e2..dca8124f8 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/CheckDisksExistsCommandHandler.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/CheckDisksExistsCommandHandler.cs @@ -34,6 +34,8 @@ public Task Handle(OperationTask message) => private EitherAsync Handle( CheckDisksExistsCommand command) => + from _ in RightAsync(unit) + let timestamp = DateTimeOffset.UtcNow from hostSettings in hostSettings.GetHostSettings() from vmHostAgentConfig in configurationManager.GetCurrentConfiguration(hostSettings) from missingDisks in command.Disks @@ -41,7 +43,8 @@ from missingDisks in command.Disks .SequenceSerial() select new CheckDisksExistsReply { - MissingDisks = missingDisks.Somes().ToArray(), + MissingDisks = missingDisks.Somes().ToList(), + Timestamp = timestamp, }; private EitherAsync> IsDiskMissing( diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangeWatcherService.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangeWatcherService.cs new file mode 100644 index 000000000..70b5ecf39 --- /dev/null +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangeWatcherService.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Eryph.Core; +using Eryph.Core.VmAgent; +using LanguageExt; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Rebus.Bus; + +using static LanguageExt.Prelude; +using static LanguageExt.Seq; + +namespace Eryph.Modules.VmHostAgent.Inventory; + +public sealed class DiskStoresChangeWatcherService( + IBus bus, + ILogger logger, + IHostSettingsProvider hostSettingsProvider, + IVmHostAgentConfigurationManager vmHostAgentConfigManager, + InventoryConfig inventoryConfig) + : IHostedService, IDisposable +{ + private IDisposable? _subscription; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private bool _stopping; + + public async Task StartAsync(CancellationToken cancellationToken) + { + await Restart(); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + // Do not pass the cancellationToken as we must always wait + // for the semaphore. Another thread might be restarting the + // watchers at this moment. We must wait for that thread to + // complete so we can stop the watchers correctly. +#pragma warning disable CA2016 + await _semaphore.WaitAsync(); +#pragma warning restore CA2016 + try + { + _stopping = true; + _subscription?.Dispose(); + } + finally + { + _semaphore.Release(); + } + } + + public async Task Restart() + { + await _semaphore.WaitAsync(); + try + { + if (_stopping) + return; + + logger.LogInformation("Starting watcher for disk stores with latest settings..."); + + _subscription?.Dispose(); + + var vmHostAgentConfig = await GetConfig(); + var paths = append( + vmHostAgentConfig.Environments.ToSeq() + .Bind(e => e.Datastores.ToSeq()) + .Filter(ds => ds.WatchFileSystem) + .Map(ds => ds.Path), + vmHostAgentConfig.Environments.ToSeq() + .Filter(e => e.Defaults.WatchFileSystem) + .Map(e => e.Defaults.Volumes), + vmHostAgentConfig.Datastores.ToSeq() + .Filter(ds => ds.WatchFileSystem) + .Map(ds => ds.Path), + Seq1(vmHostAgentConfig.Defaults) + .Filter(d => d.WatchFileSystem) + .Map(d => d.Volumes)); + + // The observable should not terminate unless we dispose it. When the observable + // ends, we stop monitoring the file system events which would be a bug. + _subscription = ObserveStores(paths).Subscribe( + onNext: _ => { }, + onError: ex => logger.LogCritical( + ex, "Failed to monitor file system events for the disk stores. Inventory updates might be delayed until eryph is restarted."), + onCompleted: () => logger.LogCritical( + "The monitoring of file system events for the disk stores stopped unexpectedly. Inventory updates might be delayed until eryph is restarted.")); + } + finally + { + _semaphore.Release(); + } + } + + private async Task GetConfig() + { + var result = await hostSettingsProvider.GetHostSettings() + .Bind(vmHostAgentConfigManager.GetCurrentConfiguration) + .ToAff(identity) + .Run(); + + return result.ThrowIfFail(); + } + + public void Dispose() + { + _subscription?.Dispose(); + _semaphore.Dispose(); + } + + /// + /// Creates an which monitors the given + /// . + /// + /// + /// This method internally uses multiple s + /// to monitor the . For simplicity, all their + /// events are folded into a single event stream. The event stream is throttled + /// to avoid triggering too many inventory actions. Every event, which emerges + /// at the end, triggers a full inventory of all disk stores by raising a + /// via the local Rebus. + /// + private IObservable ObserveStores(Seq paths) => + paths.ToObservable() + .Select(ObservePath) + .Merge() + .Throttle(inventoryConfig.DiskEventDelay) + .Select(_ => Observable.FromAsync(() => bus.SendLocal(new DiskStoresChangedEvent())) + .Catch((Exception ex) => + { + logger.LogError(ex, "Could not send Rebus event for disk store change"); + return Observable.Return(System.Reactive.Unit.Default); + })) + .Concat(); + + private IObservable ObservePath(string path) => + Observable.Defer(() => + { + if (Directory.Exists(path)) + return Observable.Return(path); + + logger.LogWarning("The store path '{Path}' does not exist and will not be monitored.", path); + return Observable.Empty(); + }) + .SelectMany( + Observe(() => new FileSystemWatcher(path) + { + EnableRaisingEvents = true, + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.DirectoryName, + }).Merge(Observe(() => new FileSystemWatcher(path) + { + EnableRaisingEvents = true, + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName, + Filter = "*.vhdx", + }))) + .SelectMany(fsw => Observable.Merge( + Observable.FromEventPattern( + h => fsw.Created += h, h => fsw.Created -= h), + Observable.FromEventPattern( + h => fsw.Deleted += h, h => fsw.Deleted -= h), + Observable.FromEventPattern( + h => fsw.Renamed += h, h => fsw.Renamed -= h)) + .Select(ep => ep.EventArgs) + .Finally(fsw.Dispose)); + + /// + /// Tries to create the . + /// + /// + /// The constructor of throws when the path is not accessible + /// or does not exist. + /// + private IObservable Observe(Func factory) => + Observable.Defer(() => Observable.Return(factory())) + .Catch((Exception ex) => + { + logger.LogWarning(ex, + "Failed to create file system watcher. The corresponding path will not be monitored."); + return Observable.Empty(); + }); +} diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangedEvent.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangedEvent.cs new file mode 100644 index 000000000..200f82435 --- /dev/null +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangedEvent.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Eryph.Modules.VmHostAgent.Inventory; + +/// +/// This event is raised by +/// when file system changes are detected in any of the disk stores. +/// +internal class DiskStoresChangedEvent +{ +} diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangedEventHandler.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangedEventHandler.cs new file mode 100644 index 000000000..051b3e48d --- /dev/null +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/DiskStoresChangedEventHandler.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Dbosoft.Rebus.Operations; +using Eryph.Core; +using Eryph.Messages.Resources.Disks; +using Eryph.VmManagement; +using Eryph.VmManagement.Inventory; +using JetBrains.Annotations; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using Microsoft.Extensions.Logging; +using Rebus.Bus; +using Rebus.Handlers; + +using static LanguageExt.Prelude; + +namespace Eryph.Modules.VmHostAgent.Inventory; + +[UsedImplicitly] +internal class DiskStoresChangedEventHandler( + IBus bus, + IFileSystemService fileSystemService, + ILogger log, + IPowershellEngine powershellEngine, + IHostSettingsProvider hostSettingsProvider, + IVmHostAgentConfigurationManager vmHostAgentConfigurationManager, + WorkflowOptions workflowOptions) + : IHandleMessages +{ + public async Task Handle(DiskStoresChangedEvent message) + { + var result = await InventoryDisks().Run(); + result.IfFail(e => { log.LogError(e, "The disk inventory has failed."); }); + if (result.IsFail) + return; + + await bus.Advanced.Routing.Send(workflowOptions.OperationsDestination, result.ToOption().ValueUnsafe()); + } + + private Aff InventoryDisks() => + from _ in SuccessAff(unit) + let timestamp = DateTimeOffset.UtcNow + from hostSettings in hostSettingsProvider.GetHostSettings() + .ToAff(identity) + from vmHostAgentConfig in vmHostAgentConfigurationManager.GetCurrentConfiguration(hostSettings) + .ToAff(identity) + from diskInfos in DiskStoreInventory.InventoryStores( + fileSystemService, powershellEngine, vmHostAgentConfig) + from __ in diskInfos.Lefts() + .Map(e => + { + log.LogError(e, "Inventory of virtual disk failed"); + return SuccessEff(unit); + }) + .Sequence() + select new UpdateDiskInventoryCommand + { + AgentName = Environment.MachineName, + Timestamp = timestamp, + Inventory = diskInfos.Rights().ToList() + }; +} diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/InventoryConfig.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/InventoryConfig.cs new file mode 100644 index 000000000..9462fe0d9 --- /dev/null +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/InventoryConfig.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Eryph.Modules.VmHostAgent.Inventory; + +public class InventoryConfig +{ + public TimeSpan DiskEventDelay { get; set; } = TimeSpan.FromSeconds(5); +} diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/InventoryRequestedEventHandler.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/InventoryRequestedEventHandler.cs index 58c184fd1..41c2d72e4 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/InventoryRequestedEventHandler.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/InventoryRequestedEventHandler.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Dbosoft.Rebus.Operations; @@ -22,8 +21,12 @@ namespace Eryph.Modules.VmHostAgent.Inventory; [UsedImplicitly] -internal class InventoryRequestedEventHandler(IBus bus, IPowershellEngine engine, ILogger log, +internal class InventoryRequestedEventHandler( + IBus bus, + IPowershellEngine engine, + ILogger log, WorkflowOptions workflowOptions, + IFileSystemService fileSystemService, IHostInfoProvider hostInfoProvider, IHostSettingsProvider hostSettingsProvider, IVmHostAgentConfigurationManager vmHostAgentConfigurationManager) @@ -56,10 +59,20 @@ from vmHostAgentConfig in vmHostAgentConfigurationManager.GetCurrentConfiguratio from vmData in inventorizableVmInfos .Map(vmInfo => InventoryVm(inventory, vmInfo)) .SequenceParallel() + from diskInfos in DiskStoreInventory.InventoryStores( + fileSystemService, engine, vmHostAgentConfig) + from __ in diskInfos.Lefts() + .Map(e => + { + log.LogError(e, "Inventory of virtual disk failed"); + return SuccessEff(unit); + }) + .Sequence() select new UpdateVMHostInventoryCommand { HostInventory = hostInventory, VMInventory = vmData.Somes().ToList(), + DiskInventory = diskInfos.Rights().ToList(), Timestamp = timestamp }; diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/VirtualMachineChangedEventHandler.cs b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/VirtualMachineChangedEventHandler.cs index 2a430ef86..2f5aa11c0 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/VirtualMachineChangedEventHandler.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/Inventory/VirtualMachineChangedEventHandler.cs @@ -66,7 +66,7 @@ from vmData in inventorizableVmInfo { AgentName = Environment.MachineName, Timestamp = timestamp, - Inventory = [data], + Inventory = data, }); private bool IsInventorizable(TypedPsObject vmInfo) diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/RemoveVirtualDiskCommandHandler.cs b/src/modules/src/Eryph.Modules.VmHostAgent/RemoveVirtualDiskCommandHandler.cs index dbff31405..d36d1fdc8 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/RemoveVirtualDiskCommandHandler.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/RemoveVirtualDiskCommandHandler.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Dbosoft.Rebus.Operations; using Eryph.Core; +using Eryph.Core.VmAgent; using Eryph.Messages.Resources.Catlets.Commands; using Eryph.VmManagement; using Eryph.VmManagement.Storage; @@ -34,30 +35,49 @@ public Task Handle(OperationTask message) .FailOrComplete(messaging, message); } - private EitherAsync RemoveDisk(string path, string fileName) => + private EitherAsync RemoveDisk( + string path, + string fileName) => from hostSettings in hostSettingsProvider.GetHostSettings() from vmHostAgentConfig in vmHostAgentConfigurationManager.GetCurrentConfiguration(hostSettings) + let vhdPath = Path.Combine(path, fileName) + from pathExists in Try(() => fileSystem.File.Exists(vhdPath)) + .ToEitherAsync() + from _2 in pathExists + ? RemoveExistingDisk(vhdPath, vmHostAgentConfig) + : RightAsync(unit) + // We can take the timestamp after the operation as we are actively + // deleting the disk. + let timestamp = DateTimeOffset.UtcNow + select new RemoveVirtualDiskCommandResponse + { + Timestamp = timestamp, + }; + + private EitherAsync RemoveExistingDisk( + string vhdPath, + VmHostAgentConfiguration vmHostAgentConfig) => from storageSettings in DiskStorageSettings.FromVhdPath( - powershellEngine, vmHostAgentConfig, Path.Combine(path, fileName)) + powershellEngine, vmHostAgentConfig, vhdPath) from _1 in guard(storageSettings.Gene.IsNone, Error.New("The disk is part of the gene pool and cannot be deleted directly. Remove the gene instead.")) from _2 in storageSettings.StorageIdentifier.IsSome && storageSettings.StorageNames.IsValid - ? Try(() => DeleteFiles(path, fileName)) + ? Try(() => DeleteFiles(vhdPath)) .ToEither(ex => Error.New("Could not delete disk files.", Error.New(ex))) .ToAsync() : unit select unit; - private Unit DeleteFiles(string path, string fileName) + private Unit DeleteFiles(string vhdPath) { - var filePath = Path.Combine(path, fileName); - if(!fileSystem.File.Exists(filePath)) + if(!fileSystem.File.Exists(vhdPath)) return unit; - fileSystem.File.Delete(filePath); + fileSystem.File.Delete(vhdPath); - if (fileSystem.Directory.Exists(path) && fileSystem.Directory.IsFolderTreeEmpty(path)) - fileSystem.Directory.Delete(path, true); + var directoryPath = fileSystem.Path.GetDirectoryName(vhdPath); + if (fileSystem.Directory.Exists(directoryPath) && fileSystem.Directory.IsFolderTreeEmpty(directoryPath)) + fileSystem.Directory.Delete(directoryPath, true); return unit; } diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/SyncService.cs b/src/modules/src/Eryph.Modules.VmHostAgent/SyncService.cs index 8df016338..79a5e2abd 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/SyncService.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/SyncService.cs @@ -9,6 +9,7 @@ using Eryph.Core.Network; using Eryph.ModuleCore; using Eryph.ModuleCore.Networks; +using Eryph.Modules.VmHostAgent.Inventory; using LanguageExt; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -20,12 +21,18 @@ internal class SyncService : BackgroundService private readonly ILogger _logger; private readonly IAgentControlService _controlService; private readonly INetworkSyncService _networkSyncService; - public SyncService(ILogger logger, - IAgentControlService controlService, INetworkSyncService networkSyncService) + private readonly DiskStoresChangeWatcherService _diskStoresChangeWatcherService; + + public SyncService( + ILogger logger, + IAgentControlService controlService, + INetworkSyncService networkSyncService, + DiskStoresChangeWatcherService diskStoresChangeWatcherService) { _logger = logger; _controlService = controlService; _networkSyncService = networkSyncService; + _diskStoresChangeWatcherService = diskStoresChangeWatcherService; } @@ -69,6 +76,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) case "START_OVN": break; case "STOP_VSWITCH": break; case "STOP_OVSDB": break; + case "SYNC_AGENT_SETTINGS": break; default: commandValid = false; break; @@ -171,6 +179,15 @@ private async Task RunCommand(SyncServiceCommand command) service = AgentService.OVSDB; operation = AgentServiceOperation.Stop; break; + case "SYNC_AGENT_SETTINGS": + return await Prelude.TryAsync(async () => await _diskStoresChangeWatcherService.Restart().ToUnit()) + .Match( + Succ: _ => new SyncServiceResponse { Response = "DONE" }, + Fail: ex => + { + _logger.LogError(ex, "Failed to restart disk store change watcher"); + return new SyncServiceResponse { Response = "FAILED", Error = ex.Message }; + }); default: return new SyncServiceResponse { Response = "INVALID" }; } diff --git a/src/modules/src/Eryph.Modules.VmHostAgent/VmHostAgentModule.cs b/src/modules/src/Eryph.Modules.VmHostAgent/VmHostAgentModule.cs index eb15e6138..9cba442d6 100644 --- a/src/modules/src/Eryph.Modules.VmHostAgent/VmHostAgentModule.cs +++ b/src/modules/src/Eryph.Modules.VmHostAgent/VmHostAgentModule.cs @@ -43,11 +43,15 @@ namespace Eryph.Modules.VmHostAgent public class VmHostAgentModule { private readonly TracingConfig _tracingConfig = new(); + private readonly InventoryConfig _inventoryConfig = new(); public VmHostAgentModule(IConfiguration configuration) { configuration.GetSection("Tracing") .Bind(_tracingConfig); + + configuration.GetSection("Inventory") + .Bind(_inventoryConfig); } public string Name => "Eryph.VmHostAgent"; @@ -88,12 +92,15 @@ public void AddSimpleInjector(SimpleInjectorAddOptions options) // based on WMI events. options.AddHostedService(); options.AddHostedService(); + options.AddHostedService(); options.AddLogging(); } [UsedImplicitly] public void ConfigureContainer(IServiceProvider serviceProvider, Container container) { + container.RegisterInstance(_inventoryConfig); + container.Register(); container.Register, HostNetworkCommands>(); container.Register(); diff --git a/src/modules/test/Eryph.Modules.Controller.Tests/ChangeTracking/ChangeTrackingTestBase.cs b/src/modules/test/Eryph.Modules.Controller.Tests/ChangeTracking/ChangeTrackingTestBase.cs index 7ae07a616..b4b08e2a4 100644 --- a/src/modules/test/Eryph.Modules.Controller.Tests/ChangeTracking/ChangeTrackingTestBase.cs +++ b/src/modules/test/Eryph.Modules.Controller.Tests/ChangeTracking/ChangeTrackingTestBase.cs @@ -68,8 +68,17 @@ protected async Task WithHostScope(Func action) var container = host.Services.GetRequiredService(); await using (var scope = AsyncScopedLifestyle.BeginScope(container)) { + var dbContext = scope.GetInstance(); var stateStore = scope.GetInstance(); + + // We use transactions in the Rebus unit-of-work and hence + // should also use transactions for the tests. The behavior + // for deleted entities changes when using transactions. + await using var dbTransaction = await dbContext.Database.BeginTransactionAsync(); + await action(stateStore); + + await dbTransaction.CommitAsync(); } await host.StopAsync(); } diff --git a/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationTests.cs b/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationTests.cs index ab28da0a8..4ca3499e6 100644 --- a/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationTests.cs +++ b/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationTests.cs @@ -61,6 +61,7 @@ public async Task GetConfigYaml_ConfigWithHyperVDefaultPaths_ReturnsYamlWithPath defaults: vms: {{_hostSettings.DefaultDataPath}} volumes: {{_hostSettings.DefaultVirtualHardDiskPath}} + watch_file_system: true datastores: environments: @@ -76,7 +77,7 @@ public async Task GetConfigYaml_ConfigWithHyperVDefaultPaths_ReturnsYamlWithPath [InlineData(" ")] public void ParseConfig_EmptyConfig_ReturnsFail(string yaml) { - var result = parseConfigYaml(yaml).Run(); + var result = parseConfigYaml(yaml, false).Run(); result.Should().BeFail() .Which.Message.Should().Be("The configuration must not be empty."); @@ -85,12 +86,46 @@ public void ParseConfig_EmptyConfig_ReturnsFail(string yaml) [Fact] public void ParseConfig_MalformedConfig_ReturnsFail() { - var result = parseConfigYaml("not a config").Run(); + var result = parseConfigYaml("not a config", false).Run(); result.Should().BeFail() .Which.Message.Should().Be("The configuration is malformed."); } + [Fact] + public void ParseConfig_UndefinedPropertyWhenUsingStrictMode_ReturnsFail() + { + var yaml = """ + datastores: + - name: teststore + path: Z:\teststore + undefined_property: test + """; + var result = parseConfigYaml(yaml, true).Run(); + + result.Should().BeFail() + .Which.Message.Should().Be("The configuration is malformed."); + } + + [Fact] + public void ParseConfig_UndefinedPropertyWhenNotUsingStrictMode_ReturnsConfig() + { + var yaml = """ + datastores: + - name: teststore + path: Z:\teststore + undefined_property: test + """; + var result = parseConfigYaml(yaml, false).Run(); + + result.Should().BeSuccess().Which.Datastores.Should().SatisfyRespectively( + ds => + { + ds.Name.Should().Be("teststore"); + ds.Path.Should().Be(@"Z:\teststore"); + }); + } + [Fact] public async Task ReadConfig_ConfigDoesNotExist_WritesAndReturnsDefaultConfig() { @@ -113,6 +148,7 @@ public async Task ReadConfig_ConfigDoesNotExist_WritesAndReturnsDefaultConfig() defaults: vms: volumes: + watch_file_system: true datastores: environments: @@ -146,6 +182,7 @@ public async Task ReadConfig_ConfigWithoutDefaultPaths_ReturnsConfigWithSystemDe var config = result.Should().BeSuccess().Subject; config.Defaults.Vms.Should().Be(_hostSettings.DefaultDataPath); config.Defaults.Volumes.Should().Be(_hostSettings.DefaultVirtualHardDiskPath); + config.Defaults.WatchFileSystem.Should().BeTrue(); config.Datastores.Should().BeNull(); config.Environments.Should().BeNull(); @@ -173,6 +210,7 @@ public async Task ReadConfig_ConfigWithDefaultPaths_ReturnsConfig() var config = result.Should().BeSuccess().Subject; config.Defaults.Vms.Should().Be(@"Z:\test\vms"); config.Defaults.Volumes.Should().Be(@"Z:\test\volumes"); + config.Defaults.WatchFileSystem.Should().BeTrue(); config.Datastores.Should().BeNull(); config.Environments.Should().BeNull(); @@ -180,6 +218,73 @@ public async Task ReadConfig_ConfigWithDefaultPaths_ReturnsConfig() _fileMock.VerifyNoOtherCalls(); } + [Theory] + [InlineData("", false)] + [InlineData("true", true)] + [InlineData("false", false)] + public async Task ReadConfig_ConfigWithWatcherSettings_ReturnsConfig( + string actual, + bool expected) + { + _fileMock.Setup(m => m.Exists(ConfigPath)) + .Returns(true) + .Verifiable(); + + _fileMock.Setup(m => m.ReadAllText(ConfigPath, Encoding.UTF8, It.IsAny())) + .ReturnsAsync($$""" + defaults: + vms: Z:\defaults\vms + volumes: Z:\defaults\volumes + watch_file_system: {{ actual }} + datastores: + - name: store1 + path: Z:\stores\store1 + watch_file_system: {{ actual }} + environments: + - name: env1 + defaults: + vms: Z:\env1\vms + volumes: Z:\env1\volumes + watch_file_system: {{ actual }} + datastores: + - name: store1 + path: Z:\env1\stores\store1 + watch_file_system: {{ actual }} + """) + .Verifiable(); + + var result = await readConfig(ConfigPath, _hostSettings).Run(_runtime); + + var config = result.Should().BeSuccess().Subject; + config.Defaults.Vms.Should().Be(@"Z:\defaults\vms"); + config.Defaults.Volumes.Should().Be(@"Z:\defaults\volumes"); + config.Defaults.WatchFileSystem.Should().Be(expected); + config.Datastores.Should().SatisfyRespectively( + datastore => + { + datastore.Name.Should().Be("store1"); + datastore.Path.Should().Be(@"Z:\stores\store1"); + datastore.WatchFileSystem.Should().Be(expected); + }); + config.Environments.Should().SatisfyRespectively( + environment => + { + environment.Defaults.Vms.Should().Be(@"Z:\env1\vms"); + environment.Defaults.Volumes.Should().Be(@"Z:\env1\volumes"); + environment.Defaults.WatchFileSystem.Should().Be(expected); + environment.Datastores.Should().SatisfyRespectively( + datastore => + { + datastore.Name.Should().Be("store1"); + datastore.Path.Should().Be(@"Z:\env1\stores\store1"); + datastore.WatchFileSystem.Should().Be(expected); + }); + }); + + _fileMock.VerifyAll(); + _fileMock.VerifyNoOtherCalls(); + } + [Fact] public async Task SaveConfig_ConfigWithHyperVDefaultPaths_SavesConfigWithoutPaths() { @@ -206,6 +311,7 @@ public async Task SaveConfig_ConfigWithHyperVDefaultPaths_SavesConfigWithoutPath defaults: vms: volumes: + watch_file_system: true datastores: environments: @@ -271,17 +377,21 @@ public async Task SaveConfig_ConfigWithUnnormalizedPaths_SavesConfigWithNormaliz defaults: vms: Z:\defaults\vms volumes: Z:\defaults\volumes + watch_file_system: true datastores: - name: store1 path: Z:\stores\store1 + watch_file_system: true environments: - name: env1 defaults: vms: Z:\env1\vms volumes: Z:\env1\volumes + watch_file_system: true datastores: - name: store1 path: Z:\env1\stores\store1 + watch_file_system: true """, EqualityComparer.Default), diff --git a/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationUpdateTests.cs b/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationUpdateTests.cs index fba22a21d..eb57f369b 100644 --- a/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationUpdateTests.cs +++ b/src/modules/test/Eryph.Modules.VmHostAgent.Test/Configuration/VmHostAgentConfigurationUpdateTests.cs @@ -80,6 +80,7 @@ public async Task UpdateConfig_ValidConfig_UpdatesConfig() defaults: vms: volumes: + watch_file_system: true datastores: environments: