// // Copyright (c) StellaOps. Licensed under BUSL-1.1. // 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; /// /// Interface for offline HLC management. /// public interface IOfflineHlcManager { /// /// Enqueues a job locally while offline, maintaining the local chain. /// /// The payload type. /// The job payload. /// The idempotency key for deterministic job ID. /// Optional partition key. /// Cancellation token. /// The enqueue result. Task EnqueueOfflineAsync( T payload, string idempotencyKey, string? partitionKey = null, CancellationToken cancellationToken = default) where T : notnull; /// /// Gets the current node's job log for export. /// /// Cancellation token. /// The node job log, or null if empty. Task GetNodeJobLogAsync(CancellationToken cancellationToken = default); /// /// Gets the node ID. /// string NodeId { get; } } /// /// Manages HLC operations for offline/air-gap scenarios. /// public sealed class OfflineHlcManager : IOfflineHlcManager { private readonly IHybridLogicalClock _hlc; private readonly IOfflineJobLogStore _jobLogStore; private readonly IGuidProvider _guidProvider; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public OfflineHlcManager( IHybridLogicalClock hlc, IOfflineJobLogStore jobLogStore, IGuidProvider guidProvider, ILogger 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)); } /// public string NodeId => _hlc.NodeId; /// public async Task EnqueueOfflineAsync( 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 }; } /// public Task GetNodeJobLogAsync(CancellationToken cancellationToken = default) => _jobLogStore.GetNodeJobLogAsync(NodeId, cancellationToken); /// /// Computes deterministic job ID from idempotency key. /// 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)); } /// /// Computes chain link: Hash(prev_link || job_id || t_hlc || payload_hash). /// 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(); } }