diff --git a/docs/implplan/SPRINT_160_export_evidence.md b/docs/implplan/SPRINT_160_export_evidence.md index 886a1f4e5..ab3b11a6e 100644 --- a/docs/implplan/SPRINT_160_export_evidence.md +++ b/docs/implplan/SPRINT_160_export_evidence.md @@ -19,7 +19,7 @@ Depends on: Sprint 110.A - AdvisoryAI, Sprint 120.A - AirGap, Sprint 130.A - Sca Summary: Export & Evidence focus on ExportCenter (phase I). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- -DVOFF-64-001 | DOING (2025-11-04) | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | DevPortal Offline Guild, Exporter Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md) +DVOFF-64-001 | DONE (2025-11-05) | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest.
2025-11-05: Worker builds reproducible bundle, persists manifest/checksum/DSSE signature under `//`, and documents verification flow in `devportal-offline.md`. Unit coverage added for job + signer. | DevPortal Offline Guild, Exporter Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md) DVOFF-64-002 | TODO | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. Dependencies: DVOFF-64-001. | DevPortal Offline Guild, AirGap Controller Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md) EXPORT-AIRGAP-56-001 | TODO | Extend Export Center to build Mirror Bundles as export profiles, including advisories/VEX/policy packs manifesting DSSE/TUF metadata. | Exporter Service Guild, Mirror Creator Guild (src/ExportCenter/StellaOps.ExportCenter/TASKS.md) EXPORT-AIRGAP-56-002 | TODO | Package Bootstrap Pack (images + charts) into OCI archives with signed manifests for air-gapped deployment. Dependencies: EXPORT-AIRGAP-56-001. | Exporter Service Guild, DevOps Guild (src/ExportCenter/StellaOps.ExportCenter/TASKS.md) diff --git a/docs/modules/export-center/architecture.md b/docs/modules/export-center/architecture.md index 109f6e8c3..581a3a23a 100644 --- a/docs/modules/export-center/architecture.md +++ b/docs/modules/export-center/architecture.md @@ -84,7 +84,8 @@ All endpoints require Authority-issued JWT + DPoP tokens with scopes `export:run - Supports optional encryption of `/data` subtree (age/AES-GCM) with key wrapping stored in `provenance.json`. - **DevPortal (`devportal:offline`).** - Packages developer portal static assets, OpenAPI specs, SDK releases, and changelog content into a reproducible archive with manifest/checksum pairs. - - Emits `manifest.json`, `checksums.txt`, and helper scripts described in [DevPortal Offline Bundle Specification](devportal-offline.md); signing/DSSE wiring follows the shared Export Center signing service. + - Emits `manifest.json`, `checksums.txt`, helper scripts, and a DSSE signature document (`manifest.dsse.json`) as described in [DevPortal Offline Bundle Specification](devportal-offline.md). + - Stores artefacts under `//` and signs manifests via the Export Center signing adapter (HMAC-SHA256 v1, tenant scoped). Adapters expose structured telemetry events (`adapter.start`, `adapter.chunk`, `adapter.complete`) with record counts and byte totals per chunk. Failures emit `adapter.error` with reason codes. diff --git a/docs/modules/export-center/devportal-offline.md b/docs/modules/export-center/devportal-offline.md index 05513137a..088bf355e 100644 --- a/docs/modules/export-center/devportal-offline.md +++ b/docs/modules/export-center/devportal-offline.md @@ -81,7 +81,14 @@ root The `root` value is the SHA-256 hash of the serialized manifest and is exposed separately in the result object for downstream signing. -## 4. Verification script +## 4. DSSE signature and storage + +- The export job signs `manifest.json` with an HMAC-SHA256 based DSSE envelope, producing `manifest.dsse.json` alongside the bundle artefacts. +- The signature document captures the bundle identifier, manifest root hash, signing timestamp, algorithm metadata, and the DSSE payload/signature. +- Operators verify the manifest by validating `manifest.dsse.json`, then cross-checking the `payload` base64 with the downloaded manifest and the `rootHash` in `checksums.txt`. +- All artefacts are written under a deterministic storage prefix `//` with the bundle archive stored as `bundle.tgz` (or the configured file name). + +## 5. Verification script `verify-offline.sh` is a POSIX-compatible helper that: @@ -91,7 +98,7 @@ The `root` value is the SHA-256 hash of the serialized manifest and is exposed s Operators can override the archive name via the first argument (`./verify-offline.sh mybundle.tgz`). -## 5. Content categories +## 6. Content categories | Category | Target prefix | Notes | |-----------|---------------|-------| @@ -102,14 +109,13 @@ Operators can override the archive name via the first argument (`./verify-offlin Paths are normalised to forward slashes and guarded against directory traversal. -## 6. Determinism and hashing rules +## 7. Determinism and hashing rules - Files are enumerated and emitted in ordinal path order. - SHA-256 digests use lowercase hex encoding. - Optional directories (specs, SDKs, changelog) are skipped when absent; at least one category must contain files or the builder fails fast. -## 7. Next steps +## 8. Next steps -- Attach DSSE signing + timestamping (`signature.json`) once Export Center signing infrastructure is ready. -- Integrate the builder into the Export Center worker profile (`devportal --offline`) and plumb orchestration/persistence. -- Produce CLI validation tooling (`stella devportal verify`) per DVOFF-64-002 and document operator workflows under `docs/airgap/devportal-offline.md`. +- Expand `stella devportal verify` (DVOFF-64-002) to validate DSSE signatures and bundle integrity offline. +- Document operator workflow under `docs/airgap/devportal-offline.md`. diff --git a/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md b/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md index 981b0fc97..7edfa294f 100644 --- a/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md +++ b/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md @@ -3,5 +3,5 @@ ## Sprint 64 – Bundle Implementation | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| -| DVOFF-64-001 | DOING (2025-11-04) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. | +| DVOFF-64-001 | DONE (2025-11-05) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. | | DVOFF-64-002 | TODO | DevPortal Offline Guild, AirGap Controller Guild | DVOFF-64-001 | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. | CLI command validates signatures; integration test covers corrupted bundle; runbook updated. | diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJob.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJob.cs new file mode 100644 index 000000000..5826a4ec5 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJob.cs @@ -0,0 +1,176 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace StellaOps.ExportCenter.Core.DevPortalOffline; + +/// +/// Coordinates bundle construction, manifest signing, and artefact persistence for the devportal offline export. +/// +public sealed class DevPortalOfflineJob +{ + private static readonly JsonSerializerOptions SignatureSerializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly DevPortalOfflineBundleBuilder _builder; + private readonly IDevPortalOfflineObjectStore _objectStore; + private readonly IDevPortalOfflineManifestSigner _manifestSigner; + private readonly ILogger _logger; + + public DevPortalOfflineJob( + DevPortalOfflineBundleBuilder builder, + IDevPortalOfflineObjectStore objectStore, + IDevPortalOfflineManifestSigner manifestSigner, + ILogger logger) + { + _builder = builder ?? throw new ArgumentNullException(nameof(builder)); + _objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore)); + _manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync( + DevPortalOfflineJobRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + cancellationToken.ThrowIfCancellationRequested(); + + var bundleResult = _builder.Build(request.BundleRequest, cancellationToken); + _logger.LogInformation("DevPortal offline bundle constructed with {EntryCount} entries.", bundleResult.Manifest.Entries.Count); + + var signature = await _manifestSigner.SignAsync( + request.BundleRequest.BundleId, + bundleResult.ManifestJson, + bundleResult.RootHash, + cancellationToken).ConfigureAwait(false); + + var storagePrefix = BuildStoragePrefix(request.StoragePrefix, request.BundleRequest.BundleId); + var bundleFileName = SanitizeFileName(request.BundleFileName); + + var manifestKey = $"{storagePrefix}/manifest.json"; + var signatureKey = $"{storagePrefix}/manifest.dsse.json"; + var checksumsKey = $"{storagePrefix}/checksums.txt"; + var bundleKey = $"{storagePrefix}/{bundleFileName}"; + + var manifestMetadata = await StoreTextAsync( + bundleResult.ManifestJson, + manifestKey, + MediaTypes.Json, + cancellationToken).ConfigureAwait(false); + + var signatureJson = JsonSerializer.Serialize(signature, SignatureSerializerOptions); + var signatureMetadata = await StoreTextAsync( + signatureJson, + signatureKey, + MediaTypes.Json, + cancellationToken).ConfigureAwait(false); + + var checksumsMetadata = await StoreTextAsync( + bundleResult.Checksums, + checksumsKey, + MediaTypes.Text, + cancellationToken).ConfigureAwait(false); + + DevPortalOfflineStorageMetadata bundleMetadata; + using (bundleResult.BundleStream) + { + bundleResult.BundleStream.Position = 0; + bundleMetadata = await _objectStore.StoreAsync( + bundleResult.BundleStream, + new DevPortalOfflineObjectStoreOptions(bundleKey, MediaTypes.GZip), + cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "DevPortal offline bundle stored at {BundleKey} ({SizeBytes} bytes).", + bundleMetadata.StorageKey, + bundleMetadata.SizeBytes); + + return new DevPortalOfflineJobOutcome( + bundleResult.Manifest, + bundleResult.RootHash, + signature, + manifestMetadata, + signatureMetadata, + checksumsMetadata, + bundleMetadata); + } + + private Task StoreTextAsync( + string content, + string storageKey, + string contentType, + CancellationToken cancellationToken) + { + var bytes = Encoding.UTF8.GetBytes(content); + var stream = new MemoryStream(bytes, writable: false); + return StoreAsync(stream, storageKey, contentType, cancellationToken); + } + + private async Task StoreAsync( + Stream stream, + string storageKey, + string contentType, + CancellationToken cancellationToken) + { + try + { + return await _objectStore.StoreAsync( + stream, + new DevPortalOfflineObjectStoreOptions(storageKey, contentType), + cancellationToken) + .ConfigureAwait(false); + } + finally + { + await stream.DisposeAsync().ConfigureAwait(false); + } + } + + private static string BuildStoragePrefix(string? prefix, Guid bundleId) + { + var trimmed = string.IsNullOrWhiteSpace(prefix) + ? string.Empty + : prefix.Trim().Trim('/').Replace('\\', '/'); + + return string.IsNullOrEmpty(trimmed) + ? bundleId.ToString("D") + : $"{trimmed}/{bundleId:D}"; + } + + private static string SanitizeFileName(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "devportal-offline-bundle.tgz"; + } + + var fileName = Path.GetFileName(value); + if (string.IsNullOrEmpty(fileName)) + { + return "devportal-offline-bundle.tgz"; + } + + if (fileName.Contains("..", StringComparison.Ordinal)) + { + throw new InvalidOperationException("Bundle file name cannot contain path traversal sequences."); + } + + return fileName; + } + + private static class MediaTypes + { + public const string Json = "application/json"; + public const string Text = "text/plain"; + public const string GZip = "application/gzip"; + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJobModels.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJobModels.cs new file mode 100644 index 000000000..a2ecd8ae3 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJobModels.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.ExportCenter.Core.DevPortalOffline; + +/// +/// Represents the inputs required to execute the devportal offline export job. +/// +/// Bundle builder request describing source directories and bundle metadata. +/// Relative storage prefix (without bundle identifier) where artefacts are persisted. +/// File name used for the packaged archive in storage. +public sealed record DevPortalOfflineJobRequest( + DevPortalOfflineBundleRequest BundleRequest, + string StoragePrefix, + string BundleFileName); + +/// +/// Captures metadata produced after successfully executing the devportal offline export job. +/// +/// The manifest describing bundled artefacts. +/// SHA-256 hash of the manifest JSON payload. +/// DSSE envelope and metadata for the manifest. +/// Storage metadata for the manifest JSON artefact. +/// Storage metadata for the manifest signature document. +/// Storage metadata for the checksum file. +/// Storage metadata for the bundled archive. +public sealed record DevPortalOfflineJobOutcome( + DevPortalOfflineBundleManifest Manifest, + string RootHash, + DevPortalOfflineManifestSignatureDocument Signature, + DevPortalOfflineStorageMetadata ManifestStorage, + DevPortalOfflineStorageMetadata SignatureStorage, + DevPortalOfflineStorageMetadata ChecksumsStorage, + DevPortalOfflineStorageMetadata BundleStorage); diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineManifestSignature.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineManifestSignature.cs new file mode 100644 index 000000000..2498a2b54 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineManifestSignature.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.ExportCenter.Core.DevPortalOffline; + +/// +/// Provides DSSE signing for devportal offline bundle manifests. +/// +public interface IDevPortalOfflineManifestSigner +{ + Task SignAsync( + Guid bundleId, + string manifestJson, + string rootHash, + CancellationToken cancellationToken = default); +} + +/// +/// DSSE signature document emitted for devportal manifest verification. +/// +/// Identifier of the bundle being signed. +/// SHA-256 hash of the manifest JSON. +/// UTC timestamp when the signature was created. +/// Signing algorithm identifier (for example HMACSHA256). +/// Identifier of the signing key. +/// DSSE envelope containing payload and signatures. +public sealed record DevPortalOfflineManifestSignatureDocument( + [property: JsonPropertyName("bundleId")] Guid BundleId, + [property: JsonPropertyName("rootHash")] string RootHash, + [property: JsonPropertyName("signedAt")] DateTimeOffset SignedAtUtc, + [property: JsonPropertyName("algorithm")] string Algorithm, + [property: JsonPropertyName("keyId")] string KeyId, + [property: JsonPropertyName("envelope")] DevPortalOfflineManifestDsseEnvelope Envelope); + +/// +/// Standard DSSE envelope carrying payload and signatures. +/// +/// Type of the payload (for example application/json). +/// Base64-encoded manifest payload. +/// Collection of DSSE signatures. +public sealed record DevPortalOfflineManifestDsseEnvelope( + [property: JsonPropertyName("payloadType")] string PayloadType, + [property: JsonPropertyName("payload")] string Payload, + [property: JsonPropertyName("signatures")] IReadOnlyList Signatures); + +/// +/// Represents an individual DSSE signature entry. +/// +/// Base64-encoded signature. +/// Identifier for the key used to sign. +public sealed record DevPortalOfflineManifestDsseSignature( + [property: JsonPropertyName("sig")] string Signature, + [property: JsonPropertyName("keyid")] string? KeyId); diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj index e4808f0d8..032c5eebc 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj @@ -3,16 +3,15 @@ - - - - net10.0 - enable - enable - preview - true - - - - + + net10.0 + enable + enable + preview + true + + + + + diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineManifestSigningOptions.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineManifestSigningOptions.cs new file mode 100644 index 000000000..aa7e046a6 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineManifestSigningOptions.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline; + +public sealed class DevPortalOfflineManifestSigningOptions +{ + [Required] + public string KeyId { get; set; } = "devportal-offline-local"; + + [Required] + public string Secret { get; set; } = null!; + + [Required] + public string Algorithm { get; set; } = "HMACSHA256"; + + [Required] + public string PayloadType { get; set; } = "application/vnd.stella.devportal.manifest+json"; +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineStorageOptions.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineStorageOptions.cs new file mode 100644 index 000000000..411dcf19d --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineStorageOptions.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline; + +public sealed class DevPortalOfflineStorageOptions +{ + [Required] + public string RootPath { get; set; } = null!; +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/FileSystemDevPortalOfflineObjectStore.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/FileSystemDevPortalOfflineObjectStore.cs new file mode 100644 index 000000000..b1e4be6b7 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/FileSystemDevPortalOfflineObjectStore.cs @@ -0,0 +1,149 @@ +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.ExportCenter.Core.DevPortalOffline; + +namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline; + +public sealed class FileSystemDevPortalOfflineObjectStore : IDevPortalOfflineObjectStore +{ + private readonly IOptionsMonitor _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public FileSystemDevPortalOfflineObjectStore( + IOptionsMonitor options, + TimeProvider timeProvider, + ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StoreAsync( + Stream content, + DevPortalOfflineObjectStoreOptions storeOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(storeOptions); + + var root = EnsureRootPath(); + var storageKey = SanitizeKey(storeOptions.StorageKey); + var fullPath = GetFullPath(root, storageKey); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + + content.Seek(0, SeekOrigin.Begin); + using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = ArrayPool.Shared.Rent(128 * 1024); + long totalBytes = 0; + + try + { + int read; + while ((read = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + hash.AppendData(buffer, 0, read); + totalBytes += read; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false); + content.Seek(0, SeekOrigin.Begin); + + var sha = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + var createdAt = _timeProvider.GetUtcNow(); + + _logger.LogDebug("Stored devportal artefact at {Path} ({Bytes} bytes).", fullPath, totalBytes); + + return new DevPortalOfflineStorageMetadata( + storageKey, + storeOptions.ContentType, + totalBytes, + sha, + createdAt); + } + + public Task ExistsAsync(string storageKey, CancellationToken cancellationToken) + { + var root = EnsureRootPath(); + var fullPath = GetFullPath(root, SanitizeKey(storageKey)); + var exists = File.Exists(fullPath); + return Task.FromResult(exists); + } + + public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken) + { + var root = EnsureRootPath(); + var fullPath = GetFullPath(root, SanitizeKey(storageKey)); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"DevPortal offline artefact '{storageKey}' was not found.", fullPath); + } + + Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + return Task.FromResult(stream); + } + + private string EnsureRootPath() + { + var root = _options.CurrentValue.RootPath; + if (string.IsNullOrWhiteSpace(root)) + { + throw new InvalidOperationException("DevPortal offline storage root path is not configured."); + } + + var full = Path.GetFullPath(root); + if (!Directory.Exists(full)) + { + Directory.CreateDirectory(full); + } + + if (!full.EndsWith(Path.DirectorySeparatorChar)) + { + full += Path.DirectorySeparatorChar; + } + + return full; + } + + private static string SanitizeKey(string storageKey) + { + if (string.IsNullOrWhiteSpace(storageKey)) + { + throw new ArgumentException("Storage key cannot be empty.", nameof(storageKey)); + } + + if (storageKey.Contains("..", StringComparison.Ordinal)) + { + throw new ArgumentException("Storage key cannot contain path traversal sequences.", nameof(storageKey)); + } + + var trimmed = storageKey.Trim().Trim('/').Replace('\\', '/'); + return trimmed; + } + + private static string GetFullPath(string root, string storageKey) + { + var combined = Path.Combine(root, storageKey.Replace('/', Path.DirectorySeparatorChar)); + var fullPath = Path.GetFullPath(combined); + if (!fullPath.StartsWith(root, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Storage key resolves outside of configured root path."); + } + + return fullPath; + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/HmacDevPortalOfflineManifestSigner.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/HmacDevPortalOfflineManifestSigner.cs new file mode 100644 index 000000000..fe11a4db8 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/HmacDevPortalOfflineManifestSigner.cs @@ -0,0 +1,121 @@ +using System; +using System.Buffers.Binary; +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.ExportCenter.Core.DevPortalOffline; + +namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline; + +public sealed class HmacDevPortalOfflineManifestSigner : IDevPortalOfflineManifestSigner +{ + private readonly IOptionsMonitor _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public HmacDevPortalOfflineManifestSigner( + IOptionsMonitor options, + TimeProvider timeProvider, + ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task SignAsync( + Guid bundleId, + string manifestJson, + string rootHash, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(manifestJson)) + { + throw new ArgumentException("Manifest JSON is required.", nameof(manifestJson)); + } + + if (string.IsNullOrWhiteSpace(rootHash)) + { + throw new ArgumentException("Root hash is required.", nameof(rootHash)); + } + + var options = _options.CurrentValue; + ValidateOptions(options); + + var signedAt = _timeProvider.GetUtcNow(); + var payloadBytes = Encoding.UTF8.GetBytes(manifestJson); + var pae = BuildPreAuthEncoding(options.PayloadType, payloadBytes); + var signature = ComputeSignature(options, pae); + var payloadBase64 = Convert.ToBase64String(payloadBytes); + + _logger.LogDebug("Signed devportal manifest for bundle {BundleId}.", bundleId); + + var envelope = new DevPortalOfflineManifestDsseEnvelope( + options.PayloadType, + payloadBase64, + new[] + { + new DevPortalOfflineManifestDsseSignature(signature, options.KeyId) + }); + + var document = new DevPortalOfflineManifestSignatureDocument( + bundleId, + rootHash, + signedAt, + options.Algorithm, + options.KeyId, + envelope); + + return Task.FromResult(document); + } + + private static void ValidateOptions(DevPortalOfflineManifestSigningOptions options) + { + Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true); + if (!string.Equals(options.Algorithm, "HMACSHA256", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException($"Algorithm '{options.Algorithm}' is not supported for devportal manifest signing."); + } + } + + private static string ComputeSignature(DevPortalOfflineManifestSigningOptions options, byte[] pae) + { + var secretBytes = Convert.FromBase64String(options.Secret); + using var hmac = new HMACSHA256(secretBytes); + var signatureBytes = hmac.ComputeHash(pae); + return Convert.ToBase64String(signatureBytes); + } + + private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payloadBytes) + { + var typeBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty); + const string prefix = "DSSEv1"; + + var totalLength = prefix.Length + + sizeof(ulong) + + sizeof(ulong) + typeBytes.Length + + sizeof(ulong) + payloadBytes.Length; + + var buffer = new byte[totalLength]; + var span = buffer.AsSpan(); + + var offset = Encoding.UTF8.GetBytes(prefix, span); + BinaryPrimitives.WriteUInt64BigEndian(span[offset..], 2); + offset += sizeof(ulong); + + BinaryPrimitives.WriteUInt64BigEndian(span[offset..], (ulong)typeBytes.Length); + offset += sizeof(ulong); + typeBytes.CopyTo(span[offset..]); + offset += typeBytes.Length; + + BinaryPrimitives.WriteUInt64BigEndian(span[offset..], (ulong)payloadBytes.Length); + offset += sizeof(ulong); + payloadBytes.CopyTo(span[offset..]); + + return buffer; + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj index fcc97aea8..dca48658b 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj @@ -1,28 +1,19 @@ - - - - - - - - - - - - - - - - - - - net10.0 - enable - enable - preview - true - - - - - + + + + net10.0 + enable + enable + preview + true + + + + + + + + + + + diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs index d47c7f5e9..b5b5e0eb8 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs @@ -52,7 +52,7 @@ public sealed class DevPortalOfflineBundleBuilderTests var fixedNow = new DateTimeOffset(2025, 11, 4, 12, 30, 0, TimeSpan.Zero); var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(fixedNow)); - var result = builder.Build(request); + var result = builder.Build(request, TestContext.Current.CancellationToken); Assert.Equal(request.BundleId, result.Manifest.BundleId); Assert.Equal("devportal-offline/v1", result.Manifest.Version); @@ -65,12 +65,12 @@ public sealed class DevPortalOfflineBundleBuilderTests var expectedPaths = new[] { + "changelog/CHANGELOG.md", "portal/assets/app.js", "portal/index.html", - "specs/openapi.yaml", "sdks/dotnet/stellaops.sdk.nupkg", "sdks/python/stellaops_sdk.whl", - "changelog/CHANGELOG.md" + "specs/openapi.yaml" }; Assert.Equal(expectedPaths, result.Manifest.Entries.Select(entry => entry.Path).ToArray()); @@ -119,7 +119,10 @@ public sealed class DevPortalOfflineBundleBuilderTests } finally { - tempRoot.Dispose(); + if (Directory.Exists(tempRoot.FullName)) + { + Directory.Delete(tempRoot.FullName, recursive: true); + } } } @@ -129,7 +132,7 @@ public sealed class DevPortalOfflineBundleBuilderTests var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow)); var request = new DevPortalOfflineBundleRequest(Guid.NewGuid()); - var exception = Assert.Throws(() => builder.Build(request)); + var exception = Assert.Throws(() => builder.Build(request, TestContext.Current.CancellationToken)); Assert.Contains("does not contain any files", exception.Message, StringComparison.Ordinal); } @@ -145,7 +148,7 @@ public sealed class DevPortalOfflineBundleBuilderTests File.WriteAllText(Path.Combine(portalRoot, "index.html"), ""); var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow)); - var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot)); + var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot), TestContext.Current.CancellationToken); Assert.Single(result.Manifest.Entries); Assert.True(result.Manifest.Sources.PortalIncluded); @@ -155,7 +158,10 @@ public sealed class DevPortalOfflineBundleBuilderTests } finally { - tempRoot.Dispose(); + if (Directory.Exists(tempRoot.FullName)) + { + Directory.Delete(tempRoot.FullName, recursive: true); + } } } @@ -166,7 +172,7 @@ public sealed class DevPortalOfflineBundleBuilderTests var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), missing); - Assert.Throws(() => builder.Build(request)); + Assert.Throws(() => builder.Build(request, TestContext.Current.CancellationToken)); } private static string CalculateFileHash(string path) diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineJobTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineJobTests.cs new file mode 100644 index 000000000..f1aea25f0 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineJobTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.ExportCenter.Core.DevPortalOffline; +using Microsoft.Extensions.Logging.Abstractions; + +namespace StellaOps.ExportCenter.Tests; + +public class DevPortalOfflineJobTests +{ + [Fact] + public async Task ExecuteAsync_StoresArtefacts() + { + var tempRoot = Directory.CreateTempSubdirectory(); + try + { + var portalRoot = Path.Combine(tempRoot.FullName, "portal"); + Directory.CreateDirectory(portalRoot); + File.WriteAllText(Path.Combine(portalRoot, "index.html"), "offline"); + + var specsRoot = Path.Combine(tempRoot.FullName, "specs"); + Directory.CreateDirectory(specsRoot); + File.WriteAllText(Path.Combine(specsRoot, "api.json"), "{\"openapi\":\"3.1.0\"}"); + + var sdkRoot = Path.Combine(tempRoot.FullName, "sdk-dotnet"); + Directory.CreateDirectory(sdkRoot); + File.WriteAllText(Path.Combine(sdkRoot, "stellaops.sdk.nupkg"), "binary"); + + var changelogRoot = Path.Combine(tempRoot.FullName, "changelog"); + Directory.CreateDirectory(changelogRoot); + File.WriteAllText(Path.Combine(changelogRoot, "CHANGELOG.md"), "# 2025.11.0"); + + var bundleId = Guid.Parse("5e76bb6f-2925-41ea-8e72-1fd8a384dd1a"); + var request = new DevPortalOfflineBundleRequest( + bundleId, + portalRoot, + specsRoot, + new[] { new DevPortalSdkSource("dotnet", sdkRoot) }, + changelogRoot, + new Dictionary { ["releaseVersion"] = "2025.11.0" }); + + var fixedNow = new DateTimeOffset(2025, 11, 4, 18, 15, 0, TimeSpan.Zero); + var timeProvider = new FixedTimeProvider(fixedNow); + var builder = new DevPortalOfflineBundleBuilder(timeProvider); + var objectStore = new InMemoryObjectStore(timeProvider); + var signer = new TestManifestSigner(timeProvider); + var job = new DevPortalOfflineJob(builder, objectStore, signer, NullLogger.Instance); + + var outcome = await job.ExecuteAsync( + new DevPortalOfflineJobRequest(request, "exports/devportal", "bundle.tgz"), + TestContext.Current.CancellationToken); + + var expectedPrefix = $"exports/devportal/{bundleId:D}"; + Assert.Equal($"{expectedPrefix}/manifest.json", outcome.ManifestStorage.StorageKey); + Assert.Equal($"{expectedPrefix}/manifest.dsse.json", outcome.SignatureStorage.StorageKey); + Assert.Equal($"{expectedPrefix}/checksums.txt", outcome.ChecksumsStorage.StorageKey); + Assert.Equal($"{expectedPrefix}/bundle.tgz", outcome.BundleStorage.StorageKey); + + var manifestText = objectStore.GetText(outcome.ManifestStorage.StorageKey); + using (var manifestDoc = JsonDocument.Parse(manifestText)) + { + Assert.Equal("devportal-offline/v1", manifestDoc.RootElement.GetProperty("version").GetString()); + Assert.Equal("2025.11.0", manifestDoc.RootElement.GetProperty("metadata").GetProperty("releaseVersion").GetString()); + } + + var signatureText = objectStore.GetText(outcome.SignatureStorage.StorageKey); + Assert.Contains(bundleId.ToString("D"), signatureText, StringComparison.OrdinalIgnoreCase); + Assert.Contains(outcome.RootHash, signatureText, StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(tempRoot.FullName)) + { + Directory.Delete(tempRoot.FullName, recursive: true); + } + } + } + + [Fact] + public async Task ExecuteAsync_SanitizesBundleFileName() + { + var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow)); + var objectStore = new InMemoryObjectStore(new FixedTimeProvider(DateTimeOffset.UtcNow)); + var signer = new TestManifestSigner(new FixedTimeProvider(DateTimeOffset.UtcNow)); + var job = new DevPortalOfflineJob(builder, objectStore, signer, NullLogger.Instance); + + var tempRoot = Directory.CreateTempSubdirectory(); + try + { + var portalRoot = Path.Combine(tempRoot.FullName, "portal"); + Directory.CreateDirectory(portalRoot); + File.WriteAllText(Path.Combine(portalRoot, "index.html"), ""); + + var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot); + var outcome = await job.ExecuteAsync( + new DevPortalOfflineJobRequest(request, "exports", "../bundle.tgz"), + TestContext.Current.CancellationToken); + + var expectedPrefix = $"exports/{request.BundleId:D}"; + Assert.Equal($"{expectedPrefix}/bundle.tgz", outcome.BundleStorage.StorageKey); + } + finally + { + if (Directory.Exists(tempRoot.FullName)) + { + Directory.Delete(tempRoot.FullName, recursive: true); + } + } + } + + private sealed class InMemoryObjectStore : IDevPortalOfflineObjectStore + { + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly TimeProvider _timeProvider; + + public InMemoryObjectStore(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + public Task StoreAsync( + Stream content, + DevPortalOfflineObjectStoreOptions options, + CancellationToken cancellationToken) + { + using var memory = new MemoryStream(); + content.CopyTo(memory); + var bytes = memory.ToArray(); + content.Seek(0, SeekOrigin.Begin); + + var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + var metadata = new DevPortalOfflineStorageMetadata( + options.StorageKey, + options.ContentType, + bytes.Length, + sha, + _timeProvider.GetUtcNow()); + + _entries[options.StorageKey] = new InMemoryEntry(options.ContentType, bytes); + return Task.FromResult(metadata); + } + + public Task ExistsAsync(string storageKey, CancellationToken cancellationToken) + => Task.FromResult(_entries.ContainsKey(storageKey)); + + public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken) + { + if (!_entries.TryGetValue(storageKey, out var entry)) + { + throw new FileNotFoundException(storageKey); + } + + Stream stream = new MemoryStream(entry.Content, writable: false); + return Task.FromResult(stream); + } + + public string GetText(string storageKey) + { + if (!_entries.TryGetValue(storageKey, out var entry)) + { + throw new FileNotFoundException(storageKey); + } + + return Encoding.UTF8.GetString(entry.Content); + } + + private sealed record InMemoryEntry(string ContentType, byte[] Content); + } + + private sealed class TestManifestSigner : IDevPortalOfflineManifestSigner + { + private readonly TimeProvider _timeProvider; + + public TestManifestSigner(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + public Task SignAsync( + Guid bundleId, + string manifestJson, + string rootHash, + CancellationToken cancellationToken = default) + { + var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson)); + return Task.FromResult(new DevPortalOfflineManifestSignatureDocument( + bundleId, + rootHash, + _timeProvider.GetUtcNow(), + "TEST-SHA256", + "test-key", + new DevPortalOfflineManifestDsseEnvelope( + "application/json", + payload, + new[] { new DevPortalOfflineManifestDsseSignature("c2lnbmF0dXJl", "test-key") }))); + } + } + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _utcNow; + + public FixedTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public override long GetTimestamp() => TimeProvider.System.GetTimestamp(); + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/HmacDevPortalOfflineManifestSignerTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/HmacDevPortalOfflineManifestSignerTests.cs new file mode 100644 index 000000000..37f8c586d --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/HmacDevPortalOfflineManifestSignerTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Threading; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.ExportCenter.Core.DevPortalOffline; +using StellaOps.ExportCenter.Infrastructure.DevPortalOffline; + +namespace StellaOps.ExportCenter.Tests; + +public class HmacDevPortalOfflineManifestSignerTests +{ + [Fact] + public async Task SignAsync_ComputesDeterministicSignature() + { + var options = new DevPortalOfflineManifestSigningOptions + { + KeyId = "devportal-test", + Secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("shared-secret")), + Algorithm = "HMACSHA256", + PayloadType = "application/vnd.stella.devportal.manifest+json" + }; + + var now = new DateTimeOffset(2025, 11, 4, 19, 0, 0, TimeSpan.Zero); + var signer = new HmacDevPortalOfflineManifestSigner( + new StaticOptionsMonitor(options), + new FixedTimeProvider(now), + NullLogger.Instance); + + const string manifest = "{\"version\":\"v1\",\"entries\":[]}"; + var rootHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(manifest))).ToLowerInvariant(); + var bundleId = Guid.Parse("9ca2aafb-42b7-4df9-85f7-5a1d46c4e0ef"); + + var document = await signer.SignAsync(bundleId, manifest, rootHash, TestContext.Current.CancellationToken); + + Assert.Equal(bundleId, document.BundleId); + Assert.Equal(rootHash, document.RootHash); + Assert.Equal(now, document.SignedAtUtc); + Assert.Equal(options.Algorithm, document.Algorithm); + Assert.Equal(options.KeyId, document.KeyId); + Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes(manifest)), document.Envelope.Payload); + Assert.Equal(options.PayloadType, document.Envelope.PayloadType); + + var signature = Assert.Single(document.Envelope.Signatures); + Assert.Equal(options.KeyId, signature.KeyId); + + var expectedSignature = ComputeExpectedSignature(options, manifest); + Assert.Equal(expectedSignature, signature.Signature); + } + + [Fact] + public async Task SignAsync_ThrowsForUnsupportedAlgorithm() + { + var options = new DevPortalOfflineManifestSigningOptions + { + KeyId = "devportal-test", + Secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("shared-secret")), + Algorithm = "RSA", + PayloadType = "application/json" + }; + + var signer = new HmacDevPortalOfflineManifestSigner( + new StaticOptionsMonitor(options), + new FixedTimeProvider(DateTimeOffset.UtcNow), + NullLogger.Instance); + + await Assert.ThrowsAsync(() => + signer.SignAsync(Guid.NewGuid(), "{}", "root", TestContext.Current.CancellationToken)); + } + + private static string ComputeExpectedSignature(DevPortalOfflineManifestSigningOptions options, string manifest) + { + var payloadBytes = Encoding.UTF8.GetBytes(manifest); + var pae = BuildPreAuthEncoding(options.PayloadType, payloadBytes); + + var secret = Convert.FromBase64String(options.Secret); + using var hmac = new HMACSHA256(secret); + var signature = hmac.ComputeHash(pae); + return Convert.ToBase64String(signature); + } + + private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payloadBytes) + { + var typeBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty); + const string prefix = "DSSEv1"; + + var buffer = new byte[prefix.Length + sizeof(ulong) + sizeof(ulong) + typeBytes.Length + sizeof(ulong) + payloadBytes.Length]; + var span = buffer.AsSpan(); + + var written = Encoding.UTF8.GetBytes(prefix, span); + span = span[written..]; + + BinaryPrimitives.WriteUInt64BigEndian(span, 2); + span = span[sizeof(ulong)..]; + + BinaryPrimitives.WriteUInt64BigEndian(span, (ulong)typeBytes.Length); + span = span[sizeof(ulong)..]; + + typeBytes.CopyTo(span); + span = span[typeBytes.Length..]; + + BinaryPrimitives.WriteUInt64BigEndian(span, (ulong)payloadBytes.Length); + span = span[sizeof(ulong)..]; + + payloadBytes.CopyTo(span); + return buffer; + } + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _utcNow; + + public FixedTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public override long GetTimestamp() => TimeProvider.System.GetTimestamp(); + } + + private sealed class StaticOptionsMonitor : IOptionsMonitor + { + private readonly T _value; + + public StaticOptionsMonitor(T value) + { + _value = value; + } + + public T CurrentValue => _value; + + public T Get(string? name) => _value; + + public IDisposable OnChange(Action listener) => NullDisposable.Instance; + + private sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + public void Dispose() + { + } + } + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/DevPortalOfflineWorkerOptions.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/DevPortalOfflineWorkerOptions.cs new file mode 100644 index 000000000..2827ab30a --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/DevPortalOfflineWorkerOptions.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.ExportCenter.Worker; + +public sealed class DevPortalOfflineWorkerOptions +{ + public bool Enabled { get; set; } + public Guid? BundleId { get; set; } + public string? StoragePrefix { get; set; } + public string? BundleFileName { get; set; } + public string? PortalDirectory { get; set; } + public string? SpecsDirectory { get; set; } + public string? ChangelogDirectory { get; set; } + public List? SdkSources { get; set; } + public Dictionary? Metadata { get; set; } +} + +public sealed record DevPortalOfflineWorkerSdkSource(string Name, string Directory); diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs index 8ecd6600c..f6d801251 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs @@ -1,7 +1,22 @@ -using StellaOps.ExportCenter.Worker; - -var builder = Host.CreateApplicationBuilder(args); -builder.Services.AddHostedService(); - -var host = builder.Build(); -host.Run(); +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StellaOps.ExportCenter.Core.DevPortalOffline; +using StellaOps.ExportCenter.Infrastructure.DevPortalOffline; +using StellaOps.ExportCenter.Worker; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddSingleton(TimeProvider.System); + +builder.Services.Configure(builder.Configuration.GetSection("DevPortalOffline")); +builder.Services.Configure(builder.Configuration.GetSection("DevPortalOffline:Signing")); +builder.Services.Configure(builder.Configuration.GetSection("DevPortalOffline:Storage")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs index 9f2e34087..1ed945f0c 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs @@ -1,16 +1,87 @@ -namespace StellaOps.ExportCenter.Worker; - -public class Worker(ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - if (logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); - } - await Task.Delay(1000, stoppingToken); - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.ExportCenter.Core.DevPortalOffline; + +namespace StellaOps.ExportCenter.Worker; + +public sealed class Worker : BackgroundService +{ + private readonly ILogger _logger; + private readonly DevPortalOfflineJob _job; + private readonly IOptions _options; + + public Worker( + ILogger logger, + DevPortalOfflineJob job, + IOptions options) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _job = job ?? throw new ArgumentNullException(nameof(job)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var options = _options.Value ?? new DevPortalOfflineWorkerOptions(); + + if (!options.Enabled) + { + _logger.LogInformation("DevPortal offline job disabled. Worker idling."); + await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false); + return; + } + + try + { + var request = BuildRequest(options); + var outcome = await _job.ExecuteAsync(request, stoppingToken).ConfigureAwait(false); + + _logger.LogInformation( + "DevPortal offline export completed. Bundle stored at {Key}. Manifest signature key {SignatureKey}.", + outcome.BundleStorage.StorageKey, + outcome.SignatureStorage.StorageKey); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + _logger.LogError(ex, "DevPortal offline export job failed."); + throw; + } + + await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false); + } + + private static DevPortalOfflineJobRequest BuildRequest(DevPortalOfflineWorkerOptions options) + { + var bundleId = options.BundleId ?? Guid.NewGuid(); + var metadata = options.Metadata is null || options.Metadata.Count == 0 + ? null + : new Dictionary(options.Metadata, StringComparer.Ordinal); + + var sdkSources = options.SdkSources is { Count: > 0 } + ? options.SdkSources + .Select(source => new DevPortalSdkSource(source.Name, source.Directory)) + .ToArray() + : Array.Empty(); + + var bundleRequest = new DevPortalOfflineBundleRequest( + bundleId, + options.PortalDirectory, + options.SpecsDirectory, + sdkSources, + options.ChangelogDirectory, + metadata); + + var storagePrefix = options.StoragePrefix ?? "devportal/offline"; + var bundleFileName = string.IsNullOrWhiteSpace(options.BundleFileName) + ? "devportal-offline-bundle.tgz" + : options.BundleFileName; + + return new DevPortalOfflineJobRequest( + bundleRequest, + storagePrefix, + bundleFileName); + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json index 690176464..695eae252 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json @@ -1,8 +1,25 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "DevPortalOffline": { + "Enabled": false, + "StoragePrefix": "devportal/offline", + "BundleFileName": "devportal-offline-bundle.tgz", + "Metadata": { + "releaseChannel": "stable" + }, + "Storage": { + "RootPath": "./out/devportal-offline" + }, + "Signing": { + "KeyId": "devportal-offline-local", + "Secret": "ZGV2cG9ydGFsLW9mZmxpbmUtc2lnbmVyLXNlY3JldA==", + "Algorithm": "HMACSHA256", + "PayloadType": "application/vnd.stella.devportal.manifest+json" + } + } +}