Implement incident mode management service and models
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added IPackRunIncidentModeService interface for managing incident mode activation, deactivation, and status retrieval. - Created PackRunIncidentModeService class implementing the service interface with methods for activating, deactivating, and escalating incident modes. - Introduced incident mode status model (PackRunIncidentModeStatus) and related enums for escalation levels and activation sources. - Developed retention policy, telemetry settings, and debug capture settings models to manage incident mode configurations. - Implemented SLO breach notification handling to activate incident mode based on severity. - Added in-memory store (InMemoryPackRunIncidentModeStore) for testing purposes. - Created comprehensive unit tests for incident mode service, covering activation, deactivation, status retrieval, and SLO breach handling.
This commit is contained in:
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal file
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for policy pack.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required PolicyPackStatus Status { get; init; }
|
||||
public IReadOnlyList<PolicyRule>? Rules { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public PolicyPack ToContract() => new()
|
||||
{
|
||||
PackId = PackId,
|
||||
Name = Name,
|
||||
Version = Version,
|
||||
Description = Description,
|
||||
Status = Status,
|
||||
Rules = Rules,
|
||||
Metadata = Metadata,
|
||||
CreatedAt = CreatedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
PublishedAt = PublishedAt,
|
||||
Digest = Digest
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for verification policy.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required string TenantScope { get; init; }
|
||||
public required IReadOnlyList<string> PredicateTypes { get; init; }
|
||||
public required SignerRequirements SignerRequirements { get; init; }
|
||||
public ValidityWindow? ValidityWindow { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public VerificationPolicy ToContract() => new()
|
||||
{
|
||||
PolicyId = PolicyId,
|
||||
Version = Version,
|
||||
Description = Description,
|
||||
TenantScope = TenantScope,
|
||||
PredicateTypes = PredicateTypes,
|
||||
SignerRequirements = SignerRequirements,
|
||||
ValidityWindow = ValidityWindow,
|
||||
Metadata = Metadata,
|
||||
CreatedAt = CreatedAt,
|
||||
UpdatedAt = UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid SnapshotId { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<Guid>? PackIds { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public Snapshot ToContract() => new()
|
||||
{
|
||||
SnapshotId = SnapshotId,
|
||||
Digest = Digest,
|
||||
Description = Description,
|
||||
PackIds = PackIds,
|
||||
Metadata = Metadata,
|
||||
CreatedAt = CreatedAt,
|
||||
CreatedBy = CreatedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for violation.
|
||||
/// </summary>
|
||||
public sealed record ViolationEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid ViolationId { get; init; }
|
||||
public string? PolicyId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required Severity Severity { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Context { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public Violation ToContract() => new()
|
||||
{
|
||||
ViolationId = ViolationId,
|
||||
PolicyId = PolicyId,
|
||||
RuleId = RuleId,
|
||||
Severity = Severity,
|
||||
Message = Message,
|
||||
Purl = Purl,
|
||||
CveId = CveId,
|
||||
Context = Context,
|
||||
CreatedAt = CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for override.
|
||||
/// </summary>
|
||||
public sealed record OverrideEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid OverrideId { get; init; }
|
||||
public Guid? ProfileId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required OverrideStatus Status { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public OverrideScope? Scope { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public string? ApprovedBy { get; init; }
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public Override ToContract() => new()
|
||||
{
|
||||
OverrideId = OverrideId,
|
||||
ProfileId = ProfileId,
|
||||
RuleId = RuleId,
|
||||
Status = Status,
|
||||
Reason = Reason,
|
||||
Scope = Scope,
|
||||
ExpiresAt = ExpiresAt,
|
||||
ApprovedBy = ApprovedBy,
|
||||
ApprovedAt = ApprovedAt,
|
||||
CreatedAt = CreatedAt,
|
||||
CreatedBy = CreatedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// History entry for policy pack changes.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackHistoryEntry
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? PerformedBy { get; init; }
|
||||
public PolicyPackStatus? PreviousStatus { get; init; }
|
||||
public PolicyPackStatus? NewStatus { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated policy pack list.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackListResult
|
||||
{
|
||||
public required IReadOnlyList<PolicyPackEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated verification policy list.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyListResult
|
||||
{
|
||||
public required IReadOnlyList<VerificationPolicyEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated snapshot list.
|
||||
/// </summary>
|
||||
public sealed record SnapshotListResult
|
||||
{
|
||||
public required IReadOnlyList<SnapshotEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated violation list.
|
||||
/// </summary>
|
||||
public sealed record ViolationListResult
|
||||
{
|
||||
public required IReadOnlyList<ViolationEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal file
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for policy pack workspace operations with history tracking.
|
||||
/// Implements REGISTRY-API-27-002: Workspace storage with CRUD + history.
|
||||
/// </summary>
|
||||
public interface IPolicyPackStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreatePolicyPackRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a policy pack by ID.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a policy pack by name.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> GetByNameAsync(
|
||||
Guid tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists policy packs with optional filtering.
|
||||
/// </summary>
|
||||
Task<PolicyPackListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
PolicyPackStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
UpdatePolicyPackRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a policy pack (only drafts can be deleted).
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> UpdateStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PolicyPackStatus newStatus,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the history of changes for a policy pack.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for verification policy operations.
|
||||
/// </summary>
|
||||
public interface IVerificationPolicyStore
|
||||
{
|
||||
Task<VerificationPolicyEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateVerificationPolicyRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicyEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicyListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicyEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
UpdateVerificationPolicyRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for policy snapshot operations.
|
||||
/// </summary>
|
||||
public interface ISnapshotStore
|
||||
{
|
||||
Task<SnapshotEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateSnapshotRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SnapshotEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SnapshotEntity?> GetByDigestAsync(
|
||||
Guid tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SnapshotListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for violation operations.
|
||||
/// </summary>
|
||||
public interface IViolationStore
|
||||
{
|
||||
Task<ViolationEntity> AppendAsync(
|
||||
Guid tenantId,
|
||||
CreateViolationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ViolationBatchResult> AppendBatchAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<CreateViolationRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ViolationEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid violationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ViolationListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
Severity? severity = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for override operations.
|
||||
/// </summary>
|
||||
public interface IOverrideStore
|
||||
{
|
||||
Task<OverrideEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateOverrideRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OverrideEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OverrideEntity?> ApproveAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
string? approvedBy = null,
|
||||
string? comment = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OverrideEntity?> DisableAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IOverrideStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryOverrideStore : IOverrideStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
|
||||
|
||||
public Task<OverrideEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateOverrideRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var overrideId = Guid.NewGuid();
|
||||
|
||||
var entity = new OverrideEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
OverrideId = overrideId,
|
||||
ProfileId = request.ProfileId,
|
||||
RuleId = request.RuleId,
|
||||
Status = OverrideStatus.Pending,
|
||||
Reason = request.Reason,
|
||||
Scope = request.Scope,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
CreatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_overrides[(tenantId, overrideId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<OverrideEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_overrides.TryGetValue((tenantId, overrideId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_overrides.TryRemove((tenantId, overrideId), out _));
|
||||
}
|
||||
|
||||
public Task<OverrideEntity?> ApproveAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
string? approvedBy = null,
|
||||
string? comment = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
|
||||
{
|
||||
return Task.FromResult<OverrideEntity?>(null);
|
||||
}
|
||||
|
||||
// Only pending overrides can be approved
|
||||
if (existing.Status != OverrideStatus.Pending)
|
||||
{
|
||||
return Task.FromResult<OverrideEntity?>(null);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = existing with
|
||||
{
|
||||
Status = OverrideStatus.Approved,
|
||||
ApprovedBy = approvedBy,
|
||||
ApprovedAt = now
|
||||
};
|
||||
|
||||
_overrides[(tenantId, overrideId)] = updated;
|
||||
|
||||
return Task.FromResult<OverrideEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<OverrideEntity?> DisableAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
|
||||
{
|
||||
return Task.FromResult<OverrideEntity?>(null);
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Status = OverrideStatus.Disabled
|
||||
};
|
||||
|
||||
_overrides[(tenantId, overrideId)] = updated;
|
||||
|
||||
return Task.FromResult<OverrideEntity?>(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IPolicyPackStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPolicyPackStore : IPolicyPackStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
|
||||
|
||||
public Task<PolicyPackEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreatePolicyPackRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var packId = Guid.NewGuid();
|
||||
|
||||
var entity = new PolicyPackEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PackId = packId,
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
Description = request.Description,
|
||||
Status = PolicyPackStatus.Draft,
|
||||
Rules = request.Rules,
|
||||
Metadata = request.Metadata,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_packs[(tenantId, packId)] = entity;
|
||||
|
||||
// Add history entry
|
||||
AddHistoryEntry(tenantId, packId, "created", createdBy, null, PolicyPackStatus.Draft, "Policy pack created");
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_packs.TryGetValue((tenantId, packId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> GetByNameAsync(
|
||||
Guid tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _packs.Values
|
||||
.Where(p => p.TenantId == tenantId && p.Name == name)
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<PolicyPackListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
PolicyPackStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _packs.Values
|
||||
.Where(p => p.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.Status == status.Value);
|
||||
}
|
||||
|
||||
var items = query
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new PolicyPackListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
UpdatePolicyPackRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||
{
|
||||
return Task.FromResult<PolicyPackEntity?>(null);
|
||||
}
|
||||
|
||||
// Only allow updates to drafts
|
||||
if (existing.Status != PolicyPackStatus.Draft)
|
||||
{
|
||||
return Task.FromResult<PolicyPackEntity?>(null);
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Rules = request.Rules ?? existing.Rules,
|
||||
Metadata = request.Metadata ?? existing.Metadata,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
|
||||
_packs[(tenantId, packId)] = updated;
|
||||
|
||||
AddHistoryEntry(tenantId, packId, "updated", updatedBy, existing.Status, updated.Status, "Policy pack updated");
|
||||
|
||||
return Task.FromResult<PolicyPackEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Only allow deletion of drafts
|
||||
if (existing.Status != PolicyPackStatus.Draft)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var removed = _packs.TryRemove((tenantId, packId), out _);
|
||||
if (removed)
|
||||
{
|
||||
_history.TryRemove((tenantId, packId), out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> UpdateStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PolicyPackStatus newStatus,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||
{
|
||||
return Task.FromResult<PolicyPackEntity?>(null);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = existing with
|
||||
{
|
||||
Status = newStatus,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = updatedBy,
|
||||
PublishedAt = newStatus == PolicyPackStatus.Published ? now : existing.PublishedAt,
|
||||
Digest = newStatus == PolicyPackStatus.Published ? ComputeDigest(existing) : existing.Digest
|
||||
};
|
||||
|
||||
_packs[(tenantId, packId)] = updated;
|
||||
|
||||
AddHistoryEntry(tenantId, packId, $"status_changed_to_{newStatus}", updatedBy, existing.Status, newStatus,
|
||||
$"Status changed from {existing.Status} to {newStatus}");
|
||||
|
||||
return Task.FromResult<PolicyPackEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_history.TryGetValue((tenantId, packId), out var history))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(Array.Empty<PolicyPackHistoryEntry>());
|
||||
}
|
||||
|
||||
var entries = history
|
||||
.OrderByDescending(h => h.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(entries);
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string action,
|
||||
string? performedBy,
|
||||
PolicyPackStatus? previousStatus,
|
||||
PolicyPackStatus? newStatus,
|
||||
string? description)
|
||||
{
|
||||
var entry = new PolicyPackHistoryEntry
|
||||
{
|
||||
PackId = packId,
|
||||
Action = action,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
PerformedBy = performedBy,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = newStatus,
|
||||
Description = description
|
||||
};
|
||||
|
||||
_history.AddOrUpdate(
|
||||
(tenantId, packId),
|
||||
_ => [entry],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Add(entry);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeDigest(PolicyPackEntity pack)
|
||||
{
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
pack.Name,
|
||||
pack.Version,
|
||||
pack.Rules
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ISnapshotStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemorySnapshotStore : ISnapshotStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
|
||||
|
||||
public Task<SnapshotEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateSnapshotRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var snapshotId = Guid.NewGuid();
|
||||
|
||||
// Compute digest from pack IDs and timestamp for uniqueness
|
||||
var digest = ComputeDigest(request.PackIds, now);
|
||||
|
||||
var entity = new SnapshotEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SnapshotId = snapshotId,
|
||||
Digest = digest,
|
||||
Description = request.Description,
|
||||
PackIds = request.PackIds,
|
||||
Metadata = request.Metadata,
|
||||
CreatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_snapshots[(tenantId, snapshotId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<SnapshotEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_snapshots.TryGetValue((tenantId, snapshotId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<SnapshotEntity?> GetByDigestAsync(
|
||||
Guid tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _snapshots.Values
|
||||
.Where(s => s.TenantId == tenantId && s.Digest == digest)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<SnapshotListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _snapshots.Values
|
||||
.Where(s => s.TenantId == tenantId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new SnapshotListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_snapshots.TryRemove((tenantId, snapshotId), out _));
|
||||
}
|
||||
|
||||
private static string ComputeDigest(IReadOnlyList<Guid> packIds, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
PackIds = packIds.OrderBy(id => id).ToList(),
|
||||
Timestamp = timestamp.ToUnixTimeMilliseconds()
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IVerificationPolicyStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
|
||||
|
||||
public Task<VerificationPolicyEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateVerificationPolicyRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var entity = new VerificationPolicyEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PolicyId = request.PolicyId,
|
||||
Version = request.Version,
|
||||
Description = request.Description,
|
||||
TenantScope = request.TenantScope ?? tenantId.ToString(),
|
||||
PredicateTypes = request.PredicateTypes,
|
||||
SignerRequirements = request.SignerRequirements ?? new SignerRequirements
|
||||
{
|
||||
MinimumSignatures = 1,
|
||||
TrustedKeyFingerprints = Array.Empty<string>()
|
||||
},
|
||||
ValidityWindow = request.ValidityWindow,
|
||||
Metadata = request.Metadata,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_policies[(tenantId, request.PolicyId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<VerificationPolicyEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_policies.TryGetValue((tenantId, policyId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<VerificationPolicyListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _policies.Values
|
||||
.Where(p => p.TenantId == tenantId)
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new VerificationPolicyListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<VerificationPolicyEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
UpdateVerificationPolicyRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_policies.TryGetValue((tenantId, policyId), out var existing))
|
||||
{
|
||||
return Task.FromResult<VerificationPolicyEntity?>(null);
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Version = request.Version ?? existing.Version,
|
||||
Description = request.Description ?? existing.Description,
|
||||
PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
|
||||
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
|
||||
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
|
||||
Metadata = request.Metadata ?? existing.Metadata,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
|
||||
_policies[(tenantId, policyId)] = updated;
|
||||
|
||||
return Task.FromResult<VerificationPolicyEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_policies.TryRemove((tenantId, policyId), out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IViolationStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryViolationStore : IViolationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
|
||||
|
||||
public Task<ViolationEntity> AppendAsync(
|
||||
Guid tenantId,
|
||||
CreateViolationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var violationId = Guid.NewGuid();
|
||||
|
||||
var entity = new ViolationEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ViolationId = violationId,
|
||||
PolicyId = request.PolicyId,
|
||||
RuleId = request.RuleId,
|
||||
Severity = request.Severity,
|
||||
Message = request.Message,
|
||||
Purl = request.Purl,
|
||||
CveId = request.CveId,
|
||||
Context = request.Context,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_violations[(tenantId, violationId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<ViolationBatchResult> AppendBatchAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<CreateViolationRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
int created = 0;
|
||||
int failed = 0;
|
||||
var errors = new List<BatchError>();
|
||||
|
||||
for (int i = 0; i < requests.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = requests[i];
|
||||
var violationId = Guid.NewGuid();
|
||||
|
||||
var entity = new ViolationEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ViolationId = violationId,
|
||||
PolicyId = request.PolicyId,
|
||||
RuleId = request.RuleId,
|
||||
Severity = request.Severity,
|
||||
Message = request.Message,
|
||||
Purl = request.Purl,
|
||||
CveId = request.CveId,
|
||||
Context = request.Context,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_violations[(tenantId, violationId)] = entity;
|
||||
created++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add(new BatchError
|
||||
{
|
||||
Index = i,
|
||||
Error = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new ViolationBatchResult
|
||||
{
|
||||
Created = created,
|
||||
Failed = failed,
|
||||
Errors = errors.Count > 0 ? errors : null
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ViolationEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid violationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_violations.TryGetValue((tenantId, violationId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<ViolationListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
Severity? severity = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _violations.Values
|
||||
.Where(v => v.TenantId == tenantId);
|
||||
|
||||
if (severity.HasValue)
|
||||
{
|
||||
query = query.Where(v => v.Severity == severity.Value);
|
||||
}
|
||||
|
||||
var items = query
|
||||
.OrderByDescending(v => v.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new ViolationListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class JavaPropertyResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolvesSimpleProperty()
|
||||
{
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["version"] = "1.0.0"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var result = resolver.Resolve("${version}");
|
||||
|
||||
Assert.Equal("1.0.0", result.ResolvedValue);
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Empty(result.UnresolvedProperties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesMultipleProperties()
|
||||
{
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["groupId"] = "com.example",
|
||||
["artifactId"] = "demo",
|
||||
["version"] = "2.0.0"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var result = resolver.Resolve("${groupId}:${artifactId}:${version}");
|
||||
|
||||
Assert.Equal("com.example:demo:2.0.0", result.ResolvedValue);
|
||||
Assert.True(result.IsFullyResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesNestedProperties()
|
||||
{
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["slf4j.version"] = "2.0.7",
|
||||
["logging.version"] = "${slf4j.version}"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var result = resolver.Resolve("${logging.version}");
|
||||
|
||||
Assert.Equal("2.0.7", result.ResolvedValue);
|
||||
Assert.True(result.IsFullyResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesCircularReference()
|
||||
{
|
||||
// Circular: a → b → a (should stop at max depth)
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["a"] = "${b}",
|
||||
["b"] = "${a}"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var result = resolver.Resolve("${a}");
|
||||
|
||||
// Should stop recursing and return whatever state it reaches
|
||||
Assert.False(result.IsFullyResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesMaxRecursionDepth()
|
||||
{
|
||||
// Create a chain of 15 nested properties (exceeds max depth of 10)
|
||||
var properties = new Dictionary<string, string>();
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
properties[$"prop{i}"] = $"${{prop{i + 1}}}";
|
||||
}
|
||||
properties["prop15"] = "final-value";
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties.ToImmutableDictionary());
|
||||
var result = resolver.Resolve("${prop0}");
|
||||
|
||||
// Should not reach final-value due to depth limit
|
||||
Assert.Contains("${", result.ResolvedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreservesUnresolvedPlaceholder()
|
||||
{
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["known"] = "value"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var result = resolver.Resolve("${unknown}");
|
||||
|
||||
Assert.Equal("${unknown}", result.ResolvedValue);
|
||||
Assert.False(result.IsFullyResolved);
|
||||
Assert.Single(result.UnresolvedProperties);
|
||||
Assert.Contains("unknown", result.UnresolvedProperties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesMavenStandardProperties()
|
||||
{
|
||||
var resolver = new JavaPropertyResolver();
|
||||
|
||||
var basedir = resolver.Resolve("${project.basedir}");
|
||||
Assert.Equal(".", basedir.ResolvedValue);
|
||||
|
||||
var buildDir = resolver.Resolve("${project.build.directory}");
|
||||
Assert.Equal("target", buildDir.ResolvedValue);
|
||||
|
||||
var sourceDir = resolver.Resolve("${project.build.sourceDirectory}");
|
||||
Assert.Equal("src/main/java", sourceDir.ResolvedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesEmptyInput()
|
||||
{
|
||||
var resolver = new JavaPropertyResolver();
|
||||
|
||||
var nullResult = resolver.Resolve(null);
|
||||
Assert.Equal(PropertyResolutionResult.Empty, nullResult);
|
||||
|
||||
var emptyResult = resolver.Resolve(string.Empty);
|
||||
Assert.Equal(PropertyResolutionResult.Empty, emptyResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesInputWithoutPlaceholders()
|
||||
{
|
||||
var resolver = new JavaPropertyResolver();
|
||||
var result = resolver.Resolve("plain-text-value");
|
||||
|
||||
Assert.Equal("plain-text-value", result.ResolvedValue);
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Empty(result.UnresolvedProperties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesFromParentChain()
|
||||
{
|
||||
var childProps = new Dictionary<string, string>
|
||||
{
|
||||
["child.version"] = "1.0.0"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var parentProps = new Dictionary<string, string>
|
||||
{
|
||||
["parent.version"] = "2.0.0",
|
||||
["shared.version"] = "parent-value"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var grandparentProps = new Dictionary<string, string>
|
||||
{
|
||||
["grandparent.version"] = "3.0.0"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(childProps, [parentProps, grandparentProps]);
|
||||
|
||||
// Child property
|
||||
Assert.Equal("1.0.0", resolver.Resolve("${child.version}").ResolvedValue);
|
||||
|
||||
// Parent property
|
||||
Assert.Equal("2.0.0", resolver.Resolve("${parent.version}").ResolvedValue);
|
||||
|
||||
// Grandparent property
|
||||
Assert.Equal("3.0.0", resolver.Resolve("${grandparent.version}").ResolvedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChildPropertyOverridesParent()
|
||||
{
|
||||
var childProps = new Dictionary<string, string>
|
||||
{
|
||||
["version"] = "child-value"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var parentProps = new Dictionary<string, string>
|
||||
{
|
||||
["version"] = "parent-value"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(childProps, [parentProps]);
|
||||
var result = resolver.Resolve("${version}");
|
||||
|
||||
Assert.Equal("child-value", result.ResolvedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesProjectCoordinateProperties()
|
||||
{
|
||||
var builder = new JavaPropertyBuilder()
|
||||
.AddProjectCoordinates("com.example", "demo", "1.0.0");
|
||||
|
||||
var resolver = new JavaPropertyResolver(builder.Build());
|
||||
|
||||
Assert.Equal("com.example", resolver.Resolve("${project.groupId}").ResolvedValue);
|
||||
Assert.Equal("com.example", resolver.Resolve("${groupId}").ResolvedValue);
|
||||
Assert.Equal("demo", resolver.Resolve("${project.artifactId}").ResolvedValue);
|
||||
Assert.Equal("1.0.0", resolver.Resolve("${project.version}").ResolvedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesDependencyVersion()
|
||||
{
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["slf4j.version"] = "2.0.7"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
|
||||
var dependency = new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = "org.slf4j",
|
||||
ArtifactId = "slf4j-api",
|
||||
Version = "${slf4j.version}",
|
||||
Source = "pom.xml"
|
||||
};
|
||||
|
||||
var resolved = resolver.ResolveDependency(dependency);
|
||||
|
||||
Assert.Equal("2.0.7", resolved.Version);
|
||||
Assert.Equal(JavaVersionSource.Property, resolved.VersionSource);
|
||||
Assert.Equal("slf4j.version", resolved.VersionProperty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesDependencyWithUnresolvedVersion()
|
||||
{
|
||||
var resolver = new JavaPropertyResolver();
|
||||
|
||||
var dependency = new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = "org.unknown",
|
||||
ArtifactId = "unknown",
|
||||
Version = "${unknown.version}",
|
||||
Source = "pom.xml"
|
||||
};
|
||||
|
||||
var resolved = resolver.ResolveDependency(dependency);
|
||||
|
||||
Assert.Equal("${unknown.version}", resolved.Version);
|
||||
Assert.Equal(JavaVersionSource.Unresolved, resolved.VersionSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertyBuilderAddRange()
|
||||
{
|
||||
var existing = new Dictionary<string, string>
|
||||
{
|
||||
["existing"] = "value1",
|
||||
["override"] = "original"
|
||||
};
|
||||
|
||||
var builder = new JavaPropertyBuilder()
|
||||
.Add("override", "new-value") // Added first
|
||||
.AddRange(existing); // Won't override
|
||||
|
||||
var props = builder.Build();
|
||||
|
||||
Assert.Equal("new-value", props["override"]);
|
||||
Assert.Equal("value1", props["existing"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertyBuilderAddParentCoordinates()
|
||||
{
|
||||
var parent = new JavaParentReference
|
||||
{
|
||||
GroupId = "org.springframework.boot",
|
||||
ArtifactId = "spring-boot-starter-parent",
|
||||
Version = "3.1.0"
|
||||
};
|
||||
|
||||
var builder = new JavaPropertyBuilder().AddParentCoordinates(parent);
|
||||
var props = builder.Build();
|
||||
|
||||
Assert.Equal("org.springframework.boot", props["project.parent.groupId"]);
|
||||
Assert.Equal("spring-boot-starter-parent", props["project.parent.artifactId"]);
|
||||
Assert.Equal("3.1.0", props["project.parent.version"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesComplexMavenExpression()
|
||||
{
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["spring.version"] = "6.0.0",
|
||||
["project.version"] = "1.0.0-SNAPSHOT"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var result = resolver.Resolve("spring-${spring.version}-app-${project.version}");
|
||||
|
||||
Assert.Equal("spring-6.0.0-app-1.0.0-SNAPSHOT", result.ResolvedValue);
|
||||
Assert.True(result.IsFullyResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesMixedResolvedAndUnresolved()
|
||||
{
|
||||
var properties = new Dictionary<string, string>
|
||||
{
|
||||
["known"] = "resolved"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var result = resolver.Resolve("${known}-${unknown}");
|
||||
|
||||
Assert.Equal("resolved-${unknown}", result.ResolvedValue);
|
||||
Assert.False(result.IsFullyResolved);
|
||||
Assert.Single(result.UnresolvedProperties);
|
||||
Assert.Contains("unknown", result.UnresolvedProperties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class MavenEffectivePomBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildsEffectivePomWithParentPropertiesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with properties
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<guava.version>31.1-jre</guava.version>
|
||||
</properties>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child using parent properties
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>${guava.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Equal("17", result.EffectiveProperties["java.version"]);
|
||||
Assert.Single(result.ResolvedDependencies);
|
||||
|
||||
var dep = result.ResolvedDependencies[0];
|
||||
Assert.Equal("31.1-jre", dep.Version);
|
||||
Assert.Equal(JavaVersionSource.Property, dep.VersionSource);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergesParentDependencyManagementAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with dependencyManagement
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.13.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child with version-less dependencies
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.Equal(2, result.ResolvedDependencies.Length);
|
||||
|
||||
var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||
Assert.Equal("2.0.7", slf4j.Version);
|
||||
|
||||
var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit");
|
||||
Assert.Equal("4.13.2", junit.Version);
|
||||
Assert.Equal("test", junit.Scope);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChildDependencyManagementOverridesParentAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with version
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>1.7.36</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child overriding version
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.9</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||
|
||||
var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||
Assert.Equal("2.0.9", dep.Version); // Child's version wins
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlesStandalonePomAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var pomPath = Path.Combine(root, "pom.xml");
|
||||
await File.WriteAllTextAsync(pomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>standalone</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<properties>
|
||||
<encoding>UTF-8</encoding>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.7</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Single(result.ParentChain); // Only the POM itself
|
||||
Assert.Equal("UTF-8", result.EffectiveProperties["encoding"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesPropertyInDependencyManagementVersionAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var pomPath = Path.Combine(root, "pom.xml");
|
||||
await File.WriteAllTextAsync(pomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>app</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<properties>
|
||||
<commons.version>3.12.0</commons.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>${commons.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||
|
||||
var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "commons-lang3");
|
||||
Assert.Equal("3.12.0", dep.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TracksVersionSourceCorrectlyAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with dependencyManagement
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.7</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child with various version sources
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
<properties>
|
||||
<guava.version>31.1-jre</guava.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<!-- Direct version -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.13.2</version>
|
||||
</dependency>
|
||||
<!-- Property version -->
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>${guava.version}</version>
|
||||
</dependency>
|
||||
<!-- Dependency management version -->
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||
|
||||
var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit");
|
||||
Assert.Equal("4.13.2", junit.Version);
|
||||
Assert.Equal(JavaVersionSource.Direct, junit.VersionSource);
|
||||
|
||||
var guava = result.ResolvedDependencies.First(d => d.ArtifactId == "guava");
|
||||
Assert.Equal("31.1-jre", guava.Version);
|
||||
Assert.Equal(JavaVersionSource.Property, guava.VersionSource);
|
||||
Assert.Equal("guava.version", guava.VersionProperty);
|
||||
|
||||
var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||
Assert.Equal("2.0.7", slf4j.Version);
|
||||
// From parent's dependencyManagement
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectsAllLicensesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with Apache license
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
</license>
|
||||
</licenses>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child (inherits license)
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.Single(result.Licenses);
|
||||
Assert.Equal("Apache-2.0", result.Licenses[0].SpdxId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetsUnresolvedDependenciesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var pomPath = Path.Combine(root, "pom.xml");
|
||||
await File.WriteAllTextAsync(pomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>app</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<dependencies>
|
||||
<!-- Unresolved property -->
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>${undefined.version}</version>
|
||||
</dependency>
|
||||
<!-- No version and not in dependencyManagement -->
|
||||
<dependency>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>missing</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||
|
||||
var unresolved = result.GetUnresolvedDependencies().ToList();
|
||||
Assert.Equal(2, unresolved.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PopulatesManagedVersionsIndexAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var pomPath = Path.Combine(root, "pom.xml");
|
||||
await File.WriteAllTextAsync(pomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>app</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>31.1-jre</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||
var builder = new MavenEffectivePomBuilder(root);
|
||||
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||
|
||||
Assert.Equal(2, result.ManagedVersions.Count);
|
||||
Assert.True(result.ManagedVersions.ContainsKey("org.slf4j:slf4j-api"));
|
||||
Assert.True(result.ManagedVersions.ContainsKey("com.google.guava:guava"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class MavenParentResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolvesRelativePathParentAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Create parent/pom.xml
|
||||
var parentDir = Path.Combine(root, "parent");
|
||||
Directory.CreateDirectory(parentDir);
|
||||
await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Create child/pom.xml with relativePath to parent
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<relativePath>../parent/pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Equal(2, result.ParentChain.Length); // child + parent
|
||||
Assert.Equal("17", result.EffectiveProperties["java.version"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesDefaultRelativePathAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Create parent pom.xml in parent directory (default ../pom.xml)
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Create child in subdirectory with no relativePath (defaults to ../pom.xml)
|
||||
var childDir = Path.Combine(root, "module");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
</parent>
|
||||
<artifactId>module</artifactId>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Equal("2.0.0", result.EffectiveVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesMultiLevelParentChainAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Grandparent
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>grandparent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<properties>
|
||||
<grandparent.prop>gp-value</grandparent.prop>
|
||||
</properties>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Parent
|
||||
var parentDir = Path.Combine(root, "parent");
|
||||
Directory.CreateDirectory(parentDir);
|
||||
await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>grandparent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>parent</artifactId>
|
||||
<properties>
|
||||
<parent.prop>parent-value</parent.prop>
|
||||
</properties>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child
|
||||
var childDir = Path.Combine(parentDir, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Equal(3, result.ParentChain.Length); // child, parent, grandparent
|
||||
Assert.Equal("gp-value", result.EffectiveProperties["grandparent.prop"]);
|
||||
Assert.Equal("parent-value", result.EffectiveProperties["parent.prop"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsUnresolvedForMissingParentAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var childPomPath = Path.Combine(root, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>missing-parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>orphan</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.False(result.IsFullyResolved);
|
||||
Assert.Single(result.UnresolvedParents);
|
||||
Assert.Contains("com.example:missing-parent:1.0.0", result.UnresolvedParents);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlesNoParentAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var pomPath = Path.Combine(root, "pom.xml");
|
||||
await File.WriteAllTextAsync(pomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>standalone</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(pom, cancellationToken);
|
||||
|
||||
Assert.True(result.IsFullyResolved);
|
||||
Assert.Single(result.ParentChain); // Only the original POM
|
||||
Assert.Equal("com.example", result.EffectiveGroupId);
|
||||
Assert.Equal("1.0.0", result.EffectiveVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InheritsGroupIdFromParentAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with groupId
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>org.parent</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child without explicit groupId
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>org.parent</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.Equal("org.parent", result.EffectiveGroupId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InheritsVersionFromParentAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>2.5.0</version>
|
||||
<packaging>pom</packaging>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>2.5.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.Equal("2.5.0", result.EffectiveVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesDependencyVersionFromManagementAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with dependencyManagement
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.7</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child with dependency without version
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.Single(result.ResolvedDependencies);
|
||||
var dep = result.ResolvedDependencies[0];
|
||||
Assert.Equal("2.0.7", dep.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesPropertyInDependencyVersionAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var pomPath = Path.Combine(root, "pom.xml");
|
||||
await File.WriteAllTextAsync(pomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>app</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<properties>
|
||||
<guava.version>31.1-jre</guava.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>${guava.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(pom, cancellationToken);
|
||||
|
||||
Assert.Single(result.ResolvedDependencies);
|
||||
var dep = result.ResolvedDependencies[0];
|
||||
Assert.Equal("31.1-jre", dep.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectsLicensesFromChainAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with license
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
</license>
|
||||
</licenses>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
Assert.Single(result.AllLicenses);
|
||||
Assert.Equal("Apache-2.0", result.AllLicenses[0].SpdxId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChildPropertyOverridesParentPropertyAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Parent with property
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<properties>
|
||||
<java.version>11</java.version>
|
||||
</properties>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
// Child overriding property
|
||||
var childDir = Path.Combine(root, "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||
await File.WriteAllTextAsync(childPomPath, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
</project>
|
||||
""", cancellationToken);
|
||||
|
||||
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||
var resolver = new MavenParentResolver(root);
|
||||
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||
|
||||
// Child property should win
|
||||
Assert.Equal("17", result.EffectiveProperties["java.version"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,18 @@ public static class PackRunEventTypes
|
||||
/// <summary>Attestation was revoked.</summary>
|
||||
public const string AttestationRevoked = "pack.attestation.revoked";
|
||||
|
||||
/// <summary>Incident mode activated (per TASKRUN-OBS-55-001).</summary>
|
||||
public const string IncidentModeActivated = "pack.incident.activated";
|
||||
|
||||
/// <summary>Incident mode deactivated.</summary>
|
||||
public const string IncidentModeDeactivated = "pack.incident.deactivated";
|
||||
|
||||
/// <summary>Incident mode escalated to higher level.</summary>
|
||||
public const string IncidentModeEscalated = "pack.incident.escalated";
|
||||
|
||||
/// <summary>SLO breach detected triggering incident mode.</summary>
|
||||
public const string SloBreachDetected = "pack.incident.slo_breach";
|
||||
|
||||
/// <summary>Checks if the event type is a pack run event.</summary>
|
||||
public static bool IsPackRunEvent(string eventType) =>
|
||||
eventType.StartsWith(Prefix, StringComparison.Ordinal);
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.IncidentMode;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing pack run incident mode.
|
||||
/// Per TASKRUN-OBS-55-001.
|
||||
/// </summary>
|
||||
public interface IPackRunIncidentModeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Activates incident mode for a run.
|
||||
/// </summary>
|
||||
Task<IncidentModeActivationResult> ActivateAsync(
|
||||
IncidentModeActivationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates incident mode for a run.
|
||||
/// </summary>
|
||||
Task<IncidentModeActivationResult> DeactivateAsync(
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current incident mode status for a run.
|
||||
/// </summary>
|
||||
Task<PackRunIncidentModeStatus> GetStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Handles an SLO breach notification.
|
||||
/// </summary>
|
||||
Task<IncidentModeActivationResult> HandleSloBreachAsync(
|
||||
SloBreachNotification notification,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Escalates incident mode to a higher level.
|
||||
/// </summary>
|
||||
Task<IncidentModeActivationResult> EscalateAsync(
|
||||
string runId,
|
||||
IncidentEscalationLevel newLevel,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets settings for the current incident mode level.
|
||||
/// </summary>
|
||||
IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for incident mode state.
|
||||
/// </summary>
|
||||
public interface IPackRunIncidentModeStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores incident mode status.
|
||||
/// </summary>
|
||||
Task StoreAsync(
|
||||
string runId,
|
||||
PackRunIncidentModeStatus status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets incident mode status.
|
||||
/// </summary>
|
||||
Task<PackRunIncidentModeStatus?> GetAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all runs in incident mode.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> ListActiveRunsAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes incident mode status.
|
||||
/// </summary>
|
||||
Task RemoveAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings for incident mode levels.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeSettings(
|
||||
/// <summary>Escalation level.</summary>
|
||||
IncidentEscalationLevel Level,
|
||||
|
||||
/// <summary>Retention policy.</summary>
|
||||
IncidentRetentionPolicy RetentionPolicy,
|
||||
|
||||
/// <summary>Telemetry settings.</summary>
|
||||
IncidentTelemetrySettings TelemetrySettings,
|
||||
|
||||
/// <summary>Debug capture settings.</summary>
|
||||
IncidentDebugCaptureSettings DebugCaptureSettings);
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of pack run incident mode service.
|
||||
/// </summary>
|
||||
public sealed class PackRunIncidentModeService : IPackRunIncidentModeService
|
||||
{
|
||||
private readonly IPackRunIncidentModeStore _store;
|
||||
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
|
||||
private readonly ILogger<PackRunIncidentModeService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PackRunIncidentModeService(
|
||||
IPackRunIncidentModeStore store,
|
||||
ILogger<PackRunIncidentModeService> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IPackRunTimelineEventEmitter? timelineEmitter = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_timelineEmitter = timelineEmitter;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IncidentModeActivationResult> ActivateAsync(
|
||||
IncidentModeActivationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var settings = GetSettingsForLevel(request.Level);
|
||||
|
||||
var expiresAt = request.DurationMinutes.HasValue
|
||||
? now.AddMinutes(request.DurationMinutes.Value)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
var status = new PackRunIncidentModeStatus(
|
||||
Active: true,
|
||||
Level: request.Level,
|
||||
ActivatedAt: now,
|
||||
ActivationReason: request.Reason,
|
||||
Source: request.Source,
|
||||
ExpiresAt: expiresAt,
|
||||
RetentionPolicy: settings.RetentionPolicy,
|
||||
TelemetrySettings: settings.TelemetrySettings,
|
||||
DebugCaptureSettings: settings.DebugCaptureSettings);
|
||||
|
||||
await _store.StoreAsync(request.RunId, status, cancellationToken);
|
||||
|
||||
// Emit timeline event
|
||||
await EmitTimelineEventAsync(
|
||||
request.TenantId,
|
||||
request.RunId,
|
||||
PackRunIncidentEventTypes.IncidentModeActivated,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["level"] = request.Level.ToString(),
|
||||
["source"] = request.Source.ToString(),
|
||||
["reason"] = request.Reason,
|
||||
["requestedBy"] = request.RequestedBy ?? "system"
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Incident mode activated for run {RunId} at level {Level} due to: {Reason}",
|
||||
request.RunId,
|
||||
request.Level,
|
||||
request.Reason);
|
||||
|
||||
return new IncidentModeActivationResult(
|
||||
Success: true,
|
||||
Status: status,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to activate incident mode for run {RunId}", request.RunId);
|
||||
|
||||
return new IncidentModeActivationResult(
|
||||
Success: false,
|
||||
Status: PackRunIncidentModeStatus.Inactive(),
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IncidentModeActivationResult> DeactivateAsync(
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = await _store.GetAsync(runId, cancellationToken);
|
||||
if (current is null || !current.Active)
|
||||
{
|
||||
return new IncidentModeActivationResult(
|
||||
Success: true,
|
||||
Status: PackRunIncidentModeStatus.Inactive(),
|
||||
Error: null);
|
||||
}
|
||||
|
||||
await _store.RemoveAsync(runId, cancellationToken);
|
||||
var inactive = PackRunIncidentModeStatus.Inactive();
|
||||
|
||||
// Emit timeline event (using default tenant since we don't have it)
|
||||
await EmitTimelineEventAsync(
|
||||
"default",
|
||||
runId,
|
||||
PackRunIncidentEventTypes.IncidentModeDeactivated,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["previousLevel"] = current.Level.ToString(),
|
||||
["reason"] = reason ?? "Manual deactivation",
|
||||
["activeDuration"] = current.ActivatedAt.HasValue
|
||||
? (_timeProvider.GetUtcNow() - current.ActivatedAt.Value).ToString()
|
||||
: "unknown"
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Incident mode deactivated for run {RunId}. Reason: {Reason}",
|
||||
runId,
|
||||
reason ?? "Manual deactivation");
|
||||
|
||||
return new IncidentModeActivationResult(
|
||||
Success: true,
|
||||
Status: inactive,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deactivate incident mode for run {RunId}", runId);
|
||||
|
||||
return new IncidentModeActivationResult(
|
||||
Success: false,
|
||||
Status: PackRunIncidentModeStatus.Inactive(),
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackRunIncidentModeStatus> GetStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = await _store.GetAsync(runId, cancellationToken);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
return PackRunIncidentModeStatus.Inactive();
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (status.ExpiresAt.HasValue && status.ExpiresAt.Value <= _timeProvider.GetUtcNow())
|
||||
{
|
||||
await _store.RemoveAsync(runId, cancellationToken);
|
||||
return PackRunIncidentModeStatus.Inactive();
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IncidentModeActivationResult> HandleSloBreachAsync(
|
||||
SloBreachNotification notification,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(notification.ResourceId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Received SLO breach notification {BreachId} without resource ID, skipping incident activation",
|
||||
notification.BreachId);
|
||||
|
||||
return new IncidentModeActivationResult(
|
||||
Success: false,
|
||||
Status: PackRunIncidentModeStatus.Inactive(),
|
||||
Error: "No resource ID in SLO breach notification");
|
||||
}
|
||||
|
||||
// Map severity to escalation level
|
||||
var level = notification.Severity?.ToUpperInvariant() switch
|
||||
{
|
||||
"CRITICAL" => IncidentEscalationLevel.Critical,
|
||||
"HIGH" => IncidentEscalationLevel.High,
|
||||
"MEDIUM" => IncidentEscalationLevel.Medium,
|
||||
"LOW" => IncidentEscalationLevel.Low,
|
||||
_ => IncidentEscalationLevel.Medium
|
||||
};
|
||||
|
||||
var request = new IncidentModeActivationRequest(
|
||||
RunId: notification.ResourceId,
|
||||
TenantId: notification.TenantId ?? "default",
|
||||
Level: level,
|
||||
Source: IncidentModeSource.SloBreach,
|
||||
Reason: $"SLO breach: {notification.SloName} ({notification.CurrentValue:F2} vs threshold {notification.Threshold:F2})",
|
||||
DurationMinutes: 60, // Auto-expire after 1 hour
|
||||
RequestedBy: "slo-monitor");
|
||||
|
||||
_logger.LogWarning(
|
||||
"Processing SLO breach {BreachId} for {SloName} on resource {ResourceId}",
|
||||
notification.BreachId,
|
||||
notification.SloName,
|
||||
notification.ResourceId);
|
||||
|
||||
return await ActivateAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IncidentModeActivationResult> EscalateAsync(
|
||||
string runId,
|
||||
IncidentEscalationLevel newLevel,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await _store.GetAsync(runId, cancellationToken);
|
||||
|
||||
if (current is null || !current.Active)
|
||||
{
|
||||
return new IncidentModeActivationResult(
|
||||
Success: false,
|
||||
Status: PackRunIncidentModeStatus.Inactive(),
|
||||
Error: "Incident mode is not active for this run");
|
||||
}
|
||||
|
||||
if (newLevel <= current.Level)
|
||||
{
|
||||
return new IncidentModeActivationResult(
|
||||
Success: false,
|
||||
Status: current,
|
||||
Error: $"Cannot escalate to {newLevel} - current level is {current.Level}");
|
||||
}
|
||||
|
||||
var settings = GetSettingsForLevel(newLevel);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var escalated = current with
|
||||
{
|
||||
Level = newLevel,
|
||||
ActivationReason = $"{current.ActivationReason} [Escalated: {reason ?? "Manual escalation"}]",
|
||||
RetentionPolicy = settings.RetentionPolicy,
|
||||
TelemetrySettings = settings.TelemetrySettings,
|
||||
DebugCaptureSettings = settings.DebugCaptureSettings
|
||||
};
|
||||
|
||||
await _store.StoreAsync(runId, escalated, cancellationToken);
|
||||
|
||||
// Emit timeline event
|
||||
await EmitTimelineEventAsync(
|
||||
"default",
|
||||
runId,
|
||||
PackRunIncidentEventTypes.IncidentModeEscalated,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["previousLevel"] = current.Level.ToString(),
|
||||
["newLevel"] = newLevel.ToString(),
|
||||
["reason"] = reason ?? "Manual escalation"
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Incident mode escalated for run {RunId} from {OldLevel} to {NewLevel}. Reason: {Reason}",
|
||||
runId,
|
||||
current.Level,
|
||||
newLevel,
|
||||
reason ?? "Manual escalation");
|
||||
|
||||
return new IncidentModeActivationResult(
|
||||
Success: true,
|
||||
Status: escalated,
|
||||
Error: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level) => level switch
|
||||
{
|
||||
IncidentEscalationLevel.None => new IncidentModeSettings(
|
||||
level,
|
||||
IncidentRetentionPolicy.Default(),
|
||||
IncidentTelemetrySettings.Default(),
|
||||
IncidentDebugCaptureSettings.Default()),
|
||||
|
||||
IncidentEscalationLevel.Low => new IncidentModeSettings(
|
||||
level,
|
||||
IncidentRetentionPolicy.Default() with { LogRetentionDays = 30 },
|
||||
IncidentTelemetrySettings.Default() with
|
||||
{
|
||||
EnhancedTelemetryActive = true,
|
||||
LogVerbosity = IncidentLogVerbosity.Verbose,
|
||||
TraceSamplingRate = 0.5
|
||||
},
|
||||
IncidentDebugCaptureSettings.Default()),
|
||||
|
||||
IncidentEscalationLevel.Medium => new IncidentModeSettings(
|
||||
level,
|
||||
IncidentRetentionPolicy.Extended(),
|
||||
IncidentTelemetrySettings.Enhanced(),
|
||||
IncidentDebugCaptureSettings.Basic()),
|
||||
|
||||
IncidentEscalationLevel.High => new IncidentModeSettings(
|
||||
level,
|
||||
IncidentRetentionPolicy.Extended() with { LogRetentionDays = 180, ArtifactRetentionDays = 365 },
|
||||
IncidentTelemetrySettings.Enhanced() with { LogVerbosity = IncidentLogVerbosity.Debug },
|
||||
IncidentDebugCaptureSettings.Full()),
|
||||
|
||||
IncidentEscalationLevel.Critical => new IncidentModeSettings(
|
||||
level,
|
||||
IncidentRetentionPolicy.Maximum(),
|
||||
IncidentTelemetrySettings.Maximum(),
|
||||
IncidentDebugCaptureSettings.Full() with { MaxCaptureSizeMb = 1000 }),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(level))
|
||||
};
|
||||
|
||||
private async Task EmitTimelineEventAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string eventType,
|
||||
IReadOnlyDictionary<string, string> attributes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_timelineEmitter is null) return;
|
||||
|
||||
await _timelineEmitter.EmitAsync(
|
||||
PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: eventType,
|
||||
source: "taskrunner-incident-mode",
|
||||
occurredAt: _timeProvider.GetUtcNow(),
|
||||
runId: runId,
|
||||
severity: PackRunEventSeverity.Warning,
|
||||
attributes: attributes),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident mode timeline event types.
|
||||
/// </summary>
|
||||
public static class PackRunIncidentEventTypes
|
||||
{
|
||||
/// <summary>Incident mode activated.</summary>
|
||||
public const string IncidentModeActivated = "pack.incident.activated";
|
||||
|
||||
/// <summary>Incident mode deactivated.</summary>
|
||||
public const string IncidentModeDeactivated = "pack.incident.deactivated";
|
||||
|
||||
/// <summary>Incident mode escalated.</summary>
|
||||
public const string IncidentModeEscalated = "pack.incident.escalated";
|
||||
|
||||
/// <summary>SLO breach detected.</summary>
|
||||
public const string SloBreachDetected = "pack.incident.slo_breach";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory incident mode store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPackRunIncidentModeStore : IPackRunIncidentModeStore
|
||||
{
|
||||
private readonly Dictionary<string, PackRunIncidentModeStatus> _statuses = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StoreAsync(
|
||||
string runId,
|
||||
PackRunIncidentModeStatus status,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_statuses[runId] = status;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PackRunIncidentModeStatus?> GetAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_statuses.TryGetValue(runId, out var status);
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListActiveRunsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var active = _statuses
|
||||
.Where(kvp => kvp.Value.Active)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(active);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_statuses.Remove(runId);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Gets count of stored statuses.</summary>
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _statuses.Count; } }
|
||||
}
|
||||
|
||||
/// <summary>Clears all statuses.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) { _statuses.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.IncidentMode;
|
||||
|
||||
/// <summary>
|
||||
/// Incident mode status for a pack run.
|
||||
/// Per TASKRUN-OBS-55-001.
|
||||
/// </summary>
|
||||
public sealed record PackRunIncidentModeStatus(
|
||||
/// <summary>Whether incident mode is active.</summary>
|
||||
bool Active,
|
||||
|
||||
/// <summary>Current escalation level.</summary>
|
||||
IncidentEscalationLevel Level,
|
||||
|
||||
/// <summary>When incident mode was activated.</summary>
|
||||
DateTimeOffset? ActivatedAt,
|
||||
|
||||
/// <summary>Reason for activation.</summary>
|
||||
string? ActivationReason,
|
||||
|
||||
/// <summary>Source of activation (SLO breach, manual, etc.).</summary>
|
||||
IncidentModeSource Source,
|
||||
|
||||
/// <summary>When incident mode will auto-deactivate (if set).</summary>
|
||||
DateTimeOffset? ExpiresAt,
|
||||
|
||||
/// <summary>Current retention policy in effect.</summary>
|
||||
IncidentRetentionPolicy RetentionPolicy,
|
||||
|
||||
/// <summary>Active telemetry escalation settings.</summary>
|
||||
IncidentTelemetrySettings TelemetrySettings,
|
||||
|
||||
/// <summary>Debug artifact capture settings.</summary>
|
||||
IncidentDebugCaptureSettings DebugCaptureSettings)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default inactive status.
|
||||
/// </summary>
|
||||
public static PackRunIncidentModeStatus Inactive() => new(
|
||||
Active: false,
|
||||
Level: IncidentEscalationLevel.None,
|
||||
ActivatedAt: null,
|
||||
ActivationReason: null,
|
||||
Source: IncidentModeSource.None,
|
||||
ExpiresAt: null,
|
||||
RetentionPolicy: IncidentRetentionPolicy.Default(),
|
||||
TelemetrySettings: IncidentTelemetrySettings.Default(),
|
||||
DebugCaptureSettings: IncidentDebugCaptureSettings.Default());
|
||||
|
||||
/// <summary>
|
||||
/// Serializes to JSON.
|
||||
/// </summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident escalation levels.
|
||||
/// </summary>
|
||||
public enum IncidentEscalationLevel
|
||||
{
|
||||
/// <summary>No incident mode.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Low severity - enhanced logging.</summary>
|
||||
Low = 1,
|
||||
|
||||
/// <summary>Medium severity - debug capture enabled.</summary>
|
||||
Medium = 2,
|
||||
|
||||
/// <summary>High severity - full debug + extended retention.</summary>
|
||||
High = 3,
|
||||
|
||||
/// <summary>Critical - maximum telemetry + indefinite retention.</summary>
|
||||
Critical = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of incident mode activation.
|
||||
/// </summary>
|
||||
public enum IncidentModeSource
|
||||
{
|
||||
/// <summary>No incident mode.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Activated manually by operator.</summary>
|
||||
Manual,
|
||||
|
||||
/// <summary>Activated by SLO breach webhook.</summary>
|
||||
SloBreach,
|
||||
|
||||
/// <summary>Activated by error rate threshold.</summary>
|
||||
ErrorRate,
|
||||
|
||||
/// <summary>Activated by policy evaluation.</summary>
|
||||
PolicyTrigger,
|
||||
|
||||
/// <summary>Activated by external system.</summary>
|
||||
External
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy during incident mode.
|
||||
/// </summary>
|
||||
public sealed record IncidentRetentionPolicy(
|
||||
/// <summary>Whether extended retention is active.</summary>
|
||||
bool ExtendedRetentionActive,
|
||||
|
||||
/// <summary>Log retention in days.</summary>
|
||||
int LogRetentionDays,
|
||||
|
||||
/// <summary>Artifact retention in days.</summary>
|
||||
int ArtifactRetentionDays,
|
||||
|
||||
/// <summary>Debug capture retention in days.</summary>
|
||||
int DebugCaptureRetentionDays,
|
||||
|
||||
/// <summary>Trace retention in days.</summary>
|
||||
int TraceRetentionDays)
|
||||
{
|
||||
/// <summary>Default retention policy.</summary>
|
||||
public static IncidentRetentionPolicy Default() => new(
|
||||
ExtendedRetentionActive: false,
|
||||
LogRetentionDays: 7,
|
||||
ArtifactRetentionDays: 30,
|
||||
DebugCaptureRetentionDays: 3,
|
||||
TraceRetentionDays: 7);
|
||||
|
||||
/// <summary>Extended retention for incident mode.</summary>
|
||||
public static IncidentRetentionPolicy Extended() => new(
|
||||
ExtendedRetentionActive: true,
|
||||
LogRetentionDays: 90,
|
||||
ArtifactRetentionDays: 180,
|
||||
DebugCaptureRetentionDays: 30,
|
||||
TraceRetentionDays: 90);
|
||||
|
||||
/// <summary>Maximum retention for critical incidents.</summary>
|
||||
public static IncidentRetentionPolicy Maximum() => new(
|
||||
ExtendedRetentionActive: true,
|
||||
LogRetentionDays: 365,
|
||||
ArtifactRetentionDays: 365,
|
||||
DebugCaptureRetentionDays: 90,
|
||||
TraceRetentionDays: 365);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry settings during incident mode.
|
||||
/// </summary>
|
||||
public sealed record IncidentTelemetrySettings(
|
||||
/// <summary>Whether enhanced telemetry is active.</summary>
|
||||
bool EnhancedTelemetryActive,
|
||||
|
||||
/// <summary>Log verbosity level.</summary>
|
||||
IncidentLogVerbosity LogVerbosity,
|
||||
|
||||
/// <summary>Trace sampling rate (0.0 to 1.0).</summary>
|
||||
double TraceSamplingRate,
|
||||
|
||||
/// <summary>Whether to capture environment variables.</summary>
|
||||
bool CaptureEnvironment,
|
||||
|
||||
/// <summary>Whether to capture step inputs/outputs.</summary>
|
||||
bool CaptureStepIo,
|
||||
|
||||
/// <summary>Whether to capture network calls.</summary>
|
||||
bool CaptureNetworkCalls,
|
||||
|
||||
/// <summary>Maximum trace spans per step.</summary>
|
||||
int MaxTraceSpansPerStep)
|
||||
{
|
||||
/// <summary>Default telemetry settings.</summary>
|
||||
public static IncidentTelemetrySettings Default() => new(
|
||||
EnhancedTelemetryActive: false,
|
||||
LogVerbosity: IncidentLogVerbosity.Normal,
|
||||
TraceSamplingRate: 0.1,
|
||||
CaptureEnvironment: false,
|
||||
CaptureStepIo: false,
|
||||
CaptureNetworkCalls: false,
|
||||
MaxTraceSpansPerStep: 100);
|
||||
|
||||
/// <summary>Enhanced telemetry for incident mode.</summary>
|
||||
public static IncidentTelemetrySettings Enhanced() => new(
|
||||
EnhancedTelemetryActive: true,
|
||||
LogVerbosity: IncidentLogVerbosity.Verbose,
|
||||
TraceSamplingRate: 1.0,
|
||||
CaptureEnvironment: true,
|
||||
CaptureStepIo: true,
|
||||
CaptureNetworkCalls: true,
|
||||
MaxTraceSpansPerStep: 1000);
|
||||
|
||||
/// <summary>Maximum telemetry for debugging.</summary>
|
||||
public static IncidentTelemetrySettings Maximum() => new(
|
||||
EnhancedTelemetryActive: true,
|
||||
LogVerbosity: IncidentLogVerbosity.Debug,
|
||||
TraceSamplingRate: 1.0,
|
||||
CaptureEnvironment: true,
|
||||
CaptureStepIo: true,
|
||||
CaptureNetworkCalls: true,
|
||||
MaxTraceSpansPerStep: 10000);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log verbosity levels for incident mode.
|
||||
/// </summary>
|
||||
public enum IncidentLogVerbosity
|
||||
{
|
||||
/// <summary>Minimal logging (errors only).</summary>
|
||||
Minimal,
|
||||
|
||||
/// <summary>Normal logging.</summary>
|
||||
Normal,
|
||||
|
||||
/// <summary>Verbose logging.</summary>
|
||||
Verbose,
|
||||
|
||||
/// <summary>Debug logging (maximum detail).</summary>
|
||||
Debug
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug artifact capture settings.
|
||||
/// </summary>
|
||||
public sealed record IncidentDebugCaptureSettings(
|
||||
/// <summary>Whether debug capture is active.</summary>
|
||||
bool CaptureActive,
|
||||
|
||||
/// <summary>Whether to capture heap dumps.</summary>
|
||||
bool CaptureHeapDumps,
|
||||
|
||||
/// <summary>Whether to capture thread dumps.</summary>
|
||||
bool CaptureThreadDumps,
|
||||
|
||||
/// <summary>Whether to capture profiling data.</summary>
|
||||
bool CaptureProfilingData,
|
||||
|
||||
/// <summary>Whether to capture system metrics.</summary>
|
||||
bool CaptureSystemMetrics,
|
||||
|
||||
/// <summary>Maximum capture size in MB.</summary>
|
||||
int MaxCaptureSizeMb,
|
||||
|
||||
/// <summary>Capture interval in seconds.</summary>
|
||||
int CaptureIntervalSeconds)
|
||||
{
|
||||
/// <summary>Default capture settings (disabled).</summary>
|
||||
public static IncidentDebugCaptureSettings Default() => new(
|
||||
CaptureActive: false,
|
||||
CaptureHeapDumps: false,
|
||||
CaptureThreadDumps: false,
|
||||
CaptureProfilingData: false,
|
||||
CaptureSystemMetrics: false,
|
||||
MaxCaptureSizeMb: 0,
|
||||
CaptureIntervalSeconds: 0);
|
||||
|
||||
/// <summary>Basic debug capture.</summary>
|
||||
public static IncidentDebugCaptureSettings Basic() => new(
|
||||
CaptureActive: true,
|
||||
CaptureHeapDumps: false,
|
||||
CaptureThreadDumps: true,
|
||||
CaptureProfilingData: false,
|
||||
CaptureSystemMetrics: true,
|
||||
MaxCaptureSizeMb: 100,
|
||||
CaptureIntervalSeconds: 60);
|
||||
|
||||
/// <summary>Full debug capture.</summary>
|
||||
public static IncidentDebugCaptureSettings Full() => new(
|
||||
CaptureActive: true,
|
||||
CaptureHeapDumps: true,
|
||||
CaptureThreadDumps: true,
|
||||
CaptureProfilingData: true,
|
||||
CaptureSystemMetrics: true,
|
||||
MaxCaptureSizeMb: 500,
|
||||
CaptureIntervalSeconds: 30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SLO breach notification payload.
|
||||
/// </summary>
|
||||
public sealed record SloBreachNotification(
|
||||
/// <summary>Breach identifier.</summary>
|
||||
[property: JsonPropertyName("breachId")]
|
||||
string BreachId,
|
||||
|
||||
/// <summary>SLO that was breached.</summary>
|
||||
[property: JsonPropertyName("sloName")]
|
||||
string SloName,
|
||||
|
||||
/// <summary>Breach severity.</summary>
|
||||
[property: JsonPropertyName("severity")]
|
||||
string Severity,
|
||||
|
||||
/// <summary>When the breach occurred.</summary>
|
||||
[property: JsonPropertyName("occurredAt")]
|
||||
DateTimeOffset OccurredAt,
|
||||
|
||||
/// <summary>Current metric value.</summary>
|
||||
[property: JsonPropertyName("currentValue")]
|
||||
double CurrentValue,
|
||||
|
||||
/// <summary>Threshold that was breached.</summary>
|
||||
[property: JsonPropertyName("threshold")]
|
||||
double Threshold,
|
||||
|
||||
/// <summary>Target metric value.</summary>
|
||||
[property: JsonPropertyName("target")]
|
||||
double Target,
|
||||
|
||||
/// <summary>Affected resource (run ID, step ID, etc.).</summary>
|
||||
[property: JsonPropertyName("resourceId")]
|
||||
string? ResourceId,
|
||||
|
||||
/// <summary>Affected tenant.</summary>
|
||||
[property: JsonPropertyName("tenantId")]
|
||||
string? TenantId,
|
||||
|
||||
/// <summary>Additional context.</summary>
|
||||
[property: JsonPropertyName("context")]
|
||||
IReadOnlyDictionary<string, string>? Context);
|
||||
|
||||
/// <summary>
|
||||
/// Request to activate incident mode.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeActivationRequest(
|
||||
/// <summary>Run ID to activate incident mode for.</summary>
|
||||
string RunId,
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
string TenantId,
|
||||
|
||||
/// <summary>Escalation level to activate.</summary>
|
||||
IncidentEscalationLevel Level,
|
||||
|
||||
/// <summary>Activation source.</summary>
|
||||
IncidentModeSource Source,
|
||||
|
||||
/// <summary>Reason for activation.</summary>
|
||||
string Reason,
|
||||
|
||||
/// <summary>Duration in minutes (null for indefinite).</summary>
|
||||
int? DurationMinutes,
|
||||
|
||||
/// <summary>Operator or system that requested activation.</summary>
|
||||
string? RequestedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Result of incident mode activation.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeActivationResult(
|
||||
/// <summary>Whether activation succeeded.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>Current incident mode status.</summary>
|
||||
PackRunIncidentModeStatus Status,
|
||||
|
||||
/// <summary>Error message if activation failed.</summary>
|
||||
string? Error);
|
||||
@@ -0,0 +1,396 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using StellaOps.TaskRunner.Core.IncidentMode;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunIncidentModeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateAsync_ActivatesIncidentModeSuccessfully()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var request = new IncidentModeActivationRequest(
|
||||
RunId: "run-001",
|
||||
TenantId: "tenant-1",
|
||||
Level: IncidentEscalationLevel.Medium,
|
||||
Source: IncidentModeSource.Manual,
|
||||
Reason: "Debugging production issue",
|
||||
DurationMinutes: 60,
|
||||
RequestedBy: "admin@example.com");
|
||||
|
||||
var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Status.Active);
|
||||
Assert.Equal(IncidentEscalationLevel.Medium, result.Status.Level);
|
||||
Assert.Equal(IncidentModeSource.Manual, result.Status.Source);
|
||||
Assert.NotNull(result.Status.ActivatedAt);
|
||||
Assert.NotNull(result.Status.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_WithoutDuration_CreatesIndefiniteIncidentMode()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var request = new IncidentModeActivationRequest(
|
||||
RunId: "run-002",
|
||||
TenantId: "tenant-1",
|
||||
Level: IncidentEscalationLevel.High,
|
||||
Source: IncidentModeSource.Manual,
|
||||
Reason: "Critical investigation",
|
||||
DurationMinutes: null,
|
||||
RequestedBy: null);
|
||||
|
||||
var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.Status.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_EmitsTimelineEvent()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var timelineSink = new InMemoryPackRunTimelineEventSink();
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
timelineSink,
|
||||
TimeProvider.System,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance,
|
||||
null,
|
||||
emitter);
|
||||
|
||||
var request = new IncidentModeActivationRequest(
|
||||
RunId: "run-003",
|
||||
TenantId: "tenant-1",
|
||||
Level: IncidentEscalationLevel.Low,
|
||||
Source: IncidentModeSource.Manual,
|
||||
Reason: "Test",
|
||||
DurationMinutes: 30,
|
||||
RequestedBy: null);
|
||||
|
||||
await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, timelineSink.Count);
|
||||
var evt = timelineSink.GetEvents()[0];
|
||||
Assert.Equal(PackRunIncidentEventTypes.IncidentModeActivated, evt.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_DeactivatesIncidentMode()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
// First activate
|
||||
var activateRequest = new IncidentModeActivationRequest(
|
||||
RunId: "run-004",
|
||||
TenantId: "tenant-1",
|
||||
Level: IncidentEscalationLevel.Medium,
|
||||
Source: IncidentModeSource.Manual,
|
||||
Reason: "Test",
|
||||
DurationMinutes: null,
|
||||
RequestedBy: null);
|
||||
|
||||
await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
|
||||
|
||||
// Then deactivate
|
||||
var result = await service.DeactivateAsync("run-004", "Issue resolved", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Status.Active);
|
||||
|
||||
var status = await service.GetStatusAsync("run-004", TestContext.Current.CancellationToken);
|
||||
Assert.False(status.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusAsync_ReturnsInactiveForUnknownRun()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var status = await service.GetStatusAsync("unknown-run", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(status.Active);
|
||||
Assert.Equal(IncidentEscalationLevel.None, status.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusAsync_AutoDeactivatesExpiredIncidentMode()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance,
|
||||
fakeTime);
|
||||
|
||||
var request = new IncidentModeActivationRequest(
|
||||
RunId: "run-005",
|
||||
TenantId: "tenant-1",
|
||||
Level: IncidentEscalationLevel.Medium,
|
||||
Source: IncidentModeSource.Manual,
|
||||
Reason: "Test",
|
||||
DurationMinutes: 30,
|
||||
RequestedBy: null);
|
||||
|
||||
await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Advance time past expiration
|
||||
fakeTime.Advance(TimeSpan.FromMinutes(31));
|
||||
|
||||
var status = await service.GetStatusAsync("run-005", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(status.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleSloBreachAsync_ActivatesIncidentModeFromBreach()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var breach = new SloBreachNotification(
|
||||
BreachId: "breach-001",
|
||||
SloName: "error_rate_5m",
|
||||
Severity: "HIGH",
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
CurrentValue: 15.5,
|
||||
Threshold: 5.0,
|
||||
Target: 1.0,
|
||||
ResourceId: "run-006",
|
||||
TenantId: "tenant-1",
|
||||
Context: new Dictionary<string, string> { ["step"] = "scan" });
|
||||
|
||||
var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Status.Active);
|
||||
Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
|
||||
Assert.Equal(IncidentModeSource.SloBreach, result.Status.Source);
|
||||
Assert.Contains("error_rate_5m", result.Status.ActivationReason!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleSloBreachAsync_MapsSeverityToLevel()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var severityToLevel = new Dictionary<string, IncidentEscalationLevel>
|
||||
{
|
||||
["CRITICAL"] = IncidentEscalationLevel.Critical,
|
||||
["HIGH"] = IncidentEscalationLevel.High,
|
||||
["MEDIUM"] = IncidentEscalationLevel.Medium,
|
||||
["LOW"] = IncidentEscalationLevel.Low
|
||||
};
|
||||
|
||||
var runIndex = 0;
|
||||
foreach (var (severity, expectedLevel) in severityToLevel)
|
||||
{
|
||||
var breach = new SloBreachNotification(
|
||||
BreachId: $"breach-{runIndex}",
|
||||
SloName: "test_slo",
|
||||
Severity: severity,
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
CurrentValue: 10.0,
|
||||
Threshold: 5.0,
|
||||
Target: 1.0,
|
||||
ResourceId: $"run-severity-{runIndex++}",
|
||||
TenantId: "tenant-1",
|
||||
Context: null);
|
||||
|
||||
var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(expectedLevel, result.Status.Level);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleSloBreachAsync_ReturnsErrorForMissingResourceId()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var breach = new SloBreachNotification(
|
||||
BreachId: "breach-no-resource",
|
||||
SloName: "test_slo",
|
||||
Severity: "HIGH",
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
CurrentValue: 10.0,
|
||||
Threshold: 5.0,
|
||||
Target: 1.0,
|
||||
ResourceId: null,
|
||||
TenantId: "tenant-1",
|
||||
Context: null);
|
||||
|
||||
var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("No resource ID", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EscalateAsync_IncreasesEscalationLevel()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
// First activate at Low level
|
||||
var activateRequest = new IncidentModeActivationRequest(
|
||||
RunId: "run-escalate",
|
||||
TenantId: "tenant-1",
|
||||
Level: IncidentEscalationLevel.Low,
|
||||
Source: IncidentModeSource.Manual,
|
||||
Reason: "Initial activation",
|
||||
DurationMinutes: null,
|
||||
RequestedBy: null);
|
||||
|
||||
await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
|
||||
|
||||
// Escalate to High
|
||||
var result = await service.EscalateAsync(
|
||||
"run-escalate",
|
||||
IncidentEscalationLevel.High,
|
||||
"Issue is more severe than expected",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
|
||||
Assert.Contains("Escalated", result.Status.ActivationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EscalateAsync_FailsWhenNotInIncidentMode()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var result = await service.EscalateAsync(
|
||||
"unknown-run",
|
||||
IncidentEscalationLevel.High,
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not active", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EscalateAsync_FailsWhenNewLevelIsLowerOrEqual()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
var activateRequest = new IncidentModeActivationRequest(
|
||||
RunId: "run-no-deescalate",
|
||||
TenantId: "tenant-1",
|
||||
Level: IncidentEscalationLevel.High,
|
||||
Source: IncidentModeSource.Manual,
|
||||
Reason: "Test",
|
||||
DurationMinutes: null,
|
||||
RequestedBy: null);
|
||||
|
||||
await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await service.EscalateAsync(
|
||||
"run-no-deescalate",
|
||||
IncidentEscalationLevel.Medium, // Lower than High
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Cannot escalate", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSettingsForLevel_ReturnsCorrectSettings()
|
||||
{
|
||||
var store = new InMemoryPackRunIncidentModeStore();
|
||||
var service = new PackRunIncidentModeService(
|
||||
store,
|
||||
NullLogger<PackRunIncidentModeService>.Instance);
|
||||
|
||||
// Test None level
|
||||
var noneSettings = service.GetSettingsForLevel(IncidentEscalationLevel.None);
|
||||
Assert.False(noneSettings.TelemetrySettings.EnhancedTelemetryActive);
|
||||
Assert.False(noneSettings.DebugCaptureSettings.CaptureActive);
|
||||
|
||||
// Test Critical level
|
||||
var criticalSettings = service.GetSettingsForLevel(IncidentEscalationLevel.Critical);
|
||||
Assert.True(criticalSettings.TelemetrySettings.EnhancedTelemetryActive);
|
||||
Assert.Equal(IncidentLogVerbosity.Debug, criticalSettings.TelemetrySettings.LogVerbosity);
|
||||
Assert.Equal(1.0, criticalSettings.TelemetrySettings.TraceSamplingRate);
|
||||
Assert.True(criticalSettings.DebugCaptureSettings.CaptureActive);
|
||||
Assert.True(criticalSettings.DebugCaptureSettings.CaptureHeapDumps);
|
||||
Assert.Equal(365, criticalSettings.RetentionPolicy.LogRetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackRunIncidentModeStatus_Inactive_ReturnsDefaultValues()
|
||||
{
|
||||
var inactive = PackRunIncidentModeStatus.Inactive();
|
||||
|
||||
Assert.False(inactive.Active);
|
||||
Assert.Equal(IncidentEscalationLevel.None, inactive.Level);
|
||||
Assert.Null(inactive.ActivatedAt);
|
||||
Assert.Null(inactive.ActivationReason);
|
||||
Assert.Equal(IncidentModeSource.None, inactive.Source);
|
||||
Assert.False(inactive.RetentionPolicy.ExtendedRetentionActive);
|
||||
Assert.False(inactive.TelemetrySettings.EnhancedTelemetryActive);
|
||||
Assert.False(inactive.DebugCaptureSettings.CaptureActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentRetentionPolicy_Extended_HasLongerRetention()
|
||||
{
|
||||
var defaultPolicy = IncidentRetentionPolicy.Default();
|
||||
var extendedPolicy = IncidentRetentionPolicy.Extended();
|
||||
|
||||
Assert.True(extendedPolicy.ExtendedRetentionActive);
|
||||
Assert.True(extendedPolicy.LogRetentionDays > defaultPolicy.LogRetentionDays);
|
||||
Assert.True(extendedPolicy.ArtifactRetentionDays > defaultPolicy.ArtifactRetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentTelemetrySettings_Enhanced_HasHigherSampling()
|
||||
{
|
||||
var defaultSettings = IncidentTelemetrySettings.Default();
|
||||
var enhancedSettings = IncidentTelemetrySettings.Enhanced();
|
||||
|
||||
Assert.True(enhancedSettings.EnhancedTelemetryActive);
|
||||
Assert.True(enhancedSettings.TraceSamplingRate > defaultSettings.TraceSamplingRate);
|
||||
Assert.True(enhancedSettings.CaptureEnvironment);
|
||||
Assert.True(enhancedSettings.CaptureStepIo);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.AirGap;
|
||||
using StellaOps.TaskRunner.Core.Attestation;
|
||||
using StellaOps.TaskRunner.Core.Configuration;
|
||||
using StellaOps.TaskRunner.Core.IncidentMode;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||
@@ -127,6 +128,10 @@ builder.Services.AddSingleton<IPackRunAttestationStore, InMemoryPackRunAttestati
|
||||
builder.Services.AddSingleton<IPackRunAttestationSigner, StubPackRunAttestationSigner>();
|
||||
builder.Services.AddSingleton<IPackRunAttestationService, PackRunAttestationService>();
|
||||
|
||||
// Pack run incident mode (TASKRUN-OBS-55-001)
|
||||
builder.Services.AddSingleton<IPackRunIncidentModeStore, InMemoryPackRunIncidentModeStore>();
|
||||
builder.Services.AddSingleton<IPackRunIncidentModeService, PackRunIncidentModeService>();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -230,6 +235,22 @@ app.MapGet("/api/attestations/{attestationId}/envelope", HandleGetAttestationEnv
|
||||
app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestation");
|
||||
app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestationApi");
|
||||
|
||||
// Incident mode endpoints (TASKRUN-OBS-55-001)
|
||||
app.MapGet("/v1/task-runner/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatus");
|
||||
app.MapGet("/api/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatusApi");
|
||||
|
||||
app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentMode");
|
||||
app.MapPost("/api/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentModeApi");
|
||||
|
||||
app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentMode");
|
||||
app.MapPost("/api/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentModeApi");
|
||||
|
||||
app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentMode");
|
||||
app.MapPost("/api/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentModeApi");
|
||||
|
||||
app.MapPost("/v1/task-runner/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhook");
|
||||
app.MapPost("/api/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhookApi");
|
||||
|
||||
app.MapGet("/.well-known/openapi", (HttpResponse response) =>
|
||||
{
|
||||
var metadata = OpenApiMetadataFactory.Create("/openapi");
|
||||
@@ -681,6 +702,175 @@ async Task<IResult> HandleVerifyAttestation(
|
||||
}, statusCode: statusCode);
|
||||
}
|
||||
|
||||
// Incident mode handlers (TASKRUN-OBS-55-001)
|
||||
async Task<IResult> HandleGetIncidentModeStatus(
|
||||
string runId,
|
||||
IPackRunIncidentModeService incidentModeService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "runId is required." });
|
||||
}
|
||||
|
||||
var status = await incidentModeService.GetStatusAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
runId,
|
||||
active = status.Active,
|
||||
level = status.Level.ToString().ToLowerInvariant(),
|
||||
activatedAt = status.ActivatedAt?.ToString("O"),
|
||||
activationReason = status.ActivationReason,
|
||||
source = status.Source.ToString().ToLowerInvariant(),
|
||||
expiresAt = status.ExpiresAt?.ToString("O"),
|
||||
retentionPolicy = new
|
||||
{
|
||||
extendedRetentionActive = status.RetentionPolicy.ExtendedRetentionActive,
|
||||
logRetentionDays = status.RetentionPolicy.LogRetentionDays,
|
||||
artifactRetentionDays = status.RetentionPolicy.ArtifactRetentionDays
|
||||
},
|
||||
telemetrySettings = new
|
||||
{
|
||||
enhancedTelemetryActive = status.TelemetrySettings.EnhancedTelemetryActive,
|
||||
logVerbosity = status.TelemetrySettings.LogVerbosity.ToString().ToLowerInvariant(),
|
||||
traceSamplingRate = status.TelemetrySettings.TraceSamplingRate
|
||||
},
|
||||
debugCaptureSettings = new
|
||||
{
|
||||
captureActive = status.DebugCaptureSettings.CaptureActive,
|
||||
captureHeapDumps = status.DebugCaptureSettings.CaptureHeapDumps,
|
||||
captureThreadDumps = status.DebugCaptureSettings.CaptureThreadDumps
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async Task<IResult> HandleActivateIncidentMode(
|
||||
string runId,
|
||||
[FromBody] ActivateIncidentModeRequest? request,
|
||||
[FromHeader(Name = "X-Tenant-ID")] string? tenantId,
|
||||
IPackRunIncidentModeService incidentModeService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "runId is required." });
|
||||
}
|
||||
|
||||
var level = Enum.TryParse<IncidentEscalationLevel>(request?.Level, ignoreCase: true, out var parsedLevel)
|
||||
? parsedLevel
|
||||
: IncidentEscalationLevel.Medium;
|
||||
|
||||
var activationRequest = new IncidentModeActivationRequest(
|
||||
RunId: runId,
|
||||
TenantId: tenantId ?? "default",
|
||||
Level: level,
|
||||
Source: StellaOps.TaskRunner.Core.IncidentMode.IncidentModeSource.Manual,
|
||||
Reason: request?.Reason ?? "Manual activation via API",
|
||||
DurationMinutes: request?.DurationMinutes,
|
||||
RequestedBy: request?.RequestedBy);
|
||||
|
||||
var result = await incidentModeService.ActivateAsync(activationRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
success = result.Success,
|
||||
active = result.Status.Active,
|
||||
level = result.Status.Level.ToString().ToLowerInvariant(),
|
||||
activatedAt = result.Status.ActivatedAt?.ToString("O"),
|
||||
expiresAt = result.Status.ExpiresAt?.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
async Task<IResult> HandleDeactivateIncidentMode(
|
||||
string runId,
|
||||
[FromBody] DeactivateIncidentModeRequest? request,
|
||||
IPackRunIncidentModeService incidentModeService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "runId is required." });
|
||||
}
|
||||
|
||||
var result = await incidentModeService.DeactivateAsync(runId, request?.Reason, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
success = result.Success,
|
||||
active = result.Status.Active
|
||||
});
|
||||
}
|
||||
|
||||
async Task<IResult> HandleEscalateIncidentMode(
|
||||
string runId,
|
||||
[FromBody] EscalateIncidentModeRequest? request,
|
||||
IPackRunIncidentModeService incidentModeService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "runId is required." });
|
||||
}
|
||||
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Level))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Level is required for escalation." });
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<IncidentEscalationLevel>(request.Level, ignoreCase: true, out var newLevel))
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Invalid escalation level: {request.Level}" });
|
||||
}
|
||||
|
||||
var result = await incidentModeService.EscalateAsync(runId, newLevel, request.Reason, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
success = result.Success,
|
||||
level = result.Status.Level.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
async Task<IResult> HandleSloBreachWebhook(
|
||||
[FromBody] SloBreachNotification notification,
|
||||
IPackRunIncidentModeService incidentModeService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Notification body is required." });
|
||||
}
|
||||
|
||||
var result = await incidentModeService.HandleSloBreachAsync(notification, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
success = result.Success,
|
||||
runId = notification.ResourceId,
|
||||
level = result.Status.Level.ToString().ToLowerInvariant(),
|
||||
activatedAt = result.Status.ActivatedAt?.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
|
||||
@@ -712,6 +902,17 @@ internal sealed record VerifyAttestationRequest(
|
||||
|
||||
internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary<string, string>? Digest);
|
||||
|
||||
// Incident mode API request models (TASKRUN-OBS-55-001)
|
||||
internal sealed record ActivateIncidentModeRequest(
|
||||
string? Level,
|
||||
string? Reason,
|
||||
int? DurationMinutes,
|
||||
string? RequestedBy);
|
||||
|
||||
internal sealed record DeactivateIncidentModeRequest(string? Reason);
|
||||
|
||||
internal sealed record EscalateIncidentModeRequest(string Level, string? Reason);
|
||||
|
||||
internal sealed record SimulationResponse(
|
||||
string PlanHash,
|
||||
FailurePolicyResponse FailurePolicy,
|
||||
|
||||
@@ -40,7 +40,7 @@ interface ChecklistItem {
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="policy-editor" aria-busy="{{ loadingPack }}">
|
||||
<section class="policy-editor" [attr.aria-busy]="loadingPack">
|
||||
<header class="policy-editor__header">
|
||||
<div class="policy-editor__title">
|
||||
<p class="policy-editor__eyebrow">Policy Studio · Authoring</p>
|
||||
@@ -640,7 +640,12 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
ariaLabel: 'Policy DSL editor',
|
||||
});
|
||||
|
||||
const contentDisposable = this.editor.onDidChangeModelContent(() => {
|
||||
const editor = this.editor;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentDisposable = editor.onDidChangeModelContent(() => {
|
||||
const value = this.model?.getValue() ?? '';
|
||||
this.content$.next(value);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user