save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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
};
}
}

View File

@@ -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."
};
}
}

View File

@@ -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
};
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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; }
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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");
}

View File

@@ -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; }
}

View File

@@ -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>();
}

View File

@@ -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;