save progress
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// <copyright file="AirGapSyncServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.AirGap.Sync.Services;
|
||||
using StellaOps.AirGap.Sync.Stores;
|
||||
using StellaOps.AirGap.Sync.Transport;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering air-gap sync services.
|
||||
/// </summary>
|
||||
public static class AirGapSyncServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds air-gap sync services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="nodeId">The node identifier for this instance.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAirGapSyncServices(
|
||||
this IServiceCollection services,
|
||||
string nodeId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
|
||||
// Core services
|
||||
services.TryAddSingleton<IConflictResolver, ConflictResolver>();
|
||||
services.TryAddSingleton<IHlcMergeService, HlcMergeService>();
|
||||
services.TryAddSingleton<IAirGapBundleImporter, AirGapBundleImporter>();
|
||||
|
||||
// Register in-memory HLC state store for offline operation
|
||||
services.TryAddSingleton<IHlcStateStore, InMemoryHlcStateStore>();
|
||||
|
||||
// Register HLC clock with node ID
|
||||
services.TryAddSingleton<IHybridLogicalClock>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var stateStore = sp.GetRequiredService<IHlcStateStore>();
|
||||
return new HybridLogicalClock.HybridLogicalClock(timeProvider, nodeId, stateStore);
|
||||
});
|
||||
|
||||
// Register deterministic GUID provider
|
||||
services.TryAddSingleton<IGuidProvider>(SystemGuidProvider.Instance);
|
||||
|
||||
// File-based store (can be overridden)
|
||||
services.TryAddSingleton<IOfflineJobLogStore, FileBasedOfflineJobLogStore>();
|
||||
|
||||
// Offline HLC manager
|
||||
services.TryAddSingleton<IOfflineHlcManager, OfflineHlcManager>();
|
||||
|
||||
// Bundle exporter
|
||||
services.TryAddSingleton<IAirGapBundleExporter, AirGapBundleExporter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds air-gap sync services with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="nodeId">The node identifier for this instance.</param>
|
||||
/// <param name="configureOptions">Action to configure file-based store options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAirGapSyncServices(
|
||||
this IServiceCollection services,
|
||||
string nodeId,
|
||||
Action<FileBasedOfflineJobLogStoreOptions> configureOptions)
|
||||
{
|
||||
// Configure file-based store options
|
||||
services.Configure(configureOptions);
|
||||
|
||||
return services.AddAirGapSyncServices(nodeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the air-gap sync service for importing bundles to the central scheduler.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// This requires ISyncSchedulerLogRepository to be registered separately,
|
||||
/// as it depends on the Scheduler.Persistence module.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddAirGapSyncImportService(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddScoped<IAirGapSyncService, AirGapSyncService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds file-based transport for job sync bundles.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFileBasedJobSyncTransport(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IJobSyncTransport, FileBasedJobSyncTransport>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds file-based transport for job sync bundles with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure transport options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFileBasedJobSyncTransport(
|
||||
this IServiceCollection services,
|
||||
Action<FileBasedJobSyncTransportOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
return services.AddFileBasedJobSyncTransport();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Router-based transport for job sync bundles.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Requires IRouterJobSyncClient to be registered separately.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddRouterJobSyncTransport(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IJobSyncTransport, RouterJobSyncTransport>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Router-based transport for job sync bundles with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure transport options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddRouterJobSyncTransport(
|
||||
this IServiceCollection services,
|
||||
Action<RouterJobSyncTransportOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
return services.AddRouterJobSyncTransport();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// <copyright file="AirGapBundle.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an air-gap bundle containing job logs from one or more offline nodes.
|
||||
/// </summary>
|
||||
public sealed record AirGapBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique bundle identifier.
|
||||
/// </summary>
|
||||
public required Guid BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for this bundle.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node ID that created this bundle.
|
||||
/// </summary>
|
||||
public required string CreatedByNodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the job logs from each offline node.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<NodeJobLog> JobLogs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bundle manifest digest for integrity verification.
|
||||
/// </summary>
|
||||
public required string ManifestDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional DSSE signature over the manifest.
|
||||
/// </summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key ID used for signing (if signed).
|
||||
/// </summary>
|
||||
public string? SignedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// <copyright file="ConflictResolution.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of conflict resolution for a job ID.
|
||||
/// </summary>
|
||||
public sealed record ConflictResolution
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of conflict detected.
|
||||
/// </summary>
|
||||
public required ConflictType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolution strategy applied.
|
||||
/// </summary>
|
||||
public required ResolutionStrategy Resolution { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selected entry (when resolution is not Error).
|
||||
/// </summary>
|
||||
public OfflineJobLogEntry? SelectedEntry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entries that were dropped.
|
||||
/// </summary>
|
||||
public IReadOnlyList<OfflineJobLogEntry>? DroppedEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message (when resolution is Error).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of conflicts that can occur during merge.
|
||||
/// </summary>
|
||||
public enum ConflictType
|
||||
{
|
||||
/// <summary>
|
||||
/// Same JobId with different HLC timestamps but identical payload.
|
||||
/// </summary>
|
||||
DuplicateTimestamp,
|
||||
|
||||
/// <summary>
|
||||
/// Same JobId with different payloads - indicates a bug.
|
||||
/// </summary>
|
||||
PayloadMismatch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strategies for resolving conflicts.
|
||||
/// </summary>
|
||||
public enum ResolutionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Take the entry with the earliest HLC timestamp.
|
||||
/// </summary>
|
||||
TakeEarliest,
|
||||
|
||||
/// <summary>
|
||||
/// Fail the merge - conflict cannot be resolved.
|
||||
/// </summary>
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// <copyright file="MergeResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of merging job logs from multiple offline nodes.
|
||||
/// </summary>
|
||||
public sealed record MergeResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the merged entries in HLC total order.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MergedJobEntry> MergedEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets duplicate entries that were dropped during merge.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DuplicateEntry> Duplicates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the merged chain head (final link after merge).
|
||||
/// </summary>
|
||||
public byte[]? MergedChainHead { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source node IDs that contributed to this merge.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> SourceNodes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A job entry after merge with unified chain link.
|
||||
/// </summary>
|
||||
public sealed class MergedJobEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the source node ID that created this entry.
|
||||
/// </summary>
|
||||
public required string SourceNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HLC timestamp.
|
||||
/// </summary>
|
||||
public required HlcTimestamp THlc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the job ID.
|
||||
/// </summary>
|
||||
public required Guid JobId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the partition key.
|
||||
/// </summary>
|
||||
public string? PartitionKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized payload.
|
||||
/// </summary>
|
||||
public required string Payload { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the payload hash.
|
||||
/// </summary>
|
||||
public required byte[] PayloadHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the original chain link from the source node.
|
||||
/// </summary>
|
||||
public required byte[] OriginalLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the merged chain link (computed during merge).
|
||||
/// </summary>
|
||||
public byte[]? MergedLink { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a duplicate entry dropped during merge.
|
||||
/// </summary>
|
||||
public sealed record DuplicateEntry(
|
||||
Guid JobId,
|
||||
string NodeId,
|
||||
HlcTimestamp THlc);
|
||||
@@ -0,0 +1,33 @@
|
||||
// <copyright file="NodeJobLog.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the job log from a single offline node.
|
||||
/// </summary>
|
||||
public sealed record NodeJobLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the node identifier.
|
||||
/// </summary>
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last HLC timestamp in this log.
|
||||
/// </summary>
|
||||
public required HlcTimestamp LastHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chain head (last link) in this log.
|
||||
/// </summary>
|
||||
public required byte[] ChainHead { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the job log entries in HLC order.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<OfflineJobLogEntry> Entries { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// <copyright file="OfflineJobLogEntry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a job log entry created while operating offline.
|
||||
/// </summary>
|
||||
public sealed record OfflineJobLogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the node ID that created this entry.
|
||||
/// </summary>
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HLC timestamp when the job was enqueued.
|
||||
/// </summary>
|
||||
public required HlcTimestamp THlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the deterministic job ID.
|
||||
/// </summary>
|
||||
public required Guid JobId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the partition key (if any).
|
||||
/// </summary>
|
||||
public string? PartitionKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serialized job payload.
|
||||
/// </summary>
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SHA-256 hash of the canonical payload.
|
||||
/// </summary>
|
||||
public required byte[] PayloadHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous chain link (null for first entry).
|
||||
/// </summary>
|
||||
public byte[]? PrevLink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chain link: Hash(prev_link || job_id || t_hlc || payload_hash).
|
||||
/// </summary>
|
||||
public required byte[] Link { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the wall-clock time when the entry was created (informational only).
|
||||
/// </summary>
|
||||
public DateTimeOffset EnqueuedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// <copyright file="SyncResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of syncing an air-gap bundle to the central scheduler.
|
||||
/// </summary>
|
||||
public sealed record SyncResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the bundle ID that was synced.
|
||||
/// </summary>
|
||||
public required Guid BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of entries in the bundle.
|
||||
/// </summary>
|
||||
public required int TotalInBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries appended to the scheduler log.
|
||||
/// </summary>
|
||||
public required int Appended { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of duplicate entries skipped.
|
||||
/// </summary>
|
||||
public required int Duplicates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries that already existed (idempotency).
|
||||
/// </summary>
|
||||
public int AlreadyExisted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the new chain head after sync.
|
||||
/// </summary>
|
||||
public byte[]? NewChainHead { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any warnings generated during sync.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an offline enqueue operation.
|
||||
/// </summary>
|
||||
public sealed record OfflineEnqueueResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HLC timestamp assigned.
|
||||
/// </summary>
|
||||
public required StellaOps.HybridLogicalClock.HlcTimestamp THlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the deterministic job ID.
|
||||
/// </summary>
|
||||
public required Guid JobId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chain link computed.
|
||||
/// </summary>
|
||||
public required byte[] Link { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node ID that created this entry.
|
||||
/// </summary>
|
||||
public required string NodeId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// <copyright file="AirGapBundleExporter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.AirGap.Sync.Stores;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for air-gap bundle export operations.
|
||||
/// </summary>
|
||||
public interface IAirGapBundleExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports an air-gap bundle containing offline job logs.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="nodeIds">The node IDs to include (null for current node only).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The exported bundle.</returns>
|
||||
Task<AirGapBundle> ExportAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string>? nodeIds = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an air-gap bundle to a file.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to export.</param>
|
||||
/// <param name="outputPath">The output file path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task ExportToFileAsync(
|
||||
AirGapBundle bundle,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an air-gap bundle to a JSON string.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to export.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The JSON string representation.</returns>
|
||||
Task<string> ExportToStringAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting air-gap bundles.
|
||||
/// </summary>
|
||||
public sealed class AirGapBundleExporter : IAirGapBundleExporter
|
||||
{
|
||||
private readonly IOfflineJobLogStore _jobLogStore;
|
||||
private readonly IOfflineHlcManager _hlcManager;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AirGapBundleExporter> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AirGapBundleExporter"/> class.
|
||||
/// </summary>
|
||||
public AirGapBundleExporter(
|
||||
IOfflineJobLogStore jobLogStore,
|
||||
IOfflineHlcManager hlcManager,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AirGapBundleExporter> logger)
|
||||
{
|
||||
_jobLogStore = jobLogStore ?? throw new ArgumentNullException(nameof(jobLogStore));
|
||||
_hlcManager = hlcManager ?? throw new ArgumentNullException(nameof(hlcManager));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AirGapBundle> ExportAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string>? nodeIds = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var effectiveNodeIds = nodeIds ?? new[] { _hlcManager.NodeId };
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exporting air-gap bundle for tenant {TenantId} with {NodeCount} nodes",
|
||||
tenantId, effectiveNodeIds.Count);
|
||||
|
||||
var jobLogs = new List<NodeJobLog>();
|
||||
|
||||
foreach (var nodeId in effectiveNodeIds)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var nodeLog = await _jobLogStore.GetNodeJobLogAsync(nodeId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (nodeLog is not null && nodeLog.Entries.Count > 0)
|
||||
{
|
||||
jobLogs.Add(nodeLog);
|
||||
_logger.LogDebug(
|
||||
"Added node {NodeId} with {EntryCount} entries to bundle",
|
||||
nodeId, nodeLog.Entries.Count);
|
||||
}
|
||||
}
|
||||
|
||||
if (jobLogs.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No offline job logs found for export");
|
||||
}
|
||||
|
||||
var bundle = new AirGapBundle
|
||||
{
|
||||
BundleId = _guidProvider.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
CreatedByNodeId = _hlcManager.NodeId,
|
||||
JobLogs = jobLogs,
|
||||
ManifestDigest = ComputeManifestDigest(jobLogs)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created bundle {BundleId} with {LogCount} node logs, {TotalEntries} total entries",
|
||||
bundle.BundleId, jobLogs.Count, jobLogs.Sum(l => l.Entries.Count));
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ExportToFileAsync(
|
||||
AirGapBundle bundle,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
|
||||
var dto = ToExportDto(bundle);
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported bundle {BundleId} to {OutputPath}",
|
||||
bundle.BundleId, outputPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string> ExportToStringAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dto = ToExportDto(bundle);
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Exported bundle {BundleId} to string ({Length} chars)",
|
||||
bundle.BundleId, json.Length);
|
||||
|
||||
return Task.FromResult(json);
|
||||
}
|
||||
|
||||
private static string ComputeManifestDigest(IReadOnlyList<NodeJobLog> jobLogs)
|
||||
{
|
||||
// Create manifest of all chain heads for integrity
|
||||
var manifest = jobLogs
|
||||
.OrderBy(l => l.NodeId, StringComparer.Ordinal)
|
||||
.Select(l => new
|
||||
{
|
||||
l.NodeId,
|
||||
LastHlc = l.LastHlc.ToSortableString(),
|
||||
ChainHead = Convert.ToHexString(l.ChainHead)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var json = CanonJson.Serialize(manifest);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static AirGapBundleExportDto ToExportDto(AirGapBundle bundle) => new()
|
||||
{
|
||||
BundleId = bundle.BundleId,
|
||||
TenantId = bundle.TenantId,
|
||||
CreatedAt = bundle.CreatedAt,
|
||||
CreatedByNodeId = bundle.CreatedByNodeId,
|
||||
ManifestDigest = bundle.ManifestDigest,
|
||||
Signature = bundle.Signature,
|
||||
SignedBy = bundle.SignedBy,
|
||||
JobLogs = bundle.JobLogs.Select(ToNodeJobLogDto).ToList()
|
||||
};
|
||||
|
||||
private static NodeJobLogExportDto ToNodeJobLogDto(NodeJobLog log) => new()
|
||||
{
|
||||
NodeId = log.NodeId,
|
||||
LastHlc = log.LastHlc.ToSortableString(),
|
||||
ChainHead = Convert.ToBase64String(log.ChainHead),
|
||||
Entries = log.Entries.Select(ToEntryDto).ToList()
|
||||
};
|
||||
|
||||
private static OfflineJobLogEntryExportDto ToEntryDto(OfflineJobLogEntry entry) => new()
|
||||
{
|
||||
NodeId = entry.NodeId,
|
||||
THlc = entry.THlc.ToSortableString(),
|
||||
JobId = entry.JobId,
|
||||
PartitionKey = entry.PartitionKey,
|
||||
Payload = entry.Payload,
|
||||
PayloadHash = Convert.ToBase64String(entry.PayloadHash),
|
||||
PrevLink = entry.PrevLink is not null ? Convert.ToBase64String(entry.PrevLink) : null,
|
||||
Link = Convert.ToBase64String(entry.Link),
|
||||
EnqueuedAt = entry.EnqueuedAt
|
||||
};
|
||||
|
||||
// Export DTOs
|
||||
private sealed record AirGapBundleExportDto
|
||||
{
|
||||
public required Guid BundleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required string CreatedByNodeId { get; init; }
|
||||
public required string ManifestDigest { get; init; }
|
||||
public string? Signature { get; init; }
|
||||
public string? SignedBy { get; init; }
|
||||
public required IReadOnlyList<NodeJobLogExportDto> JobLogs { get; init; }
|
||||
}
|
||||
|
||||
private sealed record NodeJobLogExportDto
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string LastHlc { get; init; }
|
||||
public required string ChainHead { get; init; }
|
||||
public required IReadOnlyList<OfflineJobLogEntryExportDto> Entries { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OfflineJobLogEntryExportDto
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string THlc { get; init; }
|
||||
public required Guid JobId { get; init; }
|
||||
public string? PartitionKey { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required string PayloadHash { get; init; }
|
||||
public string? PrevLink { get; init; }
|
||||
public required string Link { get; init; }
|
||||
public DateTimeOffset EnqueuedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// <copyright file="AirGapBundleImporter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for air-gap bundle import operations.
|
||||
/// </summary>
|
||||
public interface IAirGapBundleImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Imports an air-gap bundle from a file.
|
||||
/// </summary>
|
||||
/// <param name="inputPath">The input file path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The imported bundle.</returns>
|
||||
Task<AirGapBundle> ImportFromFileAsync(
|
||||
string inputPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a bundle's integrity.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to validate.</param>
|
||||
/// <returns>Validation result with any issues found.</returns>
|
||||
BundleValidationResult Validate(AirGapBundle bundle);
|
||||
|
||||
/// <summary>
|
||||
/// Imports an air-gap bundle from a JSON string.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON string representation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The imported bundle.</returns>
|
||||
Task<AirGapBundle> ImportFromStringAsync(
|
||||
string json,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle validation.
|
||||
/// </summary>
|
||||
public sealed record BundleValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the bundle is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets validation issues found.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Issues { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for importing air-gap bundles.
|
||||
/// </summary>
|
||||
public sealed class AirGapBundleImporter : IAirGapBundleImporter
|
||||
{
|
||||
private readonly ILogger<AirGapBundleImporter> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AirGapBundleImporter"/> class.
|
||||
/// </summary>
|
||||
public AirGapBundleImporter(ILogger<AirGapBundleImporter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AirGapBundle> ImportFromFileAsync(
|
||||
string inputPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(inputPath);
|
||||
|
||||
if (!File.Exists(inputPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Bundle file not found: {inputPath}", inputPath);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Importing air-gap bundle from {InputPath}", inputPath);
|
||||
|
||||
var json = await File.ReadAllTextAsync(inputPath, cancellationToken).ConfigureAwait(false);
|
||||
var dto = JsonSerializer.Deserialize<AirGapBundleImportDto>(json, JsonOptions);
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize bundle file");
|
||||
}
|
||||
|
||||
var bundle = FromImportDto(dto);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Imported bundle {BundleId} from {InputPath}: {LogCount} node logs, {TotalEntries} total entries",
|
||||
bundle.BundleId, inputPath, bundle.JobLogs.Count, bundle.JobLogs.Sum(l => l.Entries.Count));
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AirGapBundle> ImportFromStringAsync(
|
||||
string json,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug("Importing air-gap bundle from string ({Length} chars)", json.Length);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<AirGapBundleImportDto>(json, JsonOptions);
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize bundle JSON");
|
||||
}
|
||||
|
||||
var bundle = FromImportDto(dto);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Imported bundle {BundleId} from string: {LogCount} node logs, {TotalEntries} total entries",
|
||||
bundle.BundleId, bundle.JobLogs.Count, bundle.JobLogs.Sum(l => l.Entries.Count));
|
||||
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public BundleValidationResult Validate(AirGapBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
// 1. Validate manifest digest
|
||||
var computedDigest = ComputeManifestDigest(bundle.JobLogs);
|
||||
if (!string.Equals(computedDigest, bundle.ManifestDigest, StringComparison.Ordinal))
|
||||
{
|
||||
issues.Add($"Manifest digest mismatch: expected {bundle.ManifestDigest}, computed {computedDigest}");
|
||||
}
|
||||
|
||||
// 2. Validate each node log's chain integrity
|
||||
foreach (var nodeLog in bundle.JobLogs)
|
||||
{
|
||||
var nodeIssues = ValidateNodeLog(nodeLog);
|
||||
issues.AddRange(nodeIssues);
|
||||
}
|
||||
|
||||
// 3. Validate chain heads match last entry links
|
||||
foreach (var nodeLog in bundle.JobLogs)
|
||||
{
|
||||
if (nodeLog.Entries.Count > 0)
|
||||
{
|
||||
var lastEntry = nodeLog.Entries[^1];
|
||||
if (!ByteArrayEquals(nodeLog.ChainHead, lastEntry.Link))
|
||||
{
|
||||
issues.Add($"Node {nodeLog.NodeId}: chain head doesn't match last entry link");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isValid = issues.Count == 0;
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle {BundleId} validation failed with {IssueCount} issues",
|
||||
bundle.BundleId, issues.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Bundle {BundleId} validation passed", bundle.BundleId);
|
||||
}
|
||||
|
||||
return new BundleValidationResult
|
||||
{
|
||||
IsValid = isValid,
|
||||
Issues = issues
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ValidateNodeLog(NodeJobLog nodeLog)
|
||||
{
|
||||
byte[]? expectedPrevLink = null;
|
||||
|
||||
for (var i = 0; i < nodeLog.Entries.Count; i++)
|
||||
{
|
||||
var entry = nodeLog.Entries[i];
|
||||
|
||||
// Verify prev_link matches expected
|
||||
if (!ByteArrayEquals(entry.PrevLink, expectedPrevLink))
|
||||
{
|
||||
yield return $"Node {nodeLog.NodeId}, entry {i}: prev_link mismatch";
|
||||
}
|
||||
|
||||
// Recompute and verify link
|
||||
var computedLink = OfflineHlcManager.ComputeLink(
|
||||
entry.PrevLink,
|
||||
entry.JobId,
|
||||
entry.THlc,
|
||||
entry.PayloadHash);
|
||||
|
||||
if (!ByteArrayEquals(entry.Link, computedLink))
|
||||
{
|
||||
yield return $"Node {nodeLog.NodeId}, entry {i} (JobId {entry.JobId}): link mismatch";
|
||||
}
|
||||
|
||||
expectedPrevLink = entry.Link;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeManifestDigest(IReadOnlyList<NodeJobLog> jobLogs)
|
||||
{
|
||||
var manifest = jobLogs
|
||||
.OrderBy(l => l.NodeId, StringComparer.Ordinal)
|
||||
.Select(l => new
|
||||
{
|
||||
l.NodeId,
|
||||
LastHlc = l.LastHlc.ToSortableString(),
|
||||
ChainHead = Convert.ToHexString(l.ChainHead)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var json = CanonJson.Serialize(manifest);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool ByteArrayEquals(byte[]? a, byte[]? b)
|
||||
{
|
||||
if (a is null && b is null) return true;
|
||||
if (a is null || b is null) return false;
|
||||
return a.AsSpan().SequenceEqual(b);
|
||||
}
|
||||
|
||||
private static AirGapBundle FromImportDto(AirGapBundleImportDto dto) => new()
|
||||
{
|
||||
BundleId = dto.BundleId,
|
||||
TenantId = dto.TenantId,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
CreatedByNodeId = dto.CreatedByNodeId,
|
||||
ManifestDigest = dto.ManifestDigest,
|
||||
Signature = dto.Signature,
|
||||
SignedBy = dto.SignedBy,
|
||||
JobLogs = dto.JobLogs.Select(FromNodeJobLogDto).ToList()
|
||||
};
|
||||
|
||||
private static NodeJobLog FromNodeJobLogDto(NodeJobLogImportDto dto) => new()
|
||||
{
|
||||
NodeId = dto.NodeId,
|
||||
LastHlc = HlcTimestamp.Parse(dto.LastHlc),
|
||||
ChainHead = Convert.FromBase64String(dto.ChainHead),
|
||||
Entries = dto.Entries.Select(FromEntryDto).ToList()
|
||||
};
|
||||
|
||||
private static OfflineJobLogEntry FromEntryDto(OfflineJobLogEntryImportDto dto) => new()
|
||||
{
|
||||
NodeId = dto.NodeId,
|
||||
THlc = HlcTimestamp.Parse(dto.THlc),
|
||||
JobId = dto.JobId,
|
||||
PartitionKey = dto.PartitionKey,
|
||||
Payload = dto.Payload,
|
||||
PayloadHash = Convert.FromBase64String(dto.PayloadHash),
|
||||
PrevLink = dto.PrevLink is not null ? Convert.FromBase64String(dto.PrevLink) : null,
|
||||
Link = Convert.FromBase64String(dto.Link),
|
||||
EnqueuedAt = dto.EnqueuedAt
|
||||
};
|
||||
|
||||
// Import DTOs
|
||||
private sealed record AirGapBundleImportDto
|
||||
{
|
||||
public required Guid BundleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required string CreatedByNodeId { get; init; }
|
||||
public required string ManifestDigest { get; init; }
|
||||
public string? Signature { get; init; }
|
||||
public string? SignedBy { get; init; }
|
||||
public required IReadOnlyList<NodeJobLogImportDto> JobLogs { get; init; }
|
||||
}
|
||||
|
||||
private sealed record NodeJobLogImportDto
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string LastHlc { get; init; }
|
||||
public required string ChainHead { get; init; }
|
||||
public required IReadOnlyList<OfflineJobLogEntryImportDto> Entries { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OfflineJobLogEntryImportDto
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string THlc { get; init; }
|
||||
public required Guid JobId { get; init; }
|
||||
public string? PartitionKey { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required string PayloadHash { get; init; }
|
||||
public string? PrevLink { get; init; }
|
||||
public required string Link { get; init; }
|
||||
public DateTimeOffset EnqueuedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// <copyright file="AirGapSyncService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for the scheduler log repository used by sync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a subset of the full ISchedulerLogRepository to avoid circular dependencies.
|
||||
/// Implementations should delegate to the actual repository.
|
||||
/// </remarks>
|
||||
public interface ISyncSchedulerLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the chain head for a tenant/partition.
|
||||
/// </summary>
|
||||
Task<(byte[]? Link, string? THlc)> GetChainHeadAsync(
|
||||
string tenantId,
|
||||
string? partitionKey = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an entry by job ID.
|
||||
/// </summary>
|
||||
Task<bool> ExistsByJobIdAsync(
|
||||
string tenantId,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a synced entry.
|
||||
/// </summary>
|
||||
Task InsertSyncedEntryAsync(
|
||||
string tenantId,
|
||||
string tHlc,
|
||||
string? partitionKey,
|
||||
Guid jobId,
|
||||
byte[] payloadHash,
|
||||
byte[]? prevLink,
|
||||
byte[] link,
|
||||
string sourceNodeId,
|
||||
Guid syncedFromBundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for air-gap sync operations.
|
||||
/// </summary>
|
||||
public interface IAirGapSyncService
|
||||
{
|
||||
/// <summary>
|
||||
/// Syncs offline jobs from an air-gap bundle to the central scheduler.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to sync.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The sync result.</returns>
|
||||
Task<SyncResult> SyncFromBundleAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for syncing air-gap bundles to the central scheduler.
|
||||
/// </summary>
|
||||
public sealed class AirGapSyncService : IAirGapSyncService
|
||||
{
|
||||
private readonly IHlcMergeService _mergeService;
|
||||
private readonly ISyncSchedulerLogRepository _schedulerLogRepo;
|
||||
private readonly IHybridLogicalClock _hlc;
|
||||
private readonly ILogger<AirGapSyncService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AirGapSyncService"/> class.
|
||||
/// </summary>
|
||||
public AirGapSyncService(
|
||||
IHlcMergeService mergeService,
|
||||
ISyncSchedulerLogRepository schedulerLogRepo,
|
||||
IHybridLogicalClock hlc,
|
||||
ILogger<AirGapSyncService> logger)
|
||||
{
|
||||
_mergeService = mergeService ?? throw new ArgumentNullException(nameof(mergeService));
|
||||
_schedulerLogRepo = schedulerLogRepo ?? throw new ArgumentNullException(nameof(schedulerLogRepo));
|
||||
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SyncResult> SyncFromBundleAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting sync from bundle {BundleId} with {LogCount} node logs for tenant {TenantId}",
|
||||
bundle.BundleId, bundle.JobLogs.Count, bundle.TenantId);
|
||||
|
||||
// 1. Merge all offline logs
|
||||
var merged = await _mergeService.MergeAsync(bundle.JobLogs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (merged.MergedEntries.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("Bundle {BundleId} has no entries to sync", bundle.BundleId);
|
||||
return new SyncResult
|
||||
{
|
||||
BundleId = bundle.BundleId,
|
||||
TotalInBundle = 0,
|
||||
Appended = 0,
|
||||
Duplicates = 0,
|
||||
AlreadyExisted = 0
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Get current scheduler chain head
|
||||
var (currentLink, _) = await _schedulerLogRepo.GetChainHeadAsync(
|
||||
bundle.TenantId,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. For each merged entry, update HLC clock (receive)
|
||||
// This ensures central clock advances past all offline timestamps
|
||||
foreach (var entry in merged.MergedEntries)
|
||||
{
|
||||
_hlc.Receive(entry.THlc);
|
||||
}
|
||||
|
||||
// 4. Append merged entries to scheduler log
|
||||
// Chain links recomputed to extend from current head
|
||||
byte[]? prevLink = currentLink;
|
||||
var appended = 0;
|
||||
var alreadyExisted = 0;
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var entry in merged.MergedEntries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Check if job already exists (idempotency)
|
||||
var exists = await _schedulerLogRepo.ExistsByJobIdAsync(
|
||||
bundle.TenantId,
|
||||
entry.JobId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Job {JobId} already exists in scheduler log, skipping",
|
||||
entry.JobId);
|
||||
alreadyExisted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute new chain link extending from current chain
|
||||
var newLink = OfflineHlcManager.ComputeLink(
|
||||
prevLink,
|
||||
entry.JobId,
|
||||
entry.THlc,
|
||||
entry.PayloadHash);
|
||||
|
||||
// Insert the entry
|
||||
await _schedulerLogRepo.InsertSyncedEntryAsync(
|
||||
bundle.TenantId,
|
||||
entry.THlc.ToSortableString(),
|
||||
entry.PartitionKey,
|
||||
entry.JobId,
|
||||
entry.PayloadHash,
|
||||
prevLink,
|
||||
newLink,
|
||||
entry.SourceNodeId,
|
||||
bundle.BundleId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
prevLink = newLink;
|
||||
appended++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sync complete for bundle {BundleId}: {Appended} appended, {Duplicates} duplicates, {AlreadyExisted} already existed",
|
||||
bundle.BundleId, appended, merged.Duplicates.Count, alreadyExisted);
|
||||
|
||||
return new SyncResult
|
||||
{
|
||||
BundleId = bundle.BundleId,
|
||||
TotalInBundle = merged.MergedEntries.Count,
|
||||
Appended = appended,
|
||||
Duplicates = merged.Duplicates.Count,
|
||||
AlreadyExisted = alreadyExisted,
|
||||
NewChainHead = prevLink,
|
||||
Warnings = warnings.Count > 0 ? warnings : null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// <copyright file="ConflictResolver.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for conflict resolution during merge.
|
||||
/// </summary>
|
||||
public interface IConflictResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves conflicts when the same JobId appears in multiple entries.
|
||||
/// </summary>
|
||||
/// <param name="jobId">The conflicting job ID.</param>
|
||||
/// <param name="conflicting">The conflicting entries with their source nodes.</param>
|
||||
/// <returns>The resolution result.</returns>
|
||||
ConflictResolution Resolve(
|
||||
Guid jobId,
|
||||
IReadOnlyList<(string NodeId, OfflineJobLogEntry Entry)> conflicting);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves conflicts during HLC merge operations.
|
||||
/// </summary>
|
||||
public sealed class ConflictResolver : IConflictResolver
|
||||
{
|
||||
private readonly ILogger<ConflictResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConflictResolver"/> class.
|
||||
/// </summary>
|
||||
public ConflictResolver(ILogger<ConflictResolver> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ConflictResolution Resolve(
|
||||
Guid jobId,
|
||||
IReadOnlyList<(string NodeId, OfflineJobLogEntry Entry)> conflicting)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(conflicting);
|
||||
|
||||
if (conflicting.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Conflicting list cannot be empty", nameof(conflicting));
|
||||
}
|
||||
|
||||
if (conflicting.Count == 1)
|
||||
{
|
||||
// No conflict
|
||||
return new ConflictResolution
|
||||
{
|
||||
Type = ConflictType.DuplicateTimestamp,
|
||||
Resolution = ResolutionStrategy.TakeEarliest,
|
||||
SelectedEntry = conflicting[0].Entry,
|
||||
DroppedEntries = Array.Empty<OfflineJobLogEntry>()
|
||||
};
|
||||
}
|
||||
|
||||
// Verify payloads are actually different
|
||||
var uniquePayloads = conflicting
|
||||
.Select(c => Convert.ToHexString(c.Entry.PayloadHash))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (uniquePayloads.Count == 1)
|
||||
{
|
||||
// Same payload, different HLC timestamps - not a real conflict
|
||||
// Take the earliest HLC (preserves causality)
|
||||
var sorted = conflicting
|
||||
.OrderBy(c => c.Entry.THlc.PhysicalTime)
|
||||
.ThenBy(c => c.Entry.THlc.LogicalCounter)
|
||||
.ThenBy(c => c.Entry.THlc.NodeId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var earliest = sorted[0];
|
||||
var dropped = sorted.Skip(1).Select(s => s.Entry).ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Resolved duplicate timestamp conflict for JobId {JobId}: selected entry from node {NodeId} at {THlc}, dropped {DroppedCount} duplicates",
|
||||
jobId, earliest.NodeId, earliest.Entry.THlc, dropped.Count);
|
||||
|
||||
return new ConflictResolution
|
||||
{
|
||||
Type = ConflictType.DuplicateTimestamp,
|
||||
Resolution = ResolutionStrategy.TakeEarliest,
|
||||
SelectedEntry = earliest.Entry,
|
||||
DroppedEntries = dropped
|
||||
};
|
||||
}
|
||||
|
||||
// Actual conflict: same JobId, different payloads
|
||||
// This indicates a bug in deterministic ID computation
|
||||
var nodeIds = string.Join(", ", conflicting.Select(c => c.NodeId));
|
||||
var payloadHashes = string.Join(", ", conflicting.Select(c => Convert.ToHexString(c.Entry.PayloadHash)[..16] + "..."));
|
||||
|
||||
_logger.LogError(
|
||||
"Payload mismatch conflict for JobId {JobId}: different payloads from nodes [{NodeIds}] with hashes [{PayloadHashes}]",
|
||||
jobId, nodeIds, payloadHashes);
|
||||
|
||||
return new ConflictResolution
|
||||
{
|
||||
Type = ConflictType.PayloadMismatch,
|
||||
Resolution = ResolutionStrategy.Error,
|
||||
Error = $"JobId {jobId} has conflicting payloads from nodes: {nodeIds}. " +
|
||||
"This indicates a bug in deterministic job ID computation or payload tampering."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// <copyright file="HlcMergeService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for HLC-based merge operations.
|
||||
/// </summary>
|
||||
public interface IHlcMergeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Merges job logs from multiple offline nodes into a unified, HLC-ordered stream.
|
||||
/// </summary>
|
||||
/// <param name="nodeLogs">The node logs to merge.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The merge result.</returns>
|
||||
Task<MergeResult> MergeAsync(
|
||||
IReadOnlyList<NodeJobLog> nodeLogs,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for merging job logs from multiple offline nodes using HLC total ordering.
|
||||
/// </summary>
|
||||
public sealed class HlcMergeService : IHlcMergeService
|
||||
{
|
||||
private readonly IConflictResolver _conflictResolver;
|
||||
private readonly ILogger<HlcMergeService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HlcMergeService"/> class.
|
||||
/// </summary>
|
||||
public HlcMergeService(
|
||||
IConflictResolver conflictResolver,
|
||||
ILogger<HlcMergeService> logger)
|
||||
{
|
||||
_conflictResolver = conflictResolver ?? throw new ArgumentNullException(nameof(conflictResolver));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MergeResult> MergeAsync(
|
||||
IReadOnlyList<NodeJobLog> nodeLogs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeLogs);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (nodeLogs.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new MergeResult
|
||||
{
|
||||
MergedEntries = Array.Empty<MergedJobEntry>(),
|
||||
Duplicates = Array.Empty<DuplicateEntry>(),
|
||||
SourceNodes = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting merge of {NodeCount} node logs with {TotalEntries} total entries",
|
||||
nodeLogs.Count,
|
||||
nodeLogs.Sum(l => l.Entries.Count));
|
||||
|
||||
// 1. Collect all entries from all nodes
|
||||
var allEntries = nodeLogs
|
||||
.SelectMany(log => log.Entries.Select(e => (log.NodeId, Entry: e)))
|
||||
.ToList();
|
||||
|
||||
// 2. Sort by HLC total order: (PhysicalTime, LogicalCounter, NodeId, JobId)
|
||||
var sorted = allEntries
|
||||
.OrderBy(x => x.Entry.THlc.PhysicalTime)
|
||||
.ThenBy(x => x.Entry.THlc.LogicalCounter)
|
||||
.ThenBy(x => x.Entry.THlc.NodeId, StringComparer.Ordinal)
|
||||
.ThenBy(x => x.Entry.JobId)
|
||||
.ToList();
|
||||
|
||||
// 3. Group by JobId to detect duplicates
|
||||
var groupedByJobId = sorted.GroupBy(x => x.Entry.JobId).ToList();
|
||||
|
||||
var deduplicated = new List<MergedJobEntry>();
|
||||
var duplicates = new List<DuplicateEntry>();
|
||||
|
||||
foreach (var group in groupedByJobId)
|
||||
{
|
||||
var entries = group.ToList();
|
||||
|
||||
if (entries.Count == 1)
|
||||
{
|
||||
// No conflict - add directly
|
||||
var (nodeId, entry) = entries[0];
|
||||
deduplicated.Add(CreateMergedEntry(nodeId, entry));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Multiple entries with same JobId - resolve conflict
|
||||
var resolution = _conflictResolver.Resolve(group.Key, entries);
|
||||
|
||||
if (resolution.Resolution == ResolutionStrategy.Error)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Conflict resolution failed for JobId {JobId}: {Error}",
|
||||
group.Key, resolution.Error);
|
||||
throw new InvalidOperationException(resolution.Error);
|
||||
}
|
||||
|
||||
// Add the selected entry
|
||||
if (resolution.SelectedEntry is not null)
|
||||
{
|
||||
var sourceEntry = entries.First(e => e.Entry == resolution.SelectedEntry);
|
||||
deduplicated.Add(CreateMergedEntry(sourceEntry.NodeId, resolution.SelectedEntry));
|
||||
}
|
||||
|
||||
// Record duplicates
|
||||
foreach (var dropped in resolution.DroppedEntries ?? Array.Empty<OfflineJobLogEntry>())
|
||||
{
|
||||
var sourceEntry = entries.First(e => e.Entry == dropped);
|
||||
duplicates.Add(new DuplicateEntry(dropped.JobId, sourceEntry.NodeId, dropped.THlc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Sort deduplicated entries by HLC order
|
||||
deduplicated = deduplicated
|
||||
.OrderBy(x => x.THlc.PhysicalTime)
|
||||
.ThenBy(x => x.THlc.LogicalCounter)
|
||||
.ThenBy(x => x.THlc.NodeId, StringComparer.Ordinal)
|
||||
.ThenBy(x => x.JobId)
|
||||
.ToList();
|
||||
|
||||
// 5. Recompute unified chain
|
||||
byte[]? prevLink = null;
|
||||
foreach (var entry in deduplicated)
|
||||
{
|
||||
entry.MergedLink = OfflineHlcManager.ComputeLink(
|
||||
prevLink,
|
||||
entry.JobId,
|
||||
entry.THlc,
|
||||
entry.PayloadHash);
|
||||
prevLink = entry.MergedLink;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Merge complete: {MergedCount} entries, {DuplicateCount} duplicates dropped",
|
||||
deduplicated.Count, duplicates.Count);
|
||||
|
||||
return Task.FromResult(new MergeResult
|
||||
{
|
||||
MergedEntries = deduplicated,
|
||||
Duplicates = duplicates,
|
||||
MergedChainHead = prevLink,
|
||||
SourceNodes = nodeLogs.Select(l => l.NodeId).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static MergedJobEntry CreateMergedEntry(string nodeId, OfflineJobLogEntry entry) => new()
|
||||
{
|
||||
SourceNodeId = nodeId,
|
||||
THlc = entry.THlc,
|
||||
JobId = entry.JobId,
|
||||
PartitionKey = entry.PartitionKey,
|
||||
Payload = entry.Payload,
|
||||
PayloadHash = entry.PayloadHash,
|
||||
OriginalLink = entry.Link
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// <copyright file="OfflineHlcManager.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.AirGap.Sync.Stores;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for offline HLC management.
|
||||
/// </summary>
|
||||
public interface IOfflineHlcManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueues a job locally while offline, maintaining the local chain.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The payload type.</typeparam>
|
||||
/// <param name="payload">The job payload.</param>
|
||||
/// <param name="idempotencyKey">The idempotency key for deterministic job ID.</param>
|
||||
/// <param name="partitionKey">Optional partition key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The enqueue result.</returns>
|
||||
Task<OfflineEnqueueResult> EnqueueOfflineAsync<T>(
|
||||
T payload,
|
||||
string idempotencyKey,
|
||||
string? partitionKey = null,
|
||||
CancellationToken cancellationToken = default) where T : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current node's job log for export.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The node job log, or null if empty.</returns>
|
||||
Task<NodeJobLog?> GetNodeJobLogAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node ID.
|
||||
/// </summary>
|
||||
string NodeId { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages HLC operations for offline/air-gap scenarios.
|
||||
/// </summary>
|
||||
public sealed class OfflineHlcManager : IOfflineHlcManager
|
||||
{
|
||||
private readonly IHybridLogicalClock _hlc;
|
||||
private readonly IOfflineJobLogStore _jobLogStore;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<OfflineHlcManager> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OfflineHlcManager"/> class.
|
||||
/// </summary>
|
||||
public OfflineHlcManager(
|
||||
IHybridLogicalClock hlc,
|
||||
IOfflineJobLogStore jobLogStore,
|
||||
IGuidProvider guidProvider,
|
||||
ILogger<OfflineHlcManager> logger)
|
||||
{
|
||||
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
|
||||
_jobLogStore = jobLogStore ?? throw new ArgumentNullException(nameof(jobLogStore));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string NodeId => _hlc.NodeId;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<OfflineEnqueueResult> EnqueueOfflineAsync<T>(
|
||||
T payload,
|
||||
string idempotencyKey,
|
||||
string? partitionKey = null,
|
||||
CancellationToken cancellationToken = default) where T : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
|
||||
|
||||
// 1. Generate HLC timestamp
|
||||
var tHlc = _hlc.Tick();
|
||||
|
||||
// 2. Compute deterministic job ID from idempotency key
|
||||
var jobId = ComputeDeterministicJobId(idempotencyKey);
|
||||
|
||||
// 3. Serialize and hash payload
|
||||
var payloadJson = CanonJson.Serialize(payload);
|
||||
var payloadHash = SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson));
|
||||
|
||||
// 4. Get previous chain link
|
||||
var prevLink = await _jobLogStore.GetLastLinkAsync(NodeId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// 5. Compute chain link
|
||||
var link = ComputeLink(prevLink, jobId, tHlc, payloadHash);
|
||||
|
||||
// 6. Create and store entry
|
||||
var entry = new OfflineJobLogEntry
|
||||
{
|
||||
NodeId = NodeId,
|
||||
THlc = tHlc,
|
||||
JobId = jobId,
|
||||
PartitionKey = partitionKey,
|
||||
Payload = payloadJson,
|
||||
PayloadHash = payloadHash,
|
||||
PrevLink = prevLink,
|
||||
Link = link,
|
||||
EnqueuedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _jobLogStore.AppendAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Enqueued offline job {JobId} with HLC {THlc} on node {NodeId}",
|
||||
jobId, tHlc, NodeId);
|
||||
|
||||
return new OfflineEnqueueResult
|
||||
{
|
||||
THlc = tHlc,
|
||||
JobId = jobId,
|
||||
Link = link,
|
||||
NodeId = NodeId
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<NodeJobLog?> GetNodeJobLogAsync(CancellationToken cancellationToken = default)
|
||||
=> _jobLogStore.GetNodeJobLogAsync(NodeId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic job ID from idempotency key.
|
||||
/// </summary>
|
||||
private Guid ComputeDeterministicJobId(string idempotencyKey)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(idempotencyKey));
|
||||
// Use first 16 bytes of SHA-256 as deterministic GUID
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes chain link: Hash(prev_link || job_id || t_hlc || payload_hash).
|
||||
/// </summary>
|
||||
internal static byte[] ComputeLink(
|
||||
byte[]? prevLink,
|
||||
Guid jobId,
|
||||
HlcTimestamp tHlc,
|
||||
byte[] payloadHash)
|
||||
{
|
||||
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
// Previous link (or 32 zero bytes for first entry)
|
||||
hasher.AppendData(prevLink ?? new byte[32]);
|
||||
|
||||
// Job ID as bytes
|
||||
hasher.AppendData(jobId.ToByteArray());
|
||||
|
||||
// HLC timestamp as UTF-8 bytes
|
||||
hasher.AppendData(Encoding.UTF8.GetBytes(tHlc.ToSortableString()));
|
||||
|
||||
// Payload hash
|
||||
hasher.AppendData(payloadHash);
|
||||
|
||||
return hasher.GetHashAndReset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scheduler\__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,246 @@
|
||||
// <copyright file="FileBasedOfflineJobLogStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the file-based offline job log store.
|
||||
/// </summary>
|
||||
public sealed class FileBasedOfflineJobLogStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the directory for storing offline job logs.
|
||||
/// </summary>
|
||||
public string DataDirectory { get; set; } = "./offline-job-logs";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File-based implementation of <see cref="IOfflineJobLogStore"/> for air-gap scenarios.
|
||||
/// </summary>
|
||||
public sealed class FileBasedOfflineJobLogStore : IOfflineJobLogStore
|
||||
{
|
||||
private readonly IOptions<FileBasedOfflineJobLogStoreOptions> _options;
|
||||
private readonly ILogger<FileBasedOfflineJobLogStore> _logger;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileBasedOfflineJobLogStore"/> class.
|
||||
/// </summary>
|
||||
public FileBasedOfflineJobLogStore(
|
||||
IOptions<FileBasedOfflineJobLogStoreOptions> options,
|
||||
ILogger<FileBasedOfflineJobLogStore> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
EnsureDirectoryExists();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task AppendAsync(OfflineJobLogEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var filePath = GetNodeLogFilePath(entry.NodeId);
|
||||
var dto = ToDto(entry);
|
||||
var line = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
await File.AppendAllTextAsync(filePath, line + Environment.NewLine, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Appended offline job entry {JobId} for node {NodeId}",
|
||||
entry.JobId, entry.NodeId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<OfflineJobLogEntry>> GetEntriesAsync(
|
||||
string nodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
|
||||
var filePath = GetNodeLogFilePath(nodeId);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return Array.Empty<OfflineJobLogEntry>();
|
||||
}
|
||||
|
||||
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var entries = new List<OfflineJobLogEntry>(lines.Length);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var dto = JsonSerializer.Deserialize<OfflineJobLogEntryDto>(line, JsonOptions);
|
||||
if (dto is not null)
|
||||
{
|
||||
entries.Add(FromDto(dto));
|
||||
}
|
||||
}
|
||||
|
||||
// Return in HLC order
|
||||
return entries.OrderBy(e => e.THlc).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<byte[]?> GetLastLinkAsync(string nodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = await GetEntriesAsync(nodeId, cancellationToken).ConfigureAwait(false);
|
||||
return entries.Count > 0 ? entries[^1].Link : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<NodeJobLog?> GetNodeJobLogAsync(string nodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = await GetEntriesAsync(nodeId, cancellationToken).ConfigureAwait(false);
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lastEntry = entries[^1];
|
||||
return new NodeJobLog
|
||||
{
|
||||
NodeId = nodeId,
|
||||
LastHlc = lastEntry.THlc,
|
||||
ChainHead = lastEntry.Link,
|
||||
Entries = entries
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> ClearEntriesAsync(
|
||||
string nodeId,
|
||||
string upToHlc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
|
||||
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var entries = await GetEntriesAsync(nodeId, cancellationToken).ConfigureAwait(false);
|
||||
var remaining = entries
|
||||
.Where(e => string.CompareOrdinal(e.THlc.ToSortableString(), upToHlc) > 0)
|
||||
.ToList();
|
||||
|
||||
var cleared = entries.Count - remaining.Count;
|
||||
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
var filePath = GetNodeLogFilePath(nodeId);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Rewrite with remaining entries
|
||||
var filePath = GetNodeLogFilePath(nodeId);
|
||||
var lines = remaining.Select(e => JsonSerializer.Serialize(ToDto(e), JsonOptions));
|
||||
await File.WriteAllLinesAsync(filePath, lines, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cleared {Count} offline job entries for node {NodeId} up to HLC {UpToHlc}",
|
||||
cleared, nodeId, upToHlc);
|
||||
|
||||
return cleared;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetNodeLogFilePath(string nodeId)
|
||||
{
|
||||
var safeNodeId = nodeId.Replace('/', '_').Replace('\\', '_').Replace(':', '_');
|
||||
return Path.Combine(_options.Value.DataDirectory, $"offline-jobs-{safeNodeId}.ndjson");
|
||||
}
|
||||
|
||||
private void EnsureDirectoryExists()
|
||||
{
|
||||
var dir = _options.Value.DataDirectory;
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
_logger.LogInformation("Created offline job log directory: {Directory}", dir);
|
||||
}
|
||||
}
|
||||
|
||||
private static OfflineJobLogEntryDto ToDto(OfflineJobLogEntry entry) => new()
|
||||
{
|
||||
NodeId = entry.NodeId,
|
||||
THlc = entry.THlc.ToSortableString(),
|
||||
JobId = entry.JobId,
|
||||
PartitionKey = entry.PartitionKey,
|
||||
Payload = entry.Payload,
|
||||
PayloadHash = Convert.ToBase64String(entry.PayloadHash),
|
||||
PrevLink = entry.PrevLink is not null ? Convert.ToBase64String(entry.PrevLink) : null,
|
||||
Link = Convert.ToBase64String(entry.Link),
|
||||
EnqueuedAt = entry.EnqueuedAt
|
||||
};
|
||||
|
||||
private static OfflineJobLogEntry FromDto(OfflineJobLogEntryDto dto) => new()
|
||||
{
|
||||
NodeId = dto.NodeId,
|
||||
THlc = HlcTimestamp.Parse(dto.THlc),
|
||||
JobId = dto.JobId,
|
||||
PartitionKey = dto.PartitionKey,
|
||||
Payload = dto.Payload,
|
||||
PayloadHash = Convert.FromBase64String(dto.PayloadHash),
|
||||
PrevLink = dto.PrevLink is not null ? Convert.FromBase64String(dto.PrevLink) : null,
|
||||
Link = Convert.FromBase64String(dto.Link),
|
||||
EnqueuedAt = dto.EnqueuedAt
|
||||
};
|
||||
|
||||
private sealed record OfflineJobLogEntryDto
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string THlc { get; init; }
|
||||
public required Guid JobId { get; init; }
|
||||
public string? PartitionKey { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required string PayloadHash { get; init; }
|
||||
public string? PrevLink { get; init; }
|
||||
public required string Link { get; init; }
|
||||
public DateTimeOffset EnqueuedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// <copyright file="IOfflineJobLogStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for storing offline job log entries.
|
||||
/// </summary>
|
||||
public interface IOfflineJobLogStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Appends an entry to the offline job log.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to append.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task AppendAsync(OfflineJobLogEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entries for a node.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>All entries in HLC order.</returns>
|
||||
Task<IReadOnlyList<OfflineJobLogEntry>> GetEntriesAsync(
|
||||
string nodeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last chain link for a node.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The last link, or null if no entries exist.</returns>
|
||||
Task<byte[]?> GetLastLinkAsync(string nodeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node job log for export.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The complete node job log.</returns>
|
||||
Task<NodeJobLog?> GetNodeJobLogAsync(string nodeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears entries for a node after successful sync.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="upToHlc">Clear entries up to and including this HLC timestamp.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of entries cleared.</returns>
|
||||
Task<int> ClearEntriesAsync(
|
||||
string nodeId,
|
||||
string upToHlc,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// <copyright file="AirGapSyncMetrics.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for air-gap sync operations.
|
||||
/// </summary>
|
||||
public static class AirGapSyncMetrics
|
||||
{
|
||||
private const string NodeIdTag = "node_id";
|
||||
private const string TenantIdTag = "tenant_id";
|
||||
private const string ConflictTypeTag = "conflict_type";
|
||||
|
||||
private static readonly Meter Meter = new("StellaOps.AirGap.Sync");
|
||||
|
||||
// Counters
|
||||
private static readonly Counter<long> BundlesExportedCounter = Meter.CreateCounter<long>(
|
||||
"airgap_bundles_exported_total",
|
||||
unit: "{bundle}",
|
||||
description: "Total number of air-gap bundles exported");
|
||||
|
||||
private static readonly Counter<long> BundlesImportedCounter = Meter.CreateCounter<long>(
|
||||
"airgap_bundles_imported_total",
|
||||
unit: "{bundle}",
|
||||
description: "Total number of air-gap bundles imported");
|
||||
|
||||
private static readonly Counter<long> JobsSyncedCounter = Meter.CreateCounter<long>(
|
||||
"airgap_jobs_synced_total",
|
||||
unit: "{job}",
|
||||
description: "Total number of jobs synced from air-gap bundles");
|
||||
|
||||
private static readonly Counter<long> DuplicatesDroppedCounter = Meter.CreateCounter<long>(
|
||||
"airgap_duplicates_dropped_total",
|
||||
unit: "{duplicate}",
|
||||
description: "Total number of duplicate entries dropped during merge");
|
||||
|
||||
private static readonly Counter<long> MergeConflictsCounter = Meter.CreateCounter<long>(
|
||||
"airgap_merge_conflicts_total",
|
||||
unit: "{conflict}",
|
||||
description: "Total number of merge conflicts by type");
|
||||
|
||||
private static readonly Counter<long> OfflineEnqueuesCounter = Meter.CreateCounter<long>(
|
||||
"airgap_offline_enqueues_total",
|
||||
unit: "{enqueue}",
|
||||
description: "Total number of offline enqueue operations");
|
||||
|
||||
// Histograms
|
||||
private static readonly Histogram<double> BundleSizeHistogram = Meter.CreateHistogram<double>(
|
||||
"airgap_bundle_size_bytes",
|
||||
unit: "By",
|
||||
description: "Size of air-gap bundles in bytes");
|
||||
|
||||
private static readonly Histogram<double> SyncDurationHistogram = Meter.CreateHistogram<double>(
|
||||
"airgap_sync_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of air-gap sync operations");
|
||||
|
||||
private static readonly Histogram<int> MergeEntriesHistogram = Meter.CreateHistogram<int>(
|
||||
"airgap_merge_entries_count",
|
||||
unit: "{entry}",
|
||||
description: "Number of entries in merge operations");
|
||||
|
||||
/// <summary>
|
||||
/// Records a bundle export.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID that exported.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="entryCount">Number of entries in the bundle.</param>
|
||||
public static void RecordBundleExported(string nodeId, string tenantId, int entryCount)
|
||||
{
|
||||
BundlesExportedCounter.Add(1,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId),
|
||||
new KeyValuePair<string, object?>(TenantIdTag, tenantId));
|
||||
MergeEntriesHistogram.Record(entryCount,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a bundle import.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID that imported.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
public static void RecordBundleImported(string nodeId, string tenantId)
|
||||
{
|
||||
BundlesImportedCounter.Add(1,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId),
|
||||
new KeyValuePair<string, object?>(TenantIdTag, tenantId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records jobs synced from a bundle.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="count">Number of jobs synced.</param>
|
||||
public static void RecordJobsSynced(string nodeId, int count)
|
||||
{
|
||||
JobsSyncedCounter.Add(count,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records duplicates dropped during merge.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="count">Number of duplicates dropped.</param>
|
||||
public static void RecordDuplicatesDropped(string nodeId, int count)
|
||||
{
|
||||
if (count > 0)
|
||||
{
|
||||
DuplicatesDroppedCounter.Add(count,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a merge conflict.
|
||||
/// </summary>
|
||||
/// <param name="conflictType">The type of conflict.</param>
|
||||
public static void RecordMergeConflict(ConflictType conflictType)
|
||||
{
|
||||
MergeConflictsCounter.Add(1,
|
||||
new KeyValuePair<string, object?>(ConflictTypeTag, conflictType.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an offline enqueue operation.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
public static void RecordOfflineEnqueue(string nodeId)
|
||||
{
|
||||
OfflineEnqueuesCounter.Add(1,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records bundle size.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="sizeBytes">Size in bytes.</param>
|
||||
public static void RecordBundleSize(string nodeId, long sizeBytes)
|
||||
{
|
||||
BundleSizeHistogram.Record(sizeBytes,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records sync duration.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="durationSeconds">Duration in seconds.</param>
|
||||
public static void RecordSyncDuration(string nodeId, double durationSeconds)
|
||||
{
|
||||
SyncDurationHistogram.Record(durationSeconds,
|
||||
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// <copyright file="FileBasedJobSyncTransport.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.AirGap.Sync.Services;
|
||||
using StellaOps.AirGap.Sync.Telemetry;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// File-based transport for job sync bundles in air-gapped scenarios.
|
||||
/// </summary>
|
||||
public sealed class FileBasedJobSyncTransport : IJobSyncTransport
|
||||
{
|
||||
private readonly IAirGapBundleExporter _exporter;
|
||||
private readonly IAirGapBundleImporter _importer;
|
||||
private readonly FileBasedJobSyncTransportOptions _options;
|
||||
private readonly ILogger<FileBasedJobSyncTransport> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileBasedJobSyncTransport"/> class.
|
||||
/// </summary>
|
||||
public FileBasedJobSyncTransport(
|
||||
IAirGapBundleExporter exporter,
|
||||
IAirGapBundleImporter importer,
|
||||
IOptions<FileBasedJobSyncTransportOptions> options,
|
||||
ILogger<FileBasedJobSyncTransport> logger)
|
||||
{
|
||||
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
|
||||
_importer = importer ?? throw new ArgumentNullException(nameof(importer));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string TransportId => "file";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<JobSyncSendResult> SendBundleAsync(
|
||||
AirGapBundle bundle,
|
||||
string destination,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure destination directory exists
|
||||
var destPath = Path.IsPathRooted(destination)
|
||||
? destination
|
||||
: Path.Combine(_options.OutputDirectory, destination);
|
||||
|
||||
Directory.CreateDirectory(destPath);
|
||||
|
||||
// Export to file
|
||||
var filePath = Path.Combine(destPath, $"job-sync-{bundle.BundleId:N}.json");
|
||||
await _exporter.ExportToFileAsync(bundle, filePath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var sizeBytes = fileInfo.Exists ? fileInfo.Length : 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported job sync bundle {BundleId} to {Path} ({Size} bytes)",
|
||||
bundle.BundleId,
|
||||
filePath,
|
||||
sizeBytes);
|
||||
|
||||
AirGapSyncMetrics.RecordBundleSize(bundle.CreatedByNodeId, sizeBytes);
|
||||
|
||||
return new JobSyncSendResult
|
||||
{
|
||||
Success = true,
|
||||
BundleId = bundle.BundleId,
|
||||
Destination = filePath,
|
||||
TransmittedAt = startTime,
|
||||
SizeBytes = sizeBytes
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export job sync bundle {BundleId}", bundle.BundleId);
|
||||
|
||||
return new JobSyncSendResult
|
||||
{
|
||||
Success = false,
|
||||
BundleId = bundle.BundleId,
|
||||
Destination = destination,
|
||||
Error = ex.Message,
|
||||
TransmittedAt = startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AirGapBundle?> ReceiveBundleAsync(
|
||||
string source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sourcePath = Path.IsPathRooted(source)
|
||||
? source
|
||||
: Path.Combine(_options.InputDirectory, source);
|
||||
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
_logger.LogWarning("Job sync bundle file not found: {Path}", sourcePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var bundle = await _importer.ImportFromFileAsync(sourcePath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Imported job sync bundle {BundleId} from {Path}",
|
||||
bundle.BundleId,
|
||||
sourcePath);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to import job sync bundle from {Source}", source);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BundleInfo>> ListAvailableBundlesAsync(
|
||||
string source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sourcePath = Path.IsPathRooted(source)
|
||||
? source
|
||||
: Path.Combine(_options.InputDirectory, source);
|
||||
|
||||
var bundles = new List<BundleInfo>();
|
||||
|
||||
if (!Directory.Exists(sourcePath))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<BundleInfo>>(bundles);
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(sourcePath, "job-sync-*.json");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Quick parse to extract bundle metadata
|
||||
var json = File.ReadAllText(file);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("bundleId", out var bundleIdProp) &&
|
||||
root.TryGetProperty("tenantId", out var tenantIdProp) &&
|
||||
root.TryGetProperty("createdByNodeId", out var nodeIdProp) &&
|
||||
root.TryGetProperty("createdAt", out var createdAtProp))
|
||||
{
|
||||
var entryCount = 0;
|
||||
if (root.TryGetProperty("jobLogs", out var jobLogs))
|
||||
{
|
||||
foreach (var log in jobLogs.EnumerateArray())
|
||||
{
|
||||
if (log.TryGetProperty("entries", out var entries))
|
||||
{
|
||||
entryCount += entries.GetArrayLength();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bundles.Add(new BundleInfo
|
||||
{
|
||||
BundleId = Guid.Parse(bundleIdProp.GetString()!),
|
||||
TenantId = tenantIdProp.GetString()!,
|
||||
SourceNodeId = nodeIdProp.GetString()!,
|
||||
CreatedAt = DateTimeOffset.Parse(createdAtProp.GetString()!),
|
||||
EntryCount = entryCount,
|
||||
SizeBytes = new FileInfo(file).Length
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse bundle metadata from {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<BundleInfo>>(
|
||||
bundles.OrderByDescending(b => b.CreatedAt).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for file-based job sync transport.
|
||||
/// </summary>
|
||||
public sealed class FileBasedJobSyncTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the output directory for exporting bundles.
|
||||
/// </summary>
|
||||
public string OutputDirectory { get; set; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"stellaops",
|
||||
"airgap",
|
||||
"outbox");
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the input directory for importing bundles.
|
||||
/// </summary>
|
||||
public string InputDirectory { get; set; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"stellaops",
|
||||
"airgap",
|
||||
"inbox");
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// <copyright file="IJobSyncTransport.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Transport abstraction for job sync bundles.
|
||||
/// Enables bundle transfer over various transports (file, Router messaging, etc.).
|
||||
/// </summary>
|
||||
public interface IJobSyncTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the transport identifier.
|
||||
/// </summary>
|
||||
string TransportId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a job sync bundle to a destination.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to send.</param>
|
||||
/// <param name="destination">The destination identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The send result.</returns>
|
||||
Task<JobSyncSendResult> SendBundleAsync(
|
||||
AirGapBundle bundle,
|
||||
string destination,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Receives a job sync bundle from a source.
|
||||
/// </summary>
|
||||
/// <param name="source">The source identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The received bundle, or null if not available.</returns>
|
||||
Task<AirGapBundle?> ReceiveBundleAsync(
|
||||
string source,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available bundles from a source.
|
||||
/// </summary>
|
||||
/// <param name="source">The source identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of available bundle identifiers.</returns>
|
||||
Task<IReadOnlyList<BundleInfo>> ListAvailableBundlesAsync(
|
||||
string source,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of sending a job sync bundle.
|
||||
/// </summary>
|
||||
public sealed record JobSyncSendResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the send was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bundle ID.
|
||||
/// </summary>
|
||||
public required Guid BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the destination where the bundle was sent.
|
||||
/// </summary>
|
||||
public required string Destination { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if the send failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transmission timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset TransmittedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of the transmitted data in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the bundle ID.
|
||||
/// </summary>
|
||||
public required Guid BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source node ID.
|
||||
/// </summary>
|
||||
public required string SourceNodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entry count in the bundle.
|
||||
/// </summary>
|
||||
public int EntryCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bundle size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// <copyright file="RouterJobSyncTransport.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.AirGap.Sync.Services;
|
||||
using StellaOps.AirGap.Sync.Telemetry;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Router-based transport for job sync bundles when network is available.
|
||||
/// This transport uses the Router messaging infrastructure for real-time sync.
|
||||
/// </summary>
|
||||
public sealed class RouterJobSyncTransport : IJobSyncTransport
|
||||
{
|
||||
private readonly IAirGapBundleExporter _exporter;
|
||||
private readonly IAirGapBundleImporter _importer;
|
||||
private readonly IRouterJobSyncClient _routerClient;
|
||||
private readonly RouterJobSyncTransportOptions _options;
|
||||
private readonly ILogger<RouterJobSyncTransport> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RouterJobSyncTransport"/> class.
|
||||
/// </summary>
|
||||
public RouterJobSyncTransport(
|
||||
IAirGapBundleExporter exporter,
|
||||
IAirGapBundleImporter importer,
|
||||
IRouterJobSyncClient routerClient,
|
||||
IOptions<RouterJobSyncTransportOptions> options,
|
||||
ILogger<RouterJobSyncTransport> logger)
|
||||
{
|
||||
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
|
||||
_importer = importer ?? throw new ArgumentNullException(nameof(importer));
|
||||
_routerClient = routerClient ?? throw new ArgumentNullException(nameof(routerClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string TransportId => "router";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<JobSyncSendResult> SendBundleAsync(
|
||||
AirGapBundle bundle,
|
||||
string destination,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// Serialize bundle
|
||||
var json = await _exporter.ExportToStringAsync(bundle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var payload = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Sending job sync bundle {BundleId} to {Destination} ({Size} bytes)",
|
||||
bundle.BundleId,
|
||||
destination,
|
||||
payload.Length);
|
||||
|
||||
// Send via Router
|
||||
var response = await _routerClient.SendJobSyncBundleAsync(
|
||||
destination,
|
||||
bundle.BundleId,
|
||||
bundle.TenantId,
|
||||
payload,
|
||||
_options.SendTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
AirGapSyncMetrics.RecordBundleSize(bundle.CreatedByNodeId, payload.Length);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sent job sync bundle {BundleId} to {Destination}",
|
||||
bundle.BundleId,
|
||||
destination);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to send job sync bundle {BundleId} to {Destination}: {Error}",
|
||||
bundle.BundleId,
|
||||
destination,
|
||||
response.Error);
|
||||
}
|
||||
|
||||
return new JobSyncSendResult
|
||||
{
|
||||
Success = response.Success,
|
||||
BundleId = bundle.BundleId,
|
||||
Destination = destination,
|
||||
Error = response.Error,
|
||||
TransmittedAt = startTime,
|
||||
SizeBytes = payload.Length
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error sending job sync bundle {BundleId} to {Destination}",
|
||||
bundle.BundleId,
|
||||
destination);
|
||||
|
||||
return new JobSyncSendResult
|
||||
{
|
||||
Success = false,
|
||||
BundleId = bundle.BundleId,
|
||||
Destination = destination,
|
||||
Error = ex.Message,
|
||||
TransmittedAt = startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AirGapBundle?> ReceiveBundleAsync(
|
||||
string source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _routerClient.ReceiveJobSyncBundleAsync(
|
||||
source,
|
||||
_options.ReceiveTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Payload is null || response.Payload.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No bundle available from {Source}", source);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = Encoding.UTF8.GetString(response.Payload);
|
||||
var bundle = await _importer.ImportFromStringAsync(json, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Received job sync bundle {BundleId} from {Source}",
|
||||
bundle.BundleId,
|
||||
source);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error receiving job sync bundle from {Source}", source);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BundleInfo>> ListAvailableBundlesAsync(
|
||||
string source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _routerClient.ListAvailableBundlesAsync(
|
||||
source,
|
||||
_options.ListTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response.Bundles;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing available bundles from {Source}", source);
|
||||
return Array.Empty<BundleInfo>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for Router-based job sync transport.
|
||||
/// </summary>
|
||||
public sealed class RouterJobSyncTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout for send operations.
|
||||
/// </summary>
|
||||
public TimeSpan SendTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout for receive operations.
|
||||
/// </summary>
|
||||
public TimeSpan ReceiveTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout for list operations.
|
||||
/// </summary>
|
||||
public TimeSpan ListTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service endpoint for job sync.
|
||||
/// </summary>
|
||||
public string ServiceEndpoint { get; set; } = "scheduler.job-sync";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for Router job sync operations.
|
||||
/// </summary>
|
||||
public interface IRouterJobSyncClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a job sync bundle via the Router.
|
||||
/// </summary>
|
||||
Task<RouterSendResponse> SendJobSyncBundleAsync(
|
||||
string destination,
|
||||
Guid bundleId,
|
||||
string tenantId,
|
||||
byte[] payload,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Receives a job sync bundle via the Router.
|
||||
/// </summary>
|
||||
Task<RouterReceiveResponse> ReceiveJobSyncBundleAsync(
|
||||
string source,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available bundles via the Router.
|
||||
/// </summary>
|
||||
Task<RouterListResponse> ListAvailableBundlesAsync(
|
||||
string source,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a Router send operation.
|
||||
/// </summary>
|
||||
public sealed record RouterSendResponse
|
||||
{
|
||||
/// <summary>Gets a value indicating whether the send was successful.</summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>Gets the error message if failed.</summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a Router receive operation.
|
||||
/// </summary>
|
||||
public sealed record RouterReceiveResponse
|
||||
{
|
||||
/// <summary>Gets the received payload.</summary>
|
||||
public byte[]? Payload { get; init; }
|
||||
|
||||
/// <summary>Gets the bundle ID.</summary>
|
||||
public Guid? BundleId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a Router list operation.
|
||||
/// </summary>
|
||||
public sealed record RouterListResponse
|
||||
{
|
||||
/// <summary>Gets the available bundles.</summary>
|
||||
public IReadOnlyList<BundleInfo> Bundles { get; init; } = Array.Empty<BundleInfo>();
|
||||
}
|
||||
@@ -22,6 +22,9 @@ namespace StellaOps.AirGap.Bundle.Tests;
|
||||
/// Task AIRGAP-5100-016: Export bundle (online env) → import bundle (offline env) → verify data integrity
|
||||
/// Task AIRGAP-5100-017: Policy export → policy import → policy evaluation → verify identical verdict
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("BlastRadius", TestCategories.BlastRadius.Integrations)]
|
||||
[Trait("BlastRadius", TestCategories.BlastRadius.Persistence)]
|
||||
public sealed class AirGapIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
Reference in New Issue
Block a user