feat: Implement DevPortal Offline Export Job

- Added DevPortalOfflineJob to coordinate bundle construction, manifest signing, and artifact persistence.
- Introduced DevPortalOfflineWorkerOptions for configuration of the offline export job.
- Enhanced the Worker class to utilize DevPortalOfflineJob and handle execution based on configuration.
- Implemented HmacDevPortalOfflineManifestSigner for signing manifests with HMAC SHA256.
- Created FileSystemDevPortalOfflineObjectStore for storing artifacts in the file system.
- Updated appsettings.json to include configuration options for the DevPortal offline export.
- Added unit tests for DevPortalOfflineJob and HmacDevPortalOfflineManifestSigner to ensure functionality.
- Refactored existing tests to accommodate changes in method signatures and new dependencies.
This commit is contained in:
master
2025-11-05 21:57:46 +02:00
parent c467b4d4b7
commit 21a2759412
20 changed files with 1141 additions and 89 deletions

View File

@@ -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.<br>2025-11-05: Worker builds reproducible bundle, persists manifest/checksum/DSSE signature under `<prefix>/<bundleId>/`, 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)

View File

@@ -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 `<storagePrefix>/<bundleId>/` 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.

View File

@@ -81,7 +81,14 @@ root <sha256(manifest.json)>
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 `<storagePrefix>/<bundleId>/` 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`.

View File

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

View File

@@ -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;
/// <summary>
/// Coordinates bundle construction, manifest signing, and artefact persistence for the devportal offline export.
/// </summary>
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<DevPortalOfflineJob> _logger;
public DevPortalOfflineJob(
DevPortalOfflineBundleBuilder builder,
IDevPortalOfflineObjectStore objectStore,
IDevPortalOfflineManifestSigner manifestSigner,
ILogger<DevPortalOfflineJob> 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<DevPortalOfflineJobOutcome> 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<DevPortalOfflineStorageMetadata> 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<DevPortalOfflineStorageMetadata> 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";
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
/// <summary>
/// Represents the inputs required to execute the devportal offline export job.
/// </summary>
/// <param name="BundleRequest">Bundle builder request describing source directories and bundle metadata.</param>
/// <param name="StoragePrefix">Relative storage prefix (without bundle identifier) where artefacts are persisted.</param>
/// <param name="BundleFileName">File name used for the packaged archive in storage.</param>
public sealed record DevPortalOfflineJobRequest(
DevPortalOfflineBundleRequest BundleRequest,
string StoragePrefix,
string BundleFileName);
/// <summary>
/// Captures metadata produced after successfully executing the devportal offline export job.
/// </summary>
/// <param name="Manifest">The manifest describing bundled artefacts.</param>
/// <param name="RootHash">SHA-256 hash of the manifest JSON payload.</param>
/// <param name="Signature">DSSE envelope and metadata for the manifest.</param>
/// <param name="ManifestStorage">Storage metadata for the manifest JSON artefact.</param>
/// <param name="SignatureStorage">Storage metadata for the manifest signature document.</param>
/// <param name="ChecksumsStorage">Storage metadata for the checksum file.</param>
/// <param name="BundleStorage">Storage metadata for the bundled archive.</param>
public sealed record DevPortalOfflineJobOutcome(
DevPortalOfflineBundleManifest Manifest,
string RootHash,
DevPortalOfflineManifestSignatureDocument Signature,
DevPortalOfflineStorageMetadata ManifestStorage,
DevPortalOfflineStorageMetadata SignatureStorage,
DevPortalOfflineStorageMetadata ChecksumsStorage,
DevPortalOfflineStorageMetadata BundleStorage);

View File

@@ -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;
/// <summary>
/// Provides DSSE signing for devportal offline bundle manifests.
/// </summary>
public interface IDevPortalOfflineManifestSigner
{
Task<DevPortalOfflineManifestSignatureDocument> SignAsync(
Guid bundleId,
string manifestJson,
string rootHash,
CancellationToken cancellationToken = default);
}
/// <summary>
/// DSSE signature document emitted for devportal manifest verification.
/// </summary>
/// <param name="BundleId">Identifier of the bundle being signed.</param>
/// <param name="RootHash">SHA-256 hash of the manifest JSON.</param>
/// <param name="SignedAtUtc">UTC timestamp when the signature was created.</param>
/// <param name="Algorithm">Signing algorithm identifier (for example HMACSHA256).</param>
/// <param name="KeyId">Identifier of the signing key.</param>
/// <param name="Envelope">DSSE envelope containing payload and signatures.</param>
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);
/// <summary>
/// Standard DSSE envelope carrying payload and signatures.
/// </summary>
/// <param name="PayloadType">Type of the payload (for example application/json).</param>
/// <param name="Payload">Base64-encoded manifest payload.</param>
/// <param name="Signatures">Collection of DSSE signatures.</param>
public sealed record DevPortalOfflineManifestDsseEnvelope(
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("signatures")] IReadOnlyList<DevPortalOfflineManifestDsseSignature> Signatures);
/// <summary>
/// Represents an individual DSSE signature entry.
/// </summary>
/// <param name="Signature">Base64-encoded signature.</param>
/// <param name="KeyId">Identifier for the key used to sign.</param>
public sealed record DevPortalOfflineManifestDsseSignature(
[property: JsonPropertyName("sig")] string Signature,
[property: JsonPropertyName("keyid")] string? KeyId);

View File

@@ -3,16 +3,15 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
public sealed class DevPortalOfflineStorageOptions
{
[Required]
public string RootPath { get; set; } = null!;
}

View File

@@ -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<DevPortalOfflineStorageOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FileSystemDevPortalOfflineObjectStore> _logger;
public FileSystemDevPortalOfflineObjectStore(
IOptionsMonitor<DevPortalOfflineStorageOptions> options,
TimeProvider timeProvider,
ILogger<FileSystemDevPortalOfflineObjectStore> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DevPortalOfflineStorageMetadata> 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<byte>.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<byte>.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<bool> 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<Stream> 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;
}
}

View File

@@ -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<DevPortalOfflineManifestSigningOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HmacDevPortalOfflineManifestSigner> _logger;
public HmacDevPortalOfflineManifestSigner(
IOptionsMonitor<DevPortalOfflineManifestSigningOptions> options,
TimeProvider timeProvider,
ILogger<HmacDevPortalOfflineManifestSigner> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<DevPortalOfflineManifestSignatureDocument> 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;
}
}

View File

@@ -1,28 +1,19 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>

View File

@@ -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<InvalidOperationException>(() => builder.Build(request));
var exception = Assert.Throws<InvalidOperationException>(() => 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"), "<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<DirectoryNotFoundException>(() => builder.Build(request));
Assert.Throws<DirectoryNotFoundException>(() => builder.Build(request, TestContext.Current.CancellationToken));
}
private static string CalculateFileHash(string path)

View File

@@ -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"), "<html>offline</html>");
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<string, string> { ["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<DevPortalOfflineJob>.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<DevPortalOfflineJob>.Instance);
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var portalRoot = Path.Combine(tempRoot.FullName, "portal");
Directory.CreateDirectory(portalRoot);
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<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<string, InMemoryEntry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
public InMemoryObjectStore(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public Task<DevPortalOfflineStorageMetadata> 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<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_entries.ContainsKey(storageKey));
public Task<Stream> 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<DevPortalOfflineManifestSignatureDocument> 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();
}
}

View File

@@ -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<DevPortalOfflineManifestSigningOptions>(options),
new FixedTimeProvider(now),
NullLogger<HmacDevPortalOfflineManifestSigner>.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<DevPortalOfflineManifestSigningOptions>(options),
new FixedTimeProvider(DateTimeOffset.UtcNow),
NullLogger<HmacDevPortalOfflineManifestSigner>.Instance);
await Assert.ThrowsAsync<NotSupportedException>(() =>
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<T> : IOptionsMonitor<T>
{
private readonly T _value;
public StaticOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -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<DevPortalOfflineWorkerSdkSource>? SdkSources { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
}
public sealed record DevPortalOfflineWorkerSdkSource(string Name, string Directory);

View File

@@ -1,7 +1,22 @@
using StellaOps.ExportCenter.Worker;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
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<DevPortalOfflineWorkerOptions>(builder.Configuration.GetSection("DevPortalOffline"));
builder.Services.Configure<DevPortalOfflineManifestSigningOptions>(builder.Configuration.GetSection("DevPortalOffline:Signing"));
builder.Services.Configure<DevPortalOfflineStorageOptions>(builder.Configuration.GetSection("DevPortalOffline:Storage"));
builder.Services.AddSingleton<DevPortalOfflineBundleBuilder>();
builder.Services.AddSingleton<IDevPortalOfflineManifestSigner, HmacDevPortalOfflineManifestSigner>();
builder.Services.AddSingleton<IDevPortalOfflineObjectStore, FileSystemDevPortalOfflineObjectStore>();
builder.Services.AddSingleton<DevPortalOfflineJob>();
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

View File

@@ -1,16 +1,87 @@
namespace StellaOps.ExportCenter.Worker;
public class Worker(ILogger<Worker> 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<Worker> _logger;
private readonly DevPortalOfflineJob _job;
private readonly IOptions<DevPortalOfflineWorkerOptions> _options;
public Worker(
ILogger<Worker> logger,
DevPortalOfflineJob job,
IOptions<DevPortalOfflineWorkerOptions> 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<string, string>(options.Metadata, StringComparer.Ordinal);
var sdkSources = options.SdkSources is { Count: > 0 }
? options.SdkSources
.Select(source => new DevPortalSdkSource(source.Name, source.Directory))
.ToArray()
: Array.Empty<DevPortalSdkSource>();
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);
}
}

View File

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