This commit is contained in:
master
2026-01-11 11:19:42 +02:00
150 changed files with 76353 additions and 721 deletions

View File

@@ -0,0 +1,574 @@
# SPRINT INDEX: Phase 100 - Plugin System Unification
> **Epic:** Platform Foundation
> **Phase:** 100 - Plugin System Unification
> **Batch:** 100
> **Status:** TODO
> **Successor:** [101_000_INDEX](SPRINT_20260110_101_000_INDEX_foundation.md) (Release Orchestrator Foundation)
---
## Executive Summary
Phase 100 establishes a **unified plugin architecture** for the entire Stella Ops platform. This phase reworks all existing plugin systems (Crypto, Auth, LLM, SCM, Scanner, Router, Concelier) into a single, cohesive model that supports:
- **Trust-based execution** - Built-in plugins run in-process; untrusted plugins run sandboxed
- **Capability composition** - Plugins declare and implement multiple capabilities
- **Database-backed registry** - Centralized plugin management with health tracking
- **Full lifecycle management** - Discovery, loading, initialization, health monitoring, graceful shutdown
- **Multi-tenant isolation** - Per-tenant plugin instances with separate configurations
This unification is **prerequisite** to the Release Orchestrator (Phase 101+), which extends the plugin system with workflow steps, gates, and orchestration-specific connectors.
---
## Strategic Rationale
### Why Unify Now?
1. **Technical Debt Reduction** - Seven disparate plugin patterns create maintenance burden
2. **Security Posture** - Unified trust model enables consistent security enforcement
3. **Developer Experience** - Single SDK for all plugin development
4. **Observability** - Centralized registry enables unified health monitoring
5. **Future Extensibility** - Release Orchestrator requires robust plugin infrastructure
### Current State Analysis
| Plugin Type | Location | Interface | Pattern | Issues |
|-------------|----------|-----------|---------|--------|
| Crypto | `src/Cryptography/` | `ICryptoProvider` | Simple DI | No lifecycle, no health checks |
| Authority | `src/Authority/` | Various | Config-driven | Inconsistent interfaces |
| LLM | `src/AdvisoryAI/` | `ILlmProviderPlugin` | Priority selection | No isolation |
| SCM | `src/Integrations/` | `IScmConnectorPlugin` | Factory + auto-detect | No registry |
| Scanner | `src/Scanner/` | Analyzer interfaces | Pipeline | Tightly coupled |
| Router | `src/Router/` | `IRouterTransportPlugin` | Transport abstraction | No health tracking |
| Concelier | `src/Concelier/` | `IConcielierConnector` | Feed ingestion | No unified lifecycle |
### Target State
All plugins implement:
```csharp
public interface IPlugin : IAsyncDisposable
{
PluginInfo Info { get; }
PluginTrustLevel TrustLevel { get; }
PluginCapabilities Capabilities { get; }
Task InitializeAsync(IPluginContext context, CancellationToken ct);
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
}
```
With capability-specific interfaces:
```csharp
// Crypto capability
public interface ICryptoCapability { ... }
// Connector capability
public interface IConnectorCapability { ... }
// Analysis capability
public interface IAnalysisCapability { ... }
// Transport capability
public interface ITransportCapability { ... }
```
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ UNIFIED PLUGIN ARCHITECTURE │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ StellaOps.Plugin.Abstractions │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ IPlugin │ │ PluginInfo │ │ TrustLevel │ │ Capabilities│ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Capability Interfaces │ │ │
│ │ │ │ │ │
│ │ │ ICryptoCapability IConnectorCapability IAnalysisCapability │ │ │
│ │ │ IAuthCapability ITransportCapability ILlmCapability │ │ │
│ │ │ IStepProviderCapability IGateProviderCapability │ │ │
│ │ └─────────────────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ StellaOps.Plugin.Host │ │
│ │ │ │
│ │ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ │
│ │ │ PluginDiscovery │ │ PluginLoader │ │ PluginRegistry │ │ │
│ │ │ - File system │ │ - Assembly load │ │ - Database │ │ │
│ │ │ - Manifest parse │ │ - Type activate │ │ - Health track │ │ │
│ │ └───────────────────┘ └───────────────────┘ └───────────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ │
│ │ │LifecycleManager │ │ PluginContext │ │ HealthMonitor │ │ │
│ │ │ - State machine │ │ - Config bind │ │ - Periodic check │ │ │
│ │ │ - Graceful stop │ │ - Service access │ │ - Alert on fail │ │ │
│ │ └───────────────────┘ └───────────────────┘ └───────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────┼─────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │
│ │ In-Process │ │ Isolated │ │ Sandboxed │ │
│ │ Execution │ │ Execution │ │ Execution │ │
│ │ │ │ │ │ │ │
│ │ TrustLevel.BuiltIn│ │ TrustLevel.Trusted │ │TrustLevel.Untrusted│ │
│ │ - Direct calls │ │ - AppDomain/ALC │ │ - Process isolation│ │
│ │ - Shared memory │ │ - Resource limits │ │ - gRPC boundary │ │
│ │ - No overhead │ │ - Moderate overhead│ │ - Full sandboxing │ │
│ └────────────────────┘ └────────────────────┘ └────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ StellaOps.Plugin.Sandbox │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ ProcessManager │ │ ResourceLimiter │ │ NetworkPolicy │ │ │
│ │ │ - Spawn/kill │ │ - CPU/memory │ │ - Allow/block │ │ │
│ │ │ - Health watch │ │ - Disk/network │ │ - Rate limit │ │ │
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ GrpcBridge │ │ SecretProxy │ │ LogCollector │ │ │
│ │ │ - Method call │ │ - Vault access │ │ - Structured │ │ │
│ │ │ - Streaming │ │ - Scoped access │ │ - Rate limited │ │ │
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
REWORKED PLUGINS
┌─────────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Crypto │ │ Auth │ │ LLM │ │ SCM │ │
│ │ Plugins │ │ Plugins │ │ Plugins │ │ Connectors │ │
│ │ │ │ │ │ │ │ │ │
│ │ - GOST │ │ - LDAP │ │ - llama │ │ - GitHub │ │
│ │ - eIDAS │ │ - OIDC │ │ - ollama │ │ - GitLab │ │
│ │ - SM2/3/4 │ │ - SAML │ │ - OpenAI │ │ - AzDO │ │
│ │ - FIPS │ │ - Workforce │ │ - Claude │ │ - Gitea │ │
│ │ - HSM │ │ │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Scanner │ │ Router │ │ Concelier │ │ Future │ │
│ │ Analyzers │ │ Transports │ │ Connectors │ │ Plugins │ │
│ │ │ │ │ │ │ │ │ │
│ │ - Go │ │ - TCP/TLS │ │ - NVD │ │ - Steps │ │
│ │ - Java │ │ - UDP │ │ - OSV │ │ - Gates │ │
│ │ - .NET │ │ - RabbitMQ │ │ - GHSA │ │ - CI │ │
│ │ - Python │ │ - Valkey │ │ - Distros │ │ - Registry │ │
│ │ - 7 more... │ │ │ │ │ │ - Vault │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
---
## Sprint Structure
| Sprint ID | Title | Working Directory | Status | Dependencies |
|-----------|-------|-------------------|--------|--------------|
| 100_001 | Plugin Abstractions Library | `src/Plugin/StellaOps.Plugin.Abstractions/` | TODO | None |
| 100_002 | Plugin Host & Lifecycle Manager | `src/Plugin/StellaOps.Plugin.Host/` | TODO | 100_001 |
| 100_003 | Plugin Registry (Database) | `src/Plugin/StellaOps.Plugin.Registry/` | TODO | 100_001, 100_002 |
| 100_004 | Plugin Sandbox Infrastructure | `src/Plugin/StellaOps.Plugin.Sandbox/` | TODO | 100_001, 100_002 |
| 100_005 | Crypto Plugin Rework | `src/Cryptography/` | TODO | 100_001, 100_002, 100_003 |
| 100_006 | Auth Plugin Rework | `src/Authority/` | TODO | 100_001, 100_002, 100_003 |
| 100_007 | LLM Provider Rework | `src/AdvisoryAI/` | TODO | 100_001, 100_002, 100_003 |
| 100_008 | SCM Connector Rework | `src/Integrations/` | TODO | 100_001, 100_002, 100_003 |
| 100_009 | Scanner Analyzer Rework | `src/Scanner/` | TODO | 100_001, 100_002, 100_003 |
| 100_010 | Router Transport Rework | `src/Router/` | TODO | 100_001, 100_002, 100_003 |
| 100_011 | Concelier Connector Rework | `src/Concelier/` | TODO | 100_001, 100_002, 100_003 |
| 100_012 | Plugin SDK & Developer Experience | `src/Plugin/StellaOps.Plugin.Sdk/` | TODO | All above |
---
## Database Schema
### Core Tables
```sql
-- Platform-wide plugin registry
CREATE TABLE platform.plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id VARCHAR(255) NOT NULL, -- e.g., "com.stellaops.crypto.gost"
name VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL, -- SemVer
vendor VARCHAR(255) NOT NULL,
description TEXT,
license_id VARCHAR(50), -- SPDX identifier
-- Trust and security
trust_level VARCHAR(50) NOT NULL CHECK (trust_level IN ('builtin', 'trusted', 'untrusted')),
signature BYTEA, -- Plugin signature for verification
signing_key_id VARCHAR(255),
-- Capabilities (bitmask stored as array for queryability)
capabilities TEXT[] NOT NULL DEFAULT '{}', -- ['crypto', 'connector.scm', 'analysis']
capability_details JSONB NOT NULL DEFAULT '{}', -- Detailed capability metadata
-- Source and deployment
source VARCHAR(50) NOT NULL CHECK (source IN ('bundled', 'installed', 'discovered')),
assembly_path VARCHAR(500),
entry_point VARCHAR(255), -- Type name for activation
-- Lifecycle
status VARCHAR(50) NOT NULL DEFAULT 'discovered' CHECK (status IN (
'discovered', 'loading', 'initializing', 'active',
'degraded', 'stopping', 'stopped', 'failed', 'unloading'
)),
status_message TEXT,
-- Health
health_status VARCHAR(50) DEFAULT 'unknown' CHECK (health_status IN (
'unknown', 'healthy', 'degraded', 'unhealthy'
)),
last_health_check TIMESTAMPTZ,
health_check_failures INT NOT NULL DEFAULT 0,
-- Metadata
manifest JSONB, -- Full plugin manifest
runtime_info JSONB, -- Runtime metrics, resource usage
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
loaded_at TIMESTAMPTZ,
UNIQUE(plugin_id, version)
);
-- Plugin capability registry (denormalized for fast queries)
CREATE TABLE platform.plugin_capabilities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
capability_type VARCHAR(100) NOT NULL, -- 'crypto', 'connector.scm', 'analysis.java'
capability_id VARCHAR(255) NOT NULL, -- 'sign', 'github', 'maven-analyzer'
-- Capability-specific metadata
config_schema JSONB, -- JSON Schema for configuration
input_schema JSONB, -- Input contract
output_schema JSONB, -- Output contract
-- Discovery metadata
display_name VARCHAR(255),
description TEXT,
documentation_url VARCHAR(500),
-- Runtime
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(plugin_id, capability_type, capability_id)
);
-- Plugin instances for multi-tenant scenarios
CREATE TABLE platform.plugin_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES platform.tenants(id) ON DELETE CASCADE, -- NULL = global instance
instance_name VARCHAR(255), -- Optional friendly name
config JSONB NOT NULL DEFAULT '{}', -- Tenant-specific configuration
secrets_path VARCHAR(500), -- Vault path for secrets
-- Instance state
enabled BOOLEAN NOT NULL DEFAULT TRUE,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
-- Resource allocation (for sandboxed plugins)
resource_limits JSONB, -- CPU, memory, network limits
-- Usage tracking
last_used_at TIMESTAMPTZ,
invocation_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(plugin_id, tenant_id, COALESCE(instance_name, ''))
);
-- Plugin health history for trending
CREATE TABLE platform.plugin_health_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(50) NOT NULL,
response_time_ms INT,
details JSONB,
-- Partition by time for efficient cleanup
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
-- Indexes
CREATE INDEX idx_plugins_status ON platform.plugins(status) WHERE status != 'active';
CREATE INDEX idx_plugins_trust_level ON platform.plugins(trust_level);
CREATE INDEX idx_plugins_capabilities ON platform.plugins USING GIN (capabilities);
CREATE INDEX idx_plugin_capabilities_type ON platform.plugin_capabilities(capability_type);
CREATE INDEX idx_plugin_capabilities_lookup ON platform.plugin_capabilities(capability_type, capability_id);
CREATE INDEX idx_plugin_instances_tenant ON platform.plugin_instances(tenant_id) WHERE tenant_id IS NOT NULL;
CREATE INDEX idx_plugin_instances_enabled ON platform.plugin_instances(plugin_id, enabled) WHERE enabled = TRUE;
CREATE INDEX idx_plugin_health_history_plugin ON platform.plugin_health_history(plugin_id, checked_at DESC);
```
---
## Trust Model
### Trust Level Determination
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRUST LEVEL DETERMINATION │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Plugin Discovery │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Is bundled with platform? │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ TrustLevel. │ │ Has valid signature? │ │
│ │ BuiltIn │ └─────────────────────────────────────────┘ │
│ │ │ │ │ │
│ │ - In-process │ YES NO │
│ │ - No sandbox │ │ │ │
│ │ - Full access │ ▼ ▼ │
│ └─────────────────┘ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Signer in trusted │ │ TrustLevel. │ │
│ │ vendor list? │ │ Untrusted │ │
│ └─────────────────────┘ │ │ │
│ │ │ │ - Process isolation│ │
│ YES NO │ - Resource limits │ │
│ │ │ │ - Network policy │ │
│ ▼ ▼ │ - gRPC boundary │ │
│ ┌─────────────────┐ │ └─────────────────────┘ │
│ │ TrustLevel. │ │ │
│ │ Trusted │◄───┘ │
│ │ │ │
│ │ - AppDomain │ │
│ │ - Soft limits │ │
│ │ - Monitored │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Capability-Based Access Control
Each capability grants specific permissions:
| Capability | Permissions Granted |
|------------|---------------------|
| `crypto` | Access to key material, signing operations |
| `network` | Outbound HTTP/gRPC calls (host allowlist) |
| `filesystem.read` | Read-only access to specified paths |
| `filesystem.write` | Write access to plugin workspace |
| `secrets` | Access to vault secrets (scoped by policy) |
| `database` | Database connections (scoped by schema) |
| `process` | Spawn child processes (sandboxed only) |
---
## Plugin Lifecycle
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PLUGIN LIFECYCLE STATE MACHINE │
│ │
│ ┌──────────────┐ │
│ │ Discovered │ │
│ └──────┬───────┘ │
│ │ load() │
│ ▼ │
│ ┌──────────────┐ │
│ │ Loading │ │
│ └──────┬───────┘ │
│ │ assembly loaded │
│ ▼ │
│ ┌──────────────┐ │
│ │ Initializing │ │
│ └──────┬───────┘ │
│ ┌──────────────┼──────────────┐ │
│ │ success │ │ failure │
│ ▼ │ ▼ │
│ ┌──────────────┐ │ ┌──────────────┐ │
│ │ Active │ │ │ Failed │ │
│ └──────┬───────┘ │ └──────┬───────┘ │
│ │ │ │ │
│ ┌─────────────┼─────────────┐│ │ retry() │
│ │ │ ││ │ │
│ health fail stop() health degrade ▼ │
│ │ │ ││ ┌──────────────┐ │
│ ▼ │ ▼│ │ Loading │ (retry) │
│ ┌──────────────┐ │ ┌──────────────┐└──────────────┘ │
│ │ Unhealthy │ │ │ Degraded │ │
│ └──────┬───────┘ │ └──────┬───────┘ │
│ │ │ │ │
│ auto-recover │ health ok │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Stopping │ │
│ └──────┬───────┘ │
│ │ cleanup complete │
│ ▼ │
│ ┌──────────────┐ │
│ │ Stopped │ │
│ └──────┬───────┘ │
│ │ unload() │
│ ▼ │
│ ┌──────────────┐ │
│ │ Unloading │ │
│ └──────┬───────┘ │
│ │ resources freed │
│ ▼ │
│ ┌──────────────┐ │
│ │ (removed) │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Migration Strategy
### Phase Approach
Each plugin type migration follows the same pattern:
1. **Create New Implementation** - Implement `IPlugin` + capability interfaces
2. **Parallel Operation** - Both old and new implementations active
3. **Feature Parity Validation** - Automated tests verify identical behavior
4. **Gradual Cutover** - Configuration flag switches to new implementation
5. **Deprecation** - Old interfaces marked deprecated
6. **Removal** - Old implementations removed after transition period
### Breaking Change Policy
- **Internal interfaces** - Can be changed; update all internal consumers
- **Plugin SDK** - Maintain backward compatibility for one major version
- **Configuration** - Provide migration tooling for config format changes
- **Database** - Always use migrations; never break existing data
---
## Deliverables Summary
### Libraries Created
| Library | Purpose | NuGet Package |
|---------|---------|---------------|
| `StellaOps.Plugin.Abstractions` | Core interfaces | `StellaOps.Plugin.Abstractions` |
| `StellaOps.Plugin.Host` | Plugin hosting | `StellaOps.Plugin.Host` |
| `StellaOps.Plugin.Registry` | Database registry | Internal |
| `StellaOps.Plugin.Sandbox` | Process isolation | Internal |
| `StellaOps.Plugin.Sdk` | Plugin development | `StellaOps.Plugin.Sdk` |
| `StellaOps.Plugin.Testing` | Test infrastructure | `StellaOps.Plugin.Testing` |
### Plugins Reworked
| Plugin Type | Count | Capability Interface |
|-------------|-------|----------------------|
| Crypto | 5 | `ICryptoCapability` |
| Auth | 4 | `IAuthCapability` |
| LLM | 4 | `ILlmCapability` |
| SCM | 4 | `IScmCapability` |
| Scanner | 11 | `IAnalysisCapability` |
| Router | 4 | `ITransportCapability` |
| Concelier | 8+ | `IFeedCapability` |
---
## Success Criteria
### Functional Requirements
- [ ] All existing plugin functionality preserved
- [ ] All plugins implement unified `IPlugin` interface
- [ ] Database registry tracks all plugins
- [ ] Health checks report accurate status
- [ ] Trust levels correctly enforced
- [ ] Sandboxing works for untrusted plugins
### Non-Functional Requirements
- [ ] Plugin load time < 500ms (in-process)
- [ ] Plugin load time < 2s (sandboxed)
- [ ] Health check latency < 100ms
- [ ] No memory leaks in plugin lifecycle
- [ ] Graceful shutdown completes in < 10s
### Quality Requirements
- [ ] Unit test coverage >= 80%
- [ ] Integration test coverage >= 70%
- [ ] All public APIs documented
- [ ] Migration guide for each plugin type
---
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Breaking existing integrations | High | Medium | Comprehensive testing, gradual rollout |
| Performance regression | Medium | Low | Benchmarking, profiling |
| Sandbox escape vulnerability | Critical | Low | Security audit, penetration testing |
| Migration complexity | Medium | Medium | Clear documentation, tooling |
| Timeline overrun | Medium | Medium | Parallel workstreams, MVP scope |
---
## Dependencies
### External Dependencies
| Dependency | Version | Purpose |
|------------|---------|---------|
| .NET 10 | Latest | Runtime |
| gRPC | 2.x | Sandbox communication |
| Npgsql | 8.x | Database access |
| System.Text.Json | Built-in | Manifest parsing |
### Internal Dependencies
| Dependency | Purpose |
|------------|---------|
| `StellaOps.Infrastructure.Postgres` | Database utilities |
| `StellaOps.Telemetry` | Logging, metrics |
| `StellaOps.HybridLogicalClock` | Event ordering |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 100 index created |

View File

@@ -0,0 +1,326 @@
# SPRINT INDEX: Release Orchestrator Implementation
> **Epic:** Stella Ops Suite - Release Control Plane
> **Batch:** 100
> **Status:** Planning
> **Created:** 10-Jan-2026
> **Source:** [Architecture Specification](../product/advisories/09-Jan-2026%20-%20Stella%20Ops%20Orchestrator%20Architecture.md)
---
## Overview
This sprint batch implements the **Release Orchestrator** - transforming Stella Ops from a vulnerability scanning platform into **Stella Ops Suite**, a unified release control plane for non-Kubernetes container environments.
### Business Value
- **Unified release governance:** Single pane of glass for release lifecycle
- **Audit-grade evidence:** Cryptographically signed proof of every decision
- **Security as a gate:** Reachability-aware scanning integrated into promotion flow
- **Plugin extensibility:** Support for any SCM, CI, registry, and vault
- **Non-K8s first:** Docker, Compose, ECS, Nomad deployment targets
### Key Principles
1. **Digest-first release identity** - Releases are immutable OCI digests, not tags
2. **Evidence for every decision** - Every promotion/deployment produces sealed evidence
3. **Pluggable everything, stable core** - Integrations are plugins; core is stable
4. **No feature gating** - All plans include all features
5. **Offline-first operation** - Core works in air-gapped environments
6. **Immutable generated artifacts** - Every deployment generates stored artifacts
---
## Implementation Phases
| Phase | Batch | Title | Description | Duration Est. |
|-------|-------|-------|-------------|---------------|
| 1 | 101 | Foundation | Database schema, plugin infrastructure | Foundation |
| 2 | 102 | Integration Hub | Connector runtime, built-in integrations | Foundation |
| 3 | 103 | Environment Manager | Environments, targets, agent registration | Core |
| 4 | 104 | Release Manager | Components, versions, release bundles | Core |
| 5 | 105 | Workflow Engine | DAG execution, step registry | Core |
| 6 | 106 | Promotion & Gates | Approvals, security gates, decisions | Core |
| 7 | 107 | Deployment Execution | Deploy orchestrator, artifact generation | Core |
| 8 | 108 | Agents | Docker, Compose, SSH, WinRM agents | Deployment |
| 9 | 109 | Evidence & Audit | Evidence packets, version stickers | Audit |
| 10 | 110 | Progressive Delivery | A/B releases, canary, traffic routing | Advanced |
| 11 | 111 | UI Implementation | Dashboard, workflow editor, screens | Frontend |
---
## Module Dependencies
```
┌──────────────┐
│ AUTHORITY │ (existing)
└──────┬───────┘
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ PLUGIN │ │ INTHUB │ │ ENVMGR │
│ (Batch 101) │ │ (Batch 102) │ │ (Batch 103) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└──────────┬───────┴──────────────────┘
┌───────────────┐
│ RELMAN │
│ (Batch 104) │
└───────┬───────┘
┌───────────────┐
│ WORKFL │
│ (Batch 105) │
└───────┬───────┘
┌──────────┴──────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ PROMOT │ │ DEPLOY │
│ (Batch 106) │ │ (Batch 107) │
└───────┬───────┘ └───────┬───────┘
│ │
│ ▼
│ ┌───────────────┐
│ │ AGENTS │
│ │ (Batch 108) │
│ └───────┬───────┘
│ │
└──────────┬──────────┘
┌───────────────┐
│ RELEVI │
│ (Batch 109) │
└───────┬───────┘
┌───────────────┐
│ PROGDL │
│ (Batch 110) │
└───────────────┘
```
---
## Sprint Structure
### Phase 1: Foundation (Batch 101)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 101_001 | Database Schema - Core Tables | DB | - |
| 101_002 | Plugin Registry | PLUGIN | 101_001 |
| 101_003 | Plugin Loader & Sandbox | PLUGIN | 101_002 |
| 101_004 | Plugin SDK | PLUGIN | 101_003 |
### Phase 2: Integration Hub (Batch 102)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 102_001 | Integration Manager | INTHUB | 101_002 |
| 102_002 | Connector Runtime | INTHUB | 102_001 |
| 102_003 | Built-in SCM Connectors | INTHUB | 102_002 |
| 102_004 | Built-in Registry Connectors | INTHUB | 102_002 |
| 102_005 | Built-in Vault Connector | INTHUB | 102_002 |
| 102_006 | Doctor Checks | INTHUB | 102_002 |
### Phase 3: Environment Manager (Batch 103)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 103_001 | Environment CRUD | ENVMGR | 101_001 |
| 103_002 | Target Registry | ENVMGR | 103_001 |
| 103_003 | Agent Manager - Core | ENVMGR | 103_002 |
| 103_004 | Inventory Sync | ENVMGR | 103_002, 103_003 |
### Phase 4: Release Manager (Batch 104)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 104_001 | Component Registry | RELMAN | 102_004 |
| 104_002 | Version Manager | RELMAN | 104_001 |
| 104_003 | Release Manager | RELMAN | 104_002 |
| 104_004 | Release Catalog | RELMAN | 104_003 |
### Phase 5: Workflow Engine (Batch 105)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 105_001 | Workflow Template Designer | WORKFL | 101_001 |
| 105_002 | Step Registry | WORKFL | 101_002 |
| 105_003 | Workflow Engine - DAG Executor | WORKFL | 105_001, 105_002 |
| 105_004 | Step Executor | WORKFL | 105_003 |
| 105_005 | Built-in Steps | WORKFL | 105_004 |
### Phase 6: Promotion & Gates (Batch 106)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 106_001 | Promotion Manager | PROMOT | 104_003, 103_001 |
| 106_002 | Approval Gateway | PROMOT | 106_001 |
| 106_003 | Gate Registry | PROMOT | 106_001 |
| 106_004 | Security Gate | PROMOT | 106_003 |
| 106_005 | Decision Engine | PROMOT | 106_002, 106_003 |
### Phase 7: Deployment Execution (Batch 107)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 107_001 | Deploy Orchestrator | DEPLOY | 105_003, 106_005 |
| 107_002 | Target Executor | DEPLOY | 107_001, 103_002 |
| 107_003 | Artifact Generator | DEPLOY | 107_001 |
| 107_004 | Rollback Manager | DEPLOY | 107_002 |
| 107_005 | Deployment Strategies | DEPLOY | 107_002 |
### Phase 8: Agents (Batch 108)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 108_001 | Agent Core Runtime | AGENTS | 103_003 |
| 108_002 | Agent - Docker | AGENTS | 108_001 |
| 108_003 | Agent - Compose | AGENTS | 108_002 |
| 108_004 | Agent - SSH | AGENTS | 108_001 |
| 108_005 | Agent - WinRM | AGENTS | 108_001 |
### Phase 9: Evidence & Audit (Batch 109)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 109_001 | Evidence Collector | RELEVI | 106_005, 107_001 |
| 109_002 | Evidence Signer | RELEVI | 109_001 |
| 109_003 | Version Sticker Writer | RELEVI | 107_002 |
| 109_004 | Audit Exporter | RELEVI | 109_002 |
### Phase 10: Progressive Delivery (Batch 110)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 110_001 | A/B Release Manager | PROGDL | 107_005 |
| 110_002 | Traffic Router Framework | PROGDL | 110_001 |
| 110_003 | Canary Controller | PROGDL | 110_002 |
| 110_004 | Router Plugin - Nginx | PROGDL | 110_002 |
### Phase 11: UI Implementation (Batch 111)
| Sprint ID | Title | Module | Dependencies |
|-----------|-------|--------|--------------|
| 111_001 | Dashboard - Overview | FE | 107_001 |
| 111_002 | Environment Management UI | FE | 103_001 |
| 111_003 | Release Management UI | FE | 104_003 |
| 111_004 | Workflow Editor | FE | 105_001 |
| 111_005 | Promotion & Approval UI | FE | 106_001 |
| 111_006 | Deployment Monitoring UI | FE | 107_001 |
| 111_007 | Evidence Viewer | FE | 109_002 |
---
## Documentation References
All architecture documentation is available in:
```
docs/modules/release-orchestrator/
├── README.md # Entry point
├── design/
│ ├── principles.md # Design principles
│ └── decisions.md # ADRs
├── modules/
│ ├── overview.md # Module landscape
│ ├── integration-hub.md # INTHUB spec
│ ├── environment-manager.md # ENVMGR spec
│ ├── release-manager.md # RELMAN spec
│ ├── workflow-engine.md # WORKFL spec
│ ├── promotion-manager.md # PROMOT spec
│ ├── deploy-orchestrator.md # DEPLOY spec
│ ├── agents.md # AGENTS spec
│ ├── progressive-delivery.md # PROGDL spec
│ ├── evidence.md # RELEVI spec
│ └── plugin-system.md # PLUGIN spec
├── data-model/
│ ├── schema.md # PostgreSQL schema
│ └── entities.md # Entity definitions
├── api/
│ └── overview.md # API design
├── workflow/
│ ├── templates.md # Template spec
│ ├── execution.md # Execution state machine
│ └── promotion.md # Promotion state machine
├── security/
│ ├── overview.md # Security architecture
│ ├── auth.md # AuthN/AuthZ
│ ├── agent-security.md # Agent security
│ └── threat-model.md # Threat model
├── deployment/
│ ├── overview.md # Deployment architecture
│ ├── strategies.md # Deployment strategies
│ └── artifacts.md # Artifact generation
├── integrations/
│ ├── overview.md # Integration types
│ ├── connectors.md # Connector interface
│ ├── webhooks.md # Webhook architecture
│ └── ci-cd.md # CI/CD patterns
├── operations/
│ ├── overview.md # Observability
│ └── metrics.md # Prometheus metrics
├── ui/
│ └── overview.md # UI specification
└── appendices/
├── glossary.md # Terms
├── errors.md # Error codes
└── evidence-schema.md # Evidence format
```
---
## Technology Stack
| Layer | Technology |
|-------|------------|
| Backend | .NET 10, C# preview |
| Database | PostgreSQL 16+ |
| Message Queue | RabbitMQ / Valkey |
| Frontend | Angular 17 |
| Agent Runtime | .NET AOT |
| Plugin Runtime | gRPC, container sandbox |
| Observability | OpenTelemetry, Prometheus |
---
## Risk Register
| Risk | Impact | Mitigation |
|------|--------|------------|
| Plugin security vulnerabilities | High | Sandbox isolation, capability restrictions |
| Agent compromise | High | mTLS, short-lived credentials, audit |
| Evidence tampering | High | Append-only DB, cryptographic signing |
| Registry unavailability | Medium | Connection pooling, caching, fallbacks |
| Complex workflow failures | Medium | Comprehensive testing, rollback support |
---
## Success Criteria
- [ ] Complete database schema for all 10 themes
- [ ] Plugin system supports connector, step, gate types
- [ ] At least 2 built-in connectors per integration type
- [ ] Environment → Release → Promotion → Deploy flow works E2E
- [ ] Evidence packet generated for every deployment
- [ ] Agent deploys to Docker and Compose targets
- [ ] UI shows pipeline overview, approval queues, deployment logs
- [ ] Performance: <500ms API P99, <5min deployment for 10 targets
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint index created |
| | Architecture documentation complete |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,762 @@
# SPRINT: Plugin Registry (Database)
> **Sprint ID:** 100_003
> **Module:** PLUGIN
> **Phase:** 100 - Plugin System Unification
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_plugin_unification.md)
---
## Overview
Implement the database-backed plugin registry that persists plugin metadata, tracks health status, and supports multi-tenant plugin instances. The registry provides centralized plugin management and enables querying plugins by capability.
### Objectives
- Implement PostgreSQL-backed plugin registry
- Implement plugin capability indexing
- Implement tenant-specific plugin instances
- Implement health history tracking
- Implement plugin version management
- Provide migration scripts for schema creation
### Working Directory
```
src/Plugin/
├── StellaOps.Plugin.Registry/
│ ├── StellaOps.Plugin.Registry.csproj
│ ├── IPluginRegistry.cs
│ ├── PostgresPluginRegistry.cs
│ ├── Models/
│ │ ├── PluginRecord.cs
│ │ ├── PluginCapabilityRecord.cs
│ │ ├── PluginInstanceRecord.cs
│ │ └── PluginHealthRecord.cs
│ ├── Queries/
│ │ ├── PluginQueries.cs
│ │ ├── CapabilityQueries.cs
│ │ └── InstanceQueries.cs
│ └── Migrations/
│ └── 001_CreatePluginTables.sql
└── __Tests/
└── StellaOps.Plugin.Registry.Tests/
├── PostgresPluginRegistryTests.cs
└── PluginQueryTests.cs
```
---
## Deliverables
### Plugin Registry Interface
```csharp
// IPluginRegistry.cs
namespace StellaOps.Plugin.Registry;
/// <summary>
/// Database-backed plugin registry for persistent plugin management.
/// </summary>
public interface IPluginRegistry
{
// ========== Plugin Management ==========
/// <summary>
/// Register a loaded plugin in the database.
/// </summary>
Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct);
/// <summary>
/// Update plugin status.
/// </summary>
Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default);
/// <summary>
/// Update plugin health status.
/// </summary>
Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default);
/// <summary>
/// Unregister a plugin.
/// </summary>
Task UnregisterAsync(string pluginId, CancellationToken ct);
/// <summary>
/// Get plugin by ID.
/// </summary>
Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct);
/// <summary>
/// Get all registered plugins.
/// </summary>
Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct);
/// <summary>
/// Get plugins by status.
/// </summary>
Task<IReadOnlyList<PluginRecord>> GetByStatusAsync(PluginLifecycleState status, CancellationToken ct);
// ========== Capability Queries ==========
/// <summary>
/// Get plugins with a specific capability.
/// </summary>
Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct);
/// <summary>
/// Get plugins providing a specific capability type/id.
/// </summary>
Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(string capabilityType, string? capabilityId = null, CancellationToken ct = default);
/// <summary>
/// Register plugin capabilities.
/// </summary>
Task RegisterCapabilitiesAsync(Guid pluginDbId, IEnumerable<PluginCapabilityRecord> capabilities, CancellationToken ct);
// ========== Instance Management ==========
/// <summary>
/// Create a tenant-specific plugin instance.
/// </summary>
Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct);
/// <summary>
/// Get plugin instance.
/// </summary>
Task<PluginInstanceRecord?> GetInstanceAsync(Guid instanceId, CancellationToken ct);
/// <summary>
/// Get instances for a tenant.
/// </summary>
Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForTenantAsync(Guid tenantId, CancellationToken ct);
/// <summary>
/// Get instances for a plugin.
/// </summary>
Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForPluginAsync(string pluginId, CancellationToken ct);
/// <summary>
/// Update instance configuration.
/// </summary>
Task UpdateInstanceConfigAsync(Guid instanceId, JsonDocument config, CancellationToken ct);
/// <summary>
/// Enable/disable instance.
/// </summary>
Task SetInstanceEnabledAsync(Guid instanceId, bool enabled, CancellationToken ct);
/// <summary>
/// Delete instance.
/// </summary>
Task DeleteInstanceAsync(Guid instanceId, CancellationToken ct);
// ========== Health History ==========
/// <summary>
/// Record health check result.
/// </summary>
Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct);
/// <summary>
/// Get health history for a plugin.
/// </summary>
Task<IReadOnlyList<PluginHealthRecord>> GetHealthHistoryAsync(
string pluginId,
DateTimeOffset since,
int limit = 100,
CancellationToken ct = default);
}
public sealed record CreatePluginInstanceRequest(
string PluginId,
Guid? TenantId,
string? InstanceName,
JsonDocument Config,
string? SecretsPath = null,
JsonDocument? ResourceLimits = null);
```
### PostgreSQL Implementation
```csharp
// PostgresPluginRegistry.cs
namespace StellaOps.Plugin.Registry;
public sealed class PostgresPluginRegistry : IPluginRegistry
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresPluginRegistry> _logger;
private readonly TimeProvider _timeProvider;
public PostgresPluginRegistry(
NpgsqlDataSource dataSource,
ILogger<PostgresPluginRegistry> logger,
TimeProvider timeProvider)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider;
}
public async Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct)
{
const string sql = """
INSERT INTO platform.plugins (
plugin_id, name, version, vendor, description, license_id,
trust_level, capabilities, capability_details, source,
assembly_path, entry_point, status, manifest, created_at, updated_at, loaded_at
) VALUES (
@plugin_id, @name, @version, @vendor, @description, @license_id,
@trust_level, @capabilities, @capability_details, @source,
@assembly_path, @entry_point, @status, @manifest, @now, @now, @now
)
ON CONFLICT (plugin_id, version) DO UPDATE SET
status = @status,
updated_at = @now,
loaded_at = @now
RETURNING *
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
var now = _timeProvider.GetUtcNow();
cmd.Parameters.AddWithValue("plugin_id", plugin.Info.Id);
cmd.Parameters.AddWithValue("name", plugin.Info.Name);
cmd.Parameters.AddWithValue("version", plugin.Info.Version);
cmd.Parameters.AddWithValue("vendor", plugin.Info.Vendor);
cmd.Parameters.AddWithValue("description", (object?)plugin.Info.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("license_id", (object?)plugin.Info.LicenseId ?? DBNull.Value);
cmd.Parameters.AddWithValue("trust_level", plugin.TrustLevel.ToString().ToLowerInvariant());
cmd.Parameters.AddWithValue("capabilities", plugin.Capabilities.ToStringArray());
cmd.Parameters.AddWithValue("capability_details", JsonSerializer.Serialize(new { }));
cmd.Parameters.AddWithValue("source", "installed");
cmd.Parameters.AddWithValue("assembly_path", (object?)plugin.Manifest?.AssemblyPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("entry_point", (object?)plugin.Manifest?.EntryPoint ?? DBNull.Value);
cmd.Parameters.AddWithValue("status", plugin.State.ToString().ToLowerInvariant());
cmd.Parameters.AddWithValue("manifest", plugin.Manifest != null
? JsonSerializer.Serialize(plugin.Manifest)
: DBNull.Value);
cmd.Parameters.AddWithValue("now", now);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
var record = MapPluginRecord(reader);
// Register capabilities
if (plugin.Manifest?.Capabilities.Count > 0)
{
var capRecords = plugin.Manifest.Capabilities.Select(c => new PluginCapabilityRecord
{
Id = Guid.NewGuid(),
PluginId = record.Id,
CapabilityType = c.Type,
CapabilityId = c.Id ?? c.Type,
ConfigSchema = c.ConfigSchema,
Metadata = c.Metadata,
IsEnabled = true,
CreatedAt = now
});
await RegisterCapabilitiesAsync(record.Id, capRecords, ct);
}
_logger.LogDebug("Registered plugin {PluginId} with DB ID {DbId}", plugin.Info.Id, record.Id);
return record;
}
throw new InvalidOperationException($"Failed to register plugin {plugin.Info.Id}");
}
public async Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default)
{
const string sql = """
UPDATE platform.plugins
SET status = @status, status_message = @message, updated_at = @now
WHERE plugin_id = @plugin_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("plugin_id", pluginId);
cmd.Parameters.AddWithValue("status", status.ToString().ToLowerInvariant());
cmd.Parameters.AddWithValue("message", (object?)message ?? DBNull.Value);
cmd.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default)
{
const string sql = """
UPDATE platform.plugins
SET health_status = @health_status, last_health_check = @now, updated_at = @now
WHERE plugin_id = @plugin_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
var now = _timeProvider.GetUtcNow();
cmd.Parameters.AddWithValue("plugin_id", pluginId);
cmd.Parameters.AddWithValue("health_status", status.ToString().ToLowerInvariant());
cmd.Parameters.AddWithValue("now", now);
await cmd.ExecuteNonQueryAsync(ct);
// Record health history
if (result != null)
{
await RecordHealthCheckAsync(pluginId, result, ct);
}
}
public async Task UnregisterAsync(string pluginId, CancellationToken ct)
{
const string sql = "DELETE FROM platform.plugins WHERE plugin_id = @plugin_id";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("plugin_id", pluginId);
await cmd.ExecuteNonQueryAsync(ct);
_logger.LogDebug("Unregistered plugin {PluginId}", pluginId);
}
public async Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct)
{
const string sql = "SELECT * FROM platform.plugins WHERE plugin_id = @plugin_id";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("plugin_id", pluginId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? MapPluginRecord(reader) : null;
}
public async Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct)
{
const string sql = "SELECT * FROM platform.plugins ORDER BY name";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
var results = new List<PluginRecord>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapPluginRecord(reader));
}
return results;
}
public async Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct)
{
var capabilityStrings = capability.ToStringArray();
const string sql = """
SELECT * FROM platform.plugins
WHERE capabilities && @capabilities
AND status = 'active'
ORDER BY name
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("capabilities", capabilityStrings);
var results = new List<PluginRecord>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapPluginRecord(reader));
}
return results;
}
public async Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(
string capabilityType,
string? capabilityId = null,
CancellationToken ct = default)
{
var sql = """
SELECT p.* FROM platform.plugins p
INNER JOIN platform.plugin_capabilities c ON c.plugin_id = p.id
WHERE c.capability_type = @capability_type
AND c.is_enabled = TRUE
AND p.status = 'active'
""";
if (capabilityId != null)
{
sql += " AND c.capability_id = @capability_id";
}
sql += " ORDER BY p.name";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("capability_type", capabilityType);
if (capabilityId != null)
{
cmd.Parameters.AddWithValue("capability_id", capabilityId);
}
var results = new List<PluginRecord>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapPluginRecord(reader));
}
return results;
}
public async Task RegisterCapabilitiesAsync(
Guid pluginDbId,
IEnumerable<PluginCapabilityRecord> capabilities,
CancellationToken ct)
{
const string sql = """
INSERT INTO platform.plugin_capabilities (
id, plugin_id, capability_type, capability_id,
config_schema, metadata, is_enabled, created_at
) VALUES (
@id, @plugin_id, @capability_type, @capability_id,
@config_schema, @metadata, @is_enabled, @created_at
)
ON CONFLICT (plugin_id, capability_type, capability_id) DO UPDATE SET
config_schema = @config_schema,
metadata = @metadata
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var batch = new NpgsqlBatch(conn);
foreach (var cap in capabilities)
{
var cmd = new NpgsqlBatchCommand(sql);
cmd.Parameters.AddWithValue("id", cap.Id);
cmd.Parameters.AddWithValue("plugin_id", pluginDbId);
cmd.Parameters.AddWithValue("capability_type", cap.CapabilityType);
cmd.Parameters.AddWithValue("capability_id", cap.CapabilityId);
cmd.Parameters.AddWithValue("config_schema", cap.ConfigSchema != null
? JsonSerializer.Serialize(cap.ConfigSchema)
: DBNull.Value);
cmd.Parameters.AddWithValue("metadata", cap.Metadata != null
? JsonSerializer.Serialize(cap.Metadata)
: DBNull.Value);
cmd.Parameters.AddWithValue("is_enabled", cap.IsEnabled);
cmd.Parameters.AddWithValue("created_at", cap.CreatedAt);
batch.BatchCommands.Add(cmd);
}
await batch.ExecuteNonQueryAsync(ct);
}
// ========== Instance Management ==========
public async Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct)
{
const string sql = """
INSERT INTO platform.plugin_instances (
plugin_id, tenant_id, instance_name, config, secrets_path,
resource_limits, enabled, status, created_at, updated_at
)
SELECT p.id, @tenant_id, @instance_name, @config, @secrets_path,
@resource_limits, TRUE, 'pending', @now, @now
FROM platform.plugins p
WHERE p.plugin_id = @plugin_id
RETURNING *
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
var now = _timeProvider.GetUtcNow();
cmd.Parameters.AddWithValue("plugin_id", request.PluginId);
cmd.Parameters.AddWithValue("tenant_id", (object?)request.TenantId ?? DBNull.Value);
cmd.Parameters.AddWithValue("instance_name", (object?)request.InstanceName ?? DBNull.Value);
cmd.Parameters.AddWithValue("config", JsonSerializer.Serialize(request.Config));
cmd.Parameters.AddWithValue("secrets_path", (object?)request.SecretsPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("resource_limits", request.ResourceLimits != null
? JsonSerializer.Serialize(request.ResourceLimits)
: DBNull.Value);
cmd.Parameters.AddWithValue("now", now);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return MapInstanceRecord(reader);
}
throw new InvalidOperationException($"Failed to create instance for plugin {request.PluginId}");
}
public async Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct)
{
const string sql = """
INSERT INTO platform.plugin_health_history (
plugin_id, checked_at, status, response_time_ms, details, created_at
)
SELECT p.id, @checked_at, @status, @response_time_ms, @details, @checked_at
FROM platform.plugins p
WHERE p.plugin_id = @plugin_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("plugin_id", pluginId);
cmd.Parameters.AddWithValue("checked_at", _timeProvider.GetUtcNow());
cmd.Parameters.AddWithValue("status", result.Status.ToString().ToLowerInvariant());
cmd.Parameters.AddWithValue("response_time_ms", result.Duration?.TotalMilliseconds ?? 0);
cmd.Parameters.AddWithValue("details", result.Details != null
? JsonSerializer.Serialize(result.Details)
: DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
// ... additional method implementations ...
private static PluginRecord MapPluginRecord(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
PluginId = reader.GetString(reader.GetOrdinal("plugin_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
Version = reader.GetString(reader.GetOrdinal("version")),
Vendor = reader.GetString(reader.GetOrdinal("vendor")),
Description = reader.IsDBNull(reader.GetOrdinal("description")) ? null : reader.GetString(reader.GetOrdinal("description")),
TrustLevel = Enum.Parse<PluginTrustLevel>(reader.GetString(reader.GetOrdinal("trust_level")), ignoreCase: true),
Capabilities = PluginCapabilitiesExtensions.FromStringArray(reader.GetFieldValue<string[]>(reader.GetOrdinal("capabilities"))),
Status = Enum.Parse<PluginLifecycleState>(reader.GetString(reader.GetOrdinal("status")), ignoreCase: true),
HealthStatus = reader.IsDBNull(reader.GetOrdinal("health_status"))
? HealthStatus.Unknown
: Enum.Parse<HealthStatus>(reader.GetString(reader.GetOrdinal("health_status")), ignoreCase: true),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
LoadedAt = reader.IsDBNull(reader.GetOrdinal("loaded_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("loaded_at"))
};
private static PluginInstanceRecord MapInstanceRecord(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
PluginId = reader.GetGuid(reader.GetOrdinal("plugin_id")),
TenantId = reader.IsDBNull(reader.GetOrdinal("tenant_id")) ? null : reader.GetGuid(reader.GetOrdinal("tenant_id")),
InstanceName = reader.IsDBNull(reader.GetOrdinal("instance_name")) ? null : reader.GetString(reader.GetOrdinal("instance_name")),
Config = JsonDocument.Parse(reader.GetString(reader.GetOrdinal("config"))),
SecretsPath = reader.IsDBNull(reader.GetOrdinal("secrets_path")) ? null : reader.GetString(reader.GetOrdinal("secrets_path")),
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
Status = reader.GetString(reader.GetOrdinal("status")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
};
}
```
### Database Migration
```sql
-- Migrations/001_CreatePluginTables.sql
-- Plugin registry table
CREATE TABLE IF NOT EXISTS platform.plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL,
vendor VARCHAR(255) NOT NULL,
description TEXT,
license_id VARCHAR(50),
-- Trust and security
trust_level VARCHAR(50) NOT NULL CHECK (trust_level IN ('builtin', 'trusted', 'untrusted')),
signature BYTEA,
signing_key_id VARCHAR(255),
-- Capabilities
capabilities TEXT[] NOT NULL DEFAULT '{}',
capability_details JSONB NOT NULL DEFAULT '{}',
-- Source and deployment
source VARCHAR(50) NOT NULL CHECK (source IN ('bundled', 'installed', 'discovered')),
assembly_path VARCHAR(500),
entry_point VARCHAR(255),
-- Lifecycle
status VARCHAR(50) NOT NULL DEFAULT 'discovered' CHECK (status IN (
'discovered', 'loading', 'initializing', 'active',
'degraded', 'stopping', 'stopped', 'failed', 'unloading'
)),
status_message TEXT,
-- Health
health_status VARCHAR(50) DEFAULT 'unknown' CHECK (health_status IN (
'unknown', 'healthy', 'degraded', 'unhealthy'
)),
last_health_check TIMESTAMPTZ,
health_check_failures INT NOT NULL DEFAULT 0,
-- Metadata
manifest JSONB,
runtime_info JSONB,
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
loaded_at TIMESTAMPTZ,
UNIQUE(plugin_id, version)
);
-- Plugin capabilities
CREATE TABLE IF NOT EXISTS platform.plugin_capabilities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
capability_type VARCHAR(100) NOT NULL,
capability_id VARCHAR(255) NOT NULL,
config_schema JSONB,
input_schema JSONB,
output_schema JSONB,
display_name VARCHAR(255),
description TEXT,
documentation_url VARCHAR(500),
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(plugin_id, capability_type, capability_id)
);
-- Plugin instances (for multi-tenant)
CREATE TABLE IF NOT EXISTS platform.plugin_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES platform.tenants(id) ON DELETE CASCADE,
instance_name VARCHAR(255),
config JSONB NOT NULL DEFAULT '{}',
secrets_path VARCHAR(500),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
resource_limits JSONB,
last_used_at TIMESTAMPTZ,
invocation_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(plugin_id, tenant_id, COALESCE(instance_name, ''))
);
-- Plugin health history (partitioned)
CREATE TABLE IF NOT EXISTS platform.plugin_health_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(50) NOT NULL,
response_time_ms INT,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
-- Create partitions for health history (last 30 days)
CREATE TABLE IF NOT EXISTS platform.plugin_health_history_current
PARTITION OF platform.plugin_health_history
FOR VALUES FROM (CURRENT_DATE - INTERVAL '30 days') TO (CURRENT_DATE + INTERVAL '1 day');
-- Indexes
CREATE INDEX IF NOT EXISTS idx_plugins_plugin_id ON platform.plugins(plugin_id);
CREATE INDEX IF NOT EXISTS idx_plugins_status ON platform.plugins(status) WHERE status != 'active';
CREATE INDEX IF NOT EXISTS idx_plugins_trust_level ON platform.plugins(trust_level);
CREATE INDEX IF NOT EXISTS idx_plugins_capabilities ON platform.plugins USING GIN (capabilities);
CREATE INDEX IF NOT EXISTS idx_plugins_health ON platform.plugins(health_status) WHERE health_status != 'healthy';
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_type ON platform.plugin_capabilities(capability_type);
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_lookup ON platform.plugin_capabilities(capability_type, capability_id);
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_plugin ON platform.plugin_capabilities(plugin_id);
CREATE INDEX IF NOT EXISTS idx_plugin_instances_tenant ON platform.plugin_instances(tenant_id) WHERE tenant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_plugin_instances_plugin ON platform.plugin_instances(plugin_id);
CREATE INDEX IF NOT EXISTS idx_plugin_instances_enabled ON platform.plugin_instances(plugin_id, enabled) WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_plugin_health_history_plugin ON platform.plugin_health_history(plugin_id, checked_at DESC);
```
---
## Acceptance Criteria
- [ ] `IPluginRegistry` interface with all methods
- [ ] PostgreSQL implementation
- [ ] Plugin registration and unregistration
- [ ] Status updates
- [ ] Health updates and history
- [ ] Capability registration and queries
- [ ] Capability type/id lookup
- [ ] Instance creation
- [ ] Instance configuration updates
- [ ] Instance enable/disable
- [ ] Tenant-scoped instance queries
- [ ] Database migration scripts
- [ ] Partitioned health history table
- [ ] Integration tests with PostgreSQL
- [ ] Test coverage >= 80%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 100_001 Plugin Abstractions | Internal | TODO |
| 100_002 Plugin Host | Internal | TODO |
| PostgreSQL 16+ | External | Available |
| Npgsql 8.x | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IPluginRegistry interface | TODO | |
| PostgresPluginRegistry | TODO | |
| PluginRecord model | TODO | |
| PluginCapabilityRecord model | TODO | |
| PluginInstanceRecord model | TODO | |
| PluginHealthRecord model | TODO | |
| Database migration | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,421 @@
# SPRINT: Crypto Plugin Rework
> **Sprint ID:** 100_005
> **Module:** PLUGIN
> **Phase:** 100 - Plugin System Unification
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_plugin_unification.md)
---
## Overview
Rework all cryptographic providers (GOST, eIDAS, SM2/SM3/SM4, FIPS, HSM) to implement the unified plugin architecture with `IPlugin` and `ICryptoCapability` interfaces.
### Objectives
- Migrate GOST provider to unified plugin model
- Migrate eIDAS provider to unified plugin model
- Migrate SM2/SM3/SM4 provider to unified plugin model
- Migrate FIPS provider to unified plugin model
- Migrate HSM integration to unified plugin model
- Preserve all existing functionality
- Add health checks for all providers
- Add plugin manifests
### Current State
```
src/Cryptography/
├── StellaOps.Cryptography.Gost/ # GOST R 34.10-2012, R 34.11-2012
├── StellaOps.Cryptography.Eidas/ # EU eIDAS qualified signatures
├── StellaOps.Cryptography.Sm/ # Chinese SM2/SM3/SM4
├── StellaOps.Cryptography.Fips/ # US FIPS 140-2 compliant
└── StellaOps.Cryptography.Hsm/ # Hardware Security Module integration
```
### Target State
```
src/Cryptography/
├── StellaOps.Cryptography.Plugin.Gost/
│ ├── GostPlugin.cs # IPlugin implementation
│ ├── GostCryptoCapability.cs # ICryptoCapability implementation
│ ├── plugin.yaml # Plugin manifest
│ └── ...
├── StellaOps.Cryptography.Plugin.Eidas/
├── StellaOps.Cryptography.Plugin.Sm/
├── StellaOps.Cryptography.Plugin.Fips/
└── StellaOps.Cryptography.Plugin.Hsm/
```
---
## Deliverables
### GOST Plugin Implementation
```csharp
// GostPlugin.cs
namespace StellaOps.Cryptography.Plugin.Gost;
[Plugin(
id: "com.stellaops.crypto.gost",
name: "GOST Cryptography Provider",
version: "1.0.0",
vendor: "Stella Ops")]
[ProvidesCapability(PluginCapabilities.Crypto, CapabilityId = "gost")]
public sealed class GostPlugin : IPlugin, ICryptoCapability
{
private IPluginContext? _context;
private GostCryptoService? _cryptoService;
public PluginInfo Info => new(
Id: "com.stellaops.crypto.gost",
Name: "GOST Cryptography Provider",
Version: "1.0.0",
Vendor: "Stella Ops",
Description: "Russian GOST R 34.10-2012 and R 34.11-2012 cryptographic algorithms",
LicenseId: "AGPL-3.0-or-later");
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
public PluginCapabilities Capabilities => PluginCapabilities.Crypto;
public PluginLifecycleState State { get; private set; } = PluginLifecycleState.Discovered;
// ICryptoCapability implementation
public IReadOnlyList<string> SupportedAlgorithms => new[]
{
"GOST-R34.10-2012-256",
"GOST-R34.10-2012-512",
"GOST-R34.11-2012-256",
"GOST-R34.11-2012-512",
"GOST-28147-89"
};
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
{
_context = context;
State = PluginLifecycleState.Initializing;
try
{
var options = context.Configuration.Bind<GostOptions>();
_cryptoService = new GostCryptoService(options, context.Logger);
await _cryptoService.InitializeAsync(ct);
State = PluginLifecycleState.Active;
context.Logger.Info("GOST cryptography provider initialized");
}
catch (Exception ex)
{
State = PluginLifecycleState.Failed;
context.Logger.Error(ex, "Failed to initialize GOST provider");
throw;
}
}
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
if (_cryptoService == null)
return HealthCheckResult.Unhealthy("Provider not initialized");
try
{
// Verify we can perform a test operation
var testData = "test"u8.ToArray();
var hash = await HashAsync(testData, "GOST-R34.11-2012-256", ct);
if (hash.Length != 32)
return HealthCheckResult.Degraded("Hash output size mismatch");
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex);
}
}
public bool CanHandle(CryptoOperation operation, string algorithm)
{
return algorithm.StartsWith("GOST", StringComparison.OrdinalIgnoreCase) &&
SupportedAlgorithms.Contains(algorithm, StringComparer.OrdinalIgnoreCase);
}
public async Task<byte[]> SignAsync(
ReadOnlyMemory<byte> data,
CryptoSignOptions options,
CancellationToken ct)
{
EnsureInitialized();
_context!.Logger.Debug("Signing with algorithm {Algorithm}", options.Algorithm);
return await _cryptoService!.SignAsync(
data,
options.Algorithm,
options.KeyId,
options.KeyVersion,
ct);
}
public async Task<bool> VerifyAsync(
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CryptoVerifyOptions options,
CancellationToken ct)
{
EnsureInitialized();
return await _cryptoService!.VerifyAsync(
data,
signature,
options.Algorithm,
options.KeyId,
options.CertificateChain,
ct);
}
public async Task<byte[]> EncryptAsync(
ReadOnlyMemory<byte> data,
CryptoEncryptOptions options,
CancellationToken ct)
{
EnsureInitialized();
if (!options.Algorithm.Contains("28147", StringComparison.Ordinal))
throw new NotSupportedException($"Encryption not supported for {options.Algorithm}");
return await _cryptoService!.EncryptAsync(
data,
options.KeyId,
options.Iv,
ct);
}
public async Task<byte[]> DecryptAsync(
ReadOnlyMemory<byte> data,
CryptoDecryptOptions options,
CancellationToken ct)
{
EnsureInitialized();
return await _cryptoService!.DecryptAsync(
data,
options.KeyId,
options.Iv,
ct);
}
public async Task<byte[]> HashAsync(
ReadOnlyMemory<byte> data,
string algorithm,
CancellationToken ct)
{
EnsureInitialized();
return await _cryptoService!.HashAsync(data, algorithm, ct);
}
private void EnsureInitialized()
{
if (State != PluginLifecycleState.Active || _cryptoService == null)
throw new InvalidOperationException("GOST provider is not initialized");
}
public async ValueTask DisposeAsync()
{
if (_cryptoService != null)
{
await _cryptoService.DisposeAsync();
_cryptoService = null;
}
State = PluginLifecycleState.Stopped;
}
}
```
### Plugin Manifest
```yaml
# plugin.yaml
plugin:
id: com.stellaops.crypto.gost
name: GOST Cryptography Provider
version: 1.0.0
vendor: Stella Ops
description: Russian GOST R 34.10-2012 and R 34.11-2012 cryptographic algorithms
license: AGPL-3.0-or-later
entryPoint: StellaOps.Cryptography.Plugin.Gost.GostPlugin
minPlatformVersion: 1.0.0
capabilities:
- type: crypto
id: gost
algorithms:
- GOST-R34.10-2012-256
- GOST-R34.10-2012-512
- GOST-R34.11-2012-256
- GOST-R34.11-2012-512
- GOST-28147-89
configSchema:
type: object
properties:
keyStorePath:
type: string
description: Path to GOST key store
defaultKeyId:
type: string
description: Default key identifier for signing
required: []
```
### Shared Crypto Base Class
```csharp
// CryptoPluginBase.cs
namespace StellaOps.Cryptography.Plugin;
/// <summary>
/// Base class for crypto plugins with common functionality.
/// </summary>
public abstract class CryptoPluginBase : IPlugin, ICryptoCapability
{
protected IPluginContext? Context { get; private set; }
public abstract PluginInfo Info { get; }
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
public PluginCapabilities Capabilities => PluginCapabilities.Crypto;
public PluginLifecycleState State { get; protected set; } = PluginLifecycleState.Discovered;
public abstract IReadOnlyList<string> SupportedAlgorithms { get; }
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
{
Context = context;
State = PluginLifecycleState.Initializing;
try
{
await InitializeCryptoServiceAsync(context, ct);
State = PluginLifecycleState.Active;
context.Logger.Info("{PluginName} initialized", Info.Name);
}
catch (Exception ex)
{
State = PluginLifecycleState.Failed;
context.Logger.Error(ex, "Failed to initialize {PluginName}", Info.Name);
throw;
}
}
protected abstract Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct);
public virtual async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
if (State != PluginLifecycleState.Active)
return HealthCheckResult.Unhealthy($"Plugin is in state {State}");
try
{
// Default health check: verify we can hash test data
var testData = "health-check-test"u8.ToArray();
var algorithm = SupportedAlgorithms.FirstOrDefault(a => a.Contains("256") || a.Contains("SHA"));
if (algorithm != null)
{
await HashAsync(testData, algorithm, ct);
}
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex);
}
}
public abstract bool CanHandle(CryptoOperation operation, string algorithm);
public abstract Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct);
public abstract Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct);
public abstract Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct);
public abstract Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct);
public abstract Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct);
public abstract ValueTask DisposeAsync();
protected void EnsureActive()
{
if (State != PluginLifecycleState.Active)
throw new InvalidOperationException($"{Info.Name} is not active (state: {State})");
}
}
```
### Migration Tasks
| Provider | Current Interface | New Implementation | Status |
|----------|-------------------|-------------------|--------|
| GOST | `ICryptoProvider` | `GostPlugin : IPlugin, ICryptoCapability` | TODO |
| eIDAS | `ICryptoProvider` | `EidasPlugin : IPlugin, ICryptoCapability` | TODO |
| SM2/SM3/SM4 | `ICryptoProvider` | `SmPlugin : IPlugin, ICryptoCapability` | TODO |
| FIPS | `ICryptoProvider` | `FipsPlugin : IPlugin, ICryptoCapability` | TODO |
| HSM | `IHsmProvider` | `HsmPlugin : IPlugin, ICryptoCapability` | TODO |
---
## Acceptance Criteria
- [ ] All 5 crypto providers implement `IPlugin`
- [ ] All 5 crypto providers implement `ICryptoCapability`
- [ ] All providers have plugin manifests
- [ ] All existing crypto operations preserved
- [ ] Health checks implemented for all providers
- [ ] All providers discoverable by plugin host
- [ ] All providers register in plugin registry
- [ ] Backward-compatible configuration
- [ ] Unit tests migrated/updated
- [ ] Integration tests passing
- [ ] Performance benchmarks comparable to original
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 100_001 Plugin Abstractions | Internal | TODO |
| 100_002 Plugin Host | Internal | TODO |
| 100_003 Plugin Registry | Internal | TODO |
| BouncyCastle | External | Available |
| CryptoPro SDK | External | Available (GOST) |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| GostPlugin | TODO | |
| EidasPlugin | TODO | |
| SmPlugin | TODO | |
| FipsPlugin | TODO | |
| HsmPlugin | TODO | |
| CryptoPluginBase | TODO | |
| Plugin manifests (5) | TODO | |
| Unit tests | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,455 @@
# SPRINT: Auth Plugin Rework
> **Sprint ID:** 100_006
> **Module:** PLUGIN
> **Phase:** 100 - Plugin System Unification
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_plugin_unification.md)
---
## Overview
Rework all authentication providers (LDAP, OIDC, SAML, Workforce Identity) to implement the unified plugin architecture with `IPlugin` and `IAuthCapability` interfaces.
### Objectives
- Migrate LDAP provider to unified plugin model
- Migrate OIDC providers (Azure AD, Okta, Google, etc.) to unified plugin model
- Migrate SAML provider to unified plugin model
- Migrate Workforce Identity provider to unified plugin model
- Preserve all existing authentication flows
- Add health checks for all providers
- Add plugin manifests
### Current State
```
src/Authority/
├── __Plugins/
│ ├── StellaOps.Authority.Plugin.Ldap/
│ ├── StellaOps.Authority.Plugin.Oidc/
│ └── StellaOps.Authority.Plugin.Saml/
└── __Libraries/
└── StellaOps.Authority.Identity/
```
### Target State
Each auth plugin implements:
- `IPlugin` - Core plugin interface with lifecycle
- `IAuthCapability` - Authentication/authorization operations
- Health checks for connectivity
- Plugin manifest for discovery
---
## Deliverables
### Auth Capability Interface
```csharp
// IAuthCapability.cs (added to 100_001 Abstractions)
namespace StellaOps.Plugin.Abstractions.Capabilities;
/// <summary>
/// Capability interface for authentication and authorization.
/// </summary>
public interface IAuthCapability
{
/// <summary>
/// Auth provider type (ldap, oidc, saml, workforce).
/// </summary>
string ProviderType { get; }
/// <summary>
/// Supported authentication methods.
/// </summary>
IReadOnlyList<string> SupportedMethods { get; }
/// <summary>
/// Authenticate a user with credentials.
/// </summary>
Task<AuthResult> AuthenticateAsync(AuthRequest request, CancellationToken ct);
/// <summary>
/// Validate an existing token/session.
/// </summary>
Task<ValidationResult> ValidateTokenAsync(string token, CancellationToken ct);
/// <summary>
/// Get user information.
/// </summary>
Task<UserInfo?> GetUserInfoAsync(string userId, CancellationToken ct);
/// <summary>
/// Get user's group memberships.
/// </summary>
Task<IReadOnlyList<GroupInfo>> GetUserGroupsAsync(string userId, CancellationToken ct);
/// <summary>
/// Check if user has specific permission.
/// </summary>
Task<bool> HasPermissionAsync(string userId, string permission, CancellationToken ct);
/// <summary>
/// Initiate SSO flow (for OIDC/SAML).
/// </summary>
Task<SsoInitiation?> InitiateSsoAsync(SsoRequest request, CancellationToken ct);
/// <summary>
/// Complete SSO callback.
/// </summary>
Task<AuthResult> CompleteSsoAsync(SsoCallback callback, CancellationToken ct);
}
public sealed record AuthRequest(
string Method,
string? Username,
string? Password,
string? Token,
IReadOnlyDictionary<string, string>? AdditionalData);
public sealed record AuthResult(
bool Success,
string? UserId,
string? AccessToken,
string? RefreshToken,
DateTimeOffset? ExpiresAt,
IReadOnlyList<string>? Roles,
string? Error);
public sealed record ValidationResult(
bool Valid,
string? UserId,
DateTimeOffset? ExpiresAt,
IReadOnlyList<string>? Claims,
string? Error);
public sealed record UserInfo(
string Id,
string Username,
string? Email,
string? DisplayName,
IReadOnlyDictionary<string, string>? Attributes);
public sealed record GroupInfo(
string Id,
string Name,
string? Description);
public sealed record SsoRequest(
string RedirectUri,
string? State,
IReadOnlyList<string>? Scopes);
public sealed record SsoInitiation(
string AuthorizationUrl,
string State,
string? CodeVerifier);
public sealed record SsoCallback(
string? Code,
string? State,
string? Error,
string? CodeVerifier);
```
### LDAP Plugin Implementation
```csharp
// LdapPlugin.cs
namespace StellaOps.Authority.Plugin.Ldap;
[Plugin(
id: "com.stellaops.auth.ldap",
name: "LDAP Authentication Provider",
version: "1.0.0",
vendor: "Stella Ops")]
[ProvidesCapability(PluginCapabilities.Auth, CapabilityId = "ldap")]
public sealed class LdapPlugin : IPlugin, IAuthCapability
{
private IPluginContext? _context;
private LdapConnection? _connection;
private LdapOptions? _options;
public PluginInfo Info => new(
Id: "com.stellaops.auth.ldap",
Name: "LDAP Authentication Provider",
Version: "1.0.0",
Vendor: "Stella Ops",
Description: "LDAP/Active Directory authentication and user lookup");
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
public PluginCapabilities Capabilities => PluginCapabilities.Auth | PluginCapabilities.Network;
public PluginLifecycleState State { get; private set; } = PluginLifecycleState.Discovered;
public string ProviderType => "ldap";
public IReadOnlyList<string> SupportedMethods => new[] { "password", "kerberos" };
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
{
_context = context;
State = PluginLifecycleState.Initializing;
_options = context.Configuration.Bind<LdapOptions>();
// Test connection
_connection = new LdapConnection(new LdapDirectoryIdentifier(_options.Server, _options.Port));
_connection.Credential = new NetworkCredential(_options.BindDn, _options.BindPassword);
_connection.AuthType = AuthType.Basic;
_connection.SessionOptions.SecureSocketLayer = _options.UseSsl;
await Task.Run(() => _connection.Bind(), ct);
State = PluginLifecycleState.Active;
context.Logger.Info("LDAP plugin connected to {Server}", _options.Server);
}
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
if (_connection == null)
return HealthCheckResult.Unhealthy("Not initialized");
try
{
// Perform a simple search to verify connectivity
var request = new SearchRequest(
_options!.BaseDn,
"(objectClass=*)",
SearchScope.Base,
"objectClass");
var response = await Task.Run(() =>
(SearchResponse)_connection.SendRequest(request), ct);
return response.Entries.Count > 0
? HealthCheckResult.Healthy()
: HealthCheckResult.Degraded("Base DN search returned no results");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex);
}
}
public async Task<AuthResult> AuthenticateAsync(AuthRequest request, CancellationToken ct)
{
if (request.Method != "password" || string.IsNullOrEmpty(request.Username))
return new AuthResult(false, null, null, null, null, null, "Invalid auth method or missing username");
try
{
// Find user DN
var userDn = await FindUserDnAsync(request.Username, ct);
if (userDn == null)
return new AuthResult(false, null, null, null, null, null, "User not found");
// Attempt bind with user credentials
using var userConnection = new LdapConnection(
new LdapDirectoryIdentifier(_options!.Server, _options.Port));
userConnection.Credential = new NetworkCredential(userDn, request.Password);
await Task.Run(() => userConnection.Bind(), ct);
// Get user info and groups
var userInfo = await GetUserInfoAsync(request.Username, ct);
var groups = await GetUserGroupsAsync(request.Username, ct);
return new AuthResult(
Success: true,
UserId: request.Username,
AccessToken: null, // LDAP doesn't issue tokens
RefreshToken: null,
ExpiresAt: null,
Roles: groups.Select(g => g.Name).ToList(),
Error: null);
}
catch (LdapException ex)
{
_context?.Logger.Warning(ex, "LDAP authentication failed for {Username}", request.Username);
return new AuthResult(false, null, null, null, null, null, "Authentication failed");
}
}
public Task<ValidationResult> ValidateTokenAsync(string token, CancellationToken ct)
{
// LDAP doesn't use tokens
return Task.FromResult(new ValidationResult(false, null, null, null, "LDAP does not support token validation"));
}
public async Task<UserInfo?> GetUserInfoAsync(string userId, CancellationToken ct)
{
var userDn = await FindUserDnAsync(userId, ct);
if (userDn == null) return null;
var request = new SearchRequest(
userDn,
"(objectClass=*)",
SearchScope.Base,
"uid", "mail", "displayName", "cn", "sn", "givenName");
var response = await Task.Run(() =>
(SearchResponse)_connection!.SendRequest(request), ct);
if (response.Entries.Count == 0) return null;
var entry = response.Entries[0];
return new UserInfo(
Id: userId,
Username: GetAttribute(entry, "uid") ?? userId,
Email: GetAttribute(entry, "mail"),
DisplayName: GetAttribute(entry, "displayName") ?? GetAttribute(entry, "cn"),
Attributes: entry.Attributes.Cast<DirectoryAttribute>()
.ToDictionary(a => a.Name, a => a[0]?.ToString() ?? ""));
}
public async Task<IReadOnlyList<GroupInfo>> GetUserGroupsAsync(string userId, CancellationToken ct)
{
var userDn = await FindUserDnAsync(userId, ct);
if (userDn == null) return Array.Empty<GroupInfo>();
var request = new SearchRequest(
_options!.GroupBaseDn ?? _options.BaseDn,
$"(member={userDn})",
SearchScope.Subtree,
"cn", "description");
var response = await Task.Run(() =>
(SearchResponse)_connection!.SendRequest(request), ct);
return response.Entries.Cast<SearchResultEntry>()
.Select(e => new GroupInfo(
Id: e.DistinguishedName,
Name: GetAttribute(e, "cn") ?? e.DistinguishedName,
Description: GetAttribute(e, "description")))
.ToList();
}
public async Task<bool> HasPermissionAsync(string userId, string permission, CancellationToken ct)
{
var groups = await GetUserGroupsAsync(userId, ct);
// Permission checking would be based on group membership
return groups.Any(g => g.Name.Equals(permission, StringComparison.OrdinalIgnoreCase));
}
public Task<SsoInitiation?> InitiateSsoAsync(SsoRequest request, CancellationToken ct)
{
// LDAP doesn't support SSO initiation
return Task.FromResult<SsoInitiation?>(null);
}
public Task<AuthResult> CompleteSsoAsync(SsoCallback callback, CancellationToken ct)
{
return Task.FromResult(new AuthResult(false, null, null, null, null, null, "LDAP does not support SSO"));
}
private async Task<string?> FindUserDnAsync(string username, CancellationToken ct)
{
var filter = string.Format(_options!.UserFilter, username);
var request = new SearchRequest(
_options.BaseDn,
filter,
SearchScope.Subtree,
"distinguishedName");
var response = await Task.Run(() =>
(SearchResponse)_connection!.SendRequest(request), ct);
return response.Entries.Count > 0 ? response.Entries[0].DistinguishedName : null;
}
private static string? GetAttribute(SearchResultEntry entry, string name)
{
return entry.Attributes[name]?[0]?.ToString();
}
public async ValueTask DisposeAsync()
{
_connection?.Dispose();
_connection = null;
State = PluginLifecycleState.Stopped;
}
}
public sealed class LdapOptions
{
public string Server { get; set; } = "localhost";
public int Port { get; set; } = 389;
public bool UseSsl { get; set; } = false;
public string BaseDn { get; set; } = "";
public string? GroupBaseDn { get; set; }
public string BindDn { get; set; } = "";
public string BindPassword { get; set; } = "";
public string UserFilter { get; set; } = "(uid={0})";
}
```
### Migration Tasks
| Provider | Current Interface | New Implementation | Status |
|----------|-------------------|-------------------|--------|
| LDAP | Authority plugin interfaces | `LdapPlugin : IPlugin, IAuthCapability` | TODO |
| OIDC Generic | Authority plugin interfaces | `OidcPlugin : IPlugin, IAuthCapability` | TODO |
| Azure AD | Authority plugin interfaces | `AzureAdPlugin : OidcPlugin` | TODO |
| Okta | Authority plugin interfaces | `OktaPlugin : OidcPlugin` | TODO |
| Google | Authority plugin interfaces | `GooglePlugin : OidcPlugin` | TODO |
| SAML | Authority plugin interfaces | `SamlPlugin : IPlugin, IAuthCapability` | TODO |
| Workforce | Authority plugin interfaces | `WorkforcePlugin : IPlugin, IAuthCapability` | TODO |
---
## Acceptance Criteria
- [ ] All auth providers implement `IPlugin`
- [ ] All auth providers implement `IAuthCapability`
- [ ] All providers have plugin manifests
- [ ] LDAP bind/search operations work
- [ ] OIDC authorization flow works
- [ ] OIDC token validation works
- [ ] SAML assertion handling works
- [ ] SSO initiation/completion works
- [ ] User info retrieval works
- [ ] Group membership queries work
- [ ] Health checks for all providers
- [ ] Unit tests migrated/updated
- [ ] Integration tests passing
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 100_001 Plugin Abstractions | Internal | TODO |
| 100_002 Plugin Host | Internal | TODO |
| 100_003 Plugin Registry | Internal | TODO |
| System.DirectoryServices.Protocols | External | Available |
| Microsoft.IdentityModel.* | External | Available |
| ITfoxtec.Identity.Saml2 | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IAuthCapability interface | TODO | |
| LdapPlugin | TODO | |
| OidcPlugin (base) | TODO | |
| AzureAdPlugin | TODO | |
| OktaPlugin | TODO | |
| GooglePlugin | TODO | |
| SamlPlugin | TODO | |
| WorkforcePlugin | TODO | |
| Plugin manifests | TODO | |
| Unit tests | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,453 @@
# SPRINT: LLM Provider Rework
> **Sprint ID:** 100_007
> **Module:** PLUGIN
> **Phase:** 100 - Plugin System Unification
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_plugin_unification.md)
---
## Overview
Rework all LLM providers (llama-server, ollama, OpenAI, Claude) to implement the unified plugin architecture with `IPlugin` and `ILlmCapability` interfaces.
### Objectives
- Migrate llama-server provider to unified plugin model
- Migrate ollama provider to unified plugin model
- Migrate OpenAI provider to unified plugin model
- Migrate Claude provider to unified plugin model
- Preserve priority-based provider selection
- Add health checks with model availability
- Add plugin manifests
### Current State
```
src/AdvisoryAI/
├── __Libraries/
│ └── StellaOps.AdvisoryAI.Providers/
│ ├── LlamaServerProvider.cs
│ ├── OllamaProvider.cs
│ ├── OpenAiProvider.cs
│ └── ClaudeProvider.cs
```
---
## Deliverables
### LLM Capability Interface
```csharp
// ILlmCapability.cs
namespace StellaOps.Plugin.Abstractions.Capabilities;
/// <summary>
/// Capability interface for Large Language Model inference.
/// </summary>
public interface ILlmCapability
{
/// <summary>
/// Provider identifier (llama, ollama, openai, claude).
/// </summary>
string ProviderId { get; }
/// <summary>
/// Priority for provider selection (higher = preferred).
/// </summary>
int Priority { get; }
/// <summary>
/// Available models from this provider.
/// </summary>
IReadOnlyList<LlmModelInfo> AvailableModels { get; }
/// <summary>
/// Create an inference session.
/// </summary>
Task<ILlmSession> CreateSessionAsync(LlmSessionOptions options, CancellationToken ct);
/// <summary>
/// Check if provider can serve the specified model.
/// </summary>
Task<bool> CanServeModelAsync(string modelId, CancellationToken ct);
/// <summary>
/// Refresh available models list.
/// </summary>
Task RefreshModelsAsync(CancellationToken ct);
}
public interface ILlmSession : IAsyncDisposable
{
/// <summary>
/// Session identifier.
/// </summary>
string SessionId { get; }
/// <summary>
/// Model being used.
/// </summary>
string ModelId { get; }
/// <summary>
/// Generate a completion.
/// </summary>
Task<LlmCompletion> CompleteAsync(LlmPrompt prompt, CancellationToken ct);
/// <summary>
/// Generate a streaming completion.
/// </summary>
IAsyncEnumerable<LlmCompletionChunk> CompleteStreamingAsync(LlmPrompt prompt, CancellationToken ct);
/// <summary>
/// Generate embeddings for text.
/// </summary>
Task<LlmEmbedding> EmbedAsync(string text, CancellationToken ct);
}
public sealed record LlmModelInfo(
string Id,
string Name,
string? Description,
long? ParameterCount,
int? ContextLength,
IReadOnlyList<string> Capabilities); // ["chat", "completion", "embedding"]
public sealed record LlmSessionOptions(
string ModelId,
LlmParameters? Parameters = null,
string? SystemPrompt = null);
public sealed record LlmParameters(
float? Temperature = null,
float? TopP = null,
int? MaxTokens = null,
float? FrequencyPenalty = null,
float? PresencePenalty = null,
IReadOnlyList<string>? StopSequences = null);
public sealed record LlmPrompt(
IReadOnlyList<LlmMessage> Messages,
LlmParameters? ParameterOverrides = null);
public sealed record LlmMessage(
LlmRole Role,
string Content);
public enum LlmRole
{
System,
User,
Assistant
}
public sealed record LlmCompletion(
string Content,
LlmUsage Usage,
string? FinishReason);
public sealed record LlmCompletionChunk(
string Content,
bool IsComplete,
LlmUsage? Usage = null);
public sealed record LlmUsage(
int PromptTokens,
int CompletionTokens,
int TotalTokens);
public sealed record LlmEmbedding(
float[] Vector,
int Dimensions,
LlmUsage Usage);
```
### OpenAI Plugin Implementation
```csharp
// OpenAiPlugin.cs
namespace StellaOps.AdvisoryAI.Plugin.OpenAi;
[Plugin(
id: "com.stellaops.llm.openai",
name: "OpenAI LLM Provider",
version: "1.0.0",
vendor: "Stella Ops")]
[ProvidesCapability(PluginCapabilities.Llm, CapabilityId = "openai")]
[RequiresCapability(PluginCapabilities.Network)]
public sealed class OpenAiPlugin : IPlugin, ILlmCapability
{
private IPluginContext? _context;
private OpenAiClient? _client;
private List<LlmModelInfo> _models = new();
public PluginInfo Info => new(
Id: "com.stellaops.llm.openai",
Name: "OpenAI LLM Provider",
Version: "1.0.0",
Vendor: "Stella Ops",
Description: "OpenAI GPT models for AI-assisted advisory analysis");
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
public PluginCapabilities Capabilities => PluginCapabilities.Llm | PluginCapabilities.Network;
public PluginLifecycleState State { get; private set; } = PluginLifecycleState.Discovered;
public string ProviderId => "openai";
public int Priority { get; private set; } = 10;
public IReadOnlyList<LlmModelInfo> AvailableModels => _models;
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
{
_context = context;
State = PluginLifecycleState.Initializing;
var options = context.Configuration.Bind<OpenAiOptions>();
var apiKey = await context.Configuration.GetSecretAsync("openai-api-key", ct)
?? options.ApiKey;
if (string.IsNullOrEmpty(apiKey))
{
State = PluginLifecycleState.Failed;
throw new InvalidOperationException("OpenAI API key not configured");
}
_client = new OpenAiClient(apiKey, options.BaseUrl);
Priority = options.Priority;
await RefreshModelsAsync(ct);
State = PluginLifecycleState.Active;
context.Logger.Info("OpenAI plugin initialized with {ModelCount} models", _models.Count);
}
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
if (_client == null)
return HealthCheckResult.Unhealthy("Not initialized");
try
{
var models = await _client.ListModelsAsync(ct);
return HealthCheckResult.Healthy(details: new Dictionary<string, object>
{
["modelCount"] = models.Count
});
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex);
}
}
public async Task<ILlmSession> CreateSessionAsync(LlmSessionOptions options, CancellationToken ct)
{
EnsureActive();
if (!await CanServeModelAsync(options.ModelId, ct))
throw new InvalidOperationException($"Model {options.ModelId} not available");
return new OpenAiSession(_client!, options, _context!.Logger);
}
public async Task<bool> CanServeModelAsync(string modelId, CancellationToken ct)
{
return _models.Any(m => m.Id.Equals(modelId, StringComparison.OrdinalIgnoreCase));
}
public async Task RefreshModelsAsync(CancellationToken ct)
{
var models = await _client!.ListModelsAsync(ct);
_models = models
.Where(m => m.Id.StartsWith("gpt") || m.Id.Contains("embedding"))
.Select(m => new LlmModelInfo(
Id: m.Id,
Name: m.Id,
Description: null,
ParameterCount: null,
ContextLength: GetContextLength(m.Id),
Capabilities: GetModelCapabilities(m.Id)))
.ToList();
}
private static int? GetContextLength(string modelId) => modelId switch
{
var m when m.Contains("gpt-4-turbo") => 128000,
var m when m.Contains("gpt-4") => 8192,
var m when m.Contains("gpt-3.5-turbo-16k") => 16384,
var m when m.Contains("gpt-3.5") => 4096,
_ => null
};
private static List<string> GetModelCapabilities(string modelId)
{
if (modelId.Contains("embedding"))
return new List<string> { "embedding" };
return new List<string> { "chat", "completion" };
}
private void EnsureActive()
{
if (State != PluginLifecycleState.Active)
throw new InvalidOperationException($"OpenAI plugin is not active (state: {State})");
}
public ValueTask DisposeAsync()
{
_client?.Dispose();
State = PluginLifecycleState.Stopped;
return ValueTask.CompletedTask;
}
}
internal sealed class OpenAiSession : ILlmSession
{
private readonly OpenAiClient _client;
private readonly LlmSessionOptions _options;
private readonly IPluginLogger _logger;
public string SessionId { get; } = Guid.NewGuid().ToString("N");
public string ModelId => _options.ModelId;
public OpenAiSession(OpenAiClient client, LlmSessionOptions options, IPluginLogger logger)
{
_client = client;
_options = options;
_logger = logger;
}
public async Task<LlmCompletion> CompleteAsync(LlmPrompt prompt, CancellationToken ct)
{
var request = BuildRequest(prompt);
var response = await _client.ChatCompleteAsync(request, ct);
return new LlmCompletion(
Content: response.Choices[0].Message.Content,
Usage: new LlmUsage(
response.Usage.PromptTokens,
response.Usage.CompletionTokens,
response.Usage.TotalTokens),
FinishReason: response.Choices[0].FinishReason);
}
public async IAsyncEnumerable<LlmCompletionChunk> CompleteStreamingAsync(
LlmPrompt prompt,
[EnumeratorCancellation] CancellationToken ct)
{
var request = BuildRequest(prompt);
request.Stream = true;
await foreach (var chunk in _client.ChatCompleteStreamAsync(request, ct))
{
yield return new LlmCompletionChunk(
Content: chunk.Choices[0].Delta?.Content ?? "",
IsComplete: chunk.Choices[0].FinishReason != null,
Usage: chunk.Usage != null ? new LlmUsage(
chunk.Usage.PromptTokens,
chunk.Usage.CompletionTokens,
chunk.Usage.TotalTokens) : null);
}
}
public async Task<LlmEmbedding> EmbedAsync(string text, CancellationToken ct)
{
var response = await _client.EmbedAsync(text, "text-embedding-ada-002", ct);
return new LlmEmbedding(
Vector: response.Data[0].Embedding,
Dimensions: response.Data[0].Embedding.Length,
Usage: new LlmUsage(response.Usage.PromptTokens, 0, response.Usage.TotalTokens));
}
private ChatCompletionRequest BuildRequest(LlmPrompt prompt)
{
var messages = new List<ChatMessage>();
if (!string.IsNullOrEmpty(_options.SystemPrompt))
{
messages.Add(new ChatMessage("system", _options.SystemPrompt));
}
messages.AddRange(prompt.Messages.Select(m => new ChatMessage(
m.Role.ToString().ToLowerInvariant(),
m.Content)));
var parameters = prompt.ParameterOverrides ?? _options.Parameters ?? new LlmParameters();
return new ChatCompletionRequest
{
Model = ModelId,
Messages = messages,
Temperature = parameters.Temperature,
TopP = parameters.TopP,
MaxTokens = parameters.MaxTokens,
FrequencyPenalty = parameters.FrequencyPenalty,
PresencePenalty = parameters.PresencePenalty,
Stop = parameters.StopSequences?.ToArray()
};
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
```
### Migration Tasks
| Provider | Priority | New Implementation | Status |
|----------|----------|-------------------|--------|
| llama-server | 100 (local) | `LlamaServerPlugin : IPlugin, ILlmCapability` | TODO |
| ollama | 90 (local) | `OllamaPlugin : IPlugin, ILlmCapability` | TODO |
| Claude | 20 | `ClaudePlugin : IPlugin, ILlmCapability` | TODO |
| OpenAI | 10 | `OpenAiPlugin : IPlugin, ILlmCapability` | TODO |
---
## Acceptance Criteria
- [ ] All LLM providers implement `IPlugin`
- [ ] All LLM providers implement `ILlmCapability`
- [ ] Priority-based provider selection preserved
- [ ] Chat completion works
- [ ] Streaming completion works
- [ ] Embedding generation works
- [ ] Model listing works
- [ ] Health checks verify API connectivity
- [ ] Local providers (llama/ollama) check process availability
- [ ] Unit tests migrated/updated
- [ ] Integration tests with mock servers
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 100_001 Plugin Abstractions | Internal | TODO |
| 100_002 Plugin Host | Internal | TODO |
| OpenAI .NET SDK | External | Available |
| Anthropic SDK | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ILlmCapability interface | TODO | |
| LlamaServerPlugin | TODO | |
| OllamaPlugin | TODO | |
| OpenAiPlugin | TODO | |
| ClaudePlugin | TODO | |
| LlmProviderSelector | TODO | Priority-based selection |
| Plugin manifests | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,359 @@
# SPRINT: SCM Connector Rework
> **Sprint ID:** 100_008
> **Module:** PLUGIN
> **Phase:** 100 - Plugin System Unification
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_plugin_unification.md)
---
## Overview
Rework all SCM connectors (GitHub, GitLab, Azure DevOps, Gitea, Bitbucket) to implement the unified plugin architecture with `IPlugin` and `IScmCapability` interfaces.
### Objectives
- Migrate GitHub connector to unified plugin model
- Migrate GitLab connector to unified plugin model
- Migrate Azure DevOps connector to unified plugin model
- Migrate Gitea connector to unified plugin model
- Add Bitbucket connector
- Preserve URL auto-detection
- Add health checks with API connectivity
- Add plugin manifests
### Migration Tasks
| Provider | Current Interface | New Implementation | Status |
|----------|-------------------|-------------------|--------|
| GitHub | `IScmConnectorPlugin` | `GitHubPlugin : IPlugin, IScmCapability` | TODO |
| GitLab | `IScmConnectorPlugin` | `GitLabPlugin : IPlugin, IScmCapability` | TODO |
| Azure DevOps | `IScmConnectorPlugin` | `AzureDevOpsPlugin : IPlugin, IScmCapability` | TODO |
| Gitea | `IScmConnectorPlugin` | `GiteaPlugin : IPlugin, IScmCapability` | TODO |
| Bitbucket | (new) | `BitbucketPlugin : IPlugin, IScmCapability` | TODO |
---
## Deliverables
### GitHub Plugin Implementation
```csharp
// GitHubPlugin.cs
namespace StellaOps.Integrations.Plugin.GitHub;
[Plugin(
id: "com.stellaops.scm.github",
name: "GitHub SCM Connector",
version: "1.0.0",
vendor: "Stella Ops")]
[ProvidesCapability(PluginCapabilities.Scm, CapabilityId = "github")]
public sealed class GitHubPlugin : IPlugin, IScmCapability
{
private IPluginContext? _context;
private GitHubClient? _client;
private GitHubOptions? _options;
public PluginInfo Info => new(
Id: "com.stellaops.scm.github",
Name: "GitHub SCM Connector",
Version: "1.0.0",
Vendor: "Stella Ops",
Description: "GitHub repository integration for source control operations");
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
public PluginCapabilities Capabilities => PluginCapabilities.Scm | PluginCapabilities.Network;
public PluginLifecycleState State { get; private set; } = PluginLifecycleState.Discovered;
public string ConnectorType => "scm.github";
public string DisplayName => "GitHub";
public string ScmType => "github";
private static readonly Regex GitHubUrlPattern = new(
@"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
{
_context = context;
State = PluginLifecycleState.Initializing;
_options = context.Configuration.Bind<GitHubOptions>();
var token = await context.Configuration.GetSecretAsync("github-token", ct)
?? _options.Token;
_client = new GitHubClient(new ProductHeaderValue("StellaOps"))
{
Credentials = new Credentials(token)
};
if (!string.IsNullOrEmpty(_options.BaseUrl))
{
_client = new GitHubClient(
new ProductHeaderValue("StellaOps"),
new Uri(_options.BaseUrl))
{
Credentials = new Credentials(token)
};
}
State = PluginLifecycleState.Active;
context.Logger.Info("GitHub plugin initialized");
}
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
if (_client == null)
return HealthCheckResult.Unhealthy("Not initialized");
try
{
var user = await _client.User.Current();
return HealthCheckResult.Healthy(details: new Dictionary<string, object>
{
["authenticatedAs"] = user.Login,
["rateLimitRemaining"] = _client.GetLastApiInfo()?.RateLimit?.Remaining ?? -1
});
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex);
}
}
public bool CanHandle(string repositoryUrl) => GitHubUrlPattern.IsMatch(repositoryUrl);
public async Task<ConnectionTestResult> TestConnectionAsync(CancellationToken ct)
{
try
{
var sw = Stopwatch.StartNew();
var user = await _client!.User.Current();
sw.Stop();
return ConnectionTestResult.Succeeded(sw.Elapsed);
}
catch (Exception ex)
{
return ConnectionTestResult.Failed(ex.Message, ex);
}
}
public async Task<ConnectionInfo> GetConnectionInfoAsync(CancellationToken ct)
{
var user = await _client!.User.Current();
var apiInfo = _client.GetLastApiInfo();
return new ConnectionInfo(
EndpointUrl: _options?.BaseUrl ?? "https://api.github.com",
AuthenticatedAs: user.Login,
Metadata: new Dictionary<string, object>
{
["rateLimitRemaining"] = apiInfo?.RateLimit?.Remaining ?? -1,
["rateLimitReset"] = apiInfo?.RateLimit?.Reset.ToString() ?? ""
});
}
public async Task<IReadOnlyList<ScmBranch>> ListBranchesAsync(string repositoryUrl, CancellationToken ct)
{
var (owner, repo) = ParseRepositoryUrl(repositoryUrl);
var branches = await _client!.Repository.Branch.GetAll(owner, repo);
var defaultBranch = (await _client.Repository.Get(owner, repo)).DefaultBranch;
return branches.Select(b => new ScmBranch(
Name: b.Name,
CommitSha: b.Commit.Sha,
IsDefault: b.Name == defaultBranch,
IsProtected: b.Protected)).ToList();
}
public async Task<IReadOnlyList<ScmCommit>> ListCommitsAsync(
string repositoryUrl,
string branch,
int limit = 50,
CancellationToken ct = default)
{
var (owner, repo) = ParseRepositoryUrl(repositoryUrl);
var commits = await _client!.Repository.Commit.GetAll(owner, repo,
new CommitRequest { Sha = branch },
new ApiOptions { PageSize = limit, PageCount = 1 });
return commits.Select(c => new ScmCommit(
Sha: c.Sha,
Message: c.Commit.Message,
AuthorName: c.Commit.Author.Name,
AuthorEmail: c.Commit.Author.Email,
AuthoredAt: c.Commit.Author.Date,
ParentShas: c.Parents.Select(p => p.Sha).ToList())).ToList();
}
public async Task<ScmCommit> GetCommitAsync(string repositoryUrl, string commitSha, CancellationToken ct)
{
var (owner, repo) = ParseRepositoryUrl(repositoryUrl);
var commit = await _client!.Repository.Commit.Get(owner, repo, commitSha);
return new ScmCommit(
Sha: commit.Sha,
Message: commit.Commit.Message,
AuthorName: commit.Commit.Author.Name,
AuthorEmail: commit.Commit.Author.Email,
AuthoredAt: commit.Commit.Author.Date,
ParentShas: commit.Parents.Select(p => p.Sha).ToList());
}
public async Task<ScmFileContent> GetFileAsync(
string repositoryUrl,
string filePath,
string? reference = null,
CancellationToken ct = default)
{
var (owner, repo) = ParseRepositoryUrl(repositoryUrl);
var content = await _client!.Repository.Content.GetAllContentsByRef(owner, repo, filePath, reference ?? "HEAD");
var file = content.First();
return new ScmFileContent(
Path: file.Path,
Content: file.Content,
Encoding: file.Encoding.StringValue,
Sha: file.Sha,
Size: file.Size);
}
public async Task<Stream> GetArchiveAsync(
string repositoryUrl,
string reference,
ArchiveFormat format = ArchiveFormat.TarGz,
CancellationToken ct = default)
{
var (owner, repo) = ParseRepositoryUrl(repositoryUrl);
var archiveFormat = format == ArchiveFormat.Zip
? Octokit.ArchiveFormat.Zipball
: Octokit.ArchiveFormat.Tarball;
var bytes = await _client!.Repository.Content.GetArchive(owner, repo, archiveFormat, reference);
return new MemoryStream(bytes);
}
public async Task<ScmWebhook> UpsertWebhookAsync(
string repositoryUrl,
ScmWebhookConfig config,
CancellationToken ct)
{
var (owner, repo) = ParseRepositoryUrl(repositoryUrl);
var existingHooks = await _client!.Repository.Hooks.GetAll(owner, repo);
var existing = existingHooks.FirstOrDefault(h =>
h.Config.TryGetValue("url", out var url) && url == config.Url);
if (existing != null)
{
var updated = await _client.Repository.Hooks.Edit(owner, repo, (int)existing.Id,
new EditRepositoryHook(config.Events.ToArray())
{
Active = true,
Config = new Dictionary<string, string>
{
["url"] = config.Url,
["secret"] = config.Secret,
["content_type"] = "json"
}
});
return new ScmWebhook(updated.Id.ToString(), updated.Config["url"], updated.Events.ToList(), updated.Active);
}
var created = await _client.Repository.Hooks.Create(owner, repo, new NewRepositoryHook("web", new Dictionary<string, string>
{
["url"] = config.Url,
["secret"] = config.Secret,
["content_type"] = "json"
})
{
Events = config.Events.ToArray(),
Active = true
});
return new ScmWebhook(created.Id.ToString(), created.Config["url"], created.Events.ToList(), created.Active);
}
public async Task<ScmUser> GetCurrentUserAsync(CancellationToken ct)
{
var user = await _client!.User.Current();
return new ScmUser(
Id: user.Id.ToString(),
Username: user.Login,
DisplayName: user.Name,
Email: user.Email,
AvatarUrl: user.AvatarUrl);
}
private static (string Owner, string Repo) ParseRepositoryUrl(string url)
{
var match = GitHubUrlPattern.Match(url);
if (!match.Success)
throw new ArgumentException($"Invalid GitHub repository URL: {url}");
return (match.Groups[1].Value, match.Groups[2].Value);
}
public ValueTask DisposeAsync()
{
State = PluginLifecycleState.Stopped;
return ValueTask.CompletedTask;
}
}
```
---
## Acceptance Criteria
- [ ] All SCM connectors implement `IPlugin`
- [ ] All SCM connectors implement `IScmCapability`
- [ ] URL auto-detection works for all providers
- [ ] Branch listing works
- [ ] Commit listing works
- [ ] File retrieval works
- [ ] Archive download works
- [ ] Webhook management works
- [ ] Health checks verify API connectivity
- [ ] Rate limit information exposed
- [ ] Unit tests migrated/updated
- [ ] Integration tests with mock APIs
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 100_001 Plugin Abstractions | Internal | TODO |
| 100_002 Plugin Host | Internal | TODO |
| Octokit | External | Available |
| GitLabApiClient | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| GitHubPlugin | TODO | |
| GitLabPlugin | TODO | |
| AzureDevOpsPlugin | TODO | |
| GiteaPlugin | TODO | |
| BitbucketPlugin | TODO | New |
| ScmPluginBase | TODO | Shared base class |
| Plugin manifests | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
# SPRINT INDEX: Phase 1 - Foundation
> **Epic:** Release Orchestrator
> **Phase:** 1 - Foundation
> **Batch:** 101
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
> **Prerequisites:** [100_000_INDEX - Plugin System Unification](SPRINT_20260110_100_000_INDEX_plugin_unification.md) (must be completed first)
---
## Overview
Phase 1 establishes the foundational infrastructure for the Release Orchestrator: database schema and Release Orchestrator-specific plugin extensions. The unified plugin system from Phase 100 provides the core plugin infrastructure; this phase builds on it with Release Orchestrator domain-specific capabilities.
### Prerequisites
**Phase 100 - Plugin System Unification** must be completed before starting Phase 101. Phase 100 provides:
- `IPlugin` base interface and lifecycle management
- `IPluginHost` and `PluginHost` implementation
- Database-backed plugin registry
- Plugin sandbox infrastructure
- Core capability interfaces (ICryptoCapability, IAuthCapability, etc.)
- Plugin SDK and developer tooling
### Objectives
- Create PostgreSQL schema for all release orchestration tables
- Extend plugin registry with Release Orchestrator-specific capability types
- Implement `IStepProviderCapability` for workflow steps
- Implement `IGateProviderCapability` for promotion gates
- Deliver built-in step and gate providers
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 101_001 | Database Schema - Core Tables | DB | TODO | Phase 100 complete |
| 101_002 | Plugin Registry Extensions | PLUGIN | TODO | 101_001, 100_003 |
| 101_003 | Loader & Sandbox Extensions | PLUGIN | TODO | 101_002, 100_002, 100_004 |
| 101_004 | SDK Extensions | PLUGIN | TODO | 101_003, 100_012 |
> **Note:** Sprint numbers 101_002-101_004 now focus on Release Orchestrator-specific plugin extensions rather than duplicating the unified plugin infrastructure built in Phase 100.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ FOUNDATION LAYER │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DATABASE SCHEMA (101_001) │ │
│ │ │ │
│ │ release.integration_types release.environments │ │
│ │ release.integrations release.targets │ │
│ │ release.components release.releases │ │
│ │ release.workflow_templates release.promotions │ │
│ │ release.deployment_jobs release.evidence_packets │ │
│ │ release.plugins release.agents │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ PLUGIN SYSTEM │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Plugin Registry │ │ Plugin Loader │ │ Plugin Sandbox │ │ │
│ │ │ (101_002) │ │ (101_003) │ │ (101_003) │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ - Discovery │ │ - Load/Unload │ │ - Process │ │ │
│ │ │ - Versioning │ │ - Health check │ │ isolation │ │ │
│ │ │ - Dependencies │ │ - Hot reload │ │ - Resource │ │ │
│ │ │ - Manifest │ │ - Lifecycle │ │ limits │ │ │
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Plugin SDK (101_004) │ │ │
│ │ │ │ │ │
│ │ │ - Connector interfaces - Step provider interfaces │ │ │
│ │ │ - Gate provider interfaces - Manifest builder │ │ │
│ │ │ - Testing utilities - Documentation templates │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 101_001: Database Schema
| Deliverable | Type | Description |
|-------------|------|-------------|
| Migration 001 | SQL | Integration hub tables |
| Migration 002 | SQL | Environment tables |
| Migration 003 | SQL | Release management tables |
| Migration 004 | SQL | Workflow engine tables |
| Migration 005 | SQL | Promotion tables |
| Migration 006 | SQL | Deployment tables |
| Migration 007 | SQL | Agent tables |
| Migration 008 | SQL | Evidence tables |
| Migration 009 | SQL | Plugin tables |
| RLS Policies | SQL | Row-level security |
| Indexes | SQL | Performance indexes |
### 101_002: Plugin Registry
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IPluginRegistry` | Interface | Plugin discovery/versioning |
| `PluginRegistry` | Class | Implementation |
| `PluginManifest` | Record | Manifest schema |
| `PluginManifestValidator` | Class | Schema validation |
| `PluginVersion` | Record | SemVer handling |
| `PluginDependencyResolver` | Class | Dependency resolution |
### 101_003: Plugin Loader & Sandbox
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IPluginLoader` | Interface | Load/unload/reload |
| `PluginLoader` | Class | Implementation |
| `IPluginSandbox` | Interface | Isolation contract |
| `ContainerSandbox` | Class | Container-based isolation |
| `ProcessSandbox` | Class | Process-based isolation |
| `ResourceLimiter` | Class | CPU/memory limits |
| `PluginHealthMonitor` | Class | Health checking |
### 101_004: Plugin SDK
| Deliverable | Type | Description |
|-------------|------|-------------|
| `StellaOps.Plugin.Sdk` | NuGet | SDK package |
| `IConnectorPlugin` | Interface | Connector contract |
| `IStepProvider` | Interface | Step contract |
| `IGateProvider` | Interface | Gate contract |
| `ManifestBuilder` | Class | Fluent manifest building |
| Plugin Templates | dotnet new | Project templates |
| Documentation | Markdown | SDK documentation |
---
## Dependencies
### Phase Dependencies
| Phase | Purpose | Status |
|-------|---------|--------|
| **Phase 100 - Plugin System Unification** | Unified plugin infrastructure | TODO |
| 100_001 Plugin Abstractions | IPlugin, capabilities | TODO |
| 100_002 Plugin Host | Lifecycle management | TODO |
| 100_003 Plugin Registry | Database registry | TODO |
| 100_004 Plugin Sandbox | Process isolation | TODO |
| 100_012 Plugin SDK | Developer tooling | TODO |
### External Dependencies
| Dependency | Purpose |
|------------|---------|
| PostgreSQL 16+ | Database |
| Docker | Plugin sandbox (via Phase 100) |
| gRPC | Plugin communication (via Phase 100) |
### Internal Dependencies
| Module | Purpose |
|--------|---------|
| Authority | Tenant context, permissions |
| Telemetry | Metrics, tracing |
| StellaOps.Plugin.Abstractions | Core plugin interfaces (from Phase 100) |
| StellaOps.Plugin.Host | Plugin host (from Phase 100) |
| StellaOps.Plugin.Sdk | SDK library (from Phase 100) |
---
## Acceptance Criteria
- [ ] All database migrations execute successfully
- [ ] RLS policies enforce tenant isolation
- [ ] Plugin manifest validation covers all required fields
- [ ] Plugin loader can load, start, stop, and unload plugins
- [ ] Sandbox enforces resource limits
- [ ] SDK compiles to NuGet package
- [ ] Sample plugin builds and loads successfully
- [ ] Unit test coverage ≥80%
- [ ] Integration tests pass
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 1 index created |
| 10-Jan-2026 | Added Phase 100 (Plugin System Unification) as prerequisite - plugin infrastructure now centralized |

View File

@@ -0,0 +1,617 @@
# SPRINT: Database Schema - Core Tables
> **Sprint ID:** 101_001
> **Module:** DB
> **Phase:** 1 - Foundation
> **Status:** TODO
> **Parent:** [101_000_INDEX](SPRINT_20260110_101_000_INDEX_foundation.md)
---
## Overview
Create the PostgreSQL schema for all Release Orchestrator tables within the `release` schema. This sprint establishes the data model foundation for all subsequent modules.
> **NORMATIVE:** This sprint MUST comply with [docs/db/SPECIFICATION.md](../../db/SPECIFICATION.md) which defines the authoritative database design patterns for Stella Ops, including schema ownership, RLS policies, UUID generation, and JSONB conventions.
### Objectives
- Create `release` schema with RLS policies per SPECIFICATION.md
- Implement all core tables for 10 platform themes
- Add performance indexes and constraints
- Create audit triggers for append-only tables
- Use `require_current_tenant()` RLS helper pattern
- Add generated columns for JSONB hot paths
### Working Directory
```
src/Platform/__Libraries/StellaOps.Platform.Database/
├── Migrations/
│ └── Release/
│ ├── 001_IntegrationHub.sql
│ ├── 002_Environments.sql
│ ├── 003_ReleaseManagement.sql
│ ├── 004_Workflow.sql
│ ├── 005_Promotion.sql
│ ├── 006_Deployment.sql
│ ├── 007_Agents.sql
│ ├── 008_Evidence.sql
│ └── 009_Plugin.sql
└── ReleaseSchema/
├── Tables/
├── Indexes/
├── Functions/
└── Policies/
```
---
## Architecture Reference
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
- [Entity Definitions](../modules/release-orchestrator/data-model/entities.md)
- [Security Overview](../modules/release-orchestrator/security/overview.md)
---
## Deliverables
### Migration 001: Integration Hub Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.integration_types` | Enum-like type registry | `id`, `name`, `category` |
| `release.integrations` | Configured integrations | `id`, `tenant_id`, `type_id`, `name`, `config_encrypted` |
| `release.integration_health_checks` | Health check history | `id`, `integration_id`, `status`, `checked_at` |
```sql
-- release.integration_types
CREATE TABLE release.integration_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('scm', 'ci', 'registry', 'vault', 'notify')),
description TEXT,
config_schema JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- release.integrations
CREATE TABLE release.integrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
type_id TEXT NOT NULL REFERENCES release.integration_types(id),
name TEXT NOT NULL,
display_name TEXT NOT NULL,
config_encrypted BYTEA NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true,
health_status TEXT NOT NULL DEFAULT 'unknown',
last_health_check TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL,
UNIQUE (tenant_id, name)
);
```
### Migration 002: Environment Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.environments` | Deployment environments | `id`, `tenant_id`, `name`, `order_index` |
| `release.targets` | Deployment targets | `id`, `environment_id`, `type`, `connection_config` |
| `release.freeze_windows` | Deployment freeze periods | `id`, `environment_id`, `start_at`, `end_at` |
```sql
-- release.environments
CREATE TABLE release.environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
order_index INT NOT NULL,
is_production BOOLEAN NOT NULL DEFAULT false,
required_approvals INT NOT NULL DEFAULT 0,
require_separation_of_duties BOOLEAN NOT NULL DEFAULT false,
auto_promote_from UUID REFERENCES release.environments(id),
deployment_timeout_seconds INT NOT NULL DEFAULT 600,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL,
UNIQUE (tenant_id, name),
UNIQUE (tenant_id, order_index)
);
-- release.targets
CREATE TABLE release.targets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
environment_id UUID NOT NULL REFERENCES release.environments(id),
name TEXT NOT NULL,
display_name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('docker_host', 'compose_host', 'ecs_service', 'nomad_job')),
connection_config_encrypted BYTEA NOT NULL,
agent_id UUID,
health_status TEXT NOT NULL DEFAULT 'unknown',
last_health_check TIMESTAMPTZ,
last_sync_at TIMESTAMPTZ,
inventory_snapshot JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, environment_id, name)
);
```
### Migration 003: Release Management Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.components` | Container components | `id`, `tenant_id`, `name`, `registry_integration_id` |
| `release.component_versions` | Version snapshots | `id`, `component_id`, `digest`, `semver` |
| `release.releases` | Release bundles | `id`, `tenant_id`, `name`, `status` |
| `release.release_components` | Release-component mapping | `release_id`, `component_version_id` |
```sql
-- release.components
CREATE TABLE release.components (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
registry_integration_id UUID NOT NULL REFERENCES release.integrations(id),
repository TEXT NOT NULL,
scm_integration_id UUID REFERENCES release.integrations(id),
scm_repository TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
-- release.releases
CREATE TABLE release.releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'ready', 'promoting', 'deployed', 'deprecated')),
source_commit_sha TEXT,
source_branch TEXT,
ci_build_id TEXT,
ci_pipeline_url TEXT,
finalized_at TIMESTAMPTZ,
finalized_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL,
UNIQUE (tenant_id, name)
);
```
### Migration 004: Workflow Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.workflow_templates` | DAG templates | `id`, `tenant_id`, `name`, `definition` |
| `release.workflow_runs` | Workflow executions | `id`, `template_id`, `status` |
| `release.workflow_steps` | Step definitions | `id`, `run_id`, `step_type`, `status` |
```sql
-- release.workflow_templates
CREATE TABLE release.workflow_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
definition JSONB NOT NULL,
version INT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name, version)
);
-- release.workflow_runs
CREATE TABLE release.workflow_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
template_id UUID NOT NULL REFERENCES release.workflow_templates(id),
template_version INT NOT NULL,
context_type TEXT NOT NULL,
context_id UUID NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'cancelled')),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### Migration 005: Promotion Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.promotions` | Promotion requests | `id`, `release_id`, `target_environment_id`, `status` |
| `release.approvals` | Approval records | `id`, `promotion_id`, `approver_id`, `decision` |
| `release.gate_results` | Gate evaluation results | `id`, `promotion_id`, `gate_type`, `passed` |
```sql
-- release.promotions
CREATE TABLE release.promotions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
release_id UUID NOT NULL REFERENCES release.releases(id),
source_environment_id UUID REFERENCES release.environments(id),
target_environment_id UUID NOT NULL REFERENCES release.environments(id),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'awaiting_approval', 'approved', 'rejected',
'deploying', 'deployed', 'failed', 'cancelled', 'rolled_back'
)),
requested_by UUID NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
request_reason TEXT,
decision TEXT CHECK (decision IN ('allow', 'block')),
decided_at TIMESTAMPTZ,
deployment_job_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- release.approvals (append-only)
CREATE TABLE release.approvals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
promotion_id UUID NOT NULL REFERENCES release.promotions(id),
approver_id UUID NOT NULL,
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- No updated_at - append only
);
-- Prevent modifications to approvals
REVOKE UPDATE, DELETE ON release.approvals FROM app_role;
```
### Migration 006: Deployment Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.deployment_jobs` | Deployment executions | `id`, `promotion_id`, `strategy`, `status` |
| `release.deployment_tasks` | Per-target tasks | `id`, `job_id`, `target_id`, `status` |
| `release.deployment_artifacts` | Generated artifacts | `id`, `job_id`, `type`, `storage_ref` |
```sql
-- release.deployment_jobs
CREATE TABLE release.deployment_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
promotion_id UUID NOT NULL REFERENCES release.promotions(id),
strategy TEXT NOT NULL DEFAULT 'rolling' CHECK (strategy IN ('rolling', 'blue_green', 'canary', 'all_at_once')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'pulling', 'deploying', 'verifying',
'succeeded', 'failed', 'rolling_back', 'rolled_back'
)),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- release.deployment_tasks
CREATE TABLE release.deployment_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
job_id UUID NOT NULL REFERENCES release.deployment_jobs(id),
target_id UUID NOT NULL REFERENCES release.targets(id),
agent_id UUID,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'assigned', 'pulling', 'deploying',
'verifying', 'succeeded', 'failed'
)),
digest_deployed TEXT,
sticker_written BOOLEAN NOT NULL DEFAULT false,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### Migration 007: Agent Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.agents` | Registered agents | `id`, `tenant_id`, `name`, `status` |
| `release.agent_capabilities` | Agent capabilities | `agent_id`, `capability` |
| `release.agent_heartbeats` | Heartbeat history | `id`, `agent_id`, `received_at` |
```sql
-- release.agents
CREATE TABLE release.agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
display_name TEXT NOT NULL,
version TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'inactive', 'revoked')),
certificate_thumbprint TEXT,
certificate_expires_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
last_heartbeat_status JSONB,
registered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
-- release.agent_capabilities
CREATE TABLE release.agent_capabilities (
agent_id UUID NOT NULL REFERENCES release.agents(id) ON DELETE CASCADE,
capability TEXT NOT NULL CHECK (capability IN ('docker', 'compose', 'ssh', 'winrm')),
config JSONB,
PRIMARY KEY (agent_id, capability)
);
```
### Migration 008: Evidence Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.evidence_packets` | Signed evidence (append-only) | `id`, `promotion_id`, `type`, `content` |
```sql
-- release.evidence_packets (append-only, immutable)
CREATE TABLE release.evidence_packets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
promotion_id UUID NOT NULL REFERENCES release.promotions(id),
type TEXT NOT NULL CHECK (type IN ('release_decision', 'deployment', 'rollback', 'ab_promotion')),
version TEXT NOT NULL DEFAULT '1.0',
content JSONB NOT NULL,
content_hash TEXT NOT NULL,
signature TEXT NOT NULL,
signature_algorithm TEXT NOT NULL,
signer_key_ref TEXT NOT NULL,
generated_at TIMESTAMPTZ NOT NULL,
generator_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- No updated_at - packets are immutable
);
-- Prevent modifications
REVOKE UPDATE, DELETE ON release.evidence_packets FROM app_role;
-- Index for quick lookups
CREATE INDEX idx_evidence_packets_promotion ON release.evidence_packets(promotion_id);
CREATE INDEX idx_evidence_packets_type ON release.evidence_packets(tenant_id, type);
```
### Migration 009: Plugin Tables
| Table | Description | Key Columns |
|-------|-------------|-------------|
| `release.plugins` | Registered plugins | `id`, `tenant_id`, `name`, `type` |
| `release.plugin_versions` | Plugin versions | `id`, `plugin_id`, `version`, `manifest` |
```sql
-- release.plugins
CREATE TABLE release.plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id), -- NULL for system plugins
name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK (type IN ('connector', 'step', 'gate')),
is_builtin BOOLEAN NOT NULL DEFAULT false,
is_enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name)
);
-- release.plugin_versions
CREATE TABLE release.plugin_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES release.plugins(id),
version TEXT NOT NULL,
manifest JSONB NOT NULL,
package_hash TEXT NOT NULL,
package_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (plugin_id, version)
);
```
### RLS Policies
Following the pattern established in `docs/db/SPECIFICATION.md`, all RLS policies use the `require_current_tenant()` helper function for consistent tenant isolation.
```sql
-- Create helper function per SPECIFICATION.md Section 2.3
CREATE OR REPLACE FUNCTION release_app.require_current_tenant()
RETURNS UUID
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(
NULLIF(current_setting('app.current_tenant_id', true), '')::UUID,
(SELECT id FROM shared.tenants WHERE is_default = true LIMIT 1)
)
$$;
-- Enable RLS on all tables
ALTER TABLE release.integrations ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.environments ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.targets ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.components ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.releases ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.promotions ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.approvals ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.deployment_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.deployment_tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.agents ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.evidence_packets ENABLE ROW LEVEL SECURITY;
ALTER TABLE release.plugins ENABLE ROW LEVEL SECURITY;
-- Standard tenant isolation policy using helper (example for integrations)
CREATE POLICY tenant_isolation ON release.integrations
USING (tenant_id = release_app.require_current_tenant());
-- Repeat pattern for all tenant-scoped tables
```
### Performance Indexes
```sql
-- High-cardinality lookup indexes
CREATE INDEX idx_releases_tenant_status ON release.releases(tenant_id, status);
CREATE INDEX idx_promotions_tenant_status ON release.promotions(tenant_id, status);
CREATE INDEX idx_promotions_release ON release.promotions(release_id);
CREATE INDEX idx_deployment_jobs_promotion ON release.deployment_jobs(promotion_id);
CREATE INDEX idx_deployment_tasks_job ON release.deployment_tasks(job_id);
CREATE INDEX idx_agents_tenant_status ON release.agents(tenant_id, status);
CREATE INDEX idx_targets_environment ON release.targets(environment_id);
CREATE INDEX idx_targets_agent ON release.targets(agent_id);
-- Partial indexes for active records
CREATE INDEX idx_promotions_pending ON release.promotions(tenant_id, target_environment_id)
WHERE status IN ('pending', 'awaiting_approval');
CREATE INDEX idx_agents_active ON release.agents(tenant_id)
WHERE status = 'active';
```
### Generated Columns for JSONB Hot Paths
Per `docs/db/SPECIFICATION.md` Section 4.5, use generated columns for frequently-queried JSONB fields to enable efficient indexing and avoid repeated JSON parsing.
```sql
-- Evidence packets: extract release_id for quick lookups
ALTER TABLE release.evidence_packets
ADD COLUMN release_id UUID GENERATED ALWAYS AS (
(content->>'releaseId')::UUID
) STORED;
-- Evidence packets: extract what.type for filtering by evidence type
ALTER TABLE release.evidence_packets
ADD COLUMN evidence_what_type TEXT GENERATED ALWAYS AS (
content->'what'->>'type'
) STORED;
-- Workflow templates: extract step count for UI display
ALTER TABLE release.workflow_templates
ADD COLUMN step_count INT GENERATED ALWAYS AS (
jsonb_array_length(COALESCE(definition->'steps', '[]'::JSONB))
) STORED;
-- Agents: extract primary capability from last heartbeat
ALTER TABLE release.agents
ADD COLUMN primary_capability TEXT GENERATED ALWAYS AS (
last_heartbeat_status->>'primaryCapability'
) STORED;
-- Targets: extract deployed digest from inventory snapshot
ALTER TABLE release.targets
ADD COLUMN current_digest TEXT GENERATED ALWAYS AS (
inventory_snapshot->>'digest'
) STORED;
-- Index generated columns for efficient queries
CREATE INDEX idx_evidence_packets_release ON release.evidence_packets(release_id);
CREATE INDEX idx_evidence_packets_what_type ON release.evidence_packets(tenant_id, evidence_what_type);
CREATE INDEX idx_targets_current_digest ON release.targets(current_digest) WHERE current_digest IS NOT NULL;
```
---
## Acceptance Criteria
- [ ] All 9 migrations execute successfully in order
- [ ] Schema complies with docs/db/SPECIFICATION.md
- [ ] RLS policies use `require_current_tenant()` helper
- [ ] RLS policies enforce tenant isolation
- [ ] Append-only tables reject UPDATE/DELETE
- [ ] All foreign key constraints valid
- [ ] Performance indexes created
- [ ] Generated columns created for JSONB hot paths
- [ ] Schema documentation generated
- [ ] Migration rollback scripts created
- [ ] Integration tests pass with Testcontainers
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `MigrationOrderTest` | Verify migrations run in dependency order |
| `RlsPolicyTest` | Verify tenant isolation enforced |
| `AppendOnlyTest` | Verify UPDATE/DELETE rejected on evidence tables |
| `ForeignKeyTest` | Verify all FK constraints |
### Integration Tests
| Test | Description |
|------|-------------|
| `SchemaCreationTest` | Full schema creation on fresh database |
| `MigrationIdempotencyTest` | Migrations can be re-run safely |
| `PerformanceIndexTest` | Verify indexes used in common queries |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| PostgreSQL 16+ | External | Available |
| `tenants` table | Internal | Exists |
| Testcontainers | Testing | Available |
---
## Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| Schema conflicts with existing tables | High | Use dedicated `release` schema |
| Migration performance on large DBs | Medium | Use concurrent index creation |
| RLS policy overhead | Low | Benchmark and optimize |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| Migration 001 - Integration Hub | TODO | |
| Migration 002 - Environments | TODO | |
| Migration 003 - Release Management | TODO | |
| Migration 004 - Workflow | TODO | |
| Migration 005 - Promotion | TODO | |
| Migration 006 - Deployment | TODO | |
| Migration 007 - Agents | TODO | |
| Migration 008 - Evidence | TODO | |
| Migration 009 - Plugin | TODO | |
| RLS Policies | TODO | Uses `require_current_tenant()` helper |
| Performance Indexes | TODO | |
| Generated Columns | TODO | JSONB hot paths for evidence, workflows, agents |
| Integration Tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 10-Jan-2026 | Added reference to docs/db/SPECIFICATION.md as normative |
| 10-Jan-2026 | Added require_current_tenant() RLS helper pattern |
| 10-Jan-2026 | Added generated columns for JSONB hot paths (evidence_packets, workflow_templates, agents, targets) |

View File

@@ -0,0 +1,938 @@
# SPRINT: Plugin Registry Extensions for Release Orchestrator
> **Sprint ID:** 101_002
> **Module:** PLUGIN
> **Phase:** 1 - Foundation
> **Status:** TODO
> **Parent:** [101_000_INDEX](SPRINT_20260110_101_000_INDEX_foundation.md)
> **Prerequisites:** [100_003 Plugin Registry](SPRINT_20260110_100_003_PLUGIN_registry.md), [100_001 Plugin Abstractions](SPRINT_20260110_100_001_PLUGIN_abstractions.md)
---
## Overview
Extend the unified plugin registry (from Phase 100) with Release Orchestrator-specific capability types, including workflow step providers, promotion gate providers, and integration connectors. This sprint builds on top of the core `IPluginRegistry` infrastructure.
> **Note:** The core plugin registry (`IPluginRegistry`, `PostgresPluginRegistry`, database schema) is implemented in Phase 100 sprint 100_003. This sprint adds Release Orchestrator domain-specific extensions.
### Objectives
- Register Release Orchestrator capability interfaces with the plugin system
- Define `IStepProviderCapability` for workflow steps
- Define `IGateProviderCapability` for promotion gates
- Define `IConnectorCapability` variants for Integration Hub
- Create domain-specific registry queries
- Add capability-specific validation
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Plugin/
│ ├── Capabilities/
│ │ ├── IStepProviderCapability.cs
│ │ ├── IGateProviderCapability.cs
│ │ ├── IScmConnectorCapability.cs
│ │ ├── IRegistryConnectorCapability.cs
│ │ ├── IVaultConnectorCapability.cs
│ │ ├── INotifyConnectorCapability.cs
│ │ └── ICiConnectorCapability.cs
│ ├── Registry/
│ │ ├── ReleaseOrchestratorPluginRegistry.cs
│ │ ├── StepProviderRegistry.cs
│ │ ├── GateProviderRegistry.cs
│ │ └── ConnectorRegistry.cs
│ └── Models/
│ ├── StepDefinition.cs
│ ├── GateDefinition.cs
│ └── ConnectorDefinition.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Plugin.Tests/
├── StepProviderRegistryTests.cs
├── GateProviderRegistryTests.cs
└── ConnectorRegistryTests.cs
```
---
## Architecture Reference
- [Phase 100 Plugin System](SPRINT_20260110_100_000_INDEX_plugin_unification.md)
- [Plugin System](../modules/release-orchestrator/modules/plugin-system.md)
- [Workflow Engine](../modules/release-orchestrator/modules/workflow-engine.md)
---
## Deliverables
### IStepProviderCapability Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
/// <summary>
/// Capability interface for workflow step providers.
/// Plugins implementing this capability can provide custom workflow steps.
/// </summary>
public interface IStepProviderCapability
{
/// <summary>
/// Get step definitions provided by this plugin.
/// </summary>
IReadOnlyList<StepDefinition> GetStepDefinitions();
/// <summary>
/// Execute a step.
/// </summary>
Task<StepResult> ExecuteStepAsync(StepExecutionContext context, CancellationToken ct);
/// <summary>
/// Validate step configuration before execution.
/// </summary>
Task<StepValidationResult> ValidateStepConfigAsync(
string stepType,
JsonElement configuration,
CancellationToken ct);
/// <summary>
/// Get step output schema for a step type.
/// </summary>
JsonSchema? GetOutputSchema(string stepType);
}
public sealed record StepDefinition(
string Type,
string DisplayName,
string Description,
string Category,
JsonSchema ConfigSchema,
JsonSchema OutputSchema,
IReadOnlyList<string> RequiredCapabilities,
bool SupportsRetry,
TimeSpan DefaultTimeout);
public sealed record StepExecutionContext(
Guid StepId,
Guid WorkflowRunId,
Guid TenantId,
string StepType,
JsonElement Configuration,
IReadOnlyDictionary<string, object> Inputs,
IStepOutputWriter OutputWriter,
IPluginLogger Logger);
public sealed record StepResult(
StepStatus Status,
IReadOnlyDictionary<string, object> Outputs,
string? ErrorMessage = null,
TimeSpan? Duration = null);
public enum StepStatus
{
Succeeded,
Failed,
Skipped,
TimedOut
}
public sealed record StepValidationResult(
bool IsValid,
IReadOnlyList<string> Errors)
{
public static StepValidationResult Success() => new(true, []);
public static StepValidationResult Failure(params string[] errors) => new(false, errors);
}
```
### IGateProviderCapability Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
/// <summary>
/// Capability interface for promotion gate providers.
/// Plugins implementing this capability can provide custom promotion gates.
/// </summary>
public interface IGateProviderCapability
{
/// <summary>
/// Get gate definitions provided by this plugin.
/// </summary>
IReadOnlyList<GateDefinition> GetGateDefinitions();
/// <summary>
/// Evaluate a gate for a promotion.
/// </summary>
Task<GateResult> EvaluateGateAsync(GateEvaluationContext context, CancellationToken ct);
/// <summary>
/// Validate gate configuration.
/// </summary>
Task<GateValidationResult> ValidateGateConfigAsync(
string gateType,
JsonElement configuration,
CancellationToken ct);
}
public sealed record GateDefinition(
string Type,
string DisplayName,
string Description,
string Category,
JsonSchema ConfigSchema,
bool IsBlocking,
bool SupportsOverride,
IReadOnlyList<string> RequiredPermissions);
public sealed record GateEvaluationContext(
Guid GateId,
Guid PromotionId,
Guid ReleaseId,
Guid SourceEnvironmentId,
Guid TargetEnvironmentId,
Guid TenantId,
string GateType,
JsonElement Configuration,
ReleaseInfo Release,
EnvironmentInfo TargetEnvironment,
IPluginLogger Logger);
public sealed record GateResult(
GateStatus Status,
string Message,
IReadOnlyDictionary<string, object> Details,
IReadOnlyList<GateEvidence>? Evidence = null)
{
public static GateResult Pass(string message, IReadOnlyDictionary<string, object>? details = null) =>
new(GateStatus.Passed, message, details ?? new Dictionary<string, object>());
public static GateResult Fail(string message, IReadOnlyDictionary<string, object>? details = null) =>
new(GateStatus.Failed, message, details ?? new Dictionary<string, object>());
public static GateResult Warn(string message, IReadOnlyDictionary<string, object>? details = null) =>
new(GateStatus.Warning, message, details ?? new Dictionary<string, object>());
public static GateResult Pending(string message) =>
new(GateStatus.Pending, message, new Dictionary<string, object>());
}
public enum GateStatus
{
Passed,
Failed,
Warning,
Pending,
Skipped
}
public sealed record GateEvidence(
string Type,
string Description,
JsonElement Data);
public sealed record GateValidationResult(
bool IsValid,
IReadOnlyList<string> Errors)
{
public static GateValidationResult Success() => new(true, []);
public static GateValidationResult Failure(params string[] errors) => new(false, errors);
}
```
### Integration Connector Capability Interfaces
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
/// <summary>
/// Base interface for all Integration Hub connectors.
/// </summary>
public interface IIntegrationConnectorCapability
{
/// <summary>
/// Connector category (SCM, CI, Registry, Vault, Notify).
/// </summary>
ConnectorCategory Category { get; }
/// <summary>
/// Connector type identifier.
/// </summary>
string ConnectorType { get; }
/// <summary>
/// Human-readable display name.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Validate connector configuration.
/// </summary>
Task<ConfigValidationResult> ValidateConfigAsync(
JsonElement config,
CancellationToken ct);
/// <summary>
/// Test connection with current configuration.
/// </summary>
Task<ConnectionTestResult> TestConnectionAsync(
ConnectorContext context,
CancellationToken ct);
/// <summary>
/// Get connector capabilities.
/// </summary>
IReadOnlyList<string> GetSupportedOperations();
}
public enum ConnectorCategory
{
Scm,
Ci,
Registry,
Vault,
Notify
}
public sealed record ConnectorContext(
Guid IntegrationId,
Guid TenantId,
JsonElement Configuration,
ISecretResolver SecretResolver,
IPluginLogger Logger);
/// <summary>
/// Extended interface for SCM connectors in Release Orchestrator context.
/// Extends the base IScmCapability from Phase 100 with Release Orchestrator-specific operations.
/// </summary>
public interface IScmConnectorCapability : IIntegrationConnectorCapability
{
/// <summary>
/// List repositories accessible by this integration.
/// </summary>
Task<IReadOnlyList<ScmRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? searchPattern = null,
CancellationToken ct = default);
/// <summary>
/// Get commit information.
/// </summary>
Task<ScmCommitInfo?> GetCommitAsync(
ConnectorContext context,
string repository,
string commitSha,
CancellationToken ct = default);
/// <summary>
/// Create a webhook for repository events.
/// </summary>
Task<WebhookRegistration> CreateWebhookAsync(
ConnectorContext context,
string repository,
IReadOnlyList<string> events,
string callbackUrl,
CancellationToken ct = default);
/// <summary>
/// Get release/tag information.
/// </summary>
Task<IReadOnlyList<ScmRelease>> ListReleasesAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default);
}
/// <summary>
/// Extended interface for container registry connectors.
/// </summary>
public interface IRegistryConnectorCapability : IIntegrationConnectorCapability
{
/// <summary>
/// List repositories in the registry.
/// </summary>
Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? prefix = null,
CancellationToken ct = default);
/// <summary>
/// List tags for a repository.
/// </summary>
Task<IReadOnlyList<ImageTag>> ListTagsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default);
/// <summary>
/// Resolve a tag to its digest.
/// </summary>
Task<ImageDigest?> ResolveTagAsync(
ConnectorContext context,
string repository,
string tag,
CancellationToken ct = default);
/// <summary>
/// Get image manifest.
/// </summary>
Task<ImageManifest?> GetManifestAsync(
ConnectorContext context,
string repository,
string reference,
CancellationToken ct = default);
/// <summary>
/// Generate pull credentials for an image.
/// </summary>
Task<PullCredentials> GetPullCredentialsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default);
}
/// <summary>
/// Extended interface for vault/secrets connectors.
/// </summary>
public interface IVaultConnectorCapability : IIntegrationConnectorCapability
{
/// <summary>
/// Get a secret value.
/// </summary>
Task<SecretValue?> GetSecretAsync(
ConnectorContext context,
string path,
CancellationToken ct = default);
/// <summary>
/// List secrets at a path.
/// </summary>
Task<IReadOnlyList<string>> ListSecretsAsync(
ConnectorContext context,
string path,
CancellationToken ct = default);
}
/// <summary>
/// Extended interface for notification connectors.
/// </summary>
public interface INotifyConnectorCapability : IIntegrationConnectorCapability
{
/// <summary>
/// Send a notification.
/// </summary>
Task<NotificationResult> SendNotificationAsync(
ConnectorContext context,
Notification notification,
CancellationToken ct = default);
/// <summary>
/// Get supported notification channels.
/// </summary>
IReadOnlyList<string> GetSupportedChannels();
}
/// <summary>
/// Extended interface for CI/CD system connectors.
/// </summary>
public interface ICiConnectorCapability : IIntegrationConnectorCapability
{
/// <summary>
/// Trigger a pipeline/workflow.
/// </summary>
Task<PipelineTriggerResult> TriggerPipelineAsync(
ConnectorContext context,
PipelineTriggerRequest request,
CancellationToken ct = default);
/// <summary>
/// Get pipeline status.
/// </summary>
Task<PipelineStatus?> GetPipelineStatusAsync(
ConnectorContext context,
string pipelineId,
CancellationToken ct = default);
/// <summary>
/// List available pipelines.
/// </summary>
Task<IReadOnlyList<PipelineInfo>> ListPipelinesAsync(
ConnectorContext context,
string? repository = null,
CancellationToken ct = default);
}
```
### Step Provider Registry
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Registry;
/// <summary>
/// Registry for discovering and querying step providers.
/// Builds on top of the unified plugin registry from Phase 100.
/// </summary>
public interface IStepProviderRegistry
{
/// <summary>
/// Get all registered step definitions.
/// </summary>
Task<IReadOnlyList<RegisteredStep>> GetAllStepsAsync(CancellationToken ct = default);
/// <summary>
/// Get steps by category.
/// </summary>
Task<IReadOnlyList<RegisteredStep>> GetStepsByCategoryAsync(
string category,
CancellationToken ct = default);
/// <summary>
/// Get a specific step definition.
/// </summary>
Task<RegisteredStep?> GetStepAsync(string stepType, CancellationToken ct = default);
/// <summary>
/// Get the plugin that provides a step.
/// </summary>
Task<IPlugin?> GetStepProviderPluginAsync(string stepType, CancellationToken ct = default);
/// <summary>
/// Execute a step using its provider.
/// </summary>
Task<StepResult> ExecuteStepAsync(
string stepType,
StepExecutionContext context,
CancellationToken ct = default);
}
public sealed record RegisteredStep(
StepDefinition Definition,
string PluginId,
string PluginVersion,
bool IsBuiltIn);
/// <summary>
/// Implementation that queries the unified plugin registry for step providers.
/// </summary>
public sealed class StepProviderRegistry : IStepProviderRegistry
{
private readonly IPluginHost _pluginHost;
private readonly IPluginRegistry _pluginRegistry;
private readonly ILogger<StepProviderRegistry> _logger;
public StepProviderRegistry(
IPluginHost pluginHost,
IPluginRegistry pluginRegistry,
ILogger<StepProviderRegistry> logger)
{
_pluginHost = pluginHost;
_pluginRegistry = pluginRegistry;
_logger = logger;
}
public async Task<IReadOnlyList<RegisteredStep>> GetAllStepsAsync(CancellationToken ct = default)
{
var steps = new List<RegisteredStep>();
// Query plugins with WorkflowStep capability
var stepProviders = await _pluginRegistry.QueryByCapabilityAsync(
PluginCapabilities.WorkflowStep, ct);
foreach (var pluginInfo in stepProviders)
{
var plugin = _pluginHost.GetPlugin(pluginInfo.Id);
if (plugin is IStepProviderCapability stepProvider)
{
var definitions = stepProvider.GetStepDefinitions();
foreach (var def in definitions)
{
steps.Add(new RegisteredStep(
Definition: def,
PluginId: pluginInfo.Id,
PluginVersion: pluginInfo.Version,
IsBuiltIn: plugin.TrustLevel == PluginTrustLevel.BuiltIn));
}
}
}
return steps;
}
public async Task<IReadOnlyList<RegisteredStep>> GetStepsByCategoryAsync(
string category,
CancellationToken ct = default)
{
var allSteps = await GetAllStepsAsync(ct);
return allSteps.Where(s =>
s.Definition.Category.Equals(category, StringComparison.OrdinalIgnoreCase))
.ToList();
}
public async Task<RegisteredStep?> GetStepAsync(string stepType, CancellationToken ct = default)
{
var allSteps = await GetAllStepsAsync(ct);
return allSteps.FirstOrDefault(s =>
s.Definition.Type.Equals(stepType, StringComparison.OrdinalIgnoreCase));
}
public async Task<IPlugin?> GetStepProviderPluginAsync(string stepType, CancellationToken ct = default)
{
var step = await GetStepAsync(stepType, ct);
if (step == null) return null;
return _pluginHost.GetPlugin(step.PluginId);
}
public async Task<StepResult> ExecuteStepAsync(
string stepType,
StepExecutionContext context,
CancellationToken ct = default)
{
var plugin = await GetStepProviderPluginAsync(stepType, ct);
if (plugin is not IStepProviderCapability stepProvider)
{
throw new InvalidOperationException($"No step provider found for type: {stepType}");
}
return await stepProvider.ExecuteStepAsync(context, ct);
}
}
```
### Gate Provider Registry
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Registry;
/// <summary>
/// Registry for discovering and querying gate providers.
/// </summary>
public interface IGateProviderRegistry
{
/// <summary>
/// Get all registered gate definitions.
/// </summary>
Task<IReadOnlyList<RegisteredGate>> GetAllGatesAsync(CancellationToken ct = default);
/// <summary>
/// Get gates by category.
/// </summary>
Task<IReadOnlyList<RegisteredGate>> GetGatesByCategoryAsync(
string category,
CancellationToken ct = default);
/// <summary>
/// Get a specific gate definition.
/// </summary>
Task<RegisteredGate?> GetGateAsync(string gateType, CancellationToken ct = default);
/// <summary>
/// Get the plugin that provides a gate.
/// </summary>
Task<IPlugin?> GetGateProviderPluginAsync(string gateType, CancellationToken ct = default);
/// <summary>
/// Evaluate a gate using its provider.
/// </summary>
Task<GateResult> EvaluateGateAsync(
string gateType,
GateEvaluationContext context,
CancellationToken ct = default);
}
public sealed record RegisteredGate(
GateDefinition Definition,
string PluginId,
string PluginVersion,
bool IsBuiltIn);
/// <summary>
/// Implementation that queries the unified plugin registry for gate providers.
/// </summary>
public sealed class GateProviderRegistry : IGateProviderRegistry
{
private readonly IPluginHost _pluginHost;
private readonly IPluginRegistry _pluginRegistry;
private readonly ILogger<GateProviderRegistry> _logger;
public GateProviderRegistry(
IPluginHost pluginHost,
IPluginRegistry pluginRegistry,
ILogger<GateProviderRegistry> logger)
{
_pluginHost = pluginHost;
_pluginRegistry = pluginRegistry;
_logger = logger;
}
public async Task<IReadOnlyList<RegisteredGate>> GetAllGatesAsync(CancellationToken ct = default)
{
var gates = new List<RegisteredGate>();
var gateProviders = await _pluginRegistry.QueryByCapabilityAsync(
PluginCapabilities.PromotionGate, ct);
foreach (var pluginInfo in gateProviders)
{
var plugin = _pluginHost.GetPlugin(pluginInfo.Id);
if (plugin is IGateProviderCapability gateProvider)
{
var definitions = gateProvider.GetGateDefinitions();
foreach (var def in definitions)
{
gates.Add(new RegisteredGate(
Definition: def,
PluginId: pluginInfo.Id,
PluginVersion: pluginInfo.Version,
IsBuiltIn: plugin.TrustLevel == PluginTrustLevel.BuiltIn));
}
}
}
return gates;
}
public async Task<IReadOnlyList<RegisteredGate>> GetGatesByCategoryAsync(
string category,
CancellationToken ct = default)
{
var allGates = await GetAllGatesAsync(ct);
return allGates.Where(g =>
g.Definition.Category.Equals(category, StringComparison.OrdinalIgnoreCase))
.ToList();
}
public async Task<RegisteredGate?> GetGateAsync(string gateType, CancellationToken ct = default)
{
var allGates = await GetAllGatesAsync(ct);
return allGates.FirstOrDefault(g =>
g.Definition.Type.Equals(gateType, StringComparison.OrdinalIgnoreCase));
}
public async Task<IPlugin?> GetGateProviderPluginAsync(string gateType, CancellationToken ct = default)
{
var gate = await GetGateAsync(gateType, ct);
if (gate == null) return null;
return _pluginHost.GetPlugin(gate.PluginId);
}
public async Task<GateResult> EvaluateGateAsync(
string gateType,
GateEvaluationContext context,
CancellationToken ct = default)
{
var plugin = await GetGateProviderPluginAsync(gateType, ct);
if (plugin is not IGateProviderCapability gateProvider)
{
throw new InvalidOperationException($"No gate provider found for type: {gateType}");
}
return await gateProvider.EvaluateGateAsync(context, ct);
}
}
```
### Connector Registry
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Registry;
/// <summary>
/// Registry for discovering and querying integration connectors.
/// </summary>
public interface IConnectorRegistry
{
/// <summary>
/// Get all registered connectors.
/// </summary>
Task<IReadOnlyList<RegisteredConnector>> GetAllConnectorsAsync(CancellationToken ct = default);
/// <summary>
/// Get connectors by category.
/// </summary>
Task<IReadOnlyList<RegisteredConnector>> GetConnectorsByCategoryAsync(
ConnectorCategory category,
CancellationToken ct = default);
/// <summary>
/// Get a specific connector.
/// </summary>
Task<RegisteredConnector?> GetConnectorAsync(string connectorType, CancellationToken ct = default);
/// <summary>
/// Get the plugin that provides a connector.
/// </summary>
Task<IPlugin?> GetConnectorPluginAsync(string connectorType, CancellationToken ct = default);
}
public sealed record RegisteredConnector(
string Type,
string DisplayName,
ConnectorCategory Category,
string PluginId,
string PluginVersion,
IReadOnlyList<string> SupportedOperations,
bool IsBuiltIn);
/// <summary>
/// Implementation that queries the unified plugin registry for connectors.
/// </summary>
public sealed class ConnectorRegistry : IConnectorRegistry
{
private readonly IPluginHost _pluginHost;
private readonly IPluginRegistry _pluginRegistry;
private readonly ILogger<ConnectorRegistry> _logger;
private static readonly Dictionary<ConnectorCategory, PluginCapabilities> CategoryToCapability = new()
{
[ConnectorCategory.Scm] = PluginCapabilities.Scm,
[ConnectorCategory.Ci] = PluginCapabilities.Ci,
[ConnectorCategory.Registry] = PluginCapabilities.ContainerRegistry,
[ConnectorCategory.Vault] = PluginCapabilities.SecretsVault,
[ConnectorCategory.Notify] = PluginCapabilities.Notification
};
public ConnectorRegistry(
IPluginHost pluginHost,
IPluginRegistry pluginRegistry,
ILogger<ConnectorRegistry> logger)
{
_pluginHost = pluginHost;
_pluginRegistry = pluginRegistry;
_logger = logger;
}
public async Task<IReadOnlyList<RegisteredConnector>> GetAllConnectorsAsync(CancellationToken ct = default)
{
var connectors = new List<RegisteredConnector>();
foreach (var (category, capability) in CategoryToCapability)
{
var categoryConnectors = await GetConnectorsByCategoryAsync(category, ct);
connectors.AddRange(categoryConnectors);
}
return connectors;
}
public async Task<IReadOnlyList<RegisteredConnector>> GetConnectorsByCategoryAsync(
ConnectorCategory category,
CancellationToken ct = default)
{
var connectors = new List<RegisteredConnector>();
if (!CategoryToCapability.TryGetValue(category, out var capability))
return connectors;
var plugins = await _pluginRegistry.QueryByCapabilityAsync(capability, ct);
foreach (var pluginInfo in plugins)
{
var plugin = _pluginHost.GetPlugin(pluginInfo.Id);
if (plugin is IIntegrationConnectorCapability connector)
{
connectors.Add(new RegisteredConnector(
Type: connector.ConnectorType,
DisplayName: connector.DisplayName,
Category: connector.Category,
PluginId: pluginInfo.Id,
PluginVersion: pluginInfo.Version,
SupportedOperations: connector.GetSupportedOperations(),
IsBuiltIn: plugin.TrustLevel == PluginTrustLevel.BuiltIn));
}
}
return connectors;
}
public async Task<RegisteredConnector?> GetConnectorAsync(string connectorType, CancellationToken ct = default)
{
var all = await GetAllConnectorsAsync(ct);
return all.FirstOrDefault(c =>
c.Type.Equals(connectorType, StringComparison.OrdinalIgnoreCase));
}
public async Task<IPlugin?> GetConnectorPluginAsync(string connectorType, CancellationToken ct = default)
{
var connector = await GetConnectorAsync(connectorType, ct);
if (connector == null) return null;
return _pluginHost.GetPlugin(connector.PluginId);
}
}
```
---
## Acceptance Criteria
- [ ] `IStepProviderCapability` interface defined with full step lifecycle
- [ ] `IGateProviderCapability` interface defined with gate evaluation
- [ ] Integration connector interfaces defined for all categories (SCM, CI, Registry, Vault, Notify)
- [ ] `StepProviderRegistry` queries plugins from unified registry
- [ ] `GateProviderRegistry` queries plugins from unified registry
- [ ] `ConnectorRegistry` queries plugins from unified registry
- [ ] Step execution routing works through registry
- [ ] Gate evaluation routing works through registry
- [ ] Unit test coverage >= 90%
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `StepProviderRegistry_ReturnsStepsFromPlugins` | Registry queries plugins correctly |
| `GateProviderRegistry_ReturnsGatesFromPlugins` | Registry queries plugins correctly |
| `ConnectorRegistry_FiltersByCategory` | Category filtering works |
| `StepExecution_RoutesToCorrectPlugin` | Step execution routing |
| `GateEvaluation_RoutesToCorrectPlugin` | Gate evaluation routing |
### Integration Tests
| Test | Description |
|------|-------------|
| `BuiltInStepsAvailable` | Built-in steps discoverable |
| `BuiltInGatesAvailable` | Built-in gates discoverable |
| `ThirdPartyPluginIntegration` | Third-party plugins integrate |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 100_001 Plugin Abstractions | Internal | TODO |
| 100_002 Plugin Host | Internal | TODO |
| 100_003 Plugin Registry | Internal | TODO |
| 101_001 Database Schema | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IStepProviderCapability interface | TODO | |
| IGateProviderCapability interface | TODO | |
| IScmConnectorCapability interface | TODO | |
| IRegistryConnectorCapability interface | TODO | |
| IVaultConnectorCapability interface | TODO | |
| INotifyConnectorCapability interface | TODO | |
| ICiConnectorCapability interface | TODO | |
| StepProviderRegistry | TODO | |
| GateProviderRegistry | TODO | |
| ConnectorRegistry | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 10-Jan-2026 | Refocused on Release Orchestrator-specific extensions (builds on Phase 100 core) |

View File

@@ -0,0 +1,935 @@
# SPRINT: Plugin Loader & Sandbox Extensions for Release Orchestrator
> **Sprint ID:** 101_003
> **Module:** PLUGIN
> **Phase:** 1 - Foundation
> **Status:** TODO
> **Parent:** [101_000_INDEX](SPRINT_20260110_101_000_INDEX_foundation.md)
> **Prerequisites:** [100_002 Plugin Host](SPRINT_20260110_100_002_PLUGIN_host.md), [100_004 Plugin Sandbox](SPRINT_20260110_100_004_PLUGIN_sandbox.md), [101_002 Registry Extensions](SPRINT_20260110_101_002_PLUGIN_registry.md)
---
## Overview
Extend the unified plugin host and sandbox (from Phase 100) with Release Orchestrator-specific execution contexts, service integrations, and domain-specific lifecycle management. This sprint builds on the core plugin infrastructure to add release orchestration capabilities.
> **Note:** The core plugin host (`IPluginHost`, `PluginHost`, lifecycle management) and sandbox infrastructure (`ISandbox`, `ProcessSandbox`, resource limits) are implemented in Phase 100 sprints 100_002 and 100_004. This sprint adds Release Orchestrator domain-specific extensions.
### Objectives
- Create Release Orchestrator plugin context extensions
- Implement step execution context with workflow integration
- Implement gate evaluation context with promotion integration
- Add connector context with tenant-aware secret resolution
- Integrate with Release Orchestrator services (secrets, evidence, notifications)
- Add domain-specific health monitoring
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Plugin/
│ ├── Context/
│ │ ├── ReleaseOrchestratorPluginContext.cs
│ │ ├── StepExecutionContextBuilder.cs
│ │ ├── GateEvaluationContextBuilder.cs
│ │ └── ConnectorContextBuilder.cs
│ ├── Integration/
│ │ ├── TenantSecretResolver.cs
│ │ ├── EvidenceCollector.cs
│ │ ├── NotificationBridge.cs
│ │ └── AuditLogger.cs
│ ├── Execution/
│ │ ├── StepExecutor.cs
│ │ ├── GateEvaluator.cs
│ │ └── ConnectorInvoker.cs
│ └── Monitoring/
│ ├── ReleaseOrchestratorPluginMonitor.cs
│ └── PluginMetricsCollector.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Plugin.Tests/
├── StepExecutorTests.cs
├── GateEvaluatorTests.cs
└── ConnectorInvokerTests.cs
```
---
## Architecture Reference
- [Phase 100 Plugin System](SPRINT_20260110_100_000_INDEX_plugin_unification.md)
- [Workflow Engine](../modules/release-orchestrator/modules/workflow-engine.md)
- [Promotion Gates](../modules/release-orchestrator/modules/promotion-gates.md)
---
## Deliverables
### Release Orchestrator Plugin Context Extensions
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Context;
/// <summary>
/// Extended plugin context for Release Orchestrator with domain-specific services.
/// Wraps the base IPluginContext from Phase 100.
/// </summary>
public sealed class ReleaseOrchestratorPluginContext : IPluginContext
{
private readonly IPluginContext _baseContext;
private readonly ITenantSecretResolver _secretResolver;
private readonly IEvidenceCollector _evidenceCollector;
private readonly INotificationBridge _notificationBridge;
private readonly IAuditLogger _auditLogger;
public ReleaseOrchestratorPluginContext(
IPluginContext baseContext,
ITenantSecretResolver secretResolver,
IEvidenceCollector evidenceCollector,
INotificationBridge notificationBridge,
IAuditLogger auditLogger)
{
_baseContext = baseContext;
_secretResolver = secretResolver;
_evidenceCollector = evidenceCollector;
_notificationBridge = notificationBridge;
_auditLogger = auditLogger;
}
// Delegate to base context
public IPluginConfiguration Configuration => _baseContext.Configuration;
public IPluginLogger Logger => _baseContext.Logger;
public TimeProvider TimeProvider => _baseContext.TimeProvider;
public IHttpClientFactory HttpClientFactory => _baseContext.HttpClientFactory;
public IGuidGenerator GuidGenerator => _baseContext.GuidGenerator;
// Release Orchestrator-specific services
public ITenantSecretResolver SecretResolver => _secretResolver;
public IEvidenceCollector EvidenceCollector => _evidenceCollector;
public INotificationBridge NotificationBridge => _notificationBridge;
public IAuditLogger AuditLogger => _auditLogger;
/// <summary>
/// Create a scoped context for a specific tenant.
/// </summary>
public ReleaseOrchestratorPluginContext ForTenant(Guid tenantId)
{
return new ReleaseOrchestratorPluginContext(
_baseContext,
_secretResolver.ForTenant(tenantId),
_evidenceCollector.ForTenant(tenantId),
_notificationBridge.ForTenant(tenantId),
_auditLogger.ForTenant(tenantId));
}
}
```
### Tenant-Aware Secret Resolution
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Integration;
/// <summary>
/// Resolves secrets with tenant isolation and vault connector integration.
/// </summary>
public interface ITenantSecretResolver : ISecretResolver
{
/// <summary>
/// Create a resolver scoped to a specific tenant.
/// </summary>
ITenantSecretResolver ForTenant(Guid tenantId);
/// <summary>
/// Resolve a secret using a specific vault integration.
/// </summary>
Task<string?> ResolveFromVaultAsync(
Guid integrationId,
string secretPath,
CancellationToken ct = default);
/// <summary>
/// Resolve secret references in configuration.
/// Handles patterns like ${vault:integration-id/path/to/secret}
/// </summary>
Task<JsonElement> ResolveConfigurationSecretsAsync(
JsonElement configuration,
CancellationToken ct = default);
}
public sealed class TenantSecretResolver : ITenantSecretResolver
{
private readonly IConnectorRegistry _connectorRegistry;
private readonly IPluginHost _pluginHost;
private readonly ILogger<TenantSecretResolver> _logger;
private Guid? _tenantId;
public TenantSecretResolver(
IConnectorRegistry connectorRegistry,
IPluginHost pluginHost,
ILogger<TenantSecretResolver> logger)
{
_connectorRegistry = connectorRegistry;
_pluginHost = pluginHost;
_logger = logger;
}
public ITenantSecretResolver ForTenant(Guid tenantId)
{
return new TenantSecretResolver(_connectorRegistry, _pluginHost, _logger)
{
_tenantId = tenantId
};
}
public async Task<string?> ResolveAsync(string key, CancellationToken ct = default)
{
// First try environment variables
var envValue = Environment.GetEnvironmentVariable(key);
if (envValue != null) return envValue;
// Then try configured secrets store
// Implementation depends on deployment configuration
return null;
}
public async Task<string?> ResolveFromVaultAsync(
Guid integrationId,
string secretPath,
CancellationToken ct = default)
{
if (_tenantId == null)
throw new InvalidOperationException("Tenant ID not set. Call ForTenant first.");
// Find vault connector for this integration
var connector = await _connectorRegistry.GetConnectorAsync("vault", ct);
if (connector == null)
{
_logger.LogWarning("No vault connector found for integration {IntegrationId}", integrationId);
return null;
}
var plugin = await _connectorRegistry.GetConnectorPluginAsync(connector.Type, ct);
if (plugin is not IVaultConnectorCapability vaultConnector)
{
_logger.LogWarning("Connector is not a vault connector");
return null;
}
var context = new ConnectorContext(
IntegrationId: integrationId,
TenantId: _tenantId.Value,
Configuration: JsonDocument.Parse("{}").RootElement, // Loaded from DB
SecretResolver: this,
Logger: new PluginLoggerAdapter(_logger));
var secret = await vaultConnector.GetSecretAsync(context, secretPath, ct);
return secret?.Value;
}
public async Task<JsonElement> ResolveConfigurationSecretsAsync(
JsonElement configuration,
CancellationToken ct = default)
{
// Parse and resolve ${vault:...} patterns in configuration
var json = configuration.GetRawText();
var pattern = new Regex(@"\$\{vault:([^/]+)/([^}]+)\}");
var matches = pattern.Matches(json);
foreach (Match match in matches)
{
var integrationId = Guid.Parse(match.Groups[1].Value);
var secretPath = match.Groups[2].Value;
var secretValue = await ResolveFromVaultAsync(integrationId, secretPath, ct);
if (secretValue != null)
{
json = json.Replace(match.Value, secretValue);
}
}
return JsonDocument.Parse(json).RootElement;
}
}
```
### Step Executor
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Execution;
/// <summary>
/// Executes workflow steps with full context integration.
/// </summary>
public interface IStepExecutor
{
/// <summary>
/// Execute a step in the context of a workflow run.
/// </summary>
Task<StepExecutionResult> ExecuteAsync(
StepExecutionRequest request,
CancellationToken ct = default);
}
public sealed record StepExecutionRequest(
Guid StepId,
Guid WorkflowRunId,
Guid TenantId,
string StepType,
JsonElement Configuration,
IReadOnlyDictionary<string, object> Inputs,
TimeSpan Timeout);
public sealed record StepExecutionResult(
StepStatus Status,
IReadOnlyDictionary<string, object> Outputs,
TimeSpan Duration,
string? ErrorMessage,
IReadOnlyList<StepLogEntry> Logs,
EvidencePacket? Evidence);
public sealed record StepLogEntry(
DateTimeOffset Timestamp,
LogLevel Level,
string Message);
public sealed class StepExecutor : IStepExecutor
{
private readonly IStepProviderRegistry _stepRegistry;
private readonly ITenantSecretResolver _secretResolver;
private readonly IEvidenceCollector _evidenceCollector;
private readonly IAuditLogger _auditLogger;
private readonly ILogger<StepExecutor> _logger;
private readonly TimeProvider _timeProvider;
public StepExecutor(
IStepProviderRegistry stepRegistry,
ITenantSecretResolver secretResolver,
IEvidenceCollector evidenceCollector,
IAuditLogger auditLogger,
ILogger<StepExecutor> logger,
TimeProvider timeProvider)
{
_stepRegistry = stepRegistry;
_secretResolver = secretResolver;
_evidenceCollector = evidenceCollector;
_auditLogger = auditLogger;
_logger = logger;
_timeProvider = timeProvider;
}
public async Task<StepExecutionResult> ExecuteAsync(
StepExecutionRequest request,
CancellationToken ct = default)
{
var startTime = _timeProvider.GetUtcNow();
var logs = new List<StepLogEntry>();
var outputWriter = new BufferedStepOutputWriter();
// Resolve secrets in configuration
var resolvedConfig = await _secretResolver
.ForTenant(request.TenantId)
.ResolveConfigurationSecretsAsync(request.Configuration, ct);
// Create execution context
var context = new StepExecutionContext(
StepId: request.StepId,
WorkflowRunId: request.WorkflowRunId,
TenantId: request.TenantId,
StepType: request.StepType,
Configuration: resolvedConfig,
Inputs: request.Inputs,
OutputWriter: outputWriter,
Logger: new StepLogger(logs, _logger));
// Log execution start
await _auditLogger.LogAsync(new AuditEntry(
EventType: "step.execution.started",
TenantId: request.TenantId,
ResourceType: "workflow_step",
ResourceId: request.StepId.ToString(),
Details: new Dictionary<string, object>
{
["stepType"] = request.StepType,
["workflowRunId"] = request.WorkflowRunId
}));
try
{
// Execute with timeout
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(request.Timeout);
var result = await _stepRegistry.ExecuteStepAsync(
request.StepType,
context,
cts.Token);
var duration = _timeProvider.GetUtcNow() - startTime;
// Collect evidence if step succeeded
EvidencePacket? evidence = null;
if (result.Status == StepStatus.Succeeded)
{
evidence = await _evidenceCollector
.ForTenant(request.TenantId)
.CollectStepEvidenceAsync(request.StepId, result, ct);
}
// Log execution completion
await _auditLogger.LogAsync(new AuditEntry(
EventType: "step.execution.completed",
TenantId: request.TenantId,
ResourceType: "workflow_step",
ResourceId: request.StepId.ToString(),
Details: new Dictionary<string, object>
{
["stepType"] = request.StepType,
["status"] = result.Status.ToString(),
["durationMs"] = duration.TotalMilliseconds
}));
return new StepExecutionResult(
Status: result.Status,
Outputs: result.Outputs,
Duration: duration,
ErrorMessage: result.ErrorMessage,
Logs: logs,
Evidence: evidence);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
var duration = _timeProvider.GetUtcNow() - startTime;
await _auditLogger.LogAsync(new AuditEntry(
EventType: "step.execution.timeout",
TenantId: request.TenantId,
ResourceType: "workflow_step",
ResourceId: request.StepId.ToString(),
Details: new Dictionary<string, object>
{
["stepType"] = request.StepType,
["timeoutMs"] = request.Timeout.TotalMilliseconds
}));
return new StepExecutionResult(
Status: StepStatus.TimedOut,
Outputs: new Dictionary<string, object>(),
Duration: duration,
ErrorMessage: $"Step timed out after {request.Timeout.TotalSeconds}s",
Logs: logs,
Evidence: null);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
_logger.LogError(ex, "Step execution failed: {StepType}", request.StepType);
await _auditLogger.LogAsync(new AuditEntry(
EventType: "step.execution.failed",
TenantId: request.TenantId,
ResourceType: "workflow_step",
ResourceId: request.StepId.ToString(),
Details: new Dictionary<string, object>
{
["stepType"] = request.StepType,
["error"] = ex.Message
}));
return new StepExecutionResult(
Status: StepStatus.Failed,
Outputs: new Dictionary<string, object>(),
Duration: duration,
ErrorMessage: ex.Message,
Logs: logs,
Evidence: null);
}
}
}
```
### Gate Evaluator
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Execution;
/// <summary>
/// Evaluates promotion gates with full context integration.
/// </summary>
public interface IGateEvaluator
{
/// <summary>
/// Evaluate a gate for a promotion.
/// </summary>
Task<GateEvaluationResult> EvaluateAsync(
GateEvaluationRequest request,
CancellationToken ct = default);
}
public sealed record GateEvaluationRequest(
Guid GateId,
Guid PromotionId,
Guid ReleaseId,
Guid SourceEnvironmentId,
Guid TargetEnvironmentId,
Guid TenantId,
string GateType,
JsonElement Configuration);
public sealed record GateEvaluationResult(
GateStatus Status,
string Message,
IReadOnlyDictionary<string, object> Details,
IReadOnlyList<GateEvidence> Evidence,
TimeSpan Duration,
bool CanOverride,
IReadOnlyList<string> OverridePermissions);
public sealed class GateEvaluator : IGateEvaluator
{
private readonly IGateProviderRegistry _gateRegistry;
private readonly ITenantSecretResolver _secretResolver;
private readonly IEvidenceCollector _evidenceCollector;
private readonly IReleaseRepository _releaseRepository;
private readonly IEnvironmentRepository _environmentRepository;
private readonly IAuditLogger _auditLogger;
private readonly ILogger<GateEvaluator> _logger;
private readonly TimeProvider _timeProvider;
public GateEvaluator(
IGateProviderRegistry gateRegistry,
ITenantSecretResolver secretResolver,
IEvidenceCollector evidenceCollector,
IReleaseRepository releaseRepository,
IEnvironmentRepository environmentRepository,
IAuditLogger auditLogger,
ILogger<GateEvaluator> logger,
TimeProvider timeProvider)
{
_gateRegistry = gateRegistry;
_secretResolver = secretResolver;
_evidenceCollector = evidenceCollector;
_releaseRepository = releaseRepository;
_environmentRepository = environmentRepository;
_auditLogger = auditLogger;
_logger = logger;
_timeProvider = timeProvider;
}
public async Task<GateEvaluationResult> EvaluateAsync(
GateEvaluationRequest request,
CancellationToken ct = default)
{
var startTime = _timeProvider.GetUtcNow();
// Load release and environment info
var release = await _releaseRepository.GetAsync(request.ReleaseId, ct)
?? throw new InvalidOperationException($"Release not found: {request.ReleaseId}");
var targetEnvironment = await _environmentRepository.GetAsync(request.TargetEnvironmentId, ct)
?? throw new InvalidOperationException($"Environment not found: {request.TargetEnvironmentId}");
// Get gate definition
var gateDefinition = await _gateRegistry.GetGateAsync(request.GateType, ct);
if (gateDefinition == null)
{
throw new InvalidOperationException($"Gate type not found: {request.GateType}");
}
// Resolve secrets in configuration
var resolvedConfig = await _secretResolver
.ForTenant(request.TenantId)
.ResolveConfigurationSecretsAsync(request.Configuration, ct);
// Create evaluation context
var context = new GateEvaluationContext(
GateId: request.GateId,
PromotionId: request.PromotionId,
ReleaseId: request.ReleaseId,
SourceEnvironmentId: request.SourceEnvironmentId,
TargetEnvironmentId: request.TargetEnvironmentId,
TenantId: request.TenantId,
GateType: request.GateType,
Configuration: resolvedConfig,
Release: release.ToReleaseInfo(),
TargetEnvironment: targetEnvironment.ToEnvironmentInfo(),
Logger: new GateLogger(_logger));
// Log evaluation start
await _auditLogger.LogAsync(new AuditEntry(
EventType: "gate.evaluation.started",
TenantId: request.TenantId,
ResourceType: "promotion_gate",
ResourceId: request.GateId.ToString(),
Details: new Dictionary<string, object>
{
["gateType"] = request.GateType,
["promotionId"] = request.PromotionId,
["releaseId"] = request.ReleaseId
}));
try
{
var result = await _gateRegistry.EvaluateGateAsync(
request.GateType,
context,
ct);
var duration = _timeProvider.GetUtcNow() - startTime;
// Collect evidence
var evidence = result.Evidence?.ToList() ?? new List<GateEvidence>();
// Add evaluation metadata as evidence
evidence.Add(new GateEvidence(
Type: "gate_evaluation_metadata",
Description: "Gate evaluation details",
Data: JsonSerializer.SerializeToElement(new
{
gateType = request.GateType,
evaluatedAt = _timeProvider.GetUtcNow(),
durationMs = duration.TotalMilliseconds,
status = result.Status.ToString()
})));
// Log evaluation completion
await _auditLogger.LogAsync(new AuditEntry(
EventType: "gate.evaluation.completed",
TenantId: request.TenantId,
ResourceType: "promotion_gate",
ResourceId: request.GateId.ToString(),
Details: new Dictionary<string, object>
{
["gateType"] = request.GateType,
["status"] = result.Status.ToString(),
["message"] = result.Message,
["durationMs"] = duration.TotalMilliseconds
}));
return new GateEvaluationResult(
Status: result.Status,
Message: result.Message,
Details: result.Details,
Evidence: evidence,
Duration: duration,
CanOverride: gateDefinition.Definition.SupportsOverride,
OverridePermissions: gateDefinition.Definition.RequiredPermissions);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
_logger.LogError(ex, "Gate evaluation failed: {GateType}", request.GateType);
await _auditLogger.LogAsync(new AuditEntry(
EventType: "gate.evaluation.failed",
TenantId: request.TenantId,
ResourceType: "promotion_gate",
ResourceId: request.GateId.ToString(),
Details: new Dictionary<string, object>
{
["gateType"] = request.GateType,
["error"] = ex.Message
}));
return new GateEvaluationResult(
Status: GateStatus.Failed,
Message: $"Gate evaluation error: {ex.Message}",
Details: new Dictionary<string, object> { ["exception"] = ex.GetType().Name },
Evidence: new List<GateEvidence>(),
Duration: duration,
CanOverride: gateDefinition.Definition.SupportsOverride,
OverridePermissions: gateDefinition.Definition.RequiredPermissions);
}
}
}
```
### Evidence Collector Integration
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Integration;
/// <summary>
/// Collects evidence from plugin executions for audit trails.
/// </summary>
public interface IEvidenceCollector
{
/// <summary>
/// Create a collector scoped to a specific tenant.
/// </summary>
IEvidenceCollector ForTenant(Guid tenantId);
/// <summary>
/// Collect evidence from a step execution.
/// </summary>
Task<EvidencePacket> CollectStepEvidenceAsync(
Guid stepId,
StepResult result,
CancellationToken ct = default);
/// <summary>
/// Collect evidence from a gate evaluation.
/// </summary>
Task<EvidencePacket> CollectGateEvidenceAsync(
Guid gateId,
GateResult result,
CancellationToken ct = default);
/// <summary>
/// Collect evidence from a connector operation.
/// </summary>
Task<EvidencePacket> CollectConnectorEvidenceAsync(
Guid integrationId,
string operation,
JsonElement result,
CancellationToken ct = default);
}
public sealed record EvidencePacket(
Guid Id,
string Type,
Guid TenantId,
DateTimeOffset CollectedAt,
string ContentDigest,
JsonElement Content,
IReadOnlyDictionary<string, string> Metadata);
```
### Plugin Metrics Collector
```csharp
namespace StellaOps.ReleaseOrchestrator.Plugin.Monitoring;
/// <summary>
/// Collects metrics from Release Orchestrator plugin executions.
/// </summary>
public sealed class ReleaseOrchestratorPluginMonitor : IHostedService
{
private readonly IPluginHost _pluginHost;
private readonly IStepProviderRegistry _stepRegistry;
private readonly IGateProviderRegistry _gateRegistry;
private readonly IConnectorRegistry _connectorRegistry;
private readonly IMeterFactory _meterFactory;
private readonly ILogger<ReleaseOrchestratorPluginMonitor> _logger;
private readonly TimeSpan _monitoringInterval = TimeSpan.FromSeconds(30);
private Timer? _timer;
private Meter _meter;
private Counter<long> _stepExecutionCounter;
private Counter<long> _gateEvaluationCounter;
private Counter<long> _connectorOperationCounter;
private Histogram<double> _stepExecutionDuration;
private Histogram<double> _gateEvaluationDuration;
public ReleaseOrchestratorPluginMonitor(
IPluginHost pluginHost,
IStepProviderRegistry stepRegistry,
IGateProviderRegistry gateRegistry,
IConnectorRegistry connectorRegistry,
IMeterFactory meterFactory,
ILogger<ReleaseOrchestratorPluginMonitor> logger)
{
_pluginHost = pluginHost;
_stepRegistry = stepRegistry;
_gateRegistry = gateRegistry;
_connectorRegistry = connectorRegistry;
_meterFactory = meterFactory;
_logger = logger;
InitializeMetrics();
}
private void InitializeMetrics()
{
_meter = _meterFactory.Create("StellaOps.ReleaseOrchestrator.Plugin");
_stepExecutionCounter = _meter.CreateCounter<long>(
"stellaops_step_executions_total",
description: "Total number of step executions");
_gateEvaluationCounter = _meter.CreateCounter<long>(
"stellaops_gate_evaluations_total",
description: "Total number of gate evaluations");
_connectorOperationCounter = _meter.CreateCounter<long>(
"stellaops_connector_operations_total",
description: "Total number of connector operations");
_stepExecutionDuration = _meter.CreateHistogram<double>(
"stellaops_step_execution_duration_ms",
unit: "ms",
description: "Step execution duration in milliseconds");
_gateEvaluationDuration = _meter.CreateHistogram<double>(
"stellaops_gate_evaluation_duration_ms",
unit: "ms",
description: "Gate evaluation duration in milliseconds");
}
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(
MonitorPlugins,
null,
TimeSpan.FromSeconds(10),
_monitoringInterval);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
private async void MonitorPlugins(object? state)
{
try
{
// Collect plugin health status
var plugins = _pluginHost.GetLoadedPlugins();
foreach (var pluginInfo in plugins)
{
var plugin = _pluginHost.GetPlugin(pluginInfo.Id);
if (plugin != null)
{
var health = await plugin.HealthCheckAsync(CancellationToken.None);
_logger.LogDebug(
"Plugin {PluginId} health: {Status}",
pluginInfo.Id,
health.Status);
}
}
// Count available steps, gates, connectors
var steps = await _stepRegistry.GetAllStepsAsync();
var gates = await _gateRegistry.GetAllGatesAsync();
var connectors = await _connectorRegistry.GetAllConnectorsAsync();
_logger.LogDebug(
"Plugin inventory: {Steps} steps, {Gates} gates, {Connectors} connectors",
steps.Count, gates.Count, connectors.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error monitoring plugins");
}
}
/// <summary>
/// Record a step execution metric.
/// </summary>
public void RecordStepExecution(string stepType, StepStatus status, TimeSpan duration)
{
_stepExecutionCounter.Add(1,
new KeyValuePair<string, object?>("step_type", stepType),
new KeyValuePair<string, object?>("status", status.ToString()));
_stepExecutionDuration.Record(duration.TotalMilliseconds,
new KeyValuePair<string, object?>("step_type", stepType));
}
/// <summary>
/// Record a gate evaluation metric.
/// </summary>
public void RecordGateEvaluation(string gateType, GateStatus status, TimeSpan duration)
{
_gateEvaluationCounter.Add(1,
new KeyValuePair<string, object?>("gate_type", gateType),
new KeyValuePair<string, object?>("status", status.ToString()));
_gateEvaluationDuration.Record(duration.TotalMilliseconds,
new KeyValuePair<string, object?>("gate_type", gateType));
}
/// <summary>
/// Record a connector operation metric.
/// </summary>
public void RecordConnectorOperation(string connectorType, string operation, bool success)
{
_connectorOperationCounter.Add(1,
new KeyValuePair<string, object?>("connector_type", connectorType),
new KeyValuePair<string, object?>("operation", operation),
new KeyValuePair<string, object?>("success", success));
}
}
```
---
## Acceptance Criteria
- [ ] `ReleaseOrchestratorPluginContext` wraps base context with domain services
- [ ] `TenantSecretResolver` resolves secrets with tenant isolation
- [ ] Secret reference patterns (`${vault:...}`) resolved in configuration
- [ ] `StepExecutor` executes steps with full context integration
- [ ] `GateEvaluator` evaluates gates with evidence collection
- [ ] Audit logging for all plugin executions
- [ ] Evidence collection for steps and gates
- [ ] Plugin metrics exposed via OpenTelemetry
- [ ] Unit test coverage >= 85%
- [ ] Integration tests with mock plugins
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `TenantSecretResolver_IsolatesTenants` | Tenant isolation works |
| `SecretPattern_ResolvedInConfig` | ${vault:...} patterns resolved |
| `StepExecutor_RecordsAuditLogs` | Audit logging works |
| `StepExecutor_CollectsEvidence` | Evidence collected on success |
| `StepExecutor_HandlesTimeout` | Timeout handling works |
| `GateEvaluator_ReturnsOverrideInfo` | Override permissions returned |
| `PluginMonitor_CollectsMetrics` | Metrics recorded correctly |
### Integration Tests
| Test | Description |
|------|-------------|
| `StepExecutor_ExecutesBuiltInStep` | Built-in step execution |
| `GateEvaluator_EvaluatesBuiltInGate` | Built-in gate evaluation |
| `EvidenceCollector_PersistsEvidence` | Evidence persisted to storage |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 100_002 Plugin Host | Internal | TODO |
| 100_004 Plugin Sandbox | Internal | TODO |
| 101_002 Registry Extensions | Internal | TODO |
| 101_001 Database Schema | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ReleaseOrchestratorPluginContext | TODO | |
| TenantSecretResolver | TODO | |
| StepExecutor | TODO | |
| GateEvaluator | TODO | |
| ConnectorInvoker | TODO | |
| EvidenceCollector | TODO | |
| AuditLogger integration | TODO | |
| ReleaseOrchestratorPluginMonitor | TODO | |
| Unit tests | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 10-Jan-2026 | Refocused on Release Orchestrator-specific extensions (builds on Phase 100 core) |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
# SPRINT INDEX: Phase 2 - Integration Hub
> **Epic:** Release Orchestrator
> **Phase:** 2 - Integration Hub
> **Batch:** 102
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 2 builds the Integration Hub - the system for connecting to external SCM, CI, Registry, Vault, and Notification services. Includes the connector runtime and built-in connectors.
### Objectives
- Implement Integration Manager for CRUD operations
- Build Connector Runtime for plugin execution
- Create built-in SCM connectors (GitHub, GitLab, Gitea)
- Create built-in Registry connectors (Docker Hub, Harbor, ACR, ECR, GCR)
- Create built-in Vault connector
- Implement Doctor checks for integration health
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 102_001 | Integration Manager | INTHUB | TODO | 101_002 |
| 102_002 | Connector Runtime | INTHUB | TODO | 102_001 |
| 102_003 | Built-in SCM Connectors | INTHUB | TODO | 102_002 |
| 102_004 | Built-in Registry Connectors | INTHUB | TODO | 102_002 |
| 102_005 | Built-in Vault Connector | INTHUB | TODO | 102_002 |
| 102_006 | Doctor Checks | INTHUB | TODO | 102_002 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ INTEGRATION HUB │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ INTEGRATION MANAGER (102_001) │ │
│ │ │ │
│ │ - Integration CRUD - Config encryption │ │
│ │ - Health status tracking - Integration events │ │
│ │ - Tenant isolation - Audit logging │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ CONNECTOR RUNTIME (102_002) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Connector │ │ Connector │ │ Pool │ │ │
│ │ │ Factory │ │ Pool │ │ Manager │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Retry │ │ Circuit │ │ Rate │ │ │
│ │ │ Policy │ │ Breaker │ │ Limiter │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ BUILT-IN CONNECTORS │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ SCM (102_003) │ │ Registry (102_004) │ │ │
│ │ │ │ │ │ │ │
│ │ │ - GitHub │ │ - Docker Hub │ │ │
│ │ │ - GitLab │ │ - Harbor │ │ │
│ │ │ - Gitea │ │ - ACR / ECR / GCR │ │ │
│ │ │ - Azure DevOps │ │ - Generic OCI │ │ │
│ │ └─────────────────────┘ └─────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ Vault (102_005) │ │ Doctor (102_006) │ │ │
│ │ │ │ │ │ │ │
│ │ │ - HashiCorp Vault │ │ - Connectivity │ │ │
│ │ │ - Azure Key Vault │ │ - Credentials │ │ │
│ │ │ - AWS Secrets Mgr │ │ - Permissions │ │ │
│ │ │ │ │ - Rate limits │ │ │
│ │ └─────────────────────┘ └─────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 102_001: Integration Manager
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IIntegrationManager` | Interface | CRUD operations |
| `IntegrationManager` | Class | Implementation |
| `IntegrationStore` | Class | Database persistence |
| `IntegrationEncryption` | Class | Config encryption |
| `IntegrationEvents` | Events | Domain events |
### 102_002: Connector Runtime
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IConnectorFactory` | Interface | Creates connectors |
| `ConnectorFactory` | Class | Plugin-aware factory |
| `ConnectorPool` | Class | Connection pooling |
| `ConnectorRetryPolicy` | Class | Retry with backoff |
| `ConnectorCircuitBreaker` | Class | Fault tolerance |
| `ConnectorRateLimiter` | Class | Rate limiting |
### 102_003: Built-in SCM Connectors
| Deliverable | Type | Description |
|-------------|------|-------------|
| `GitHubConnector` | Connector | GitHub.com / GHE |
| `GitLabConnector` | Connector | GitLab.com / Self-hosted |
| `GiteaConnector` | Connector | Gitea self-hosted |
| `AzureDevOpsConnector` | Connector | Azure DevOps Services |
### 102_004: Built-in Registry Connectors
| Deliverable | Type | Description |
|-------------|------|-------------|
| `DockerHubConnector` | Connector | Docker Hub |
| `HarborConnector` | Connector | Harbor registry |
| `AcrConnector` | Connector | Azure Container Registry |
| `EcrConnector` | Connector | AWS ECR |
| `GcrConnector` | Connector | Google Container Registry |
| `GenericOciConnector` | Connector | Any OCI-compliant registry |
### 102_005: Built-in Vault Connector
| Deliverable | Type | Description |
|-------------|------|-------------|
| `HashiCorpVaultConnector` | Connector | HashiCorp Vault |
| `AzureKeyVaultConnector` | Connector | Azure Key Vault |
| `AwsSecretsManagerConnector` | Connector | AWS Secrets Manager |
### 102_006: Doctor Checks
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IDoctorCheck` | Interface | Health check contract |
| `ConnectivityCheck` | Check | Network connectivity |
| `CredentialsCheck` | Check | Credential validity |
| `PermissionsCheck` | Check | Required permissions |
| `RateLimitCheck` | Check | Rate limit status |
| `DoctorReport` | Record | Aggregated results |
---
## Dependencies
### External Dependencies
| Dependency | Purpose |
|------------|---------|
| Octokit | GitHub API client |
| GitLabApiClient | GitLab API client |
| AWSSDK.* | AWS service clients |
| Azure.* | Azure service clients |
| Docker.DotNet | Docker API client |
### Internal Dependencies
| Module | Purpose |
|--------|---------|
| 101_002 Plugin Registry | Plugin discovery |
| 101_003 Plugin Loader | Plugin execution |
| Authority | Tenant context, credentials |
---
## Acceptance Criteria
- [ ] Integration CRUD operations work
- [ ] Config encryption with tenant keys
- [ ] Connector factory creates correct instances
- [ ] Connection pooling reduces overhead
- [ ] Retry policy handles transient failures
- [ ] Circuit breaker prevents cascading failures
- [ ] All built-in SCM connectors work
- [ ] All built-in registry connectors work
- [ ] Vault connectors retrieve secrets
- [ ] Doctor checks identify issues
- [ ] Unit test coverage ≥80%
- [ ] Integration tests pass
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 2 index created |

View File

@@ -0,0 +1,328 @@
# SPRINT: Integration Manager
> **Sprint ID:** 102_001
> **Module:** INTHUB
> **Phase:** 2 - Integration Hub
> **Status:** TODO
> **Parent:** [102_000_INDEX](SPRINT_20260110_102_000_INDEX_integration_hub.md)
---
## Overview
Implement the Integration Manager for creating, updating, and managing integrations with external systems. Includes encrypted configuration storage and tenant isolation.
### Objectives
- CRUD operations for integrations
- Encrypted configuration storage
- Health status tracking
- Tenant isolation
- Domain events for integration changes
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.IntegrationHub/
│ ├── Manager/
│ │ ├── IIntegrationManager.cs
│ │ ├── IntegrationManager.cs
│ │ └── IntegrationValidator.cs
│ ├── Store/
│ │ ├── IIntegrationStore.cs
│ │ ├── IntegrationStore.cs
│ │ └── IntegrationMapper.cs
│ ├── Encryption/
│ │ ├── IIntegrationEncryption.cs
│ │ └── IntegrationEncryption.cs
│ ├── Events/
│ │ ├── IntegrationCreated.cs
│ │ ├── IntegrationUpdated.cs
│ │ └── IntegrationDeleted.cs
│ └── Models/
│ ├── Integration.cs
│ ├── IntegrationType.cs
│ └── HealthStatus.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/
└── Manager/
```
---
## Architecture Reference
- [Integration Hub](../modules/release-orchestrator/modules/integration-hub.md)
- [Security Overview](../modules/release-orchestrator/security/overview.md)
---
## Deliverables
### IIntegrationManager Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Manager;
public interface IIntegrationManager
{
Task<Integration> CreateAsync(
CreateIntegrationRequest request,
CancellationToken ct = default);
Task<Integration> UpdateAsync(
Guid id,
UpdateIntegrationRequest request,
CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
Task<Integration?> GetAsync(Guid id, CancellationToken ct = default);
Task<Integration?> GetByNameAsync(
string name,
CancellationToken ct = default);
Task<IReadOnlyList<Integration>> ListAsync(
IntegrationFilter? filter = null,
CancellationToken ct = default);
Task<IReadOnlyList<Integration>> ListByTypeAsync(
IntegrationType type,
CancellationToken ct = default);
Task SetEnabledAsync(Guid id, bool enabled, CancellationToken ct = default);
Task UpdateHealthAsync(
Guid id,
HealthStatus status,
CancellationToken ct = default);
Task<ConnectionTestResult> TestConnectionAsync(
Guid id,
CancellationToken ct = default);
}
public sealed record CreateIntegrationRequest(
string Name,
string DisplayName,
IntegrationType Type,
JsonElement Configuration
);
public sealed record UpdateIntegrationRequest(
string? DisplayName = null,
JsonElement? Configuration = null,
bool? IsEnabled = null
);
public sealed record IntegrationFilter(
IntegrationType? Type = null,
bool? IsEnabled = null,
HealthStatus? HealthStatus = null
);
```
### Integration Model
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Models;
public sealed record Integration
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string Name { get; init; }
public required string DisplayName { get; init; }
public required IntegrationType Type { get; init; }
public required bool IsEnabled { get; init; }
public required HealthStatus HealthStatus { get; init; }
public DateTimeOffset? LastHealthCheck { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public Guid CreatedBy { get; init; }
// Configuration is decrypted on demand, not stored in memory
public JsonElement? Configuration { get; init; }
}
public enum IntegrationType
{
Scm,
Ci,
Registry,
Vault,
Notify
}
public enum HealthStatus
{
Unknown,
Healthy,
Degraded,
Unhealthy
}
```
### IntegrationEncryption
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Encryption;
public interface IIntegrationEncryption
{
Task<byte[]> EncryptAsync(
Guid tenantId,
JsonElement configuration,
CancellationToken ct = default);
Task<JsonElement> DecryptAsync(
Guid tenantId,
byte[] encryptedConfig,
CancellationToken ct = default);
}
public sealed class IntegrationEncryption : IIntegrationEncryption
{
private readonly ITenantKeyProvider _keyProvider;
public IntegrationEncryption(ITenantKeyProvider keyProvider)
{
_keyProvider = keyProvider;
}
public async Task<byte[]> EncryptAsync(
Guid tenantId,
JsonElement configuration,
CancellationToken ct = default)
{
var key = await _keyProvider.GetKeyAsync(tenantId, ct);
var json = configuration.GetRawText();
var plaintext = Encoding.UTF8.GetBytes(json);
using var aes = Aes.Create();
aes.Key = key;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var ciphertext = encryptor.TransformFinalBlock(
plaintext, 0, plaintext.Length);
// Prepend IV to ciphertext
var result = new byte[aes.IV.Length + ciphertext.Length];
aes.IV.CopyTo(result, 0);
ciphertext.CopyTo(result, aes.IV.Length);
return result;
}
public async Task<JsonElement> DecryptAsync(
Guid tenantId,
byte[] encryptedConfig,
CancellationToken ct = default)
{
var key = await _keyProvider.GetKeyAsync(tenantId, ct);
using var aes = Aes.Create();
aes.Key = key;
// Extract IV from beginning
var iv = encryptedConfig[..16];
var ciphertext = encryptedConfig[16..];
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
var plaintext = decryptor.TransformFinalBlock(
ciphertext, 0, ciphertext.Length);
var json = Encoding.UTF8.GetString(plaintext);
return JsonDocument.Parse(json).RootElement;
}
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Events;
public sealed record IntegrationCreated(
Guid IntegrationId,
Guid TenantId,
string Name,
IntegrationType Type,
DateTimeOffset CreatedAt,
Guid CreatedBy
) : IDomainEvent;
public sealed record IntegrationUpdated(
Guid IntegrationId,
Guid TenantId,
IReadOnlyList<string> ChangedFields,
DateTimeOffset UpdatedAt,
Guid UpdatedBy
) : IDomainEvent;
public sealed record IntegrationDeleted(
Guid IntegrationId,
Guid TenantId,
string Name,
DateTimeOffset DeletedAt,
Guid DeletedBy
) : IDomainEvent;
public sealed record IntegrationHealthChanged(
Guid IntegrationId,
Guid TenantId,
HealthStatus OldStatus,
HealthStatus NewStatus,
DateTimeOffset ChangedAt
) : IDomainEvent;
```
---
## Acceptance Criteria
- [ ] Create integration with encrypted config
- [ ] Update integration preserves encryption
- [ ] Delete integration removes all data
- [ ] List integrations with filtering
- [ ] Tenant isolation enforced
- [ ] Domain events published
- [ ] Health status tracked
- [ ] Connection test works
- [ ] Unit test coverage ≥85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 101_001 Database Schema | Internal | TODO |
| 101_002 Plugin Registry | Internal | TODO |
| Authority | Internal | Exists |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IIntegrationManager | TODO | |
| IntegrationManager | TODO | |
| IntegrationStore | TODO | |
| IntegrationEncryption | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,522 @@
# SPRINT: Connector Runtime
> **Sprint ID:** 102_002
> **Module:** INTHUB
> **Phase:** 2 - Integration Hub
> **Status:** TODO
> **Parent:** [102_000_INDEX](SPRINT_20260110_102_000_INDEX_integration_hub.md)
---
## Overview
Build the Connector Runtime that manages connector instantiation, pooling, and resilience patterns. Handles both built-in and plugin connectors uniformly.
### Objectives
- Connector factory for creating instances
- Connection pooling for efficiency
- Retry policies with exponential backoff
- Circuit breaker for fault isolation
- Rate limiting per integration
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.IntegrationHub/
│ └── Runtime/
│ ├── IConnectorFactory.cs
│ ├── ConnectorFactory.cs
│ ├── ConnectorPool.cs
│ ├── ConnectorPoolManager.cs
│ ├── ConnectorRetryPolicy.cs
│ ├── ConnectorCircuitBreaker.cs
│ ├── ConnectorRateLimiter.cs
│ └── ConnectorContext.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/
└── Runtime/
```
---
## Deliverables
### IConnectorFactory
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Runtime;
public interface IConnectorFactory
{
Task<IConnectorPlugin> CreateAsync(
Integration integration,
CancellationToken ct = default);
Task<T> CreateAsync<T>(
Integration integration,
CancellationToken ct = default) where T : IConnectorPlugin;
bool CanCreate(IntegrationType type, string? pluginName = null);
IReadOnlyList<string> GetAvailableConnectors(IntegrationType type);
}
public sealed class ConnectorFactory : IConnectorFactory
{
private readonly IPluginRegistry _pluginRegistry;
private readonly IPluginLoader _pluginLoader;
private readonly IIntegrationEncryption _encryption;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ConnectorFactory> _logger;
private readonly Dictionary<string, Type> _builtInConnectors = new()
{
["github"] = typeof(GitHubConnector),
["gitlab"] = typeof(GitLabConnector),
["gitea"] = typeof(GiteaConnector),
["dockerhub"] = typeof(DockerHubConnector),
["harbor"] = typeof(HarborConnector),
["acr"] = typeof(AcrConnector),
["ecr"] = typeof(EcrConnector),
["gcr"] = typeof(GcrConnector),
["hashicorp-vault"] = typeof(HashiCorpVaultConnector),
["azure-keyvault"] = typeof(AzureKeyVaultConnector)
};
public async Task<IConnectorPlugin> CreateAsync(
Integration integration,
CancellationToken ct = default)
{
var config = await _encryption.DecryptAsync(
integration.TenantId,
integration.EncryptedConfig!,
ct);
// Check built-in first
var connectorKey = GetConnectorKey(config);
if (_builtInConnectors.TryGetValue(connectorKey, out var type))
{
return CreateBuiltIn(type, integration, config);
}
// Fall back to plugin
var plugin = await _pluginLoader.GetPlugin(connectorKey);
if (plugin?.Sandbox is not null)
{
return CreateFromPlugin(plugin, integration, config);
}
throw new ConnectorNotFoundException(
$"No connector found for type {connectorKey}");
}
}
```
### ConnectorPool
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Runtime;
public sealed class ConnectorPool : IAsyncDisposable
{
private readonly Integration _integration;
private readonly IConnectorFactory _factory;
private readonly Channel<PooledConnector> _available;
private readonly ConcurrentDictionary<Guid, PooledConnector> _inUse = new();
private readonly int _maxSize;
private int _currentSize;
public ConnectorPool(
Integration integration,
IConnectorFactory factory,
int maxSize = 10)
{
_integration = integration;
_factory = factory;
_maxSize = maxSize;
_available = Channel.CreateBounded<PooledConnector>(maxSize);
}
public async Task<PooledConnector> AcquireAsync(
CancellationToken ct = default)
{
// Try to get existing
if (_available.Reader.TryRead(out var existing))
{
existing.MarkInUse();
_inUse[existing.Id] = existing;
return existing;
}
// Create new if under limit
if (Interlocked.Increment(ref _currentSize) <= _maxSize)
{
var connector = await _factory.CreateAsync(_integration, ct);
var pooled = new PooledConnector(connector, this);
pooled.MarkInUse();
_inUse[pooled.Id] = pooled;
return pooled;
}
Interlocked.Decrement(ref _currentSize);
// Wait for available
var released = await _available.Reader.ReadAsync(ct);
released.MarkInUse();
_inUse[released.Id] = released;
return released;
}
public void Release(PooledConnector connector)
{
_inUse.TryRemove(connector.Id, out _);
connector.MarkAvailable();
if (!_available.Writer.TryWrite(connector))
{
// Pool full, dispose
connector.DisposeConnector();
Interlocked.Decrement(ref _currentSize);
}
}
public async ValueTask DisposeAsync()
{
_available.Writer.Complete();
await foreach (var connector in _available.Reader.ReadAllAsync())
{
connector.DisposeConnector();
}
foreach (var (_, connector) in _inUse)
{
connector.DisposeConnector();
}
}
}
public sealed class PooledConnector : IAsyncDisposable
{
private readonly ConnectorPool _pool;
private readonly IConnectorPlugin _connector;
public Guid Id { get; } = Guid.NewGuid();
public IConnectorPlugin Connector => _connector;
public bool InUse { get; private set; }
public DateTimeOffset LastUsed { get; private set; }
internal PooledConnector(IConnectorPlugin connector, ConnectorPool pool)
{
_connector = connector;
_pool = pool;
}
internal void MarkInUse()
{
InUse = true;
LastUsed = TimeProvider.System.GetUtcNow();
}
internal void MarkAvailable() => InUse = false;
internal void DisposeConnector()
{
if (_connector is IDisposable disposable)
disposable.Dispose();
}
public ValueTask DisposeAsync()
{
_pool.Release(this);
return ValueTask.CompletedTask;
}
}
```
### ConnectorRetryPolicy
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Runtime;
public sealed class ConnectorRetryPolicy
{
private readonly int _maxRetries;
private readonly TimeSpan _baseDelay;
private readonly ILogger<ConnectorRetryPolicy> _logger;
public ConnectorRetryPolicy(
int maxRetries = 3,
TimeSpan? baseDelay = null,
ILogger<ConnectorRetryPolicy>? logger = null)
{
_maxRetries = maxRetries;
_baseDelay = baseDelay ?? TimeSpan.FromMilliseconds(200);
_logger = logger ?? NullLogger<ConnectorRetryPolicy>.Instance;
}
public async Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> action,
CancellationToken ct = default)
{
var attempt = 0;
var exceptions = new List<Exception>();
while (true)
{
try
{
return await action(ct);
}
catch (Exception ex) when (IsTransient(ex) && attempt < _maxRetries)
{
exceptions.Add(ex);
attempt++;
var delay = CalculateDelay(attempt);
_logger.LogWarning(
"Connector operation failed (attempt {Attempt}/{Max}), retrying in {Delay}ms: {Error}",
attempt, _maxRetries, delay.TotalMilliseconds, ex.Message);
await Task.Delay(delay, ct);
}
catch (Exception ex)
{
exceptions.Add(ex);
throw new ConnectorRetryExhaustedException(
$"Operation failed after {attempt + 1} attempts",
new AggregateException(exceptions));
}
}
}
private TimeSpan CalculateDelay(int attempt)
{
// Exponential backoff with jitter
var exponential = Math.Pow(2, attempt - 1);
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85; // 0.85-1.15
return TimeSpan.FromMilliseconds(
_baseDelay.TotalMilliseconds * exponential * jitter);
}
private static bool IsTransient(Exception ex) =>
ex is HttpRequestException or
TimeoutException or
TaskCanceledException { CancellationToken.IsCancellationRequested: false } or
OperationCanceledException { CancellationToken.IsCancellationRequested: false };
}
```
### ConnectorCircuitBreaker
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Runtime;
public sealed class ConnectorCircuitBreaker
{
private readonly int _failureThreshold;
private readonly TimeSpan _resetTimeout;
private readonly ILogger<ConnectorCircuitBreaker> _logger;
private int _failureCount;
private CircuitState _state = CircuitState.Closed;
private DateTimeOffset _lastFailure;
private DateTimeOffset _openedAt;
public ConnectorCircuitBreaker(
int failureThreshold = 5,
TimeSpan? resetTimeout = null,
ILogger<ConnectorCircuitBreaker>? logger = null)
{
_failureThreshold = failureThreshold;
_resetTimeout = resetTimeout ?? TimeSpan.FromMinutes(1);
_logger = logger ?? NullLogger<ConnectorCircuitBreaker>.Instance;
}
public CircuitState State => _state;
public async Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> action,
CancellationToken ct = default)
{
if (_state == CircuitState.Open)
{
if (ShouldAttemptReset())
{
_state = CircuitState.HalfOpen;
_logger.LogInformation("Circuit breaker half-open, attempting reset");
}
else
{
throw new CircuitBreakerOpenException(
$"Circuit breaker is open, retry after {_openedAt.Add(_resetTimeout)}");
}
}
try
{
var result = await action(ct);
OnSuccess();
return result;
}
catch (Exception ex) when (!IsCritical(ex))
{
OnFailure();
throw;
}
}
private void OnSuccess()
{
_failureCount = 0;
if (_state == CircuitState.HalfOpen)
{
_state = CircuitState.Closed;
_logger.LogInformation("Circuit breaker closed after successful request");
}
}
private void OnFailure()
{
_lastFailure = TimeProvider.System.GetUtcNow();
_failureCount++;
if (_state == CircuitState.HalfOpen)
{
_state = CircuitState.Open;
_openedAt = _lastFailure;
_logger.LogWarning("Circuit breaker opened after half-open failure");
}
else if (_failureCount >= _failureThreshold)
{
_state = CircuitState.Open;
_openedAt = _lastFailure;
_logger.LogWarning(
"Circuit breaker opened after {Count} failures",
_failureCount);
}
}
private bool ShouldAttemptReset() =>
TimeProvider.System.GetUtcNow() >= _openedAt.Add(_resetTimeout);
private static bool IsCritical(Exception ex) =>
ex is OutOfMemoryException or StackOverflowException;
}
public enum CircuitState
{
Closed,
Open,
HalfOpen
}
```
### ConnectorRateLimiter
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Runtime;
public sealed class ConnectorRateLimiter : IAsyncDisposable
{
private readonly SemaphoreSlim _semaphore;
private readonly RateLimitLease[] _leases;
private readonly TimeSpan _window;
private readonly int _limit;
private int _leaseIndex;
public ConnectorRateLimiter(int requestsPerWindow, TimeSpan window)
{
_limit = requestsPerWindow;
_window = window;
_semaphore = new SemaphoreSlim(requestsPerWindow, requestsPerWindow);
_leases = new RateLimitLease[requestsPerWindow];
}
public async Task<IDisposable> AcquireAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct);
var index = Interlocked.Increment(ref _leaseIndex) % _limit;
var oldLease = _leases[index];
if (oldLease is not null)
{
var elapsed = TimeProvider.System.GetUtcNow() - oldLease.AcquiredAt;
if (elapsed < _window)
{
await Task.Delay(_window - elapsed, ct);
}
}
var lease = new RateLimitLease(this);
_leases[index] = lease;
return lease;
}
private void Release() => _semaphore.Release();
public ValueTask DisposeAsync()
{
_semaphore.Dispose();
return ValueTask.CompletedTask;
}
private sealed class RateLimitLease : IDisposable
{
private readonly ConnectorRateLimiter _limiter;
public DateTimeOffset AcquiredAt { get; } = TimeProvider.System.GetUtcNow();
public RateLimitLease(ConnectorRateLimiter limiter) => _limiter = limiter;
public void Dispose() => _limiter.Release();
}
}
```
---
## Acceptance Criteria
- [ ] Factory creates built-in connectors
- [ ] Factory creates plugin connectors
- [ ] Connection pooling works
- [ ] Retry policy retries transient failures
- [ ] Circuit breaker opens on failures
- [ ] Rate limiter enforces limits
- [ ] Metrics exposed for monitoring
- [ ] Unit test coverage ≥85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 102_001 Integration Manager | Internal | TODO |
| 101_003 Plugin Loader | Internal | TODO |
| Polly | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IConnectorFactory | TODO | |
| ConnectorFactory | TODO | |
| ConnectorPool | TODO | |
| ConnectorRetryPolicy | TODO | |
| ConnectorCircuitBreaker | TODO | |
| ConnectorRateLimiter | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,460 @@
# SPRINT: Built-in SCM Connectors
> **Sprint ID:** 102_003
> **Module:** INTHUB
> **Phase:** 2 - Integration Hub
> **Status:** TODO
> **Parent:** [102_000_INDEX](SPRINT_20260110_102_000_INDEX_integration_hub.md)
---
## Overview
Implement built-in SCM (Source Control Management) connectors for GitHub, GitLab, Gitea, and Azure DevOps. Each connector implements the `IScmConnector` interface.
### Objectives
- GitHub connector (GitHub.com and GitHub Enterprise)
- GitLab connector (GitLab.com and self-hosted)
- Gitea connector (self-hosted)
- Azure DevOps connector
- Webhook support for all connectors
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.IntegrationHub/
│ └── Connectors/
│ └── Scm/
│ ├── GitHubConnector.cs
│ ├── GitLabConnector.cs
│ ├── GiteaConnector.cs
│ └── AzureDevOpsConnector.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/
└── Connectors/
└── Scm/
```
---
## Deliverables
### GitHubConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Scm;
public sealed class GitHubConnector : IScmConnector
{
public ConnectorCategory Category => ConnectorCategory.Scm;
public IReadOnlyList<string> Capabilities { get; } = [
"repositories", "commits", "branches", "webhooks", "actions"
];
private GitHubClient? _client;
private ConnectorContext? _context;
public Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
_context = context;
var config = ParseConfig(context.Configuration);
var credentials = new Credentials(
config.Token ?? await ResolveTokenAsync(context, ct));
_client = new GitHubClient(new ProductHeaderValue("StellaOps"))
{
Credentials = credentials
};
if (!string.IsNullOrEmpty(config.BaseUrl))
{
_client = new GitHubClient(
new ProductHeaderValue("StellaOps"),
new Uri(config.BaseUrl))
{
Credentials = credentials
};
}
return Task.CompletedTask;
}
public async Task<ConfigValidationResult> ValidateConfigAsync(
JsonElement config,
CancellationToken ct = default)
{
var errors = new List<string>();
var parsed = ParseConfig(config);
if (string.IsNullOrEmpty(parsed.Token) &&
string.IsNullOrEmpty(parsed.TokenSecretRef))
{
errors.Add("Either 'token' or 'tokenSecretRef' is required");
}
return errors.Count == 0
? ConfigValidationResult.Success()
: ConfigValidationResult.Failure(errors.ToArray());
}
public async Task<ConnectionTestResult> TestConnectionAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
try
{
var user = await _client!.User.Current();
return new ConnectionTestResult(
Success: true,
Message: $"Authenticated as {user.Login}",
ResponseTime: sw.Elapsed);
}
catch (Exception ex)
{
return new ConnectionTestResult(
Success: false,
Message: ex.Message,
ResponseTime: sw.Elapsed);
}
}
public async Task<IReadOnlyList<ScmRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? searchPattern = null,
CancellationToken ct = default)
{
var repos = await _client!.Repository.GetAllForCurrent();
var result = repos
.Where(r => searchPattern is null ||
r.FullName.Contains(searchPattern, StringComparison.OrdinalIgnoreCase))
.Select(r => new ScmRepository(
Id: r.Id.ToString(CultureInfo.InvariantCulture),
Name: r.Name,
FullName: r.FullName,
DefaultBranch: r.DefaultBranch,
CloneUrl: r.CloneUrl,
IsPrivate: r.Private))
.ToList();
return result;
}
public async Task<ScmCommit?> GetCommitAsync(
ConnectorContext context,
string repository,
string commitSha,
CancellationToken ct = default)
{
var (owner, repo) = ParseRepository(repository);
var commit = await _client!.Repository.Commit.Get(owner, repo, commitSha);
return new ScmCommit(
Sha: commit.Sha,
Message: commit.Commit.Message,
AuthorName: commit.Commit.Author.Name,
AuthorEmail: commit.Commit.Author.Email,
AuthoredAt: commit.Commit.Author.Date,
ParentSha: commit.Parents.FirstOrDefault()?.Sha);
}
public async Task<WebhookRegistration> CreateWebhookAsync(
ConnectorContext context,
string repository,
IReadOnlyList<string> events,
string callbackUrl,
CancellationToken ct = default)
{
var (owner, repo) = ParseRepository(repository);
var webhook = await _client!.Repository.Hooks.Create(owner, repo, new NewRepositoryHook(
"web",
new Dictionary<string, string>
{
["url"] = callbackUrl,
["content_type"] = "json",
["secret"] = GenerateWebhookSecret()
})
{
Events = events.ToArray(),
Active = true
});
return new WebhookRegistration(
Id: webhook.Id.ToString(CultureInfo.InvariantCulture),
Url: callbackUrl,
Secret: webhook.Config["secret"],
Events: events);
}
private static (string Owner, string Repo) ParseRepository(string fullName)
{
var parts = fullName.Split('/');
return (parts[0], parts[1]);
}
}
internal sealed record GitHubConfig(
string? BaseUrl,
string? Token,
string? TokenSecretRef
);
```
### GitLabConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Scm;
public sealed class GitLabConnector : IScmConnector
{
public ConnectorCategory Category => ConnectorCategory.Scm;
public IReadOnlyList<string> Capabilities { get; } = [
"repositories", "commits", "branches", "webhooks", "pipelines"
];
private GitLabClient? _client;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
var token = config.Token ??
await context.SecretResolver.ResolveAsync(config.TokenSecretRef!, ct);
var baseUrl = config.BaseUrl ?? "https://gitlab.com";
_client = new GitLabClient(baseUrl, token);
}
public async Task<IReadOnlyList<ScmRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? searchPattern = null,
CancellationToken ct = default)
{
var projects = await _client!.Projects.GetAsync(new ProjectQueryOptions
{
Search = searchPattern,
Membership = true
});
return projects.Select(p => new ScmRepository(
Id: p.Id.ToString(CultureInfo.InvariantCulture),
Name: p.Name,
FullName: p.PathWithNamespace,
DefaultBranch: p.DefaultBranch,
CloneUrl: p.HttpUrlToRepo,
IsPrivate: p.Visibility == ProjectVisibility.Private))
.ToList();
}
public async Task<ScmCommit?> GetCommitAsync(
ConnectorContext context,
string repository,
string commitSha,
CancellationToken ct = default)
{
var commit = await _client!.Commits.GetAsync(repository, commitSha);
return new ScmCommit(
Sha: commit.Id,
Message: commit.Message,
AuthorName: commit.AuthorName,
AuthorEmail: commit.AuthorEmail,
AuthoredAt: commit.AuthoredDate,
ParentSha: commit.ParentIds?.FirstOrDefault());
}
public async Task<WebhookRegistration> CreateWebhookAsync(
ConnectorContext context,
string repository,
IReadOnlyList<string> events,
string callbackUrl,
CancellationToken ct = default)
{
var secret = GenerateWebhookSecret();
var hook = await _client!.Projects.CreateWebhookAsync(repository, new CreateWebhookRequest
{
Url = callbackUrl,
Token = secret,
PushEvents = events.Contains("push"),
TagPushEvents = events.Contains("tag_push"),
MergeRequestsEvents = events.Contains("merge_request"),
PipelineEvents = events.Contains("pipeline")
});
return new WebhookRegistration(
Id: hook.Id.ToString(CultureInfo.InvariantCulture),
Url: callbackUrl,
Secret: secret,
Events: events);
}
}
```
### GiteaConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Scm;
public sealed class GiteaConnector : IScmConnector
{
public ConnectorCategory Category => ConnectorCategory.Scm;
public IReadOnlyList<string> Capabilities { get; } = [
"repositories", "commits", "branches", "webhooks"
];
private HttpClient? _httpClient;
private string? _baseUrl;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
var token = config.Token ??
await context.SecretResolver.ResolveAsync(config.TokenSecretRef!, ct);
_baseUrl = config.BaseUrl?.TrimEnd('/');
_httpClient = new HttpClient
{
BaseAddress = new Uri(_baseUrl + "/api/v1/")
};
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("token", token);
}
public async Task<IReadOnlyList<ScmRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? searchPattern = null,
CancellationToken ct = default)
{
var response = await _httpClient!.GetAsync("user/repos", ct);
response.EnsureSuccessStatusCode();
var repos = await response.Content
.ReadFromJsonAsync<List<GiteaRepository>>(ct);
return repos!
.Where(r => searchPattern is null ||
r.FullName.Contains(searchPattern, StringComparison.OrdinalIgnoreCase))
.Select(r => new ScmRepository(
Id: r.Id.ToString(CultureInfo.InvariantCulture),
Name: r.Name,
FullName: r.FullName,
DefaultBranch: r.DefaultBranch,
CloneUrl: r.CloneUrl,
IsPrivate: r.Private))
.ToList();
}
// Additional methods...
}
```
### AzureDevOpsConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Scm;
public sealed class AzureDevOpsConnector : IScmConnector
{
public ConnectorCategory Category => ConnectorCategory.Scm;
public IReadOnlyList<string> Capabilities { get; } = [
"repositories", "commits", "branches", "webhooks", "pipelines", "workitems"
];
private VssConnection? _connection;
private GitHttpClient? _gitClient;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
var pat = config.Pat ??
await context.SecretResolver.ResolveAsync(config.PatSecretRef!, ct);
var credentials = new VssBasicCredential(string.Empty, pat);
_connection = new VssConnection(new Uri(config.OrganizationUrl), credentials);
_gitClient = await _connection.GetClientAsync<GitHttpClient>(ct);
}
public async Task<IReadOnlyList<ScmRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? searchPattern = null,
CancellationToken ct = default)
{
var repos = await _gitClient!.GetRepositoriesAsync(cancellationToken: ct);
return repos
.Where(r => searchPattern is null ||
r.Name.Contains(searchPattern, StringComparison.OrdinalIgnoreCase))
.Select(r => new ScmRepository(
Id: r.Id.ToString(),
Name: r.Name,
FullName: $"{r.ProjectReference.Name}/{r.Name}",
DefaultBranch: r.DefaultBranch?.Replace("refs/heads/", "") ?? "main",
CloneUrl: r.RemoteUrl,
IsPrivate: true)) // Azure DevOps repos are always private to org
.ToList();
}
// Additional methods...
}
```
---
## Acceptance Criteria
- [ ] GitHub connector authenticates
- [ ] GitHub connector lists repositories
- [ ] GitHub connector creates webhooks
- [ ] GitLab connector works
- [ ] Gitea connector works
- [ ] Azure DevOps connector works
- [ ] All connectors handle errors gracefully
- [ ] Webhook secret generation is secure
- [ ] Config validation catches issues
- [ ] Integration tests pass
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 102_002 Connector Runtime | Internal | TODO |
| Octokit | NuGet | Available |
| GitLabApiClient | NuGet | Available |
| Microsoft.TeamFoundationServer.Client | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| GitHubConnector | TODO | |
| GitLabConnector | TODO | |
| GiteaConnector | TODO | |
| AzureDevOpsConnector | TODO | |
| Unit tests | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,617 @@
# SPRINT: Built-in Registry Connectors
> **Sprint ID:** 102_004
> **Module:** INTHUB
> **Phase:** 2 - Integration Hub
> **Status:** TODO
> **Parent:** [102_000_INDEX](SPRINT_20260110_102_000_INDEX_integration_hub.md)
---
## Overview
Implement built-in container registry connectors for Docker Hub, Harbor, ACR, ECR, GCR, and generic OCI registries. Each implements `IRegistryConnector`.
### Objectives
- Docker Hub connector with rate limit handling
- Harbor connector for self-hosted registries
- Azure Container Registry (ACR) connector
- AWS Elastic Container Registry (ECR) connector
- Google Container Registry (GCR) connector
- Generic OCI connector for any compliant registry
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.IntegrationHub/
│ └── Connectors/
│ └── Registry/
│ ├── DockerHubConnector.cs
│ ├── HarborConnector.cs
│ ├── AcrConnector.cs
│ ├── EcrConnector.cs
│ ├── GcrConnector.cs
│ └── GenericOciConnector.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/
└── Connectors/
└── Registry/
```
---
## Deliverables
### DockerHubConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
public sealed class DockerHubConnector : IRegistryConnector
{
private const string RegistryUrl = "https://registry-1.docker.io";
private const string AuthUrl = "https://auth.docker.io/token";
private const string HubApiUrl = "https://hub.docker.com/v2";
public ConnectorCategory Category => ConnectorCategory.Registry;
public IReadOnlyList<string> Capabilities { get; } = [
"list_repos", "list_tags", "resolve_tag", "get_manifest", "pull_credentials"
];
private HttpClient? _httpClient;
private string? _username;
private string? _password;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
_username = config.Username;
_password = config.Password ??
await context.SecretResolver.ResolveAsync(config.PasswordSecretRef!, ct);
_httpClient = new HttpClient();
}
public async Task<IReadOnlyList<ImageTag>> ListTagsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
var token = await GetAuthTokenAsync(repository, "pull", ct);
var request = new HttpRequestMessage(
HttpMethod.Get,
$"{RegistryUrl}/v2/{repository}/tags/list");
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient!.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content
.ReadFromJsonAsync<TagListResponse>(ct);
// Get tag details from Hub API
var tags = new List<ImageTag>();
foreach (var tag in result!.Tags)
{
var detail = await GetTagDetailAsync(repository, tag, ct);
tags.Add(new ImageTag(
Name: tag,
Digest: detail?.Digest,
PushedAt: detail?.LastPushed));
}
return tags;
}
public async Task<ImageDigest?> ResolveTagAsync(
ConnectorContext context,
string repository,
string tag,
CancellationToken ct = default)
{
var token = await GetAuthTokenAsync(repository, "pull", ct);
var request = new HttpRequestMessage(
HttpMethod.Head,
$"{RegistryUrl}/v2/{repository}/manifests/{tag}");
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.manifest.v1+json"));
var response = await _httpClient!.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var digest = response.Headers.GetValues("Docker-Content-Digest").First();
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
var size = response.Content.Headers.ContentLength ?? 0;
return new ImageDigest(digest, contentType, size);
}
public async Task<ImageManifest?> GetManifestAsync(
ConnectorContext context,
string repository,
string reference,
CancellationToken ct = default)
{
var token = await GetAuthTokenAsync(repository, "pull", ct);
var request = new HttpRequestMessage(
HttpMethod.Get,
$"{RegistryUrl}/v2/{repository}/manifests/{reference}");
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.manifest.v1+json"));
var response = await _httpClient!.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
var digest = response.Headers.GetValues("Docker-Content-Digest").First();
return new ImageManifest(
Digest: digest,
MediaType: response.Content.Headers.ContentType?.MediaType ?? "",
Size: response.Content.Headers.ContentLength ?? 0,
RawManifest: json);
}
public Task<PullCredentials> GetPullCredentialsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
return Task.FromResult(new PullCredentials(
Registry: "docker.io",
Username: _username!,
Password: _password!,
ExpiresAt: DateTimeOffset.MaxValue));
}
private async Task<string> GetAuthTokenAsync(
string repository,
string scope,
CancellationToken ct)
{
var url = $"{AuthUrl}?service=registry.docker.io&scope=repository:{repository}:{scope}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrEmpty(_username))
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_username}:{_password}"));
request.Headers.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
var response = await _httpClient!.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<TokenResponse>(ct);
return result!.Token;
}
}
```
### AcrConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
public sealed class AcrConnector : IRegistryConnector
{
public ConnectorCategory Category => ConnectorCategory.Registry;
private ContainerRegistryClient? _client;
private string? _registryUrl;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
_registryUrl = config.RegistryUrl;
// Support both service principal and managed identity
TokenCredential credential = config.AuthMethod switch
{
"service_principal" => new ClientSecretCredential(
config.TenantId,
config.ClientId,
config.ClientSecret ??
await context.SecretResolver.ResolveAsync(config.ClientSecretRef!, ct)),
"managed_identity" => new ManagedIdentityCredential(),
_ => new DefaultAzureCredential()
};
_client = new ContainerRegistryClient(
new Uri($"https://{_registryUrl}"),
credential);
}
public async Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? prefix = null,
CancellationToken ct = default)
{
var repos = new List<RegistryRepository>();
await foreach (var name in _client!.GetRepositoryNamesAsync(ct))
{
if (prefix is null || name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
repos.Add(new RegistryRepository(name));
}
}
return repos;
}
public async Task<IReadOnlyList<ImageTag>> ListTagsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
var repo = _client!.GetRepository(repository);
var tags = new List<ImageTag>();
await foreach (var manifest in repo.GetAllManifestPropertiesAsync(ct))
{
foreach (var tag in manifest.Tags)
{
tags.Add(new ImageTag(
Name: tag,
Digest: manifest.Digest,
PushedAt: manifest.CreatedOn));
}
}
return tags;
}
public async Task<ImageDigest?> ResolveTagAsync(
ConnectorContext context,
string repository,
string tag,
CancellationToken ct = default)
{
try
{
var repo = _client!.GetRepository(repository);
var artifact = repo.GetArtifact(tag);
var manifest = await artifact.GetManifestPropertiesAsync(ct);
return new ImageDigest(
Digest: manifest.Value.Digest,
MediaType: manifest.Value.MediaType ?? "",
Size: manifest.Value.SizeInBytes ?? 0);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
public async Task<PullCredentials> GetPullCredentialsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
// Get short-lived token for pull
var exchangeClient = new ContainerRegistryContentClient(
new Uri($"https://{_registryUrl}"),
repository,
new DefaultAzureCredential());
// Use refresh token exchange
return new PullCredentials(
Registry: _registryUrl!,
Username: "00000000-0000-0000-0000-000000000000",
Password: await GetAcrRefreshTokenAsync(ct),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1));
}
}
```
### EcrConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
public sealed class EcrConnector : IRegistryConnector
{
public ConnectorCategory Category => ConnectorCategory.Registry;
private AmazonECRClient? _ecrClient;
private string? _registryId;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
AWSCredentials credentials = config.AuthMethod switch
{
"access_key" => new BasicAWSCredentials(
config.AccessKeyId,
config.SecretAccessKey ??
await context.SecretResolver.ResolveAsync(config.SecretAccessKeyRef!, ct)),
"assume_role" => new AssumeRoleAWSCredentials(
new BasicAWSCredentials(config.AccessKeyId, config.SecretAccessKey),
config.RoleArn,
"StellaOps"),
_ => new InstanceProfileAWSCredentials()
};
_ecrClient = new AmazonECRClient(credentials, RegionEndpoint.GetBySystemName(config.Region));
_registryId = config.RegistryId;
}
public async Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? prefix = null,
CancellationToken ct = default)
{
var repos = new List<RegistryRepository>();
string? nextToken = null;
do
{
var response = await _ecrClient!.DescribeRepositoriesAsync(
new DescribeRepositoriesRequest
{
RegistryId = _registryId,
NextToken = nextToken
}, ct);
foreach (var repo in response.Repositories)
{
if (prefix is null ||
repo.RepositoryName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
repos.Add(new RegistryRepository(repo.RepositoryName));
}
}
nextToken = response.NextToken;
} while (!string.IsNullOrEmpty(nextToken));
return repos;
}
public async Task<IReadOnlyList<ImageTag>> ListTagsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
var tags = new List<ImageTag>();
string? nextToken = null;
do
{
var response = await _ecrClient!.DescribeImagesAsync(
new DescribeImagesRequest
{
RegistryId = _registryId,
RepositoryName = repository,
NextToken = nextToken
}, ct);
foreach (var image in response.ImageDetails)
{
foreach (var tag in image.ImageTags)
{
tags.Add(new ImageTag(
Name: tag,
Digest: image.ImageDigest,
PushedAt: image.ImagePushedAt));
}
}
nextToken = response.NextToken;
} while (!string.IsNullOrEmpty(nextToken));
return tags;
}
public async Task<PullCredentials> GetPullCredentialsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
var response = await _ecrClient!.GetAuthorizationTokenAsync(
new GetAuthorizationTokenRequest
{
RegistryIds = _registryId is not null ? [_registryId] : null
}, ct);
var auth = response.AuthorizationData.First();
var decoded = Encoding.UTF8.GetString(
Convert.FromBase64String(auth.AuthorizationToken));
var parts = decoded.Split(':');
return new PullCredentials(
Registry: new Uri(auth.ProxyEndpoint).Host,
Username: parts[0],
Password: parts[1],
ExpiresAt: auth.ExpiresAt);
}
}
```
### GenericOciConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
/// <summary>
/// Generic OCI Distribution-compliant registry connector.
/// Works with any registry implementing OCI Distribution Spec.
/// </summary>
public sealed class GenericOciConnector : IRegistryConnector
{
public ConnectorCategory Category => ConnectorCategory.Registry;
private HttpClient? _httpClient;
private string? _registryUrl;
private string? _username;
private string? _password;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
_registryUrl = config.RegistryUrl.TrimEnd('/');
_username = config.Username;
_password = config.Password ??
(config.PasswordSecretRef is not null
? await context.SecretResolver.ResolveAsync(config.PasswordSecretRef, ct)
: null);
_httpClient = new HttpClient();
if (!string.IsNullOrEmpty(_username))
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_username}:{_password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
}
public async Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? prefix = null,
CancellationToken ct = default)
{
var response = await _httpClient!.GetAsync(
$"{_registryUrl}/v2/_catalog", ct);
response.EnsureSuccessStatusCode();
var result = await response.Content
.ReadFromJsonAsync<CatalogResponse>(ct);
return result!.Repositories
.Where(r => prefix is null ||
r.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Select(r => new RegistryRepository(r))
.ToList();
}
public async Task<ImageDigest?> ResolveTagAsync(
ConnectorContext context,
string repository,
string tag,
CancellationToken ct = default)
{
var request = new HttpRequestMessage(
HttpMethod.Head,
$"{_registryUrl}/v2/{repository}/manifests/{tag}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.manifest.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.v2+json"));
var response = await _httpClient!.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var digest = response.Headers
.GetValues("Docker-Content-Digest")
.FirstOrDefault() ?? "";
var mediaType = response.Content.Headers.ContentType?.MediaType ?? "";
var size = response.Content.Headers.ContentLength ?? 0;
return new ImageDigest(digest, mediaType, size);
}
public Task<PullCredentials> GetPullCredentialsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
var uri = new Uri(_registryUrl!);
return Task.FromResult(new PullCredentials(
Registry: uri.Host,
Username: _username ?? "",
Password: _password ?? "",
ExpiresAt: DateTimeOffset.MaxValue));
}
}
```
---
## Acceptance Criteria
- [ ] Docker Hub connector works with rate limiting
- [ ] Harbor connector supports webhooks
- [ ] ACR connector uses Azure Identity
- [ ] ECR connector handles token refresh
- [ ] GCR connector uses GCP credentials
- [ ] Generic OCI connector works with any registry
- [ ] All connectors resolve tags to digests
- [ ] Pull credentials generated correctly
- [ ] Integration tests pass
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 102_002 Connector Runtime | Internal | TODO |
| Azure.Containers.ContainerRegistry | NuGet | Available |
| AWSSDK.ECR | NuGet | Available |
| Google.Cloud.ArtifactRegistry.V1 | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| DockerHubConnector | TODO | |
| HarborConnector | TODO | |
| AcrConnector | TODO | |
| EcrConnector | TODO | |
| GcrConnector | TODO | |
| GenericOciConnector | TODO | |
| Unit tests | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,503 @@
# SPRINT: Built-in Vault Connector
> **Sprint ID:** 102_005
> **Module:** INTHUB
> **Phase:** 2 - Integration Hub
> **Status:** TODO
> **Parent:** [102_000_INDEX](SPRINT_20260110_102_000_INDEX_integration_hub.md)
---
## Overview
Implement built-in vault connectors for secrets management: HashiCorp Vault, Azure Key Vault, and AWS Secrets Manager.
### Objectives
- HashiCorp Vault connector with multiple auth methods
- Azure Key Vault connector with managed identity support
- AWS Secrets Manager connector
- Unified secret resolution interface
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.IntegrationHub/
│ └── Connectors/
│ └── Vault/
│ ├── IVaultConnector.cs
│ ├── HashiCorpVaultConnector.cs
│ ├── AzureKeyVaultConnector.cs
│ └── AwsSecretsManagerConnector.cs
└── __Tests/
```
---
## Deliverables
### IVaultConnector Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Vault;
public interface IVaultConnector : IConnectorPlugin
{
Task<string?> GetSecretAsync(
ConnectorContext context,
string path,
string? key = null,
CancellationToken ct = default);
Task<IReadOnlyDictionary<string, string>> GetSecretsAsync(
ConnectorContext context,
string path,
CancellationToken ct = default);
Task SetSecretAsync(
ConnectorContext context,
string path,
string key,
string value,
CancellationToken ct = default);
Task<IReadOnlyList<string>> ListSecretsAsync(
ConnectorContext context,
string? path = null,
CancellationToken ct = default);
}
```
### HashiCorpVaultConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Vault;
public sealed class HashiCorpVaultConnector : IVaultConnector
{
public ConnectorCategory Category => ConnectorCategory.Vault;
private VaultClient? _client;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
IAuthMethodInfo authMethod = config.AuthMethod switch
{
"token" => new TokenAuthMethodInfo(
config.Token ?? await context.SecretResolver.ResolveAsync(
config.TokenSecretRef!, ct)),
"approle" => new AppRoleAuthMethodInfo(
config.RoleId,
config.SecretId ?? await context.SecretResolver.ResolveAsync(
config.SecretIdRef!, ct)),
"kubernetes" => new KubernetesAuthMethodInfo(
config.Role,
await File.ReadAllTextAsync(
"/var/run/secrets/kubernetes.io/serviceaccount/token", ct)),
_ => throw new ArgumentException($"Unknown auth method: {config.AuthMethod}")
};
var vaultSettings = new VaultClientSettings(config.Address, authMethod);
_client = new VaultClient(vaultSettings);
}
public async Task<string?> GetSecretAsync(
ConnectorContext context,
string path,
string? key = null,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
var mountPoint = config.MountPoint ?? "secret";
var secret = await _client!.V1.Secrets.KeyValue.V2
.ReadSecretAsync(path, mountPoint: mountPoint);
if (secret?.Data?.Data is null)
return null;
if (key is null)
{
// Return first value if no key specified
return secret.Data.Data.Values.FirstOrDefault()?.ToString();
}
return secret.Data.Data.TryGetValue(key, out var value)
? value?.ToString()
: null;
}
public async Task<IReadOnlyDictionary<string, string>> GetSecretsAsync(
ConnectorContext context,
string path,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
var mountPoint = config.MountPoint ?? "secret";
var secret = await _client!.V1.Secrets.KeyValue.V2
.ReadSecretAsync(path, mountPoint: mountPoint);
if (secret?.Data?.Data is null)
return new Dictionary<string, string>();
return secret.Data.Data
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? "");
}
public async Task SetSecretAsync(
ConnectorContext context,
string path,
string key,
string value,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
var mountPoint = config.MountPoint ?? "secret";
// Get existing secrets to merge
var existing = await GetSecretsAsync(context, path, ct);
var data = new Dictionary<string, object>(
existing.ToDictionary(k => k.Key, v => (object)v.Value))
{
[key] = value
};
await _client!.V1.Secrets.KeyValue.V2
.WriteSecretAsync(path, data, mountPoint: mountPoint);
}
public async Task<IReadOnlyList<string>> ListSecretsAsync(
ConnectorContext context,
string? path = null,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
var mountPoint = config.MountPoint ?? "secret";
var result = await _client!.V1.Secrets.KeyValue.V2
.ReadSecretPathsAsync(path ?? "", mountPoint: mountPoint);
return result?.Data?.Keys?.ToList() ?? [];
}
}
```
### AzureKeyVaultConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Vault;
public sealed class AzureKeyVaultConnector : IVaultConnector
{
public ConnectorCategory Category => ConnectorCategory.Vault;
private SecretClient? _client;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
TokenCredential credential = config.AuthMethod switch
{
"service_principal" => new ClientSecretCredential(
config.TenantId,
config.ClientId,
config.ClientSecret ?? await context.SecretResolver.ResolveAsync(
config.ClientSecretRef!, ct)),
"managed_identity" => new ManagedIdentityCredential(
config.ManagedIdentityClientId),
_ => new DefaultAzureCredential()
};
_client = new SecretClient(
new Uri($"https://{config.VaultName}.vault.azure.net/"),
credential);
}
public async Task<string?> GetSecretAsync(
ConnectorContext context,
string path,
string? key = null,
CancellationToken ct = default)
{
try
{
// Azure Key Vault uses flat namespace, path is the secret name
var secretName = key is not null ? $"{path}--{key}" : path;
var response = await _client!.GetSecretAsync(secretName, cancellationToken: ct);
return response.Value.Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
public async Task<IReadOnlyDictionary<string, string>> GetSecretsAsync(
ConnectorContext context,
string path,
CancellationToken ct = default)
{
var result = new Dictionary<string, string>();
await foreach (var secret in _client!.GetPropertiesOfSecretsAsync(ct))
{
if (secret.Name.StartsWith(path, StringComparison.OrdinalIgnoreCase))
{
var value = await _client.GetSecretAsync(secret.Name, cancellationToken: ct);
var key = secret.Name[(path.Length + 2)..]; // Remove prefix and "--"
result[key] = value.Value.Value;
}
}
return result;
}
public async Task SetSecretAsync(
ConnectorContext context,
string path,
string key,
string value,
CancellationToken ct = default)
{
var secretName = $"{path}--{key}";
await _client!.SetSecretAsync(secretName, value, ct);
}
public async Task<IReadOnlyList<string>> ListSecretsAsync(
ConnectorContext context,
string? path = null,
CancellationToken ct = default)
{
var result = new List<string>();
await foreach (var secret in _client!.GetPropertiesOfSecretsAsync(ct))
{
if (path is null || secret.Name.StartsWith(path, StringComparison.OrdinalIgnoreCase))
{
result.Add(secret.Name);
}
}
return result;
}
}
```
### AwsSecretsManagerConnector
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Vault;
public sealed class AwsSecretsManagerConnector : IVaultConnector
{
public ConnectorCategory Category => ConnectorCategory.Vault;
private AmazonSecretsManagerClient? _client;
public async Task InitializeAsync(
ConnectorContext context,
CancellationToken ct = default)
{
var config = ParseConfig(context.Configuration);
AWSCredentials credentials = config.AuthMethod switch
{
"access_key" => new BasicAWSCredentials(
config.AccessKeyId,
config.SecretAccessKey ?? await context.SecretResolver.ResolveAsync(
config.SecretAccessKeyRef!, ct)),
"assume_role" => new AssumeRoleAWSCredentials(
new BasicAWSCredentials(config.AccessKeyId, config.SecretAccessKey),
config.RoleArn,
"StellaOps"),
_ => new InstanceProfileAWSCredentials()
};
_client = new AmazonSecretsManagerClient(
credentials,
RegionEndpoint.GetBySystemName(config.Region));
}
public async Task<string?> GetSecretAsync(
ConnectorContext context,
string path,
string? key = null,
CancellationToken ct = default)
{
try
{
var response = await _client!.GetSecretValueAsync(
new GetSecretValueRequest { SecretId = path }, ct);
var secretValue = response.SecretString;
if (key is null)
return secretValue;
// AWS secrets can be JSON, try to parse
try
{
var json = JsonDocument.Parse(secretValue);
if (json.RootElement.TryGetProperty(key, out var prop))
{
return prop.GetString();
}
}
catch (JsonException)
{
// Not JSON, return full value
}
return secretValue;
}
catch (ResourceNotFoundException)
{
return null;
}
}
public async Task<IReadOnlyDictionary<string, string>> GetSecretsAsync(
ConnectorContext context,
string path,
CancellationToken ct = default)
{
var secretValue = await GetSecretAsync(context, path, ct: ct);
if (secretValue is null)
return new Dictionary<string, string>();
try
{
var json = JsonDocument.Parse(secretValue);
return json.RootElement.EnumerateObject()
.ToDictionary(p => p.Name, p => p.Value.GetString() ?? "");
}
catch (JsonException)
{
return new Dictionary<string, string> { ["value"] = secretValue };
}
}
public async Task SetSecretAsync(
ConnectorContext context,
string path,
string key,
string value,
CancellationToken ct = default)
{
// Get existing secret to merge
var existing = await GetSecretsAsync(context, path, ct);
var data = new Dictionary<string, string>(existing) { [key] = value };
var json = JsonSerializer.Serialize(data);
try
{
await _client!.UpdateSecretAsync(
new UpdateSecretRequest
{
SecretId = path,
SecretString = json
}, ct);
}
catch (ResourceNotFoundException)
{
await _client.CreateSecretAsync(
new CreateSecretRequest
{
Name = path,
SecretString = json
}, ct);
}
}
public async Task<IReadOnlyList<string>> ListSecretsAsync(
ConnectorContext context,
string? path = null,
CancellationToken ct = default)
{
var result = new List<string>();
string? nextToken = null;
do
{
var response = await _client!.ListSecretsAsync(
new ListSecretsRequest
{
NextToken = nextToken,
Filters = path is not null
? [new Filter { Key = FilterNameStringType.Name, Values = [path] }]
: null
}, ct);
result.AddRange(response.SecretList.Select(s => s.Name));
nextToken = response.NextToken;
} while (!string.IsNullOrEmpty(nextToken));
return result;
}
}
```
---
## Acceptance Criteria
- [ ] HashiCorp Vault token auth works
- [ ] HashiCorp Vault AppRole auth works
- [ ] HashiCorp Vault Kubernetes auth works
- [ ] Azure Key Vault service principal works
- [ ] Azure Key Vault managed identity works
- [ ] AWS Secrets Manager IAM auth works
- [ ] All connectors read/write secrets
- [ ] Secret listing works with path prefix
- [ ] Integration tests pass
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 102_002 Connector Runtime | Internal | TODO |
| VaultSharp | NuGet | Available |
| Azure.Security.KeyVault.Secrets | NuGet | Available |
| AWSSDK.SecretsManager | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IVaultConnector | TODO | |
| HashiCorpVaultConnector | TODO | |
| AzureKeyVaultConnector | TODO | |
| AwsSecretsManagerConnector | TODO | |
| Unit tests | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,605 @@
# SPRINT: Doctor Checks
> **Sprint ID:** 102_006
> **Module:** INTHUB
> **Phase:** 2 - Integration Hub
> **Status:** TODO
> **Parent:** [102_000_INDEX](SPRINT_20260110_102_000_INDEX_integration_hub.md)
---
## Overview
Implement Doctor checks that diagnose integration health issues. Checks validate connectivity, credentials, permissions, and rate limit status.
### Objectives
- Connectivity check for all integration types
- Credential validation checks
- Permission verification checks
- Rate limit status checks
- Aggregated health report generation
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.IntegrationHub/
│ └── Doctor/
│ ├── IDoctorCheck.cs
│ ├── DoctorService.cs
│ ├── Checks/
│ │ ├── ConnectivityCheck.cs
│ │ ├── CredentialsCheck.cs
│ │ ├── PermissionsCheck.cs
│ │ └── RateLimitCheck.cs
│ └── Reports/
│ ├── DoctorReport.cs
│ └── CheckResult.cs
└── __Tests/
```
---
## Deliverables
### IDoctorCheck Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Doctor;
public interface IDoctorCheck
{
string Name { get; }
string Description { get; }
CheckCategory Category { get; }
Task<CheckResult> ExecuteAsync(
Integration integration,
IConnectorPlugin connector,
CancellationToken ct = default);
}
public enum CheckCategory
{
Connectivity,
Credentials,
Permissions,
RateLimit
}
public sealed record CheckResult(
string CheckName,
CheckStatus Status,
string Message,
IReadOnlyDictionary<string, object>? Details = null,
TimeSpan Duration = default
)
{
public static CheckResult Pass(string name, string message,
IReadOnlyDictionary<string, object>? details = null) =>
new(name, CheckStatus.Pass, message, details);
public static CheckResult Warn(string name, string message,
IReadOnlyDictionary<string, object>? details = null) =>
new(name, CheckStatus.Warning, message, details);
public static CheckResult Fail(string name, string message,
IReadOnlyDictionary<string, object>? details = null) =>
new(name, CheckStatus.Fail, message, details);
public static CheckResult Skip(string name, string message) =>
new(name, CheckStatus.Skipped, message);
}
public enum CheckStatus
{
Pass,
Warning,
Fail,
Skipped
}
```
### DoctorService
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Doctor;
public sealed class DoctorService
{
private readonly IIntegrationManager _integrationManager;
private readonly IConnectorFactory _connectorFactory;
private readonly IEnumerable<IDoctorCheck> _checks;
private readonly ILogger<DoctorService> _logger;
public DoctorService(
IIntegrationManager integrationManager,
IConnectorFactory connectorFactory,
IEnumerable<IDoctorCheck> checks,
ILogger<DoctorService> logger)
{
_integrationManager = integrationManager;
_connectorFactory = connectorFactory;
_checks = checks;
_logger = logger;
}
public async Task<DoctorReport> CheckIntegrationAsync(
Guid integrationId,
CancellationToken ct = default)
{
var integration = await _integrationManager.GetAsync(integrationId, ct)
?? throw new IntegrationNotFoundException(integrationId);
var connector = await _connectorFactory.CreateAsync(integration, ct);
var results = new List<CheckResult>();
foreach (var check in _checks)
{
try
{
var sw = Stopwatch.StartNew();
var result = await check.ExecuteAsync(integration, connector, ct);
results.Add(result with { Duration = sw.Elapsed });
}
catch (Exception ex)
{
_logger.LogError(ex,
"Doctor check {CheckName} failed for integration {IntegrationId}",
check.Name, integrationId);
results.Add(CheckResult.Fail(
check.Name,
$"Check threw exception: {ex.Message}"));
}
}
return new DoctorReport(
IntegrationId: integrationId,
IntegrationName: integration.Name,
IntegrationType: integration.Type,
CheckedAt: TimeProvider.System.GetUtcNow(),
Results: results,
OverallStatus: DetermineOverallStatus(results));
}
public async Task<IReadOnlyList<DoctorReport>> CheckAllIntegrationsAsync(
CancellationToken ct = default)
{
var integrations = await _integrationManager.ListAsync(ct: ct);
var reports = new List<DoctorReport>();
foreach (var integration in integrations)
{
var report = await CheckIntegrationAsync(integration.Id, ct);
reports.Add(report);
}
return reports;
}
private static HealthStatus DetermineOverallStatus(
IReadOnlyList<CheckResult> results)
{
if (results.Any(r => r.Status == CheckStatus.Fail))
return HealthStatus.Unhealthy;
if (results.Any(r => r.Status == CheckStatus.Warning))
return HealthStatus.Degraded;
return HealthStatus.Healthy;
}
}
public sealed record DoctorReport(
Guid IntegrationId,
string IntegrationName,
IntegrationType IntegrationType,
DateTimeOffset CheckedAt,
IReadOnlyList<CheckResult> Results,
HealthStatus OverallStatus
);
```
### ConnectivityCheck
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Doctor.Checks;
public sealed class ConnectivityCheck : IDoctorCheck
{
public string Name => "connectivity";
public string Description => "Verifies network connectivity to the integration endpoint";
public CheckCategory Category => CheckCategory.Connectivity;
public async Task<CheckResult> ExecuteAsync(
Integration integration,
IConnectorPlugin connector,
CancellationToken ct = default)
{
try
{
var result = await connector.TestConnectionAsync(
new ConnectorContext(
integration.Id,
integration.TenantId,
default, // Config already loaded in connector
null!,
NullLogger.Instance),
ct);
if (result.Success)
{
return CheckResult.Pass(
Name,
$"Connected successfully in {result.ResponseTime.TotalMilliseconds:F0}ms",
new Dictionary<string, object>
{
["response_time_ms"] = result.ResponseTime.TotalMilliseconds
});
}
return CheckResult.Fail(
Name,
$"Connection failed: {result.Message}");
}
catch (HttpRequestException ex)
{
return CheckResult.Fail(
Name,
$"Network error: {ex.Message}",
new Dictionary<string, object>
{
["exception_type"] = ex.GetType().Name
});
}
catch (TaskCanceledException)
{
return CheckResult.Fail(Name, "Connection timed out");
}
}
}
```
### CredentialsCheck
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Doctor.Checks;
public sealed class CredentialsCheck : IDoctorCheck
{
public string Name => "credentials";
public string Description => "Validates that credentials are valid and not expired";
public CheckCategory Category => CheckCategory.Credentials;
public async Task<CheckResult> ExecuteAsync(
Integration integration,
IConnectorPlugin connector,
CancellationToken ct = default)
{
// First verify we can connect
var connectionResult = await connector.TestConnectionAsync(
CreateContext(integration), ct);
if (!connectionResult.Success)
{
// Check if it's specifically a credential issue
if (IsCredentialError(connectionResult.Message))
{
return CheckResult.Fail(
Name,
$"Invalid credentials: {connectionResult.Message}",
new Dictionary<string, object>
{
["error_type"] = "authentication_failed"
});
}
return CheckResult.Skip(
Name,
"Skipped: connectivity check failed first");
}
// Check for expiring credentials if applicable
if (connector is ICredentialExpiration credExpiration)
{
var expiration = await credExpiration.GetCredentialExpirationAsync(ct);
if (expiration.HasValue)
{
var remaining = expiration.Value - TimeProvider.System.GetUtcNow();
if (remaining < TimeSpan.Zero)
{
return CheckResult.Fail(
Name,
"Credentials have expired",
new Dictionary<string, object>
{
["expired_at"] = expiration.Value.ToString("O")
});
}
if (remaining < TimeSpan.FromDays(7))
{
return CheckResult.Warn(
Name,
$"Credentials expire in {remaining.Days} days",
new Dictionary<string, object>
{
["expires_at"] = expiration.Value.ToString("O"),
["days_remaining"] = remaining.Days
});
}
}
}
return CheckResult.Pass(Name, "Credentials are valid");
}
private static bool IsCredentialError(string? message)
{
if (message is null) return false;
var credentialKeywords = new[]
{
"401", "unauthorized", "authentication",
"invalid token", "invalid credentials",
"access denied", "forbidden"
};
return credentialKeywords.Any(k =>
message.Contains(k, StringComparison.OrdinalIgnoreCase));
}
}
```
### PermissionsCheck
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Doctor.Checks;
public sealed class PermissionsCheck : IDoctorCheck
{
public string Name => "permissions";
public string Description => "Verifies the integration has required permissions";
public CheckCategory Category => CheckCategory.Permissions;
public async Task<CheckResult> ExecuteAsync(
Integration integration,
IConnectorPlugin connector,
CancellationToken ct = default)
{
var requiredCapabilities = GetRequiredCapabilities(integration.Type);
var availableCapabilities = connector.GetCapabilities();
var missing = requiredCapabilities
.Except(availableCapabilities)
.ToList();
if (missing.Count > 0)
{
return CheckResult.Warn(
Name,
$"Missing capabilities: {string.Join(", ", missing)}",
new Dictionary<string, object>
{
["missing_capabilities"] = missing,
["available_capabilities"] = availableCapabilities
});
}
// Type-specific permission checks
var specificResult = integration.Type switch
{
IntegrationType.Scm => await CheckScmPermissionsAsync(
(IScmConnector)connector, ct),
IntegrationType.Registry => await CheckRegistryPermissionsAsync(
(IRegistryConnector)connector, ct),
IntegrationType.Vault => await CheckVaultPermissionsAsync(
(IVaultConnector)connector, ct),
_ => null
};
if (specificResult is not null && specificResult.Status != CheckStatus.Pass)
{
return specificResult;
}
return CheckResult.Pass(
Name,
"All required permissions available",
new Dictionary<string, object>
{
["capabilities"] = availableCapabilities
});
}
private async Task<CheckResult?> CheckScmPermissionsAsync(
IScmConnector connector,
CancellationToken ct)
{
try
{
// Try to list repos to verify read access
var repos = await connector.ListRepositoriesAsync(
CreateContext(), null, ct);
return repos.Count == 0
? CheckResult.Warn(Name, "No repositories accessible")
: null;
}
catch (Exception ex)
{
return CheckResult.Fail(
Name,
$"Cannot list repositories: {ex.Message}");
}
}
private async Task<CheckResult?> CheckRegistryPermissionsAsync(
IRegistryConnector connector,
CancellationToken ct)
{
try
{
var repos = await connector.ListRepositoriesAsync(
CreateContext(), null, ct);
return repos.Count == 0
? CheckResult.Warn(Name, "No repositories accessible")
: null;
}
catch (Exception ex)
{
return CheckResult.Fail(
Name,
$"Cannot list repositories: {ex.Message}");
}
}
private async Task<CheckResult?> CheckVaultPermissionsAsync(
IVaultConnector connector,
CancellationToken ct)
{
try
{
var secrets = await connector.ListSecretsAsync(CreateContext(), ct: ct);
return null; // Can list = has permissions
}
catch (Exception ex)
{
return CheckResult.Fail(
Name,
$"Cannot list secrets: {ex.Message}");
}
}
}
```
### RateLimitCheck
```csharp
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Doctor.Checks;
public sealed class RateLimitCheck : IDoctorCheck
{
public string Name => "rate_limit";
public string Description => "Checks remaining API rate limit quota";
public CheckCategory Category => CheckCategory.RateLimit;
public async Task<CheckResult> ExecuteAsync(
Integration integration,
IConnectorPlugin connector,
CancellationToken ct = default)
{
if (connector is not IRateLimitInfo rateLimitInfo)
{
return CheckResult.Skip(
Name,
"Connector does not expose rate limit information");
}
try
{
var info = await rateLimitInfo.GetRateLimitInfoAsync(ct);
var percentUsed = info.Limit > 0
? (double)(info.Limit - info.Remaining) / info.Limit * 100
: 0;
var details = new Dictionary<string, object>
{
["limit"] = info.Limit,
["remaining"] = info.Remaining,
["reset_at"] = info.ResetAt?.ToString("O") ?? "unknown",
["percent_used"] = percentUsed
};
if (info.Remaining == 0)
{
return CheckResult.Fail(
Name,
$"Rate limit exhausted, resets at {info.ResetAt:HH:mm:ss}",
details);
}
if (percentUsed > 80)
{
return CheckResult.Warn(
Name,
$"Rate limit {percentUsed:F0}% consumed ({info.Remaining}/{info.Limit} remaining)",
details);
}
return CheckResult.Pass(
Name,
$"Rate limit healthy: {info.Remaining}/{info.Limit} remaining",
details);
}
catch (Exception ex)
{
return CheckResult.Skip(
Name,
$"Could not retrieve rate limit info: {ex.Message}");
}
}
}
public interface IRateLimitInfo
{
Task<RateLimitStatus> GetRateLimitInfoAsync(CancellationToken ct = default);
}
public sealed record RateLimitStatus(
int Limit,
int Remaining,
DateTimeOffset? ResetAt
);
```
---
## Acceptance Criteria
- [ ] Connectivity check works for all types
- [ ] Credential check detects auth failures
- [ ] Credential expiration warning works
- [ ] Permission check verifies capabilities
- [ ] Rate limit check warns on low quota
- [ ] Doctor report aggregates all results
- [ ] Check all integrations at once works
- [ ] Health status updates after checks
- [ ] Unit test coverage ≥85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 102_001 Integration Manager | Internal | TODO |
| 102_002 Connector Runtime | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IDoctorCheck interface | TODO | |
| DoctorService | TODO | |
| ConnectivityCheck | TODO | |
| CredentialsCheck | TODO | |
| PermissionsCheck | TODO | |
| RateLimitCheck | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,197 @@
# SPRINT INDEX: Phase 3 - Environment Manager
> **Epic:** Release Orchestrator
> **Phase:** 3 - Environment Manager
> **Batch:** 103
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 3 implements the Environment Manager - managing deployment environments (Dev, Stage, Prod), targets within environments, and agent registration.
### Objectives
- Environment CRUD with promotion order
- Target registry for deployment destinations
- Agent registration and lifecycle
- Inventory synchronization from targets
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 103_001 | Environment CRUD | ENVMGR | TODO | 101_001 |
| 103_002 | Target Registry | ENVMGR | TODO | 103_001 |
| 103_003 | Agent Manager - Core | ENVMGR | TODO | 103_002 |
| 103_004 | Inventory Sync | ENVMGR | TODO | 103_002, 103_003 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ ENVIRONMENT MANAGER │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ ENVIRONMENT SERVICE (103_001) │ │
│ │ │ │
│ │ - Create/Update/Delete environments │ │
│ │ - Promotion order management │ │
│ │ - Freeze window configuration │ │
│ │ - Auto-promotion rules │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ TARGET REGISTRY (103_002) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │Docker Host │ │Compose Host │ │ECS Service │ │ Nomad Job │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ - Target registration - Health monitoring │ │
│ │ - Connection validation - Capability detection │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ AGENT MANAGER (103_003) │ │
│ │ │ │
│ │ - Agent registration flow - Certificate issuance │ │
│ │ - Heartbeat processing - Capability registration │ │
│ │ - Agent lifecycle (active/inactive/revoked) │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ INVENTORY SYNC (103_004) │ │
│ │ │ │
│ │ - Pull current state from targets │ │
│ │ - Detect drift from expected state │ │
│ │ - Container inventory snapshot │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 103_001: Environment CRUD
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IEnvironmentService` | Interface | Environment operations |
| `EnvironmentService` | Class | Implementation |
| `Environment` | Model | Environment entity |
| `FreezeWindow` | Model | Deployment freeze windows |
| `EnvironmentValidator` | Class | Business rule validation |
### 103_002: Target Registry
| Deliverable | Type | Description |
|-------------|------|-------------|
| `ITargetRegistry` | Interface | Target registration |
| `TargetRegistry` | Class | Implementation |
| `Target` | Model | Deployment target entity |
| `TargetType` | Enum | docker_host, compose_host, ecs_service, nomad_job |
| `TargetHealthChecker` | Class | Health monitoring |
### 103_003: Agent Manager - Core
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IAgentManager` | Interface | Agent lifecycle |
| `AgentManager` | Class | Implementation |
| `AgentRegistration` | Flow | One-time token registration |
| `AgentCertificateService` | Class | mTLS certificate issuance |
| `HeartbeatProcessor` | Class | Process agent heartbeats |
### 103_004: Inventory Sync
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IInventorySyncService` | Interface | Sync operations |
| `InventorySyncService` | Class | Implementation |
| `InventorySnapshot` | Model | Container state snapshot |
| `DriftDetector` | Class | Detect configuration drift |
---
## Key Interfaces
```csharp
public interface IEnvironmentService
{
Task<Environment> CreateAsync(CreateEnvironmentRequest request, CancellationToken ct);
Task<Environment> UpdateAsync(Guid id, UpdateEnvironmentRequest request, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
Task<Environment?> GetAsync(Guid id, CancellationToken ct);
Task<IReadOnlyList<Environment>> ListAsync(CancellationToken ct);
Task ReorderAsync(IReadOnlyList<Guid> orderedIds, CancellationToken ct);
Task<bool> IsFrozenAsync(Guid id, CancellationToken ct);
}
public interface ITargetRegistry
{
Task<Target> RegisterAsync(RegisterTargetRequest request, CancellationToken ct);
Task<Target> UpdateAsync(Guid id, UpdateTargetRequest request, CancellationToken ct);
Task UnregisterAsync(Guid id, CancellationToken ct);
Task<Target?> GetAsync(Guid id, CancellationToken ct);
Task<IReadOnlyList<Target>> ListByEnvironmentAsync(Guid environmentId, CancellationToken ct);
Task UpdateHealthAsync(Guid id, HealthStatus status, CancellationToken ct);
}
public interface IAgentManager
{
Task<AgentRegistrationToken> CreateRegistrationTokenAsync(CreateTokenRequest request, CancellationToken ct);
Task<Agent> RegisterAsync(AgentRegistrationRequest request, CancellationToken ct);
Task ProcessHeartbeatAsync(AgentHeartbeat heartbeat, CancellationToken ct);
Task<Agent?> GetAsync(Guid id, CancellationToken ct);
Task RevokeAsync(Guid id, CancellationToken ct);
}
```
---
## Dependencies
### External Dependencies
| Dependency | Purpose |
|------------|---------|
| PostgreSQL 16+ | Database |
| gRPC | Agent communication |
### Internal Dependencies
| Module | Purpose |
|--------|---------|
| 101_001 Database Schema | Tables |
| Authority | Tenant context, PKI |
---
## Acceptance Criteria
- [ ] Environment CRUD with ordering
- [ ] Freeze window blocks deployments
- [ ] Target types validated
- [ ] Agent registration flow works
- [ ] mTLS certificates issued
- [ ] Heartbeats update status
- [ ] Inventory snapshot captured
- [ ] Drift detection works
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 3 index created |

View File

@@ -0,0 +1,415 @@
# SPRINT: Environment CRUD
> **Sprint ID:** 103_001
> **Module:** ENVMGR
> **Phase:** 3 - Environment Manager
> **Status:** TODO
> **Parent:** [103_000_INDEX](SPRINT_20260110_103_000_INDEX_environment_manager.md)
---
## Overview
Implement Environment CRUD operations including promotion order management, freeze windows, and auto-promotion configuration.
### Objectives
- Create/Read/Update/Delete environments
- Manage promotion order (Dev → Stage → Prod)
- Configure freeze windows for deployment blocks
- Set up auto-promotion rules between environments
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Environment/
│ ├── Services/
│ │ ├── IEnvironmentService.cs
│ │ ├── EnvironmentService.cs
│ │ └── EnvironmentValidator.cs
│ ├── Store/
│ │ ├── IEnvironmentStore.cs
│ │ ├── EnvironmentStore.cs
│ │ └── EnvironmentMapper.cs
│ ├── FreezeWindow/
│ │ ├── IFreezeWindowService.cs
│ │ ├── FreezeWindowService.cs
│ │ └── FreezeWindowChecker.cs
│ ├── Models/
│ │ ├── Environment.cs
│ │ ├── FreezeWindow.cs
│ │ ├── EnvironmentConfig.cs
│ │ └── PromotionPolicy.cs
│ └── Events/
│ ├── EnvironmentCreated.cs
│ ├── EnvironmentUpdated.cs
│ └── FreezeWindowActivated.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Environment.Tests/
```
---
## Architecture Reference
- [Environment Manager](../modules/release-orchestrator/modules/environment-manager.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IEnvironmentService Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Services;
public interface IEnvironmentService
{
Task<Environment> CreateAsync(CreateEnvironmentRequest request, CancellationToken ct = default);
Task<Environment> UpdateAsync(Guid id, UpdateEnvironmentRequest request, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
Task<Environment?> GetAsync(Guid id, CancellationToken ct = default);
Task<Environment?> GetByNameAsync(string name, CancellationToken ct = default);
Task<IReadOnlyList<Environment>> ListAsync(CancellationToken ct = default);
Task<IReadOnlyList<Environment>> ListOrderedAsync(CancellationToken ct = default);
Task ReorderAsync(IReadOnlyList<Guid> orderedIds, CancellationToken ct = default);
Task<Environment?> GetNextPromotionTargetAsync(Guid environmentId, CancellationToken ct = default);
}
public sealed record CreateEnvironmentRequest(
string Name,
string DisplayName,
string? Description,
int OrderIndex,
bool IsProduction,
int RequiredApprovals,
bool RequireSeparationOfDuties,
Guid? AutoPromoteFrom,
int DeploymentTimeoutSeconds
);
public sealed record UpdateEnvironmentRequest(
string? DisplayName = null,
string? Description = null,
int? OrderIndex = null,
bool? IsProduction = null,
int? RequiredApprovals = null,
bool? RequireSeparationOfDuties = null,
Guid? AutoPromoteFrom = null,
int? DeploymentTimeoutSeconds = null
);
```
### Environment Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Models;
public sealed record Environment
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string Name { get; init; }
public required string DisplayName { get; init; }
public string? Description { get; init; }
public required int OrderIndex { get; init; }
public required bool IsProduction { get; init; }
public required int RequiredApprovals { get; init; }
public required bool RequireSeparationOfDuties { get; init; }
public Guid? AutoPromoteFrom { get; init; }
public required int DeploymentTimeoutSeconds { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public Guid CreatedBy { get; init; }
}
```
### IFreezeWindowService Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.FreezeWindow;
public interface IFreezeWindowService
{
Task<FreezeWindow> CreateAsync(CreateFreezeWindowRequest request, CancellationToken ct = default);
Task<FreezeWindow> UpdateAsync(Guid id, UpdateFreezeWindowRequest request, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<FreezeWindow>> ListByEnvironmentAsync(Guid environmentId, CancellationToken ct = default);
Task<bool> IsEnvironmentFrozenAsync(Guid environmentId, CancellationToken ct = default);
Task<FreezeWindow?> GetActiveFreezeWindowAsync(Guid environmentId, CancellationToken ct = default);
Task<FreezeWindowExemption> GrantExemptionAsync(Guid freezeWindowId, GrantExemptionRequest request, CancellationToken ct = default);
}
public sealed record FreezeWindow
{
public required Guid Id { get; init; }
public required Guid EnvironmentId { get; init; }
public required string Name { get; init; }
public required DateTimeOffset StartAt { get; init; }
public required DateTimeOffset EndAt { get; init; }
public string? Reason { get; init; }
public bool IsRecurring { get; init; }
public string? RecurrenceRule { get; init; } // iCal RRULE format
public DateTimeOffset CreatedAt { get; init; }
public Guid CreatedBy { get; init; }
}
public sealed record CreateFreezeWindowRequest(
Guid EnvironmentId,
string Name,
DateTimeOffset StartAt,
DateTimeOffset EndAt,
string? Reason,
bool IsRecurring = false,
string? RecurrenceRule = null
);
```
### EnvironmentValidator
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Services;
public sealed class EnvironmentValidator
{
private readonly IEnvironmentStore _store;
public EnvironmentValidator(IEnvironmentStore store)
{
_store = store;
}
public async Task<ValidationResult> ValidateCreateAsync(
CreateEnvironmentRequest request,
CancellationToken ct = default)
{
var errors = new List<string>();
// Name format validation
if (!IsValidEnvironmentName(request.Name))
{
errors.Add("Environment name must be lowercase alphanumeric with hyphens, 2-32 characters");
}
// Check for duplicate name
var existing = await _store.GetByNameAsync(request.Name, ct);
if (existing is not null)
{
errors.Add($"Environment with name '{request.Name}' already exists");
}
// Check for duplicate order index
var existingOrder = await _store.GetByOrderIndexAsync(request.OrderIndex, ct);
if (existingOrder is not null)
{
errors.Add($"Environment with order index {request.OrderIndex} already exists");
}
// Validate auto-promote reference
if (request.AutoPromoteFrom.HasValue)
{
var sourceEnv = await _store.GetAsync(request.AutoPromoteFrom.Value, ct);
if (sourceEnv is null)
{
errors.Add("Auto-promote source environment not found");
}
else if (sourceEnv.OrderIndex >= request.OrderIndex)
{
errors.Add("Auto-promote source must have lower order index (earlier in pipeline)");
}
}
// Production environment validation
if (request.IsProduction && request.RequiredApprovals < 1)
{
errors.Add("Production environments must require at least 1 approval");
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
public async Task<ValidationResult> ValidateReorderAsync(
IReadOnlyList<Guid> orderedIds,
CancellationToken ct = default)
{
var errors = new List<string>();
var allEnvironments = await _store.ListAsync(ct);
// Check all environments are included
var existingIds = allEnvironments.Select(e => e.Id).ToHashSet();
var providedIds = orderedIds.ToHashSet();
if (!existingIds.SetEquals(providedIds))
{
errors.Add("Reorder must include all existing environments exactly once");
}
// Check no duplicates
if (orderedIds.Count != orderedIds.Distinct().Count())
{
errors.Add("Reorder list contains duplicate environment IDs");
}
// Validate auto-promote chains don't break
foreach (var env in allEnvironments.Where(e => e.AutoPromoteFrom.HasValue))
{
var sourceIndex = orderedIds.ToList().IndexOf(env.AutoPromoteFrom!.Value);
var targetIndex = orderedIds.ToList().IndexOf(env.Id);
if (sourceIndex >= targetIndex)
{
errors.Add($"Reorder would break auto-promote chain: {env.Name} must come after its source");
}
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
private static bool IsValidEnvironmentName(string name) =>
Regex.IsMatch(name, @"^[a-z][a-z0-9-]{1,31}$");
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Events;
public sealed record EnvironmentCreated(
Guid EnvironmentId,
Guid TenantId,
string Name,
int OrderIndex,
bool IsProduction,
DateTimeOffset CreatedAt,
Guid CreatedBy
) : IDomainEvent;
public sealed record EnvironmentUpdated(
Guid EnvironmentId,
Guid TenantId,
IReadOnlyList<string> ChangedFields,
DateTimeOffset UpdatedAt,
Guid UpdatedBy
) : IDomainEvent;
public sealed record EnvironmentDeleted(
Guid EnvironmentId,
Guid TenantId,
string Name,
DateTimeOffset DeletedAt,
Guid DeletedBy
) : IDomainEvent;
public sealed record FreezeWindowActivated(
Guid FreezeWindowId,
Guid EnvironmentId,
Guid TenantId,
DateTimeOffset StartAt,
DateTimeOffset EndAt,
string? Reason
) : IDomainEvent;
public sealed record FreezeWindowDeactivated(
Guid FreezeWindowId,
Guid EnvironmentId,
Guid TenantId,
DateTimeOffset EndedAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/environments.md` (partial) | Markdown | API endpoint documentation for environment management (CRUD, freeze windows) |
---
## Acceptance Criteria
### Code
- [ ] Create environment with all fields
- [ ] Update environment preserves audit fields
- [ ] Delete environment checks for targets/releases
- [ ] List environments returns ordered by OrderIndex
- [ ] Reorder validates chain integrity
- [ ] Auto-promote reference validated
- [ ] Freeze window blocks deployments
- [ ] Freeze window exemptions work
- [ ] Recurring freeze windows calculated correctly
- [ ] Domain events published
- [ ] Unit test coverage ≥85%
### Documentation
- [ ] API documentation created for environment endpoints
- [ ] All environment CRUD endpoints documented with request/response schemas
- [ ] Freeze window endpoints documented
- [ ] Cross-references to environment-manager.md added
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `CreateEnvironment_ValidRequest_Succeeds` | Valid creation works |
| `CreateEnvironment_DuplicateName_Fails` | Duplicate name rejected |
| `CreateEnvironment_DuplicateOrder_Fails` | Duplicate order rejected |
| `UpdateEnvironment_ValidRequest_Succeeds` | Update works |
| `DeleteEnvironment_WithTargets_Fails` | Cannot delete with children |
| `Reorder_AllEnvironments_Succeeds` | Reorder works |
| `Reorder_BreaksAutoPromote_Fails` | Chain validation works |
| `IsFrozen_ActiveWindow_ReturnsTrue` | Freeze detection works |
| `IsFrozen_WithExemption_ReturnsFalse` | Exemption works |
### Integration Tests
| Test | Description |
|------|-------------|
| `EnvironmentLifecycle_E2E` | Full CRUD cycle |
| `FreezeWindowRecurrence_E2E` | Recurring windows |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 101_001 Database Schema | Internal | TODO |
| Authority | Internal | Exists |
| ICal.Net | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IEnvironmentService | TODO | |
| EnvironmentService | TODO | |
| EnvironmentValidator | TODO | |
| IFreezeWindowService | TODO | |
| FreezeWindowService | TODO | |
| FreezeWindowChecker | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
| Integration tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/environments.md (partial) |

View File

@@ -0,0 +1,421 @@
# SPRINT: Target Registry
> **Sprint ID:** 103_002
> **Module:** ENVMGR
> **Phase:** 3 - Environment Manager
> **Status:** TODO
> **Parent:** [103_000_INDEX](SPRINT_20260110_103_000_INDEX_environment_manager.md)
---
## Overview
Implement the Target Registry for managing deployment targets within environments. Targets represent where containers are deployed (Docker hosts, Compose hosts, ECS services, Nomad jobs).
### Objectives
- Register deployment targets in environments
- Support multiple target types (docker_host, compose_host, ecs_service, nomad_job)
- Validate target connection configurations
- Track target health status
- Manage target-agent associations
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Environment/
│ ├── Target/
│ │ ├── ITargetRegistry.cs
│ │ ├── TargetRegistry.cs
│ │ ├── TargetValidator.cs
│ │ └── TargetConnectionTester.cs
│ ├── Store/
│ │ ├── ITargetStore.cs
│ │ └── TargetStore.cs
│ ├── Health/
│ │ ├── ITargetHealthChecker.cs
│ │ ├── TargetHealthChecker.cs
│ │ └── HealthCheckScheduler.cs
│ └── Models/
│ ├── Target.cs
│ ├── TargetType.cs
│ ├── TargetConfig.cs
│ └── TargetHealthStatus.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Environment.Tests/
└── Target/
```
---
## Architecture Reference
- [Environment Manager](../modules/release-orchestrator/modules/environment-manager.md)
- [Agents](../modules/release-orchestrator/modules/agents.md)
---
## Deliverables
### ITargetRegistry Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Target;
public interface ITargetRegistry
{
Task<Target> RegisterAsync(RegisterTargetRequest request, CancellationToken ct = default);
Task<Target> UpdateAsync(Guid id, UpdateTargetRequest request, CancellationToken ct = default);
Task UnregisterAsync(Guid id, CancellationToken ct = default);
Task<Target?> GetAsync(Guid id, CancellationToken ct = default);
Task<Target?> GetByNameAsync(Guid environmentId, string name, CancellationToken ct = default);
Task<IReadOnlyList<Target>> ListByEnvironmentAsync(Guid environmentId, CancellationToken ct = default);
Task<IReadOnlyList<Target>> ListByAgentAsync(Guid agentId, CancellationToken ct = default);
Task<IReadOnlyList<Target>> ListHealthyAsync(Guid environmentId, CancellationToken ct = default);
Task AssignAgentAsync(Guid targetId, Guid agentId, CancellationToken ct = default);
Task UnassignAgentAsync(Guid targetId, CancellationToken ct = default);
Task UpdateHealthAsync(Guid id, HealthStatus status, string? message, CancellationToken ct = default);
Task<ConnectionTestResult> TestConnectionAsync(Guid id, CancellationToken ct = default);
}
public sealed record RegisterTargetRequest(
Guid EnvironmentId,
string Name,
string DisplayName,
TargetType Type,
TargetConnectionConfig ConnectionConfig,
Guid? AgentId = null
);
public sealed record UpdateTargetRequest(
string? DisplayName = null,
TargetConnectionConfig? ConnectionConfig = null,
Guid? AgentId = null
);
```
### Target Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Models;
public sealed record Target
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required Guid EnvironmentId { get; init; }
public required string Name { get; init; }
public required string DisplayName { get; init; }
public required TargetType Type { get; init; }
public Guid? AgentId { get; init; }
public required HealthStatus HealthStatus { get; init; }
public string? HealthMessage { get; init; }
public DateTimeOffset? LastHealthCheck { get; init; }
public DateTimeOffset? LastSyncAt { get; init; }
public InventorySnapshot? InventorySnapshot { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
public enum TargetType
{
DockerHost,
ComposeHost,
EcsService,
NomadJob
}
public enum HealthStatus
{
Unknown,
Healthy,
Degraded,
Unhealthy,
Unreachable
}
```
### TargetConnectionConfig
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Models;
public abstract record TargetConnectionConfig
{
public abstract TargetType TargetType { get; }
}
public sealed record DockerHostConfig : TargetConnectionConfig
{
public override TargetType TargetType => TargetType.DockerHost;
public required string Host { get; init; }
public int Port { get; init; } = 2376;
public bool UseTls { get; init; } = true;
public string? CaCertSecretRef { get; init; }
public string? ClientCertSecretRef { get; init; }
public string? ClientKeySecretRef { get; init; }
}
public sealed record ComposeHostConfig : TargetConnectionConfig
{
public override TargetType TargetType => TargetType.ComposeHost;
public required string Host { get; init; }
public int Port { get; init; } = 2376;
public bool UseTls { get; init; } = true;
public required string ComposeProjectPath { get; init; }
public string? ComposeFile { get; init; } = "docker-compose.yml";
public string? CaCertSecretRef { get; init; }
public string? ClientCertSecretRef { get; init; }
public string? ClientKeySecretRef { get; init; }
}
public sealed record EcsServiceConfig : TargetConnectionConfig
{
public override TargetType TargetType => TargetType.EcsService;
public required string Region { get; init; }
public required string ClusterArn { get; init; }
public required string ServiceName { get; init; }
public string? RoleArn { get; init; }
public string? AccessKeyIdSecretRef { get; init; }
public string? SecretAccessKeySecretRef { get; init; }
}
public sealed record NomadJobConfig : TargetConnectionConfig
{
public override TargetType TargetType => TargetType.NomadJob;
public required string Address { get; init; }
public required string Namespace { get; init; }
public required string JobId { get; init; }
public string? TokenSecretRef { get; init; }
public bool UseTls { get; init; } = true;
}
```
### TargetHealthChecker
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Health;
public interface ITargetHealthChecker
{
Task<HealthCheckResult> CheckAsync(Target target, CancellationToken ct = default);
}
public sealed class TargetHealthChecker : ITargetHealthChecker
{
private readonly ITargetConnectionTester _connectionTester;
private readonly IAgentManager _agentManager;
private readonly ILogger<TargetHealthChecker> _logger;
public async Task<HealthCheckResult> CheckAsync(
Target target,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
try
{
// If target has assigned agent, check via agent
if (target.AgentId.HasValue)
{
return await CheckViaAgentAsync(target, ct);
}
// Otherwise, check directly
return await CheckDirectlyAsync(target, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Health check failed for target {TargetId}",
target.Id);
return new HealthCheckResult(
Status: HealthStatus.Unreachable,
Message: ex.Message,
Duration: sw.Elapsed,
CheckedAt: TimeProvider.System.GetUtcNow()
);
}
}
private async Task<HealthCheckResult> CheckViaAgentAsync(
Target target,
CancellationToken ct)
{
var agent = await _agentManager.GetAsync(target.AgentId!.Value, ct);
if (agent is null || agent.Status != AgentStatus.Active)
{
return new HealthCheckResult(
Status: HealthStatus.Unreachable,
Message: "Assigned agent is not active",
Duration: TimeSpan.Zero,
CheckedAt: TimeProvider.System.GetUtcNow()
);
}
// Dispatch health check task to agent
var result = await _agentManager.ExecuteTaskAsync(
target.AgentId!.Value,
new HealthCheckTask(target.Id, target.Type),
ct);
return ParseAgentHealthResult(result);
}
private async Task<HealthCheckResult> CheckDirectlyAsync(
Target target,
CancellationToken ct)
{
var testResult = await _connectionTester.TestAsync(target, ct);
return new HealthCheckResult(
Status: testResult.Success ? HealthStatus.Healthy : HealthStatus.Unreachable,
Message: testResult.Message,
Duration: testResult.Duration,
CheckedAt: TimeProvider.System.GetUtcNow()
);
}
}
public sealed record HealthCheckResult(
HealthStatus Status,
string? Message,
TimeSpan Duration,
DateTimeOffset CheckedAt
);
```
### HealthCheckScheduler
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Health;
public sealed class HealthCheckScheduler : IHostedService, IDisposable
{
private readonly ITargetRegistry _targetRegistry;
private readonly ITargetHealthChecker _healthChecker;
private readonly ILogger<HealthCheckScheduler> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
private Timer? _timer;
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(
DoHealthChecks,
null,
TimeSpan.FromSeconds(30),
_checkInterval);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
private async void DoHealthChecks(object? state)
{
try
{
var environments = await _environmentService.ListAsync();
foreach (var env in environments)
{
var targets = await _targetRegistry.ListByEnvironmentAsync(env.Id);
foreach (var target in targets)
{
try
{
var result = await _healthChecker.CheckAsync(target);
await _targetRegistry.UpdateHealthAsync(
target.Id,
result.Status,
result.Message);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to update health for target {TargetId}",
target.Id);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check scheduler failed");
}
}
public void Dispose() => _timer?.Dispose();
}
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/environments.md` (partial) | Markdown | API endpoint documentation for target management (target groups, targets, health) |
---
## Acceptance Criteria
### Code
- [ ] Register target with connection config
- [ ] Update target preserves encrypted config
- [ ] Unregister checks for active deployments
- [ ] List targets by environment works
- [ ] List healthy targets filters correctly
- [ ] Assign/unassign agent works
- [ ] Connection test validates config
- [ ] Health check updates status
- [ ] Scheduled health checks run
- [ ] Unit test coverage ≥85%
### Documentation
- [ ] API documentation created for target endpoints
- [ ] All target CRUD endpoints documented
- [ ] Target group endpoints documented
- [ ] Health check endpoints documented
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 103_001 Environment CRUD | Internal | TODO |
| 101_001 Database Schema | Internal | TODO |
| Docker.DotNet | NuGet | Available |
| AWSSDK.ECS | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ITargetRegistry | TODO | |
| TargetRegistry | TODO | |
| TargetValidator | TODO | |
| TargetConnectionTester | TODO | |
| ITargetHealthChecker | TODO | |
| TargetHealthChecker | TODO | |
| HealthCheckScheduler | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/environments.md (partial - targets) |

View File

@@ -0,0 +1,554 @@
# SPRINT: Agent Manager - Core
> **Sprint ID:** 103_003
> **Module:** ENVMGR
> **Phase:** 3 - Environment Manager
> **Status:** TODO
> **Parent:** [103_000_INDEX](SPRINT_20260110_103_000_INDEX_environment_manager.md)
---
## Overview
Implement the Agent Manager for registering, authenticating, and managing deployment agents. Agents are secure executors that run on target hosts.
### Objectives
- One-time token generation for agent registration
- Agent registration with certificate issuance
- Heartbeat processing and status tracking
- Agent capability registration
- Agent lifecycle management (active/inactive/revoked)
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Agent/
│ ├── Manager/
│ │ ├── IAgentManager.cs
│ │ ├── AgentManager.cs
│ │ └── AgentValidator.cs
│ ├── Registration/
│ │ ├── IAgentRegistration.cs
│ │ ├── AgentRegistration.cs
│ │ ├── RegistrationTokenService.cs
│ │ └── RegistrationToken.cs
│ ├── Certificate/
│ │ ├── IAgentCertificateService.cs
│ │ ├── AgentCertificateService.cs
│ │ └── CertificateTemplate.cs
│ ├── Heartbeat/
│ │ ├── IHeartbeatProcessor.cs
│ │ ├── HeartbeatProcessor.cs
│ │ └── HeartbeatTimeoutMonitor.cs
│ ├── Capability/
│ │ ├── AgentCapability.cs
│ │ └── CapabilityRegistry.cs
│ └── Models/
│ ├── Agent.cs
│ ├── AgentStatus.cs
│ └── AgentHeartbeat.cs
└── __Tests/
```
---
## Architecture Reference
- [Agent Security](../modules/release-orchestrator/security/agent-security.md)
- [Agents](../modules/release-orchestrator/modules/agents.md)
---
## Deliverables
### IAgentManager Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Agent.Manager;
public interface IAgentManager
{
// Registration
Task<RegistrationToken> CreateRegistrationTokenAsync(
CreateRegistrationTokenRequest request,
CancellationToken ct = default);
Task<Agent> RegisterAsync(
AgentRegistrationRequest request,
CancellationToken ct = default);
// Lifecycle
Task<Agent?> GetAsync(Guid id, CancellationToken ct = default);
Task<Agent?> GetByNameAsync(string name, CancellationToken ct = default);
Task<IReadOnlyList<Agent>> ListAsync(AgentFilter? filter = null, CancellationToken ct = default);
Task<IReadOnlyList<Agent>> ListActiveAsync(CancellationToken ct = default);
Task ActivateAsync(Guid id, CancellationToken ct = default);
Task DeactivateAsync(Guid id, CancellationToken ct = default);
Task RevokeAsync(Guid id, string reason, CancellationToken ct = default);
// Heartbeat
Task ProcessHeartbeatAsync(AgentHeartbeat heartbeat, CancellationToken ct = default);
// Certificate
Task<AgentCertificate> RenewCertificateAsync(Guid id, CancellationToken ct = default);
// Task execution
Task<TaskResult> ExecuteTaskAsync(
Guid agentId,
AgentTask task,
CancellationToken ct = default);
}
public sealed record CreateRegistrationTokenRequest(
string AgentName,
string DisplayName,
IReadOnlyList<AgentCapability> Capabilities,
TimeSpan? ValidFor = null
);
public sealed record AgentRegistrationRequest(
string Token,
string AgentVersion,
string Hostname,
IReadOnlyDictionary<string, string> Labels
);
```
### Agent Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Agent.Models;
public sealed record Agent
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string Name { get; init; }
public required string DisplayName { get; init; }
public required string Version { get; init; }
public string? Hostname { get; init; }
public required AgentStatus Status { get; init; }
public required ImmutableArray<AgentCapability> Capabilities { get; init; }
public ImmutableDictionary<string, string> Labels { get; init; } = ImmutableDictionary<string, string>.Empty;
public string? CertificateThumbprint { get; init; }
public DateTimeOffset? CertificateExpiresAt { get; init; }
public DateTimeOffset? LastHeartbeatAt { get; init; }
public AgentResourceStatus? LastResourceStatus { get; init; }
public DateTimeOffset? RegisteredAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
public enum AgentStatus
{
Pending, // Token created, not yet registered
Active, // Registered and healthy
Inactive, // Manually deactivated
Stale, // Missed heartbeats
Revoked // Permanently disabled
}
public enum AgentCapability
{
Docker,
Compose,
Ssh,
WinRm
}
public sealed record AgentResourceStatus(
double CpuPercent,
long MemoryUsedBytes,
long MemoryTotalBytes,
long DiskUsedBytes,
long DiskTotalBytes
);
```
### RegistrationTokenService
```csharp
namespace StellaOps.ReleaseOrchestrator.Agent.Registration;
public sealed class RegistrationTokenService
{
private readonly IAgentStore _store;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private static readonly TimeSpan DefaultTokenValidity = TimeSpan.FromHours(24);
public async Task<RegistrationToken> CreateAsync(
CreateRegistrationTokenRequest request,
CancellationToken ct = default)
{
// Validate agent name is unique
var existing = await _store.GetByNameAsync(request.AgentName, ct);
if (existing is not null)
{
throw new AgentAlreadyExistsException(request.AgentName);
}
var token = GenerateSecureToken();
var validity = request.ValidFor ?? DefaultTokenValidity;
var expiresAt = _timeProvider.GetUtcNow().Add(validity);
var registrationToken = new RegistrationToken
{
Id = _guidGenerator.NewGuid(),
Token = token,
AgentName = request.AgentName,
DisplayName = request.DisplayName,
Capabilities = request.Capabilities.ToImmutableArray(),
ExpiresAt = expiresAt,
CreatedAt = _timeProvider.GetUtcNow(),
IsUsed = false
};
await _store.SaveRegistrationTokenAsync(registrationToken, ct);
return registrationToken;
}
public async Task<RegistrationToken?> ValidateAndConsumeAsync(
string token,
CancellationToken ct = default)
{
var registrationToken = await _store.GetRegistrationTokenAsync(token, ct);
if (registrationToken is null)
{
return null;
}
if (registrationToken.IsUsed)
{
throw new RegistrationTokenAlreadyUsedException(token);
}
if (registrationToken.ExpiresAt < _timeProvider.GetUtcNow())
{
throw new RegistrationTokenExpiredException(token);
}
// Mark as used
await _store.MarkRegistrationTokenUsedAsync(registrationToken.Id, ct);
return registrationToken;
}
private static string GenerateSecureToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
}
public sealed record RegistrationToken
{
public required Guid Id { get; init; }
public required string Token { get; init; }
public required string AgentName { get; init; }
public required string DisplayName { get; init; }
public required ImmutableArray<AgentCapability> Capabilities { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required bool IsUsed { get; init; }
}
```
### AgentCertificateService
```csharp
namespace StellaOps.ReleaseOrchestrator.Agent.Certificate;
public interface IAgentCertificateService
{
Task<AgentCertificate> IssueAsync(Agent agent, CancellationToken ct = default);
Task<AgentCertificate> RenewAsync(Agent agent, CancellationToken ct = default);
Task RevokeAsync(Agent agent, CancellationToken ct = default);
Task<bool> ValidateAsync(string thumbprint, CancellationToken ct = default);
}
public sealed class AgentCertificateService : IAgentCertificateService
{
private readonly ICertificateAuthority _ca;
private readonly IAgentStore _store;
private readonly TimeProvider _timeProvider;
private static readonly TimeSpan CertificateValidity = TimeSpan.FromHours(24);
public async Task<AgentCertificate> IssueAsync(
Agent agent,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var notAfter = now.Add(CertificateValidity);
var subject = new X500DistinguishedName(
$"CN={agent.Name}, O=StellaOps Agent, OU={agent.TenantId}");
var certificate = await _ca.IssueCertificateAsync(
subject: subject,
notBefore: now,
notAfter: notAfter,
keyUsage: X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
extendedKeyUsage: [Oids.ClientAuthentication],
ct: ct);
var agentCertificate = new AgentCertificate
{
Thumbprint = certificate.Thumbprint,
SubjectName = certificate.Subject,
NotBefore = now,
NotAfter = notAfter,
CertificatePem = certificate.ExportCertificatePem(),
PrivateKeyPem = certificate.GetRSAPrivateKey()!.ExportRSAPrivateKeyPem()
};
// Update agent with new certificate
await _store.UpdateCertificateAsync(
agent.Id,
agentCertificate.Thumbprint,
notAfter,
ct);
return agentCertificate;
}
public async Task<AgentCertificate> RenewAsync(
Agent agent,
CancellationToken ct = default)
{
// Revoke old certificate if exists
if (!string.IsNullOrEmpty(agent.CertificateThumbprint))
{
await _ca.RevokeCertificateAsync(agent.CertificateThumbprint, ct);
}
// Issue new certificate
return await IssueAsync(agent, ct);
}
public async Task RevokeAsync(Agent agent, CancellationToken ct = default)
{
if (!string.IsNullOrEmpty(agent.CertificateThumbprint))
{
await _ca.RevokeCertificateAsync(agent.CertificateThumbprint, ct);
await _store.ClearCertificateAsync(agent.Id, ct);
}
}
}
public sealed record AgentCertificate
{
public required string Thumbprint { get; init; }
public required string SubjectName { get; init; }
public required DateTimeOffset NotBefore { get; init; }
public required DateTimeOffset NotAfter { get; init; }
public required string CertificatePem { get; init; }
public required string PrivateKeyPem { get; init; }
}
```
### HeartbeatProcessor
```csharp
namespace StellaOps.ReleaseOrchestrator.Agent.Heartbeat;
public interface IHeartbeatProcessor
{
Task ProcessAsync(AgentHeartbeat heartbeat, CancellationToken ct = default);
}
public sealed class HeartbeatProcessor : IHeartbeatProcessor
{
private readonly IAgentStore _store;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HeartbeatProcessor> _logger;
public async Task ProcessAsync(
AgentHeartbeat heartbeat,
CancellationToken ct = default)
{
var agent = await _store.GetAsync(heartbeat.AgentId, ct);
if (agent is null)
{
_logger.LogWarning(
"Received heartbeat from unknown agent {AgentId}",
heartbeat.AgentId);
return;
}
if (agent.Status == AgentStatus.Revoked)
{
_logger.LogWarning(
"Received heartbeat from revoked agent {AgentName}",
agent.Name);
return;
}
// Update last heartbeat
await _store.UpdateHeartbeatAsync(
heartbeat.AgentId,
_timeProvider.GetUtcNow(),
heartbeat.ResourceStatus,
ct);
// If agent was stale, reactivate it
if (agent.Status == AgentStatus.Stale)
{
await _store.UpdateStatusAsync(
heartbeat.AgentId,
AgentStatus.Active,
ct);
_logger.LogInformation(
"Agent {AgentName} recovered from stale state",
agent.Name);
}
}
}
public sealed record AgentHeartbeat(
Guid AgentId,
string Version,
AgentResourceStatus ResourceStatus,
IReadOnlyList<string> RunningTasks,
DateTimeOffset Timestamp
);
```
### HeartbeatTimeoutMonitor
```csharp
namespace StellaOps.ReleaseOrchestrator.Agent.Heartbeat;
public sealed class HeartbeatTimeoutMonitor : IHostedService, IDisposable
{
private readonly IAgentManager _agentManager;
private readonly ILogger<HeartbeatTimeoutMonitor> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(30);
private readonly TimeSpan _heartbeatTimeout = TimeSpan.FromMinutes(2);
private Timer? _timer;
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(
CheckForTimeouts,
null,
TimeSpan.FromMinutes(1),
_checkInterval);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
private async void CheckForTimeouts(object? state)
{
try
{
var agents = await _agentManager.ListActiveAsync();
var now = TimeProvider.System.GetUtcNow();
foreach (var agent in agents)
{
if (agent.LastHeartbeatAt is null)
continue;
var timeSinceHeartbeat = now - agent.LastHeartbeatAt.Value;
if (timeSinceHeartbeat > _heartbeatTimeout)
{
_logger.LogWarning(
"Agent {AgentName} missed heartbeat (last: {LastHeartbeat})",
agent.Name,
agent.LastHeartbeatAt);
await _agentManager.MarkStaleAsync(agent.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Heartbeat timeout check failed");
}
}
public void Dispose() => _timer?.Dispose();
}
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/agents.md` | Markdown | API endpoint documentation for agent registration, heartbeat, task management |
---
## Acceptance Criteria
### Code
- [ ] Registration token created with expiry
- [ ] Token can only be used once
- [ ] Agent registered with certificate
- [ ] mTLS certificate issued correctly
- [ ] Certificate renewed before expiry
- [ ] Heartbeat updates agent status
- [ ] Stale agents detected after timeout
- [ ] Revoked agents cannot send heartbeats
- [ ] Agent capabilities stored correctly
- [ ] Unit test coverage ≥85%
### Documentation
- [ ] API documentation file created (api/agents.md)
- [ ] Agent registration endpoint documented
- [ ] Heartbeat endpoint documented
- [ ] Task endpoints documented
- [ ] mTLS flow documented
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 103_002 Target Registry | Internal | TODO |
| 101_001 Database Schema | Internal | TODO |
| Authority | Internal | Exists |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IAgentManager | TODO | |
| AgentManager | TODO | |
| RegistrationTokenService | TODO | |
| IAgentCertificateService | TODO | |
| AgentCertificateService | TODO | |
| HeartbeatProcessor | TODO | |
| HeartbeatTimeoutMonitor | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/agents.md |

View File

@@ -0,0 +1,385 @@
# SPRINT: Inventory Sync
> **Sprint ID:** 103_004
> **Module:** ENVMGR
> **Phase:** 3 - Environment Manager
> **Status:** TODO
> **Parent:** [103_000_INDEX](SPRINT_20260110_103_000_INDEX_environment_manager.md)
---
## Overview
Implement Inventory Sync for capturing current container state from targets and detecting configuration drift.
### Objectives
- Pull current container state from targets
- Create inventory snapshots
- Detect drift from expected/deployed state
- Support scheduled and on-demand sync
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Environment/
│ └── Inventory/
│ ├── IInventorySyncService.cs
│ ├── InventorySyncService.cs
│ ├── InventoryCollector.cs
│ ├── DriftDetector.cs
│ ├── InventorySnapshot.cs
│ └── SyncScheduler.cs
└── __Tests/
```
---
## Deliverables
### IInventorySyncService Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Inventory;
public interface IInventorySyncService
{
Task<InventorySnapshot> SyncTargetAsync(Guid targetId, CancellationToken ct = default);
Task<IReadOnlyList<InventorySnapshot>> SyncEnvironmentAsync(Guid environmentId, CancellationToken ct = default);
Task<InventorySnapshot?> GetLatestSnapshotAsync(Guid targetId, CancellationToken ct = default);
Task<DriftReport> DetectDriftAsync(Guid targetId, CancellationToken ct = default);
Task<DriftReport> DetectDriftAsync(Guid targetId, Guid releaseId, CancellationToken ct = default);
}
```
### InventorySnapshot Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Inventory;
public sealed record InventorySnapshot
{
public required Guid Id { get; init; }
public required Guid TargetId { get; init; }
public required DateTimeOffset CollectedAt { get; init; }
public required ImmutableArray<ContainerInfo> Containers { get; init; }
public required ImmutableArray<NetworkInfo> Networks { get; init; }
public required ImmutableArray<VolumeInfo> Volumes { get; init; }
public string? CollectionError { get; init; }
}
public sealed record ContainerInfo(
string Id,
string Name,
string Image,
string ImageDigest,
string Status,
ImmutableDictionary<string, string> Labels,
ImmutableArray<PortMapping> Ports,
DateTimeOffset CreatedAt,
DateTimeOffset? StartedAt
);
public sealed record NetworkInfo(
string Id,
string Name,
string Driver,
ImmutableArray<string> ConnectedContainers
);
public sealed record VolumeInfo(
string Name,
string Driver,
string Mountpoint
);
public sealed record PortMapping(
int PrivatePort,
int? PublicPort,
string Type
);
```
### DriftDetector
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Inventory;
public sealed class DriftDetector
{
public DriftReport Detect(
InventorySnapshot currentState,
ExpectedState expectedState)
{
var drifts = new List<DriftItem>();
// Check for missing containers
foreach (var expected in expectedState.Containers)
{
var actual = currentState.Containers
.FirstOrDefault(c => c.Name == expected.Name);
if (actual is null)
{
drifts.Add(new DriftItem(
Type: DriftType.Missing,
Resource: "container",
Name: expected.Name,
Expected: expected.ImageDigest,
Actual: null,
Message: $"Container '{expected.Name}' not found"
));
continue;
}
// Check digest mismatch
if (actual.ImageDigest != expected.ImageDigest)
{
drifts.Add(new DriftItem(
Type: DriftType.DigestMismatch,
Resource: "container",
Name: expected.Name,
Expected: expected.ImageDigest,
Actual: actual.ImageDigest,
Message: $"Container '{expected.Name}' has different image digest"
));
}
// Check status
if (actual.Status != "running")
{
drifts.Add(new DriftItem(
Type: DriftType.StatusMismatch,
Resource: "container",
Name: expected.Name,
Expected: "running",
Actual: actual.Status,
Message: $"Container '{expected.Name}' is not running"
));
}
}
// Check for unexpected containers
var expectedNames = expectedState.Containers.Select(c => c.Name).ToHashSet();
foreach (var actual in currentState.Containers)
{
if (!expectedNames.Contains(actual.Name) &&
!IsSystemContainer(actual.Name))
{
drifts.Add(new DriftItem(
Type: DriftType.Unexpected,
Resource: "container",
Name: actual.Name,
Expected: null,
Actual: actual.ImageDigest,
Message: $"Unexpected container '{actual.Name}' found"
));
}
}
return new DriftReport(
TargetId: currentState.TargetId,
DetectedAt: TimeProvider.System.GetUtcNow(),
HasDrift: drifts.Count > 0,
Drifts: drifts.ToImmutableArray()
);
}
private static bool IsSystemContainer(string name) =>
name.StartsWith("stella-agent") ||
name.StartsWith("k8s_") ||
name.StartsWith("rancher-");
}
public sealed record DriftReport(
Guid TargetId,
DateTimeOffset DetectedAt,
bool HasDrift,
ImmutableArray<DriftItem> Drifts
);
public sealed record DriftItem(
DriftType Type,
string Resource,
string Name,
string? Expected,
string? Actual,
string Message
);
public enum DriftType
{
Missing,
Unexpected,
DigestMismatch,
StatusMismatch,
ConfigMismatch
}
```
### InventoryCollector
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Inventory;
public sealed class InventoryCollector
{
private readonly IAgentManager _agentManager;
private readonly ILogger<InventoryCollector> _logger;
public async Task<InventorySnapshot> CollectAsync(
Target target,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
try
{
if (target.AgentId is null)
{
throw new InvalidOperationException(
$"Target {target.Name} has no assigned agent");
}
var agent = await _agentManager.GetAsync(target.AgentId.Value, ct);
if (agent?.Status != AgentStatus.Active)
{
throw new InvalidOperationException(
$"Agent for target {target.Name} is not active");
}
// Dispatch inventory collection task to agent
var result = await _agentManager.ExecuteTaskAsync(
target.AgentId.Value,
new InventoryCollectionTask(target.Id, target.Type),
ct);
return ParseInventoryResult(target.Id, result);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to collect inventory from target {TargetName}",
target.Name);
return new InventorySnapshot
{
Id = Guid.NewGuid(),
TargetId = target.Id,
CollectedAt = TimeProvider.System.GetUtcNow(),
Containers = [],
Networks = [],
Volumes = [],
CollectionError = ex.Message
};
}
}
}
```
### SyncScheduler
```csharp
namespace StellaOps.ReleaseOrchestrator.Environment.Inventory;
public sealed class SyncScheduler : IHostedService, IDisposable
{
private readonly IInventorySyncService _syncService;
private readonly IEnvironmentService _environmentService;
private readonly ILogger<SyncScheduler> _logger;
private readonly TimeSpan _syncInterval = TimeSpan.FromMinutes(5);
private Timer? _timer;
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(
DoSync,
null,
TimeSpan.FromMinutes(2),
_syncInterval);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
private async void DoSync(object? state)
{
try
{
var environments = await _environmentService.ListAsync();
foreach (var env in environments)
{
try
{
await _syncService.SyncEnvironmentAsync(env.Id);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to sync environment {EnvironmentName}",
env.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Inventory sync scheduler failed");
}
}
public void Dispose() => _timer?.Dispose();
}
```
---
## Acceptance Criteria
- [ ] Collect inventory from Docker targets
- [ ] Collect inventory from Compose targets
- [ ] Store inventory snapshots
- [ ] Detect missing containers
- [ ] Detect digest mismatches
- [ ] Detect unexpected containers
- [ ] Generate drift report
- [ ] Scheduled sync runs periodically
- [ ] On-demand sync works
- [ ] Unit test coverage ≥85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 103_002 Target Registry | Internal | TODO |
| 103_003 Agent Manager | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IInventorySyncService | TODO | |
| InventorySyncService | TODO | |
| InventoryCollector | TODO | |
| DriftDetector | TODO | |
| SyncScheduler | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,200 @@
# SPRINT INDEX: Phase 4 - Release Manager
> **Epic:** Release Orchestrator
> **Phase:** 4 - Release Manager
> **Batch:** 104
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 4 implements the Release Manager - handling components (container images), versions, release bundles, and the release catalog.
### Objectives
- Component registry for tracking container images
- Version management with digest-first identity
- Release bundle creation (multiple components)
- Release catalog with status lifecycle
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 104_001 | Component Registry | RELMAN | TODO | 102_004 |
| 104_002 | Version Manager | RELMAN | TODO | 104_001 |
| 104_003 | Release Manager | RELMAN | TODO | 104_002 |
| 104_004 | Release Catalog | RELMAN | TODO | 104_003 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ RELEASE MANAGER │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ COMPONENT REGISTRY (104_001) │ │
│ │ │ │
│ │ Component ──────────────────────────────────────────────┐ │ │
│ │ │ id: UUID │ │ │
│ │ │ name: "api" │ │ │
│ │ │ registry: acr.example.io │ │ │
│ │ │ repository: myorg/api │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ VERSION MANAGER (104_002) │ │
│ │ │ │
│ │ ComponentVersion ───────────────────────────────────────┐ │ │
│ │ │ digest: sha256:abc123... │ │ │
│ │ │ semver: 2.3.1 │ ◄── SOURCE │ │
│ │ │ tag: v2.3.1 │ OF TRUTH│ │
│ │ │ discovered_at: 2026-01-10T10:00:00Z │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ RELEASE MANAGER (104_003) │ │
│ │ │ │
│ │ Release Bundle ─────────────────────────────────────────┐ │ │
│ │ │ name: "myapp-v2.3.1" │ │ │
│ │ │ status: ready │ │ │
│ │ │ components: [ │ │ │
│ │ │ { component: api, version: sha256:abc... } │ │ │
│ │ │ { component: worker, version: sha256:def... } │ │ │
│ │ │ ] │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ RELEASE CATALOG (104_004) │ │
│ │ │ │
│ │ Status Lifecycle: │ │
│ │ ┌──────┐ finalize ┌───────┐ promote ┌──────────┐ │ │
│ │ │draft │──────────►│ ready │─────────►│promoting │ │ │
│ │ └──────┘ └───────┘ └────┬─────┘ │ │
│ │ │ │ │
│ │ ┌──────────┴──────────┐ │ │
│ │ ▼ ▼ │ │
│ │ ┌──────────┐ ┌────────────┐ │ │
│ │ │ deployed │ │ deprecated │ │ │
│ │ └──────────┘ └────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 104_001: Component Registry
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IComponentRegistry` | Interface | Component CRUD |
| `ComponentRegistry` | Class | Implementation |
| `Component` | Model | Container component entity |
| `ComponentDiscovery` | Class | Auto-discover from registry |
### 104_002: Version Manager
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IVersionManager` | Interface | Version tracking |
| `VersionManager` | Class | Implementation |
| `ComponentVersion` | Model | Digest-first version |
| `VersionResolver` | Class | Tag → Digest resolution |
| `VersionWatcher` | Service | Watch for new versions |
### 104_003: Release Manager
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IReleaseManager` | Interface | Release operations |
| `ReleaseManager` | Class | Implementation |
| `Release` | Model | Release bundle entity |
| `ReleaseComponent` | Model | Release-component mapping |
| `ReleaseFinalizer` | Class | Finalize and lock release |
### 104_004: Release Catalog
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IReleaseCatalog` | Interface | Catalog queries |
| `ReleaseCatalog` | Class | Implementation |
| `ReleaseStatusMachine` | Class | Status transitions |
| `ReleaseHistory` | Service | Track release history |
---
## Key Interfaces
```csharp
public interface IComponentRegistry
{
Task<Component> CreateAsync(CreateComponentRequest request, CancellationToken ct);
Task<Component?> GetAsync(Guid id, CancellationToken ct);
Task<IReadOnlyList<Component>> ListAsync(CancellationToken ct);
Task<IReadOnlyList<ComponentVersion>> GetVersionsAsync(Guid componentId, CancellationToken ct);
}
public interface IVersionManager
{
Task<ComponentVersion> ResolveAsync(Guid componentId, string tagOrDigest, CancellationToken ct);
Task<ComponentVersion?> GetByDigestAsync(Guid componentId, string digest, CancellationToken ct);
Task<IReadOnlyList<ComponentVersion>> ListLatestAsync(Guid componentId, int count, CancellationToken ct);
}
public interface IReleaseManager
{
Task<Release> CreateAsync(CreateReleaseRequest request, CancellationToken ct);
Task<Release> AddComponentAsync(Guid releaseId, AddReleaseComponentRequest request, CancellationToken ct);
Task<Release> FinalizeAsync(Guid releaseId, CancellationToken ct);
Task<Release?> GetAsync(Guid releaseId, CancellationToken ct);
}
public interface IReleaseCatalog
{
Task<IReadOnlyList<Release>> ListAsync(ReleaseFilter? filter, CancellationToken ct);
Task<Release?> GetLatestDeployedAsync(Guid environmentId, CancellationToken ct);
Task<ReleaseDeploymentHistory> GetHistoryAsync(Guid releaseId, CancellationToken ct);
}
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| 102_004 Registry Connectors | Tag resolution |
| 101_001 Database Schema | Tables |
---
## Acceptance Criteria
- [ ] Component registration works
- [ ] Tag resolves to immutable digest
- [ ] Release bundles multiple components
- [ ] Release finalization locks versions
- [ ] Status transitions validated
- [ ] Release history tracked
- [ ] Deprecation prevents promotion
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 4 index created |

View File

@@ -0,0 +1,535 @@
# SPRINT: Component Registry
> **Sprint ID:** 104_001
> **Module:** RELMAN
> **Phase:** 4 - Release Manager
> **Status:** TODO
> **Parent:** [104_000_INDEX](SPRINT_20260110_104_000_INDEX_release_manager.md)
---
## Overview
Implement the Component Registry for tracking container images as deployable components.
### Objectives
- Register container components with registry/repository metadata
- Discover components from connected registries
- Track component configurations and labels
- Support component lifecycle (active/deprecated)
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Release/
│ ├── Component/
│ │ ├── IComponentRegistry.cs
│ │ ├── ComponentRegistry.cs
│ │ ├── ComponentValidator.cs
│ │ └── ComponentDiscovery.cs
│ ├── Store/
│ │ ├── IComponentStore.cs
│ │ └── ComponentStore.cs
│ └── Models/
│ ├── Component.cs
│ ├── ComponentConfig.cs
│ └── ComponentStatus.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Release.Tests/
└── Component/
```
---
## Architecture Reference
- [Release Manager](../modules/release-orchestrator/modules/release-manager.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IComponentRegistry Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Component;
public interface IComponentRegistry
{
Task<Component> RegisterAsync(RegisterComponentRequest request, CancellationToken ct = default);
Task<Component> UpdateAsync(Guid id, UpdateComponentRequest request, CancellationToken ct = default);
Task<Component?> GetAsync(Guid id, CancellationToken ct = default);
Task<Component?> GetByNameAsync(string name, CancellationToken ct = default);
Task<IReadOnlyList<Component>> ListAsync(ComponentFilter? filter = null, CancellationToken ct = default);
Task<IReadOnlyList<Component>> ListActiveAsync(CancellationToken ct = default);
Task DeprecateAsync(Guid id, string reason, CancellationToken ct = default);
Task ReactivateAsync(Guid id, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}
public sealed record RegisterComponentRequest(
string Name,
string DisplayName,
string RegistryUrl,
string Repository,
string? Description = null,
IReadOnlyDictionary<string, string>? Labels = null,
ComponentConfig? Config = null
);
public sealed record UpdateComponentRequest(
string? DisplayName = null,
string? Description = null,
IReadOnlyDictionary<string, string>? Labels = null,
ComponentConfig? Config = null
);
public sealed record ComponentFilter(
string? NameContains = null,
string? RegistryUrl = null,
ComponentStatus? Status = null,
IReadOnlyDictionary<string, string>? Labels = null
);
```
### Component Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Models;
public sealed record Component
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string Name { get; init; }
public required string DisplayName { get; init; }
public string? Description { get; init; }
public required string RegistryUrl { get; init; }
public required string Repository { get; init; }
public required ComponentStatus Status { get; init; }
public string? DeprecationReason { get; init; }
public ImmutableDictionary<string, string> Labels { get; init; } = ImmutableDictionary<string, string>.Empty;
public ComponentConfig? Config { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public Guid CreatedBy { get; init; }
public string FullImageRef => $"{RegistryUrl}/{Repository}";
}
public enum ComponentStatus
{
Active,
Deprecated
}
public sealed record ComponentConfig
{
public string? DefaultTag { get; init; }
public bool WatchForNewVersions { get; init; } = true;
public string? TagPattern { get; init; } // Regex for valid tags
public int? RetainVersionCount { get; init; }
public ImmutableArray<string> RequiredLabels { get; init; } = [];
}
```
### ComponentRegistry Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Component;
public sealed class ComponentRegistry : IComponentRegistry
{
private readonly IComponentStore _store;
private readonly IComponentValidator _validator;
private readonly IRegistryConnectorFactory _registryFactory;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<ComponentRegistry> _logger;
public async Task<Component> RegisterAsync(
RegisterComponentRequest request,
CancellationToken ct = default)
{
// Validate request
var validation = await _validator.ValidateRegisterAsync(request, ct);
if (!validation.IsValid)
{
throw new ComponentValidationException(validation.Errors);
}
// Verify registry connectivity
var connector = await _registryFactory.GetConnectorAsync(request.RegistryUrl, ct);
var exists = await connector.RepositoryExistsAsync(request.Repository, ct);
if (!exists)
{
throw new RepositoryNotFoundException(request.RegistryUrl, request.Repository);
}
var component = new Component
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
Name = request.Name,
DisplayName = request.DisplayName,
Description = request.Description,
RegistryUrl = request.RegistryUrl,
Repository = request.Repository,
Status = ComponentStatus.Active,
Labels = request.Labels?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
Config = request.Config,
CreatedAt = _timeProvider.GetUtcNow(),
UpdatedAt = _timeProvider.GetUtcNow(),
CreatedBy = _userContext.UserId
};
await _store.SaveAsync(component, ct);
await _eventPublisher.PublishAsync(new ComponentRegistered(
component.Id,
component.TenantId,
component.Name,
component.FullImageRef,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Registered component {ComponentName} ({FullRef})",
component.Name,
component.FullImageRef);
return component;
}
public async Task DeprecateAsync(
Guid id,
string reason,
CancellationToken ct = default)
{
var component = await _store.GetAsync(id, ct)
?? throw new ComponentNotFoundException(id);
if (component.Status == ComponentStatus.Deprecated)
{
return;
}
var updated = component with
{
Status = ComponentStatus.Deprecated,
DeprecationReason = reason,
UpdatedAt = _timeProvider.GetUtcNow()
};
await _store.SaveAsync(updated, ct);
await _eventPublisher.PublishAsync(new ComponentDeprecated(
component.Id,
component.TenantId,
component.Name,
reason,
_timeProvider.GetUtcNow()
), ct);
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var component = await _store.GetAsync(id, ct)
?? throw new ComponentNotFoundException(id);
// Check for existing releases using this component
var hasReleases = await _store.HasReleasesAsync(id, ct);
if (hasReleases)
{
throw new ComponentInUseException(id,
"Cannot delete component with existing releases");
}
await _store.DeleteAsync(id, ct);
await _eventPublisher.PublishAsync(new ComponentDeleted(
component.Id,
component.TenantId,
component.Name,
_timeProvider.GetUtcNow()
), ct);
}
}
```
### ComponentDiscovery
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Component;
public sealed class ComponentDiscovery
{
private readonly IRegistryConnectorFactory _registryFactory;
private readonly IComponentRegistry _registry;
private readonly ILogger<ComponentDiscovery> _logger;
public async Task<IReadOnlyList<DiscoveredComponent>> DiscoverAsync(
string registryUrl,
string repositoryPattern,
CancellationToken ct = default)
{
var connector = await _registryFactory.GetConnectorAsync(registryUrl, ct);
var repositories = await connector.ListRepositoriesAsync(repositoryPattern, ct);
var discovered = new List<DiscoveredComponent>();
foreach (var repo in repositories)
{
var existing = await _registry.GetByNameAsync(
NormalizeComponentName(repo), ct);
discovered.Add(new DiscoveredComponent(
RegistryUrl: registryUrl,
Repository: repo,
SuggestedName: NormalizeComponentName(repo),
AlreadyRegistered: existing is not null,
ExistingComponentId: existing?.Id
));
}
return discovered.AsReadOnly();
}
public async Task<IReadOnlyList<Component>> ImportDiscoveredAsync(
IReadOnlyList<DiscoveredComponent> components,
CancellationToken ct = default)
{
var imported = new List<Component>();
foreach (var discovered in components.Where(c => !c.AlreadyRegistered))
{
try
{
var component = await _registry.RegisterAsync(
new RegisterComponentRequest(
Name: discovered.SuggestedName,
DisplayName: FormatDisplayName(discovered.SuggestedName),
RegistryUrl: discovered.RegistryUrl,
Repository: discovered.Repository
), ct);
imported.Add(component);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to import discovered component {Repository}",
discovered.Repository);
}
}
return imported.AsReadOnly();
}
private static string NormalizeComponentName(string repository) =>
repository.Replace("/", "-").ToLowerInvariant();
private static string FormatDisplayName(string name) =>
CultureInfo.InvariantCulture.TextInfo.ToTitleCase(
name.Replace("-", " ").Replace("_", " "));
}
public sealed record DiscoveredComponent(
string RegistryUrl,
string Repository,
string SuggestedName,
bool AlreadyRegistered,
Guid? ExistingComponentId
);
```
### ComponentValidator
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Component;
public sealed class ComponentValidator : IComponentValidator
{
private readonly IComponentStore _store;
public async Task<ValidationResult> ValidateRegisterAsync(
RegisterComponentRequest request,
CancellationToken ct = default)
{
var errors = new List<string>();
// Name validation
if (!IsValidComponentName(request.Name))
{
errors.Add("Component name must be lowercase alphanumeric with hyphens, 2-64 characters");
}
// Check for duplicate name
var existing = await _store.GetByNameAsync(request.Name, ct);
if (existing is not null)
{
errors.Add($"Component with name '{request.Name}' already exists");
}
// Check for duplicate registry/repository combination
var duplicate = await _store.GetByRegistryAndRepositoryAsync(
request.RegistryUrl,
request.Repository,
ct);
if (duplicate is not null)
{
errors.Add($"Component already registered for {request.RegistryUrl}/{request.Repository}");
}
// Validate registry URL format
if (!Uri.TryCreate($"https://{request.RegistryUrl}", UriKind.Absolute, out _))
{
errors.Add("Invalid registry URL format");
}
// Validate tag pattern if specified
if (request.Config?.TagPattern is not null)
{
try
{
_ = new Regex(request.Config.TagPattern);
}
catch (RegexParseException)
{
errors.Add("Invalid tag pattern regex");
}
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
private static bool IsValidComponentName(string name) =>
Regex.IsMatch(name, @"^[a-z][a-z0-9-]{1,63}$");
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Events;
public sealed record ComponentRegistered(
Guid ComponentId,
Guid TenantId,
string Name,
string FullImageRef,
DateTimeOffset RegisteredAt
) : IDomainEvent;
public sealed record ComponentUpdated(
Guid ComponentId,
Guid TenantId,
IReadOnlyList<string> ChangedFields,
DateTimeOffset UpdatedAt
) : IDomainEvent;
public sealed record ComponentDeprecated(
Guid ComponentId,
Guid TenantId,
string Name,
string Reason,
DateTimeOffset DeprecatedAt
) : IDomainEvent;
public sealed record ComponentDeleted(
Guid ComponentId,
Guid TenantId,
string Name,
DateTimeOffset DeletedAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/releases.md` (partial) | Markdown | API endpoint documentation for component registry (list, create, update components) |
---
## Acceptance Criteria
### Code
- [ ] Register component with registry/repository
- [ ] Validate registry connectivity on register
- [ ] Check for duplicate components
- [ ] List components with filters
- [ ] Deprecate component with reason
- [ ] Reactivate deprecated component
- [ ] Delete component (only if no releases)
- [ ] Discover components from registry
- [ ] Import discovered components
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Component API endpoints documented
- [ ] List/Get/Create/Update/Delete component endpoints included
- [ ] Component version strategy schema documented
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `RegisterComponent_ValidRequest_Succeeds` | Registration works |
| `RegisterComponent_DuplicateName_Fails` | Duplicate name rejected |
| `RegisterComponent_InvalidRegistry_Fails` | Bad registry rejected |
| `DeprecateComponent_Active_Succeeds` | Deprecation works |
| `DeleteComponent_WithReleases_Fails` | In-use check works |
| `DiscoverComponents_ReturnsRepositories` | Discovery works |
| `ImportDiscovered_CreatesComponents` | Import works |
### Integration Tests
| Test | Description |
|------|-------------|
| `ComponentLifecycle_E2E` | Full CRUD cycle |
| `RegistryDiscovery_E2E` | Discovery from real registry |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 102_004 Registry Connectors | Internal | TODO |
| 101_001 Database Schema | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IComponentRegistry | TODO | |
| ComponentRegistry | TODO | |
| ComponentValidator | TODO | |
| ComponentDiscovery | TODO | |
| IComponentStore | TODO | |
| ComponentStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/releases.md (partial - components) |

View File

@@ -0,0 +1,541 @@
# SPRINT: Version Manager
> **Sprint ID:** 104_002
> **Module:** RELMAN
> **Phase:** 4 - Release Manager
> **Status:** TODO
> **Parent:** [104_000_INDEX](SPRINT_20260110_104_000_INDEX_release_manager.md)
---
## Overview
Implement the Version Manager for digest-first version tracking of container images.
### Objectives
- Resolve tags to immutable digests
- Track component versions with metadata
- Watch for new versions from registries
- Support semantic versioning extraction
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Release/
│ ├── Version/
│ │ ├── IVersionManager.cs
│ │ ├── VersionManager.cs
│ │ ├── VersionResolver.cs
│ │ ├── VersionWatcher.cs
│ │ └── SemVerExtractor.cs
│ ├── Store/
│ │ ├── IVersionStore.cs
│ │ └── VersionStore.cs
│ └── Models/
│ ├── ComponentVersion.cs
│ └── VersionMetadata.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Release.Tests/
└── Version/
```
---
## Architecture Reference
- [Release Manager](../modules/release-orchestrator/modules/release-manager.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IVersionManager Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Version;
public interface IVersionManager
{
Task<ComponentVersion> ResolveAsync(
Guid componentId,
string tagOrDigest,
CancellationToken ct = default);
Task<ComponentVersion?> GetByDigestAsync(
Guid componentId,
string digest,
CancellationToken ct = default);
Task<ComponentVersion?> GetLatestAsync(
Guid componentId,
CancellationToken ct = default);
Task<IReadOnlyList<ComponentVersion>> ListAsync(
Guid componentId,
VersionFilter? filter = null,
CancellationToken ct = default);
Task<IReadOnlyList<ComponentVersion>> ListLatestAsync(
Guid componentId,
int count = 10,
CancellationToken ct = default);
Task<ComponentVersion> RecordVersionAsync(
RecordVersionRequest request,
CancellationToken ct = default);
Task<bool> DigestExistsAsync(
Guid componentId,
string digest,
CancellationToken ct = default);
}
public sealed record VersionFilter(
string? DigestPrefix = null,
string? TagContains = null,
SemanticVersion? MinVersion = null,
SemanticVersion? MaxVersion = null,
DateTimeOffset? DiscoveredAfter = null,
DateTimeOffset? DiscoveredBefore = null
);
public sealed record RecordVersionRequest(
Guid ComponentId,
string Digest,
string? Tag = null,
SemanticVersion? SemVer = null,
VersionMetadata? Metadata = null
);
```
### ComponentVersion Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Models;
public sealed record ComponentVersion
{
public required Guid Id { get; init; }
public required Guid ComponentId { get; init; }
public required string Digest { get; init; } // sha256:abc123...
public string? Tag { get; init; } // v2.3.1, latest, etc.
public SemanticVersion? SemVer { get; init; } // Parsed semantic version
public VersionMetadata Metadata { get; init; } = new();
public required DateTimeOffset DiscoveredAt { get; init; }
public DateTimeOffset? BuiltAt { get; init; }
public Guid? DiscoveredBy { get; init; } // User or system
public string ShortDigest => Digest.Length > 19
? Digest[7..19] // sha256: prefix + 12 chars
: Digest;
}
public sealed record SemanticVersion(
int Major,
int Minor,
int Patch,
string? Prerelease = null,
string? BuildMetadata = null
) : IComparable<SemanticVersion>
{
public override string ToString()
{
var version = $"{Major}.{Minor}.{Patch}";
if (Prerelease is not null)
version += $"-{Prerelease}";
if (BuildMetadata is not null)
version += $"+{BuildMetadata}";
return version;
}
public int CompareTo(SemanticVersion? other)
{
if (other is null) return 1;
var majorCmp = Major.CompareTo(other.Major);
if (majorCmp != 0) return majorCmp;
var minorCmp = Minor.CompareTo(other.Minor);
if (minorCmp != 0) return minorCmp;
var patchCmp = Patch.CompareTo(other.Patch);
if (patchCmp != 0) return patchCmp;
// Prerelease versions have lower precedence
if (Prerelease is null && other.Prerelease is not null) return 1;
if (Prerelease is not null && other.Prerelease is null) return -1;
return string.Compare(Prerelease, other.Prerelease,
StringComparison.OrdinalIgnoreCase);
}
}
public sealed record VersionMetadata
{
public long? SizeBytes { get; init; }
public string? Architecture { get; init; }
public string? Os { get; init; }
public string? Author { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public ImmutableDictionary<string, string> Labels { get; init; } =
ImmutableDictionary<string, string>.Empty;
public ImmutableArray<string> Layers { get; init; } = [];
}
```
### VersionResolver
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Version;
public sealed class VersionResolver
{
private readonly IRegistryConnectorFactory _registryFactory;
private readonly IComponentStore _componentStore;
private readonly ILogger<VersionResolver> _logger;
public async Task<ResolvedVersion> ResolveAsync(
Guid componentId,
string tagOrDigest,
CancellationToken ct = default)
{
var component = await _componentStore.GetAsync(componentId, ct)
?? throw new ComponentNotFoundException(componentId);
var connector = await _registryFactory.GetConnectorAsync(
component.RegistryUrl, ct);
// Check if already a digest
if (IsDigest(tagOrDigest))
{
var manifest = await connector.GetManifestAsync(
component.Repository,
tagOrDigest,
ct);
return new ResolvedVersion(
Digest: tagOrDigest,
Tag: null,
Manifest: manifest,
ResolvedAt: TimeProvider.System.GetUtcNow()
);
}
// Resolve tag to digest
var tag = tagOrDigest;
var resolvedDigest = await connector.ResolveTagAsync(
component.Repository,
tag,
ct);
var manifestData = await connector.GetManifestAsync(
component.Repository,
resolvedDigest,
ct);
_logger.LogDebug(
"Resolved {Component}:{Tag} to {Digest}",
component.Name,
tag,
resolvedDigest);
return new ResolvedVersion(
Digest: resolvedDigest,
Tag: tag,
Manifest: manifestData,
ResolvedAt: TimeProvider.System.GetUtcNow()
);
}
private static bool IsDigest(string value) =>
value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) &&
value.Length == 71; // sha256: + 64 hex chars
}
public sealed record ResolvedVersion(
string Digest,
string? Tag,
ManifestData Manifest,
DateTimeOffset ResolvedAt
);
public sealed record ManifestData(
string MediaType,
long TotalSize,
string? Architecture,
string? Os,
IReadOnlyList<LayerInfo> Layers,
IReadOnlyDictionary<string, string> Labels,
DateTimeOffset? CreatedAt
);
public sealed record LayerInfo(
string Digest,
long Size,
string MediaType
);
```
### VersionWatcher
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Version;
public sealed class VersionWatcher : IHostedService, IDisposable
{
private readonly IComponentRegistry _componentRegistry;
private readonly IVersionManager _versionManager;
private readonly IRegistryConnectorFactory _registryFactory;
private readonly IEventPublisher _eventPublisher;
private readonly ILogger<VersionWatcher> _logger;
private readonly TimeSpan _pollInterval = TimeSpan.FromMinutes(5);
private Timer? _timer;
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(
PollForNewVersions,
null,
TimeSpan.FromMinutes(1),
_pollInterval);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
private async void PollForNewVersions(object? state)
{
try
{
var components = await _componentRegistry.ListActiveAsync();
foreach (var component in components)
{
if (component.Config?.WatchForNewVersions != true)
continue;
await CheckForNewVersionsAsync(component);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Version watch poll failed");
}
}
private async Task CheckForNewVersionsAsync(Component component)
{
try
{
var connector = await _registryFactory.GetConnectorAsync(
component.RegistryUrl);
var tags = await connector.ListTagsAsync(
component.Repository,
component.Config?.TagPattern);
foreach (var tag in tags)
{
var digest = await connector.ResolveTagAsync(
component.Repository,
tag);
var exists = await _versionManager.DigestExistsAsync(
component.Id,
digest);
if (!exists)
{
var version = await _versionManager.RecordVersionAsync(
new RecordVersionRequest(
ComponentId: component.Id,
Digest: digest,
Tag: tag,
SemVer: SemVerExtractor.TryParse(tag)
));
await _eventPublisher.PublishAsync(new NewVersionDiscovered(
component.Id,
component.TenantId,
component.Name,
version.Digest,
tag,
TimeProvider.System.GetUtcNow()
));
_logger.LogInformation(
"Discovered new version for {Component}: {Tag} ({Digest})",
component.Name,
tag,
version.ShortDigest);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to check versions for component {Component}",
component.Name);
}
}
public void Dispose() => _timer?.Dispose();
}
```
### SemVerExtractor
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Version;
public static class SemVerExtractor
{
private static readonly Regex SemVerPattern = new(
@"^v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)" +
@"(?:-(?<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" +
@"(?:\+(?<build>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static SemanticVersion? TryParse(string? tag)
{
if (string.IsNullOrEmpty(tag))
return null;
var match = SemVerPattern.Match(tag);
if (!match.Success)
return null;
return new SemanticVersion(
Major: int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture),
Minor: int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture),
Patch: int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture),
Prerelease: match.Groups["prerelease"].Success
? match.Groups["prerelease"].Value
: null,
BuildMetadata: match.Groups["build"].Success
? match.Groups["build"].Value
: null
);
}
public static bool IsValidSemVer(string tag) =>
SemVerPattern.IsMatch(tag);
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Events;
public sealed record NewVersionDiscovered(
Guid ComponentId,
Guid TenantId,
string ComponentName,
string Digest,
string? Tag,
DateTimeOffset DiscoveredAt
) : IDomainEvent;
public sealed record VersionResolved(
Guid ComponentId,
Guid TenantId,
string Tag,
string Digest,
DateTimeOffset ResolvedAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/releases.md` (partial) | Markdown | API endpoint documentation for version resolution (tag to digest, version maps) |
---
## Acceptance Criteria
### Code
- [ ] Resolve tag to digest
- [ ] Resolve digest returns same digest
- [ ] Record new version with metadata
- [ ] Extract semantic version from tag
- [ ] Watch for new versions
- [ ] Filter versions by criteria
- [ ] Get latest version for component
- [ ] List versions with pagination
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Version API endpoints documented
- [ ] Tag resolution endpoint documented
- [ ] Version map listing documented
- [ ] Digest-first principle explained
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `ResolveTag_ReturnsDigest` | Tag resolution works |
| `ResolveDigest_ReturnsSameDigest` | Digest passthrough works |
| `RecordVersion_StoresMetadata` | Recording works |
| `SemVerExtractor_ParsesValid` | SemVer parsing works |
| `SemVerExtractor_RejectsInvalid` | Invalid tags rejected |
| `GetLatest_ReturnsNewest` | Latest selection works |
| `ListVersions_AppliesFilter` | Filtering works |
### Integration Tests
| Test | Description |
|------|-------------|
| `VersionResolution_E2E` | Full resolution flow |
| `VersionWatcher_E2E` | Discovery polling |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 104_001 Component Registry | Internal | TODO |
| 102_004 Registry Connectors | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IVersionManager | TODO | |
| VersionManager | TODO | |
| VersionResolver | TODO | |
| VersionWatcher | TODO | |
| SemVerExtractor | TODO | |
| ComponentVersion model | TODO | |
| IVersionStore | TODO | |
| VersionStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/releases.md (partial - versions) |

View File

@@ -0,0 +1,643 @@
# SPRINT: Release Manager
> **Sprint ID:** 104_003
> **Module:** RELMAN
> **Phase:** 4 - Release Manager
> **Status:** TODO
> **Parent:** [104_000_INDEX](SPRINT_20260110_104_000_INDEX_release_manager.md)
---
## Overview
Implement the Release Manager for creating and managing release bundles containing multiple component versions.
### Objectives
- Create release bundles with multiple components
- Add/remove components from draft releases
- Finalize releases to lock component versions
- Generate release manifests
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Release/
│ ├── Manager/
│ │ ├── IReleaseManager.cs
│ │ ├── ReleaseManager.cs
│ │ ├── ReleaseValidator.cs
│ │ ├── ReleaseFinalizer.cs
│ │ └── ReleaseManifestGenerator.cs
│ ├── Store/
│ │ ├── IReleaseStore.cs
│ │ └── ReleaseStore.cs
│ └── Models/
│ ├── Release.cs
│ ├── ReleaseComponent.cs
│ ├── ReleaseStatus.cs
│ └── ReleaseManifest.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Release.Tests/
└── Manager/
```
---
## Architecture Reference
- [Release Manager](../modules/release-orchestrator/modules/release-manager.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IReleaseManager Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Manager;
public interface IReleaseManager
{
// CRUD
Task<Release> CreateAsync(CreateReleaseRequest request, CancellationToken ct = default);
Task<Release> UpdateAsync(Guid id, UpdateReleaseRequest request, CancellationToken ct = default);
Task<Release?> GetAsync(Guid id, CancellationToken ct = default);
Task<Release?> GetByNameAsync(string name, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
// Component management
Task<Release> AddComponentAsync(Guid releaseId, AddComponentRequest request, CancellationToken ct = default);
Task<Release> UpdateComponentAsync(Guid releaseId, Guid componentId, UpdateReleaseComponentRequest request, CancellationToken ct = default);
Task<Release> RemoveComponentAsync(Guid releaseId, Guid componentId, CancellationToken ct = default);
// Lifecycle
Task<Release> FinalizeAsync(Guid id, CancellationToken ct = default);
Task<Release> DeprecateAsync(Guid id, string reason, CancellationToken ct = default);
// Manifest
Task<ReleaseManifest> GetManifestAsync(Guid id, CancellationToken ct = default);
}
public sealed record CreateReleaseRequest(
string Name,
string DisplayName,
string? Description = null,
IReadOnlyList<AddComponentRequest>? Components = null
);
public sealed record UpdateReleaseRequest(
string? DisplayName = null,
string? Description = null
);
public sealed record AddComponentRequest(
Guid ComponentId,
string VersionRef, // Tag or digest
IReadOnlyDictionary<string, string>? Config = null
);
public sealed record UpdateReleaseComponentRequest(
string? VersionRef = null,
IReadOnlyDictionary<string, string>? Config = null
);
```
### Release Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Models;
public sealed record Release
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string Name { get; init; }
public required string DisplayName { get; init; }
public string? Description { get; init; }
public required ReleaseStatus Status { get; init; }
public required ImmutableArray<ReleaseComponent> Components { get; init; }
public string? ManifestDigest { get; init; } // Set on finalization
public DateTimeOffset? FinalizedAt { get; init; }
public Guid? FinalizedBy { get; init; }
public string? DeprecationReason { get; init; }
public DateTimeOffset? DeprecatedAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public Guid CreatedBy { get; init; }
public bool IsDraft => Status == ReleaseStatus.Draft;
public bool IsFinalized => Status != ReleaseStatus.Draft;
}
public enum ReleaseStatus
{
Draft, // Can be modified
Ready, // Finalized, can be promoted
Promoting, // Currently being promoted
Deployed, // Deployed to at least one environment
Deprecated // Should not be used
}
public sealed record ReleaseComponent
{
public required Guid Id { get; init; }
public required Guid ComponentId { get; init; }
public required string ComponentName { get; init; }
public required string Digest { get; init; }
public string? Tag { get; init; }
public string? SemVer { get; init; }
public ImmutableDictionary<string, string> Config { get; init; } =
ImmutableDictionary<string, string>.Empty;
public int OrderIndex { get; init; }
}
```
### ReleaseManager Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Manager;
public sealed class ReleaseManager : IReleaseManager
{
private readonly IReleaseStore _store;
private readonly IReleaseValidator _validator;
private readonly IVersionManager _versionManager;
private readonly IComponentRegistry _componentRegistry;
private readonly IReleaseFinalizer _finalizer;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<ReleaseManager> _logger;
public async Task<Release> CreateAsync(
CreateReleaseRequest request,
CancellationToken ct = default)
{
var validation = await _validator.ValidateCreateAsync(request, ct);
if (!validation.IsValid)
{
throw new ReleaseValidationException(validation.Errors);
}
var release = new Release
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
Name = request.Name,
DisplayName = request.DisplayName,
Description = request.Description,
Status = ReleaseStatus.Draft,
Components = [],
CreatedAt = _timeProvider.GetUtcNow(),
UpdatedAt = _timeProvider.GetUtcNow(),
CreatedBy = _userContext.UserId
};
await _store.SaveAsync(release, ct);
// Add initial components if provided
if (request.Components?.Count > 0)
{
foreach (var compRequest in request.Components)
{
release = await AddComponentInternalAsync(release, compRequest, ct);
}
}
await _eventPublisher.PublishAsync(new ReleaseCreated(
release.Id,
release.TenantId,
release.Name,
_timeProvider.GetUtcNow()
), ct);
return release;
}
public async Task<Release> AddComponentAsync(
Guid releaseId,
AddComponentRequest request,
CancellationToken ct = default)
{
var release = await _store.GetAsync(releaseId, ct)
?? throw new ReleaseNotFoundException(releaseId);
if (!release.IsDraft)
{
throw new ReleaseNotEditableException(releaseId,
"Cannot modify finalized release");
}
return await AddComponentInternalAsync(release, request, ct);
}
private async Task<Release> AddComponentInternalAsync(
Release release,
AddComponentRequest request,
CancellationToken ct)
{
// Check component exists
var component = await _componentRegistry.GetAsync(request.ComponentId, ct)
?? throw new ComponentNotFoundException(request.ComponentId);
// Check for duplicate component
if (release.Components.Any(c => c.ComponentId == request.ComponentId))
{
throw new DuplicateReleaseComponentException(release.Id, request.ComponentId);
}
// Resolve version
var version = await _versionManager.ResolveAsync(
request.ComponentId,
request.VersionRef,
ct);
var releaseComponent = new ReleaseComponent
{
Id = _guidGenerator.NewGuid(),
ComponentId = component.Id,
ComponentName = component.Name,
Digest = version.Digest,
Tag = version.Tag,
SemVer = version.SemVer?.ToString(),
Config = request.Config?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty,
OrderIndex = release.Components.Length
};
var updatedRelease = release with
{
Components = release.Components.Add(releaseComponent),
UpdatedAt = _timeProvider.GetUtcNow()
};
await _store.SaveAsync(updatedRelease, ct);
_logger.LogInformation(
"Added component {Component}@{Digest} to release {Release}",
component.Name,
version.ShortDigest,
release.Name);
return updatedRelease;
}
public async Task<Release> FinalizeAsync(
Guid id,
CancellationToken ct = default)
{
var release = await _store.GetAsync(id, ct)
?? throw new ReleaseNotFoundException(id);
if (!release.IsDraft)
{
throw new ReleaseAlreadyFinalizedException(id);
}
// Validate release is complete
var validation = await _validator.ValidateFinalizeAsync(release, ct);
if (!validation.IsValid)
{
throw new ReleaseValidationException(validation.Errors);
}
// Generate manifest and digest
var (manifest, manifestDigest) = await _finalizer.FinalizeAsync(release, ct);
var finalizedRelease = release with
{
Status = ReleaseStatus.Ready,
ManifestDigest = manifestDigest,
FinalizedAt = _timeProvider.GetUtcNow(),
FinalizedBy = _userContext.UserId,
UpdatedAt = _timeProvider.GetUtcNow()
};
await _store.SaveAsync(finalizedRelease, ct);
await _store.SaveManifestAsync(id, manifest, ct);
await _eventPublisher.PublishAsync(new ReleaseFinalized(
release.Id,
release.TenantId,
release.Name,
manifestDigest,
release.Components.Length,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Finalized release {Release} with {Count} components (manifest: {Digest})",
release.Name,
release.Components.Length,
manifestDigest[..16]);
return finalizedRelease;
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var release = await _store.GetAsync(id, ct)
?? throw new ReleaseNotFoundException(id);
if (!release.IsDraft)
{
throw new ReleaseNotEditableException(id,
"Cannot delete finalized release");
}
await _store.DeleteAsync(id, ct);
await _eventPublisher.PublishAsync(new ReleaseDeleted(
release.Id,
release.TenantId,
release.Name,
_timeProvider.GetUtcNow()
), ct);
}
}
```
### ReleaseFinalizer
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Manager;
public sealed class ReleaseFinalizer : IReleaseFinalizer
{
private readonly IReleaseManifestGenerator _manifestGenerator;
private readonly ILogger<ReleaseFinalizer> _logger;
public async Task<(ReleaseManifest Manifest, string Digest)> FinalizeAsync(
Release release,
CancellationToken ct = default)
{
// Generate canonical manifest
var manifest = await _manifestGenerator.GenerateAsync(release, ct);
// Compute digest of canonical JSON
var canonicalJson = CanonicalJsonSerializer.Serialize(manifest);
var digest = ComputeDigest(canonicalJson);
_logger.LogDebug(
"Generated manifest for release {Release}: {Digest}",
release.Name,
digest);
return (manifest, digest);
}
private static string ComputeDigest(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
```
### ReleaseManifest
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Models;
public sealed record ReleaseManifest
{
public required string SchemaVersion { get; init; } = "1.0";
public required string Name { get; init; }
public required string DisplayName { get; init; }
public string? Description { get; init; }
public required DateTimeOffset FinalizedAt { get; init; }
public required string FinalizedBy { get; init; }
public required ImmutableArray<ManifestComponent> Components { get; init; }
public required ManifestMetadata Metadata { get; init; }
}
public sealed record ManifestComponent(
string Name,
string Registry,
string Repository,
string Digest,
string? Tag,
string? SemVer,
int Order
);
public sealed record ManifestMetadata(
string TenantId,
string CreatedBy,
DateTimeOffset CreatedAt,
int TotalComponents,
long? TotalSizeBytes
);
```
### ReleaseValidator
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Manager;
public sealed class ReleaseValidator : IReleaseValidator
{
private readonly IReleaseStore _store;
public async Task<ValidationResult> ValidateCreateAsync(
CreateReleaseRequest request,
CancellationToken ct = default)
{
var errors = new List<string>();
// Name format validation
if (!IsValidReleaseName(request.Name))
{
errors.Add("Release name must be lowercase alphanumeric with hyphens, 2-64 characters");
}
// Check for duplicate name
var existing = await _store.GetByNameAsync(request.Name, ct);
if (existing is not null)
{
errors.Add($"Release with name '{request.Name}' already exists");
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
public async Task<ValidationResult> ValidateFinalizeAsync(
Release release,
CancellationToken ct = default)
{
var errors = new List<string>();
// Must have at least one component
if (release.Components.Length == 0)
{
errors.Add("Release must have at least one component");
}
// All components must have valid digests
foreach (var component in release.Components)
{
if (string.IsNullOrEmpty(component.Digest))
{
errors.Add($"Component {component.ComponentName} has no digest");
}
if (!IsValidDigest(component.Digest))
{
errors.Add($"Component {component.ComponentName} has invalid digest format");
}
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
private static bool IsValidReleaseName(string name) =>
Regex.IsMatch(name, @"^[a-z][a-z0-9-]{1,63}$");
private static bool IsValidDigest(string digest) =>
digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) &&
digest.Length == 71;
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Events;
public sealed record ReleaseCreated(
Guid ReleaseId,
Guid TenantId,
string Name,
DateTimeOffset CreatedAt
) : IDomainEvent;
public sealed record ReleaseComponentAdded(
Guid ReleaseId,
Guid TenantId,
Guid ComponentId,
string ComponentName,
string Digest,
DateTimeOffset AddedAt
) : IDomainEvent;
public sealed record ReleaseFinalized(
Guid ReleaseId,
Guid TenantId,
string Name,
string ManifestDigest,
int ComponentCount,
DateTimeOffset FinalizedAt
) : IDomainEvent;
public sealed record ReleaseDeprecated(
Guid ReleaseId,
Guid TenantId,
string Name,
string Reason,
DateTimeOffset DeprecatedAt
) : IDomainEvent;
public sealed record ReleaseDeleted(
Guid ReleaseId,
Guid TenantId,
string Name,
DateTimeOffset DeletedAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/releases.md` (partial) | Markdown | API endpoint documentation for release management (create, quick create, compare) |
---
## Acceptance Criteria
### Code
- [ ] Create draft release
- [ ] Add components to draft release
- [ ] Remove components from draft release
- [ ] Finalize release locks versions
- [ ] Cannot modify finalized release
- [ ] Generate release manifest
- [ ] Compute manifest digest
- [ ] Deprecate release
- [ ] Delete only draft releases
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Release API endpoints documented
- [ ] Create release endpoint documented with full schema
- [ ] Quick create release endpoint documented
- [ ] Compare releases endpoint documented
- [ ] Release creation modes explained
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `CreateRelease_ValidRequest_Succeeds` | Creation works |
| `AddComponent_ToDraft_Succeeds` | Add component works |
| `AddComponent_ToFinalized_Fails` | Finalized protection works |
| `AddComponent_Duplicate_Fails` | Duplicate check works |
| `FinalizeRelease_GeneratesManifest` | Finalization works |
| `FinalizeRelease_NoComponents_Fails` | Validation works |
| `DeleteRelease_Draft_Succeeds` | Draft deletion works |
| `DeleteRelease_Finalized_Fails` | Finalized protection works |
### Integration Tests
| Test | Description |
|------|-------------|
| `ReleaseLifecycle_E2E` | Full create-add-finalize flow |
| `ManifestGeneration_E2E` | Manifest correctness |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 104_002 Version Manager | Internal | TODO |
| 104_001 Component Registry | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IReleaseManager | TODO | |
| ReleaseManager | TODO | |
| ReleaseValidator | TODO | |
| ReleaseFinalizer | TODO | |
| ReleaseManifestGenerator | TODO | |
| Release model | TODO | |
| IReleaseStore | TODO | |
| ReleaseStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/releases.md (partial - releases) |

View File

@@ -0,0 +1,623 @@
# SPRINT: Release Catalog
> **Sprint ID:** 104_004
> **Module:** RELMAN
> **Phase:** 4 - Release Manager
> **Status:** TODO
> **Parent:** [104_000_INDEX](SPRINT_20260110_104_000_INDEX_release_manager.md)
---
## Overview
Implement the Release Catalog for querying releases and tracking deployment history.
### Objectives
- Query releases with filtering and pagination
- Track release status transitions
- Maintain deployment history per environment
- Support release comparison
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Release/
│ ├── Catalog/
│ │ ├── IReleaseCatalog.cs
│ │ ├── ReleaseCatalog.cs
│ │ ├── ReleaseStatusMachine.cs
│ │ └── ReleaseComparer.cs
│ ├── History/
│ │ ├── IReleaseHistory.cs
│ │ ├── ReleaseHistory.cs
│ │ └── DeploymentRecord.cs
│ └── Models/
│ ├── ReleaseDeploymentHistory.cs
│ └── ReleaseComparison.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Release.Tests/
└── Catalog/
```
---
## Architecture Reference
- [Release Manager](../modules/release-orchestrator/modules/release-manager.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IReleaseCatalog Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Catalog;
public interface IReleaseCatalog
{
// Queries
Task<IReadOnlyList<Release>> ListAsync(
ReleaseFilter? filter = null,
CancellationToken ct = default);
Task<PagedResult<Release>> ListPagedAsync(
ReleaseFilter? filter,
PaginationParams pagination,
CancellationToken ct = default);
Task<Release?> GetLatestAsync(CancellationToken ct = default);
Task<Release?> GetLatestDeployedAsync(
Guid environmentId,
CancellationToken ct = default);
Task<IReadOnlyList<Release>> GetDeployedReleasesAsync(
Guid environmentId,
CancellationToken ct = default);
// History
Task<ReleaseDeploymentHistory> GetHistoryAsync(
Guid releaseId,
CancellationToken ct = default);
Task<IReadOnlyList<DeploymentRecord>> GetEnvironmentHistoryAsync(
Guid environmentId,
int limit = 50,
CancellationToken ct = default);
// Comparison
Task<ReleaseComparison> CompareAsync(
Guid sourceReleaseId,
Guid targetReleaseId,
CancellationToken ct = default);
}
public sealed record ReleaseFilter(
string? NameContains = null,
ReleaseStatus? Status = null,
Guid? ComponentId = null,
DateTimeOffset? CreatedAfter = null,
DateTimeOffset? CreatedBefore = null,
DateTimeOffset? FinalizedAfter = null,
DateTimeOffset? FinalizedBefore = null,
bool? HasDeployments = null
);
public sealed record PaginationParams(
int PageNumber = 1,
int PageSize = 20,
string? SortBy = null,
bool SortDescending = true
);
public sealed record PagedResult<T>(
IReadOnlyList<T> Items,
int TotalCount,
int PageNumber,
int PageSize,
int TotalPages
);
```
### ReleaseDeploymentHistory
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Models;
public sealed record ReleaseDeploymentHistory
{
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required ImmutableArray<EnvironmentDeployment> Deployments { get; init; }
public DateTimeOffset? FirstDeployedAt { get; init; }
public DateTimeOffset? LastDeployedAt { get; init; }
public int TotalDeployments { get; init; }
}
public sealed record EnvironmentDeployment(
Guid EnvironmentId,
string EnvironmentName,
DeploymentStatus Status,
DateTimeOffset DeployedAt,
Guid DeployedBy,
DateTimeOffset? ReplacedAt,
Guid? ReplacedByReleaseId
);
public sealed record DeploymentRecord
{
public required Guid Id { get; init; }
public required Guid EnvironmentId { get; init; }
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required DeploymentStatus Status { get; init; }
public required DateTimeOffset DeployedAt { get; init; }
public required Guid DeployedBy { get; init; }
public TimeSpan? Duration { get; init; }
public string? Notes { get; init; }
}
public enum DeploymentStatus
{
Current, // Currently deployed
Replaced, // Was deployed, replaced by newer
RolledBack, // Was rolled back from
Failed // Deployment failed
}
```
### ReleaseStatusMachine
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Catalog;
public sealed class ReleaseStatusMachine
{
private static readonly ImmutableDictionary<ReleaseStatus, ImmutableArray<ReleaseStatus>> ValidTransitions =
new Dictionary<ReleaseStatus, ImmutableArray<ReleaseStatus>>
{
[ReleaseStatus.Draft] = [ReleaseStatus.Ready],
[ReleaseStatus.Ready] = [ReleaseStatus.Promoting, ReleaseStatus.Deprecated],
[ReleaseStatus.Promoting] = [ReleaseStatus.Ready, ReleaseStatus.Deployed],
[ReleaseStatus.Deployed] = [ReleaseStatus.Promoting, ReleaseStatus.Deprecated],
[ReleaseStatus.Deprecated] = [] // Terminal state
}.ToImmutableDictionary();
public bool CanTransition(ReleaseStatus from, ReleaseStatus to)
{
if (!ValidTransitions.TryGetValue(from, out var validTargets))
return false;
return validTargets.Contains(to);
}
public ValidationResult ValidateTransition(ReleaseStatus from, ReleaseStatus to)
{
if (CanTransition(from, to))
return ValidationResult.Success();
return ValidationResult.Failure(
$"Invalid status transition from {from} to {to}");
}
public IReadOnlyList<ReleaseStatus> GetValidTransitions(ReleaseStatus current)
{
return ValidTransitions.TryGetValue(current, out var targets)
? targets
: [];
}
}
```
### ReleaseCatalog Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Catalog;
public sealed class ReleaseCatalog : IReleaseCatalog
{
private readonly IReleaseStore _releaseStore;
private readonly IDeploymentStore _deploymentStore;
private readonly IEnvironmentService _environmentService;
private readonly ILogger<ReleaseCatalog> _logger;
public async Task<PagedResult<Release>> ListPagedAsync(
ReleaseFilter? filter,
PaginationParams pagination,
CancellationToken ct = default)
{
var (releases, totalCount) = await _releaseStore.QueryAsync(
filter,
pagination.PageNumber,
pagination.PageSize,
pagination.SortBy,
pagination.SortDescending,
ct);
var totalPages = (int)Math.Ceiling((double)totalCount / pagination.PageSize);
return new PagedResult<Release>(
Items: releases,
TotalCount: totalCount,
PageNumber: pagination.PageNumber,
PageSize: pagination.PageSize,
TotalPages: totalPages
);
}
public async Task<Release?> GetLatestDeployedAsync(
Guid environmentId,
CancellationToken ct = default)
{
var deployment = await _deploymentStore.GetCurrentDeploymentAsync(
environmentId, ct);
if (deployment is null)
return null;
return await _releaseStore.GetAsync(deployment.ReleaseId, ct);
}
public async Task<ReleaseDeploymentHistory> GetHistoryAsync(
Guid releaseId,
CancellationToken ct = default)
{
var release = await _releaseStore.GetAsync(releaseId, ct)
?? throw new ReleaseNotFoundException(releaseId);
var deployments = await _deploymentStore.GetDeploymentsForReleaseAsync(
releaseId, ct);
var environments = await _environmentService.ListAsync(ct);
var envLookup = environments.ToDictionary(e => e.Id);
var envDeployments = deployments
.Select(d => new EnvironmentDeployment(
EnvironmentId: d.EnvironmentId,
EnvironmentName: envLookup.TryGetValue(d.EnvironmentId, out var env)
? env.Name
: "Unknown",
Status: d.Status,
DeployedAt: d.DeployedAt,
DeployedBy: d.DeployedBy,
ReplacedAt: d.ReplacedAt,
ReplacedByReleaseId: d.ReplacedByReleaseId
))
.ToImmutableArray();
return new ReleaseDeploymentHistory
{
ReleaseId = release.Id,
ReleaseName = release.Name,
Deployments = envDeployments,
FirstDeployedAt = envDeployments.MinBy(d => d.DeployedAt)?.DeployedAt,
LastDeployedAt = envDeployments.MaxBy(d => d.DeployedAt)?.DeployedAt,
TotalDeployments = envDeployments.Length
};
}
}
```
### ReleaseComparer
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Catalog;
public sealed class ReleaseComparer
{
public ReleaseComparison Compare(Release source, Release target)
{
var sourceComponents = source.Components.ToDictionary(c => c.ComponentId);
var targetComponents = target.Components.ToDictionary(c => c.ComponentId);
var added = new List<ComponentChange>();
var removed = new List<ComponentChange>();
var changed = new List<ComponentChange>();
var unchanged = new List<ComponentChange>();
// Find added and changed components
foreach (var (componentId, targetComp) in targetComponents)
{
if (!sourceComponents.TryGetValue(componentId, out var sourceComp))
{
added.Add(new ComponentChange(
ComponentId: componentId,
ComponentName: targetComp.ComponentName,
ChangeType: ComponentChangeType.Added,
OldDigest: null,
NewDigest: targetComp.Digest,
OldTag: null,
NewTag: targetComp.Tag
));
}
else if (sourceComp.Digest != targetComp.Digest)
{
changed.Add(new ComponentChange(
ComponentId: componentId,
ComponentName: targetComp.ComponentName,
ChangeType: ComponentChangeType.Changed,
OldDigest: sourceComp.Digest,
NewDigest: targetComp.Digest,
OldTag: sourceComp.Tag,
NewTag: targetComp.Tag
));
}
else
{
unchanged.Add(new ComponentChange(
ComponentId: componentId,
ComponentName: targetComp.ComponentName,
ChangeType: ComponentChangeType.Unchanged,
OldDigest: sourceComp.Digest,
NewDigest: targetComp.Digest,
OldTag: sourceComp.Tag,
NewTag: targetComp.Tag
));
}
}
// Find removed components
foreach (var (componentId, sourceComp) in sourceComponents)
{
if (!targetComponents.ContainsKey(componentId))
{
removed.Add(new ComponentChange(
ComponentId: componentId,
ComponentName: sourceComp.ComponentName,
ChangeType: ComponentChangeType.Removed,
OldDigest: sourceComp.Digest,
NewDigest: null,
OldTag: sourceComp.Tag,
NewTag: null
));
}
}
return new ReleaseComparison(
SourceReleaseId: source.Id,
SourceReleaseName: source.Name,
TargetReleaseId: target.Id,
TargetReleaseName: target.Name,
Added: added.ToImmutableArray(),
Removed: removed.ToImmutableArray(),
Changed: changed.ToImmutableArray(),
Unchanged: unchanged.ToImmutableArray(),
HasChanges: added.Count > 0 || removed.Count > 0 || changed.Count > 0
);
}
}
public sealed record ReleaseComparison(
Guid SourceReleaseId,
string SourceReleaseName,
Guid TargetReleaseId,
string TargetReleaseName,
ImmutableArray<ComponentChange> Added,
ImmutableArray<ComponentChange> Removed,
ImmutableArray<ComponentChange> Changed,
ImmutableArray<ComponentChange> Unchanged,
bool HasChanges
)
{
public int TotalChanges => Added.Length + Removed.Length + Changed.Length;
}
public sealed record ComponentChange(
Guid ComponentId,
string ComponentName,
ComponentChangeType ChangeType,
string? OldDigest,
string? NewDigest,
string? OldTag,
string? NewTag
);
public enum ComponentChangeType
{
Added,
Removed,
Changed,
Unchanged
}
```
### ReleaseHistory Service
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.History;
public interface IReleaseHistory
{
Task RecordDeploymentAsync(
Guid releaseId,
Guid environmentId,
Guid deploymentId,
CancellationToken ct = default);
Task RecordReplacementAsync(
Guid oldReleaseId,
Guid newReleaseId,
Guid environmentId,
CancellationToken ct = default);
Task RecordRollbackAsync(
Guid fromReleaseId,
Guid toReleaseId,
Guid environmentId,
CancellationToken ct = default);
}
public sealed class ReleaseHistory : IReleaseHistory
{
private readonly IDeploymentStore _store;
private readonly IReleaseStore _releaseStore;
private readonly ReleaseStatusMachine _statusMachine;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ReleaseHistory> _logger;
public async Task RecordDeploymentAsync(
Guid releaseId,
Guid environmentId,
Guid deploymentId,
CancellationToken ct = default)
{
var release = await _releaseStore.GetAsync(releaseId, ct)
?? throw new ReleaseNotFoundException(releaseId);
// Mark any existing deployment as replaced
var currentDeployment = await _store.GetCurrentDeploymentAsync(
environmentId, ct);
if (currentDeployment is not null)
{
await _store.MarkReplacedAsync(
currentDeployment.Id,
releaseId,
_timeProvider.GetUtcNow(),
ct);
}
// Update release status if first deployment
if (release.Status == ReleaseStatus.Ready ||
release.Status == ReleaseStatus.Promoting)
{
var updatedRelease = release with
{
Status = ReleaseStatus.Deployed,
UpdatedAt = _timeProvider.GetUtcNow()
};
await _releaseStore.SaveAsync(updatedRelease, ct);
}
_logger.LogInformation(
"Recorded deployment of release {Release} to environment {Environment}",
release.Name,
environmentId);
}
public async Task RecordRollbackAsync(
Guid fromReleaseId,
Guid toReleaseId,
Guid environmentId,
CancellationToken ct = default)
{
// Mark the from-deployment as rolled back
var currentDeployment = await _store.GetCurrentDeploymentAsync(
environmentId, ct);
if (currentDeployment?.ReleaseId == fromReleaseId)
{
await _store.MarkRolledBackAsync(
currentDeployment.Id,
_timeProvider.GetUtcNow(),
ct);
}
await _eventPublisher.PublishAsync(new ReleaseRolledBack(
FromReleaseId: fromReleaseId,
ToReleaseId: toReleaseId,
EnvironmentId: environmentId,
RolledBackAt: _timeProvider.GetUtcNow()
), ct);
}
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Release.Events;
public sealed record ReleaseStatusChanged(
Guid ReleaseId,
Guid TenantId,
ReleaseStatus OldStatus,
ReleaseStatus NewStatus,
DateTimeOffset ChangedAt
) : IDomainEvent;
public sealed record ReleaseRolledBack(
Guid FromReleaseId,
Guid ToReleaseId,
Guid EnvironmentId,
DateTimeOffset RolledBackAt
) : IDomainEvent;
```
---
## Acceptance Criteria
- [ ] List releases with filtering
- [ ] Paginate release list
- [ ] Get latest deployed release for environment
- [ ] Track deployment history
- [ ] Record status transitions
- [ ] Compare two releases
- [ ] Identify added/removed/changed components
- [ ] Record rollback history
- [ ] Status machine validates transitions
- [ ] Unit test coverage >=85%
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `ListReleases_WithFilter_ReturnsFiltered` | Filtering works |
| `ListReleases_Paginated_ReturnsPaged` | Pagination works |
| `GetLatestDeployed_ReturnsCorrect` | Latest lookup works |
| `CompareReleases_DetectsChanges` | Comparison works |
| `StatusMachine_ValidTransitions` | Valid transitions work |
| `StatusMachine_InvalidTransitions_Rejected` | Invalid rejected |
| `RecordDeployment_UpdatesHistory` | History recording works |
### Integration Tests
| Test | Description |
|------|-------------|
| `ReleaseCatalog_E2E` | Full query/history flow |
| `ReleaseComparison_E2E` | Comparison accuracy |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 104_003 Release Manager | Internal | TODO |
| 103_001 Environment CRUD | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IReleaseCatalog | TODO | |
| ReleaseCatalog | TODO | |
| ReleaseStatusMachine | TODO | |
| ReleaseComparer | TODO | |
| IReleaseHistory | TODO | |
| ReleaseHistory | TODO | |
| IDeploymentStore | TODO | |
| DeploymentStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,263 @@
# SPRINT INDEX: Phase 5 - Workflow Engine
> **Epic:** Release Orchestrator
> **Phase:** 5 - Workflow Engine
> **Batch:** 105
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 5 implements the Workflow Engine - DAG-based workflow execution for deployments, promotions, and custom automation.
### Objectives
- Workflow template designer with YAML/JSON DSL
- Step registry for built-in and plugin steps
- DAG executor with parallel and sequential execution
- Step executor with retry and timeout handling
- Built-in steps (script, approval, notification)
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 105_001 | Workflow Template Designer | WORKFL | TODO | 101_001 |
| 105_002 | Step Registry | WORKFL | TODO | 101_002 |
| 105_003 | Workflow Engine - DAG Executor | WORKFL | TODO | 105_001, 105_002 |
| 105_004 | Step Executor | WORKFL | TODO | 105_003 |
| 105_005 | Built-in Steps | WORKFL | TODO | 105_004 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ WORKFLOW ENGINE │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ WORKFLOW TEMPLATE (105_001) │ │
│ │ │ │
│ │ name: deploy-to-production │ │
│ │ steps: │ │
│ │ - id: security-scan │ │
│ │ type: security-gate │ │
│ │ - id: approval │ │
│ │ type: approval │ │
│ │ dependsOn: [security-scan] │ │
│ │ - id: deploy │ │
│ │ type: deploy │ │
│ │ dependsOn: [approval] │ │
│ │ - id: notify │ │
│ │ type: notify │ │
│ │ dependsOn: [deploy] │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ STEP REGISTRY (105_002) │ │
│ │ │ │
│ │ Built-in Steps: Plugin Steps: │ │
│ │ ├── script ├── custom-gate │ │
│ │ ├── approval ├── jira-update │ │
│ │ ├── notify ├── terraform-apply │ │
│ │ ├── wait └── k8s-rollout │ │
│ │ ├── security-gate │ │
│ │ └── deploy │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DAG EXECUTOR (105_003) │ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │security-scan│ │ │
│ │ └──────┬──────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │ approval │ │ │
│ │ └──────┬──────┘ │ │
│ │ │ │ │
│ │ ┌────┴────┐ │ │
│ │ ▼ ▼ │ │
│ │ ┌──────┐ ┌──────┐ (parallel) │ │
│ │ │deploy│ │smoke │ │ │
│ │ └──┬───┘ └──┬───┘ │ │
│ │ └────┬───┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │ notify │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 105_001: Workflow Template Designer
| Deliverable | Type | Description |
|-------------|------|-------------|
| `WorkflowTemplate` | Model | Template entity |
| `WorkflowParser` | Class | YAML/JSON parser |
| `WorkflowValidator` | Class | DAG validation |
| `TemplateStore` | Class | Persistence |
### 105_002: Step Registry
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IStepRegistry` | Interface | Step lookup |
| `StepRegistry` | Class | Implementation |
| `StepDefinition` | Model | Step metadata |
| `StepSchema` | Class | Config schema |
### 105_003: DAG Executor
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IWorkflowEngine` | Interface | Execution control |
| `WorkflowEngine` | Class | Implementation |
| `DagScheduler` | Class | Step scheduling |
| `WorkflowRun` | Model | Execution state |
### 105_004: Step Executor
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IStepExecutor` | Interface | Step execution |
| `StepExecutor` | Class | Implementation |
| `StepContext` | Model | Execution context |
| `StepRetryPolicy` | Class | Retry handling |
### 105_005: Built-in Steps
| Deliverable | Type | Description |
|-------------|------|-------------|
| `ScriptStep` | Step | Execute shell scripts |
| `ApprovalStep` | Step | Manual approval |
| `NotifyStep` | Step | Send notifications |
| `WaitStep` | Step | Time delay |
| `SecurityGateStep` | Step | Security check |
| `DeployStep` | Step | Deployment trigger |
---
## Key Interfaces
```csharp
public interface IWorkflowEngine
{
Task<WorkflowRun> StartAsync(Guid templateId, WorkflowContext context, CancellationToken ct);
Task<WorkflowRun> ResumeAsync(Guid runId, CancellationToken ct);
Task CancelAsync(Guid runId, CancellationToken ct);
Task<WorkflowRun?> GetRunAsync(Guid runId, CancellationToken ct);
}
public interface IStepExecutor
{
Task<StepResult> ExecuteAsync(StepDefinition step, StepContext context, CancellationToken ct);
}
public interface IStepRegistry
{
void RegisterBuiltIn<T>(string type) where T : IStepProvider;
Task<IStepProvider?> GetAsync(string type, CancellationToken ct);
IReadOnlyList<StepDefinition> GetAllDefinitions();
}
```
---
## Workflow DSL Example
```yaml
name: production-deployment
version: 1
triggers:
- type: promotion
environment: production
steps:
- id: security-check
type: security-gate
config:
maxCritical: 0
maxHigh: 5
- id: lead-approval
type: approval
dependsOn: [security-check]
config:
approvers: ["@release-managers"]
minApprovals: 1
- id: deploy
type: deploy
dependsOn: [lead-approval]
config:
strategy: rolling
batchSize: 25%
- id: smoke-test
type: script
dependsOn: [deploy]
config:
script: ./scripts/smoke-test.sh
timeout: 300
- id: notify-success
type: notify
dependsOn: [smoke-test]
condition: success()
config:
channel: slack
message: "Deployment to production succeeded"
- id: notify-failure
type: notify
dependsOn: [smoke-test]
condition: failure()
config:
channel: slack
message: "Deployment to production FAILED"
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| 101_002 Plugin Registry | Plugin steps |
| 101_003 Plugin Loader | Execute plugin steps |
| 106_* Promotion | Gate integration |
---
## Acceptance Criteria
- [ ] Workflow templates parse correctly
- [ ] DAG cycle detection works
- [ ] Parallel steps execute concurrently
- [ ] Step dependencies respected
- [ ] Retry policy works
- [ ] Timeout cancels steps
- [ ] Built-in steps functional
- [ ] Workflow state persisted
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 5 index created |

View File

@@ -0,0 +1,686 @@
# SPRINT: Workflow Template Designer
> **Sprint ID:** 105_001
> **Module:** WORKFL
> **Phase:** 5 - Workflow Engine
> **Status:** TODO
> **Parent:** [105_000_INDEX](SPRINT_20260110_105_000_INDEX_workflow_engine.md)
---
## Overview
Implement the Workflow Template Designer for defining deployment and automation workflows using YAML/JSON DSL.
### Objectives
- Define workflow template data model
- Parse YAML/JSON workflow definitions
- Validate DAG structure (no cycles)
- Store and version workflow templates
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Workflow/
│ ├── Template/
│ │ ├── IWorkflowTemplateService.cs
│ │ ├── WorkflowTemplateService.cs
│ │ ├── WorkflowParser.cs
│ │ ├── WorkflowValidator.cs
│ │ └── DagBuilder.cs
│ ├── Store/
│ │ ├── IWorkflowTemplateStore.cs
│ │ └── WorkflowTemplateStore.cs
│ └── Models/
│ ├── WorkflowTemplate.cs
│ ├── WorkflowStep.cs
│ ├── StepConfig.cs
│ └── WorkflowTrigger.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Workflow.Tests/
└── Template/
```
---
## Architecture Reference
- [Workflow Engine](../modules/release-orchestrator/modules/workflow-engine.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IWorkflowTemplateService Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Template;
public interface IWorkflowTemplateService
{
Task<WorkflowTemplate> CreateAsync(CreateWorkflowTemplateRequest request, CancellationToken ct = default);
Task<WorkflowTemplate> UpdateAsync(Guid id, UpdateWorkflowTemplateRequest request, CancellationToken ct = default);
Task<WorkflowTemplate?> GetAsync(Guid id, CancellationToken ct = default);
Task<WorkflowTemplate?> GetByNameAsync(string name, CancellationToken ct = default);
Task<WorkflowTemplate?> GetByNameAndVersionAsync(string name, int version, CancellationToken ct = default);
Task<IReadOnlyList<WorkflowTemplate>> ListAsync(WorkflowTemplateFilter? filter = null, CancellationToken ct = default);
Task<WorkflowTemplate> PublishAsync(Guid id, CancellationToken ct = default);
Task DeprecateAsync(Guid id, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
Task<WorkflowValidationResult> ValidateAsync(string content, WorkflowFormat format, CancellationToken ct = default);
}
public sealed record CreateWorkflowTemplateRequest(
string Name,
string DisplayName,
string Content,
WorkflowFormat Format,
string? Description = null
);
public sealed record UpdateWorkflowTemplateRequest(
string? DisplayName = null,
string? Content = null,
string? Description = null
);
public enum WorkflowFormat
{
Yaml,
Json
}
```
### WorkflowTemplate Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Models;
public sealed record WorkflowTemplate
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string Name { get; init; }
public required string DisplayName { get; init; }
public string? Description { get; init; }
public required int Version { get; init; }
public required WorkflowTemplateStatus Status { get; init; }
public required string Content { get; init; }
public required WorkflowFormat Format { get; init; }
public required ImmutableArray<WorkflowStep> Steps { get; init; }
public required ImmutableArray<WorkflowTrigger> Triggers { get; init; }
public ImmutableDictionary<string, string> Variables { get; init; } =
ImmutableDictionary<string, string>.Empty;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? PublishedAt { get; init; }
public Guid CreatedBy { get; init; }
}
public enum WorkflowTemplateStatus
{
Draft,
Published,
Deprecated
}
public sealed record WorkflowStep
{
public required string Id { get; init; }
public required string Type { get; init; }
public string? DisplayName { get; init; }
public ImmutableArray<string> DependsOn { get; init; } = [];
public string? Condition { get; init; }
public ImmutableDictionary<string, object> Config { get; init; } =
ImmutableDictionary<string, object>.Empty;
public TimeSpan? Timeout { get; init; }
public RetryConfig? Retry { get; init; }
public bool ContinueOnError { get; init; } = false;
}
public sealed record RetryConfig(
int MaxAttempts = 3,
TimeSpan InitialDelay = default,
double BackoffMultiplier = 2.0
)
{
public TimeSpan InitialDelay { get; init; } = InitialDelay == default
? TimeSpan.FromSeconds(5)
: InitialDelay;
}
public sealed record WorkflowTrigger
{
public required TriggerType Type { get; init; }
public Guid? EnvironmentId { get; init; }
public string? EnvironmentName { get; init; }
public string? CronExpression { get; init; }
public ImmutableDictionary<string, string> Filters { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
public enum TriggerType
{
Manual,
Promotion,
Schedule,
Webhook,
NewVersion
}
```
### WorkflowParser
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Template;
public sealed class WorkflowParser
{
private readonly ILogger<WorkflowParser> _logger;
public ParsedWorkflow Parse(string content, WorkflowFormat format)
{
return format switch
{
WorkflowFormat.Yaml => ParseYaml(content),
WorkflowFormat.Json => ParseJson(content),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
private ParsedWorkflow ParseYaml(string content)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
try
{
var raw = deserializer.Deserialize<RawWorkflowDefinition>(content);
return MapToWorkflow(raw);
}
catch (YamlException ex)
{
throw new WorkflowParseException($"YAML parse error at line {ex.Start.Line}: {ex.Message}", ex);
}
}
private ParsedWorkflow ParseJson(string content)
{
try
{
var raw = JsonSerializer.Deserialize<RawWorkflowDefinition>(content,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip
});
return MapToWorkflow(raw!);
}
catch (JsonException ex)
{
throw new WorkflowParseException($"JSON parse error: {ex.Message}", ex);
}
}
private ParsedWorkflow MapToWorkflow(RawWorkflowDefinition raw)
{
var steps = raw.Steps.Select(s => new WorkflowStep
{
Id = s.Id,
Type = s.Type,
DisplayName = s.DisplayName,
DependsOn = s.DependsOn?.ToImmutableArray() ?? [],
Condition = s.Condition,
Config = s.Config?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty,
Timeout = s.Timeout.HasValue ? TimeSpan.FromSeconds(s.Timeout.Value) : null,
Retry = s.Retry is not null ? new RetryConfig(
s.Retry.MaxAttempts ?? 3,
TimeSpan.FromSeconds(s.Retry.InitialDelaySeconds ?? 5),
s.Retry.BackoffMultiplier ?? 2.0
) : null,
ContinueOnError = s.ContinueOnError ?? false
}).ToImmutableArray();
var triggers = raw.Triggers?.Select(t => new WorkflowTrigger
{
Type = Enum.Parse<TriggerType>(t.Type, ignoreCase: true),
EnvironmentName = t.Environment,
CronExpression = t.Cron,
Filters = t.Filters?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
}).ToImmutableArray() ?? [];
return new ParsedWorkflow(
Name: raw.Name,
Version: raw.Version ?? 1,
Steps: steps,
Triggers: triggers,
Variables: raw.Variables?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
);
}
}
public sealed record ParsedWorkflow(
string Name,
int Version,
ImmutableArray<WorkflowStep> Steps,
ImmutableArray<WorkflowTrigger> Triggers,
ImmutableDictionary<string, string> Variables
);
```
### WorkflowValidator
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Template;
public sealed class WorkflowValidator
{
private readonly IStepRegistry _stepRegistry;
public async Task<WorkflowValidationResult> ValidateAsync(
ParsedWorkflow workflow,
CancellationToken ct = default)
{
var errors = new List<ValidationError>();
var warnings = new List<ValidationWarning>();
// Validate workflow name
if (!IsValidWorkflowName(workflow.Name))
{
errors.Add(new ValidationError(
"workflow.name",
"Workflow name must be lowercase alphanumeric with hyphens, 2-64 characters"));
}
// Validate steps exist
if (workflow.Steps.Length == 0)
{
errors.Add(new ValidationError(
"workflow.steps",
"Workflow must have at least one step"));
}
// Validate step IDs are unique
var stepIds = workflow.Steps.Select(s => s.Id).ToList();
var duplicates = stepIds.GroupBy(id => id)
.Where(g => g.Count() > 1)
.Select(g => g.Key);
foreach (var dup in duplicates)
{
errors.Add(new ValidationError(
$"steps.{dup}",
$"Duplicate step ID: {dup}"));
}
// Validate step types exist
foreach (var step in workflow.Steps)
{
var stepDef = await _stepRegistry.GetAsync(step.Type, ct);
if (stepDef is null)
{
errors.Add(new ValidationError(
$"steps.{step.Id}.type",
$"Unknown step type: {step.Type}"));
}
}
// Validate dependencies exist
var stepIdSet = stepIds.ToHashSet();
foreach (var step in workflow.Steps)
{
foreach (var dep in step.DependsOn)
{
if (!stepIdSet.Contains(dep))
{
errors.Add(new ValidationError(
$"steps.{step.Id}.dependsOn",
$"Unknown dependency: {dep}"));
}
}
}
// Validate DAG has no cycles
var cycleError = DetectCycles(workflow.Steps);
if (cycleError is not null)
{
errors.Add(cycleError);
}
// Validate triggers
foreach (var (trigger, index) in workflow.Triggers.Select((t, i) => (t, i)))
{
if (trigger.Type == TriggerType.Schedule &&
string.IsNullOrEmpty(trigger.CronExpression))
{
errors.Add(new ValidationError(
$"triggers[{index}].cron",
"Schedule trigger requires cron expression"));
}
}
// Check for unreachable steps (warning only)
var reachable = FindReachableSteps(workflow.Steps);
var unreachable = stepIdSet.Except(reachable);
foreach (var stepId in unreachable)
{
warnings.Add(new ValidationWarning(
$"steps.{stepId}",
$"Step {stepId} is unreachable (has dependencies but nothing depends on it)"));
}
return new WorkflowValidationResult(
IsValid: errors.Count == 0,
Errors: errors.ToImmutableArray(),
Warnings: warnings.ToImmutableArray()
);
}
private static ValidationError? DetectCycles(ImmutableArray<WorkflowStep> steps)
{
var visited = new HashSet<string>();
var recursionStack = new HashSet<string>();
var stepMap = steps.ToDictionary(s => s.Id);
foreach (var step in steps)
{
if (HasCycle(step.Id, stepMap, visited, recursionStack, out var cycle))
{
return new ValidationError(
"workflow.steps",
$"Circular dependency detected: {string.Join(" -> ", cycle)}");
}
}
return null;
}
private static bool HasCycle(
string stepId,
Dictionary<string, WorkflowStep> stepMap,
HashSet<string> visited,
HashSet<string> recursionStack,
out List<string> cycle)
{
cycle = [];
if (recursionStack.Contains(stepId))
{
cycle.Add(stepId);
return true;
}
if (visited.Contains(stepId))
return false;
visited.Add(stepId);
recursionStack.Add(stepId);
if (stepMap.TryGetValue(stepId, out var step))
{
foreach (var dep in step.DependsOn)
{
if (HasCycle(dep, stepMap, visited, recursionStack, out cycle))
{
cycle.Insert(0, stepId);
return true;
}
}
}
recursionStack.Remove(stepId);
return false;
}
private static HashSet<string> FindReachableSteps(ImmutableArray<WorkflowStep> steps)
{
// Steps with no dependencies are entry points
var entryPoints = steps.Where(s => s.DependsOn.Length == 0)
.Select(s => s.Id)
.ToHashSet();
// All steps that are depended upon
var dependedUpon = steps.SelectMany(s => s.DependsOn).ToHashSet();
// Entry points + all steps that something depends on
return entryPoints.Union(dependedUpon).ToHashSet();
}
private static bool IsValidWorkflowName(string name) =>
Regex.IsMatch(name, @"^[a-z][a-z0-9-]{1,63}$");
}
public sealed record WorkflowValidationResult(
bool IsValid,
ImmutableArray<ValidationError> Errors,
ImmutableArray<ValidationWarning> Warnings
);
public sealed record ValidationError(string Path, string Message);
public sealed record ValidationWarning(string Path, string Message);
```
### DagBuilder
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Template;
public sealed class DagBuilder
{
public WorkflowDag Build(ImmutableArray<WorkflowStep> steps)
{
var nodes = new Dictionary<string, DagNode>();
// Create nodes
foreach (var step in steps)
{
nodes[step.Id] = new DagNode(step.Id, step);
}
// Build edges
foreach (var step in steps)
{
var node = nodes[step.Id];
foreach (var dep in step.DependsOn)
{
if (nodes.TryGetValue(dep, out var depNode))
{
node.Dependencies.Add(depNode);
depNode.Dependents.Add(node);
}
}
}
// Find entry nodes (no dependencies)
var entryNodes = nodes.Values
.Where(n => n.Dependencies.Count == 0)
.ToImmutableArray();
// Compute topological order
var order = TopologicalSort(nodes.Values);
return new WorkflowDag(
Nodes: nodes.Values.ToImmutableArray(),
EntryNodes: entryNodes,
TopologicalOrder: order
);
}
private static ImmutableArray<DagNode> TopologicalSort(IEnumerable<DagNode> nodes)
{
var sorted = new List<DagNode>();
var visited = new HashSet<string>();
var nodeList = nodes.ToList();
void Visit(DagNode node)
{
if (visited.Contains(node.Id))
return;
visited.Add(node.Id);
foreach (var dep in node.Dependencies)
{
Visit(dep);
}
sorted.Add(node);
}
foreach (var node in nodeList)
{
Visit(node);
}
return sorted.ToImmutableArray();
}
}
public sealed record WorkflowDag(
ImmutableArray<DagNode> Nodes,
ImmutableArray<DagNode> EntryNodes,
ImmutableArray<DagNode> TopologicalOrder
);
public sealed class DagNode
{
public string Id { get; }
public WorkflowStep Step { get; }
public List<DagNode> Dependencies { get; } = [];
public List<DagNode> Dependents { get; } = [];
public DagNode(string id, WorkflowStep step)
{
Id = id;
Step = step;
}
public bool IsReady(IReadOnlySet<string> completedSteps) =>
Dependencies.All(d => completedSteps.Contains(d.Id));
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Events;
public sealed record WorkflowTemplateCreated(
Guid TemplateId,
Guid TenantId,
string Name,
int Version,
int StepCount,
DateTimeOffset CreatedAt
) : IDomainEvent;
public sealed record WorkflowTemplatePublished(
Guid TemplateId,
Guid TenantId,
string Name,
int Version,
DateTimeOffset PublishedAt
) : IDomainEvent;
public sealed record WorkflowTemplateDeprecated(
Guid TemplateId,
Guid TenantId,
string Name,
DateTimeOffset DeprecatedAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/workflows.md` (partial) | Markdown | API endpoint documentation for workflow templates (CRUD, validate) |
---
## Acceptance Criteria
### Code
- [ ] Parse YAML workflow definitions
- [ ] Parse JSON workflow definitions
- [ ] Validate step types exist
- [ ] Detect circular dependencies
- [ ] Validate dependencies exist
- [ ] Create workflow templates
- [ ] Version workflow templates
- [ ] Publish workflow templates
- [ ] Deprecate workflow templates
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Workflow template API endpoints documented
- [ ] Template validation endpoint documented
- [ ] Full workflow template JSON schema included
- [ ] DAG validation rules documented
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `ParseYaml_ValidWorkflow_Succeeds` | YAML parsing works |
| `ParseJson_ValidWorkflow_Succeeds` | JSON parsing works |
| `Validate_CyclicDependency_Fails` | Cycle detection works |
| `Validate_MissingDependency_Fails` | Dependency check works |
| `Validate_UnknownStepType_Fails` | Step type check works |
| `DagBuilder_CreatesTopologicalOrder` | DAG building works |
| `CreateTemplate_StoresContent` | Creation works |
| `PublishTemplate_ChangesStatus` | Publishing works |
### Integration Tests
| Test | Description |
|------|-------------|
| `WorkflowTemplateLifecycle_E2E` | Full CRUD cycle |
| `WorkflowParsing_E2E` | Real workflow files |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 101_001 Database Schema | Internal | TODO |
| 105_002 Step Registry | Internal | TODO |
| YamlDotNet | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IWorkflowTemplateService | TODO | |
| WorkflowTemplateService | TODO | |
| WorkflowParser | TODO | |
| WorkflowValidator | TODO | |
| DagBuilder | TODO | |
| WorkflowTemplate model | TODO | |
| IWorkflowTemplateStore | TODO | |
| WorkflowTemplateStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/workflows.md (partial - templates) |

View File

@@ -0,0 +1,564 @@
# SPRINT: Step Registry
> **Sprint ID:** 105_002
> **Module:** WORKFL
> **Phase:** 5 - Workflow Engine
> **Status:** TODO
> **Parent:** [105_000_INDEX](SPRINT_20260110_105_000_INDEX_workflow_engine.md)
---
## Overview
Implement the Step Registry for managing built-in and plugin workflow steps.
### Objectives
- Register built-in step types
- Load plugin step types dynamically
- Define step schemas for configuration validation
- Provide step discovery and documentation
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Workflow/
│ ├── Steps/
│ │ ├── IStepRegistry.cs
│ │ ├── StepRegistry.cs
│ │ ├── IStepProvider.cs
│ │ ├── StepDefinition.cs
│ │ └── StepSchema.cs
│ ├── Steps.BuiltIn/
│ │ └── (see 105_005)
│ └── Steps.Plugin/
│ ├── IPluginStepLoader.cs
│ └── PluginStepLoader.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Workflow.Tests/
└── Steps/
```
---
## Architecture Reference
- [Workflow Engine](../modules/release-orchestrator/modules/workflow-engine.md)
- [Plugin System](../modules/release-orchestrator/plugins/step-plugins.md)
---
## Deliverables
### IStepRegistry Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps;
public interface IStepRegistry
{
void RegisterBuiltIn<T>(string type) where T : class, IStepProvider;
void RegisterPlugin(StepDefinition definition, IStepProvider provider);
Task<IStepProvider?> GetProviderAsync(string type, CancellationToken ct = default);
StepDefinition? GetDefinition(string type);
IReadOnlyList<StepDefinition> GetAllDefinitions();
IReadOnlyList<StepDefinition> GetBuiltInDefinitions();
IReadOnlyList<StepDefinition> GetPluginDefinitions();
bool IsRegistered(string type);
}
public interface IStepProvider
{
string Type { get; }
string DisplayName { get; }
string Description { get; }
StepSchema ConfigSchema { get; }
StepCapabilities Capabilities { get; }
Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct = default);
Task<ValidationResult> ValidateConfigAsync(IReadOnlyDictionary<string, object> config, CancellationToken ct = default);
}
```
### StepDefinition Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps;
public sealed record StepDefinition
{
public required string Type { get; init; }
public required string DisplayName { get; init; }
public required string Description { get; init; }
public required StepCategory Category { get; init; }
public required StepSource Source { get; init; }
public string? PluginId { get; init; }
public required StepSchema ConfigSchema { get; init; }
public required StepCapabilities Capabilities { get; init; }
public string? DocumentationUrl { get; init; }
public string? IconUrl { get; init; }
public ImmutableArray<StepExample> Examples { get; init; } = [];
}
public enum StepCategory
{
Deployment,
Gate,
Approval,
Notification,
Script,
Integration,
Utility
}
public enum StepSource
{
BuiltIn,
Plugin
}
public sealed record StepCapabilities
{
public bool SupportsRetry { get; init; } = true;
public bool SupportsTimeout { get; init; } = true;
public bool SupportsCondition { get; init; } = true;
public bool RequiresAgent { get; init; } = false;
public bool IsAsync { get; init; } = false; // Requires callback to complete
public ImmutableArray<string> RequiredPermissions { get; init; } = [];
}
public sealed record StepExample(
string Name,
string Description,
ImmutableDictionary<string, object> Config
);
```
### StepSchema
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps;
public sealed record StepSchema
{
public ImmutableArray<StepProperty> Properties { get; init; } = [];
public ImmutableArray<string> Required { get; init; } = [];
public ValidationResult Validate(IReadOnlyDictionary<string, object> config)
{
var errors = new List<string>();
// Check required properties
foreach (var required in Required)
{
if (!config.ContainsKey(required) || config[required] is null)
{
errors.Add($"Required property '{required}' is missing");
}
}
// Validate property types
foreach (var prop in Properties)
{
if (config.TryGetValue(prop.Name, out var value) && value is not null)
{
var propError = ValidateProperty(prop, value);
if (propError is not null)
{
errors.Add(propError);
}
}
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
private static string? ValidateProperty(StepProperty prop, object value)
{
return prop.Type switch
{
StepPropertyType.String when value is not string =>
$"Property '{prop.Name}' must be a string",
StepPropertyType.Integer when !IsInteger(value) =>
$"Property '{prop.Name}' must be an integer",
StepPropertyType.Number when !IsNumber(value) =>
$"Property '{prop.Name}' must be a number",
StepPropertyType.Boolean when value is not bool =>
$"Property '{prop.Name}' must be a boolean",
StepPropertyType.Array when value is not IEnumerable<object> =>
$"Property '{prop.Name}' must be an array",
StepPropertyType.Object when value is not IDictionary<string, object> =>
$"Property '{prop.Name}' must be an object",
_ => null
};
}
private static bool IsInteger(object value) =>
value is int or long or short or byte;
private static bool IsNumber(object value) =>
value is int or long or short or byte or float or double or decimal;
}
public sealed record StepProperty
{
public required string Name { get; init; }
public required StepPropertyType Type { get; init; }
public string? Description { get; init; }
public object? Default { get; init; }
public ImmutableArray<object>? Enum { get; init; }
public int? MinValue { get; init; }
public int? MaxValue { get; init; }
public int? MinLength { get; init; }
public int? MaxLength { get; init; }
public string? Pattern { get; init; }
}
public enum StepPropertyType
{
String,
Integer,
Number,
Boolean,
Array,
Object,
Secret
}
```
### StepRegistry Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps;
public sealed class StepRegistry : IStepRegistry
{
private readonly ConcurrentDictionary<string, (StepDefinition Definition, IStepProvider Provider)> _steps = new();
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<StepRegistry> _logger;
public StepRegistry(IServiceProvider serviceProvider, ILogger<StepRegistry> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public void RegisterBuiltIn<T>(string type) where T : class, IStepProvider
{
var provider = _serviceProvider.GetRequiredService<T>();
var definition = new StepDefinition
{
Type = type,
DisplayName = provider.DisplayName,
Description = provider.Description,
Category = InferCategory(type),
Source = StepSource.BuiltIn,
ConfigSchema = provider.ConfigSchema,
Capabilities = provider.Capabilities
};
if (!_steps.TryAdd(type, (definition, provider)))
{
throw new InvalidOperationException($"Step type '{type}' is already registered");
}
_logger.LogInformation("Registered built-in step: {Type}", type);
}
public void RegisterPlugin(StepDefinition definition, IStepProvider provider)
{
if (definition.Source != StepSource.Plugin)
{
throw new ArgumentException("Definition must have Plugin source");
}
if (!_steps.TryAdd(definition.Type, (definition, provider)))
{
throw new InvalidOperationException($"Step type '{definition.Type}' is already registered");
}
_logger.LogInformation(
"Registered plugin step: {Type} from {PluginId}",
definition.Type,
definition.PluginId);
}
public Task<IStepProvider?> GetProviderAsync(string type, CancellationToken ct = default)
{
return _steps.TryGetValue(type, out var entry)
? Task.FromResult<IStepProvider?>(entry.Provider)
: Task.FromResult<IStepProvider?>(null);
}
public StepDefinition? GetDefinition(string type)
{
return _steps.TryGetValue(type, out var entry)
? entry.Definition
: null;
}
public IReadOnlyList<StepDefinition> GetAllDefinitions()
{
return _steps.Values.Select(e => e.Definition).ToList().AsReadOnly();
}
public IReadOnlyList<StepDefinition> GetBuiltInDefinitions()
{
return _steps.Values
.Where(e => e.Definition.Source == StepSource.BuiltIn)
.Select(e => e.Definition)
.ToList()
.AsReadOnly();
}
public IReadOnlyList<StepDefinition> GetPluginDefinitions()
{
return _steps.Values
.Where(e => e.Definition.Source == StepSource.Plugin)
.Select(e => e.Definition)
.ToList()
.AsReadOnly();
}
public bool IsRegistered(string type) => _steps.ContainsKey(type);
private static StepCategory InferCategory(string type) =>
type switch
{
"deploy" or "rollback" => StepCategory.Deployment,
"security-gate" or "policy-gate" => StepCategory.Gate,
"approval" => StepCategory.Approval,
"notify" => StepCategory.Notification,
"script" => StepCategory.Script,
"wait" => StepCategory.Utility,
_ => StepCategory.Integration
};
}
```
### PluginStepLoader
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.Plugin;
public interface IPluginStepLoader
{
Task LoadPluginStepsAsync(CancellationToken ct = default);
Task ReloadPluginAsync(string pluginId, CancellationToken ct = default);
}
public sealed class PluginStepLoader : IPluginStepLoader
{
private readonly IPluginLoader _pluginLoader;
private readonly IStepRegistry _stepRegistry;
private readonly ILogger<PluginStepLoader> _logger;
public async Task LoadPluginStepsAsync(CancellationToken ct = default)
{
var plugins = await _pluginLoader.GetPluginsAsync<IStepPlugin>(ct);
foreach (var plugin in plugins)
{
try
{
await LoadPluginAsync(plugin, ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to load step plugin {PluginId}",
plugin.Manifest.Id);
}
}
}
private async Task LoadPluginAsync(LoadedPlugin<IStepPlugin> plugin, CancellationToken ct)
{
var stepProviders = plugin.Instance.GetStepProviders();
foreach (var provider in stepProviders)
{
var definition = new StepDefinition
{
Type = provider.Type,
DisplayName = provider.DisplayName,
Description = provider.Description,
Category = plugin.Instance.Category,
Source = StepSource.Plugin,
PluginId = plugin.Manifest.Id,
ConfigSchema = provider.ConfigSchema,
Capabilities = provider.Capabilities,
DocumentationUrl = plugin.Manifest.DocumentationUrl
};
_stepRegistry.RegisterPlugin(definition, provider);
_logger.LogInformation(
"Loaded step '{Type}' from plugin '{PluginId}'",
provider.Type,
plugin.Manifest.Id);
}
}
public async Task ReloadPluginAsync(string pluginId, CancellationToken ct = default)
{
// Unregister existing steps from this plugin
var existingDefs = _stepRegistry.GetPluginDefinitions()
.Where(d => d.PluginId == pluginId)
.ToList();
// Note: Full unregistration would require registry modification
// For now, just log and reload (new registration will override)
_logger.LogInformation("Reloading step plugin {PluginId}", pluginId);
var plugin = await _pluginLoader.GetPluginAsync<IStepPlugin>(pluginId, ct);
if (plugin is not null)
{
await LoadPluginAsync(plugin, ct);
}
}
}
public interface IStepPlugin
{
StepCategory Category { get; }
IReadOnlyList<IStepProvider> GetStepProviders();
}
```
### StepRegistryInitializer
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps;
public sealed class StepRegistryInitializer : IHostedService
{
private readonly IStepRegistry _registry;
private readonly IPluginStepLoader _pluginLoader;
private readonly ILogger<StepRegistryInitializer> _logger;
public async Task StartAsync(CancellationToken ct)
{
_logger.LogInformation("Initializing step registry");
// Register built-in steps
_registry.RegisterBuiltIn<ScriptStepProvider>("script");
_registry.RegisterBuiltIn<ApprovalStepProvider>("approval");
_registry.RegisterBuiltIn<NotifyStepProvider>("notify");
_registry.RegisterBuiltIn<WaitStepProvider>("wait");
_registry.RegisterBuiltIn<SecurityGateStepProvider>("security-gate");
_registry.RegisterBuiltIn<DeployStepProvider>("deploy");
_registry.RegisterBuiltIn<RollbackStepProvider>("rollback");
_logger.LogInformation(
"Registered {Count} built-in steps",
_registry.GetBuiltInDefinitions().Count);
// Load plugin steps
await _pluginLoader.LoadPluginStepsAsync(ct);
_logger.LogInformation(
"Loaded {Count} plugin steps",
_registry.GetPluginDefinitions().Count);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/workflows.md` (partial) | Markdown | API endpoint documentation for step registry (list available steps, get step schema) |
---
## Acceptance Criteria
### Code
- [ ] Register built-in step types
- [ ] Load plugin step types
- [ ] Validate step configurations against schema
- [ ] Get step provider by type
- [ ] List all step definitions
- [ ] Filter by built-in vs plugin
- [ ] Step schema validation works
- [ ] Required property validation works
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Step registry API endpoints documented
- [ ] List steps endpoint documented (GET /api/v1/steps)
- [ ] Built-in step types listed
- [ ] Plugin-provided step discovery explained
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `RegisterBuiltIn_AddsStep` | Registration works |
| `RegisterPlugin_AddsStep` | Plugin registration works |
| `GetProvider_ReturnsProvider` | Lookup works |
| `GetDefinition_ReturnsDefinition` | Definition lookup works |
| `SchemaValidation_RequiredMissing_Fails` | Required check works |
| `SchemaValidation_WrongType_Fails` | Type check works |
| `SchemaValidation_Valid_Succeeds` | Valid config passes |
### Integration Tests
| Test | Description |
|------|-------------|
| `StepRegistryInit_E2E` | Full initialization |
| `PluginStepLoading_E2E` | Plugin step loading |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 101_002 Plugin Registry | Internal | TODO |
| 101_003 Plugin Loader | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IStepRegistry | TODO | |
| StepRegistry | TODO | |
| IStepProvider | TODO | |
| StepDefinition | TODO | |
| StepSchema | TODO | |
| IPluginStepLoader | TODO | |
| PluginStepLoader | TODO | |
| StepRegistryInitializer | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/workflows.md (partial - step registry) |

View File

@@ -0,0 +1,734 @@
# SPRINT: Workflow Engine - DAG Executor
> **Sprint ID:** 105_003
> **Module:** WORKFL
> **Phase:** 5 - Workflow Engine
> **Status:** TODO
> **Parent:** [105_000_INDEX](SPRINT_20260110_105_000_INDEX_workflow_engine.md)
---
## Overview
Implement the DAG Executor for orchestrating workflow step execution with parallel and sequential support.
### Objectives
- Start workflow runs from templates
- Schedule steps based on DAG dependencies
- Execute parallel steps concurrently
- Track workflow run state
- Support pause/resume/cancel
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Workflow/
│ ├── Engine/
│ │ ├── IWorkflowEngine.cs
│ │ ├── WorkflowEngine.cs
│ │ ├── DagScheduler.cs
│ │ └── WorkflowRuntime.cs
│ ├── State/
│ │ ├── IWorkflowStateManager.cs
│ │ ├── WorkflowStateManager.cs
│ │ └── WorkflowCheckpoint.cs
│ ├── Store/
│ │ ├── IWorkflowRunStore.cs
│ │ └── WorkflowRunStore.cs
│ └── Models/
│ ├── WorkflowRun.cs
│ ├── StepRun.cs
│ └── WorkflowContext.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Workflow.Tests/
└── Engine/
```
---
## Architecture Reference
- [Workflow Engine](../modules/release-orchestrator/modules/workflow-engine.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IWorkflowEngine Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Engine;
public interface IWorkflowEngine
{
Task<WorkflowRun> StartAsync(
Guid templateId,
WorkflowContext context,
CancellationToken ct = default);
Task<WorkflowRun> StartFromTemplateAsync(
WorkflowTemplate template,
WorkflowContext context,
CancellationToken ct = default);
Task<WorkflowRun> ResumeAsync(Guid runId, CancellationToken ct = default);
Task PauseAsync(Guid runId, CancellationToken ct = default);
Task CancelAsync(Guid runId, string? reason = null, CancellationToken ct = default);
Task<WorkflowRun?> GetRunAsync(Guid runId, CancellationToken ct = default);
Task<IReadOnlyList<WorkflowRun>> ListRunsAsync(WorkflowRunFilter? filter = null, CancellationToken ct = default);
Task RetryStepAsync(Guid runId, string stepId, CancellationToken ct = default);
Task SkipStepAsync(Guid runId, string stepId, CancellationToken ct = default);
}
```
### WorkflowRun Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Models;
public sealed record WorkflowRun
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required Guid TemplateId { get; init; }
public required string TemplateName { get; init; }
public required int TemplateVersion { get; init; }
public required WorkflowRunStatus Status { get; init; }
public required WorkflowContext Context { get; init; }
public required ImmutableArray<StepRun> Steps { get; init; }
public string? FailureReason { get; init; }
public string? CancelReason { get; init; }
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public DateTimeOffset? PausedAt { get; init; }
public TimeSpan? Duration => CompletedAt.HasValue
? CompletedAt.Value - StartedAt
: null;
public Guid StartedBy { get; init; }
public bool IsTerminal => Status is
WorkflowRunStatus.Completed or
WorkflowRunStatus.Failed or
WorkflowRunStatus.Cancelled;
}
public enum WorkflowRunStatus
{
Pending,
Running,
Paused,
WaitingForApproval,
Completed,
Failed,
Cancelled
}
public sealed record StepRun
{
public required string StepId { get; init; }
public required string StepType { get; init; }
public required StepRunStatus Status { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public StepResult? Result { get; init; }
public string? Error { get; init; }
public ImmutableArray<StepAttempt> Attempts { get; init; } = [];
}
public enum StepRunStatus
{
Pending,
Ready,
Running,
WaitingForCallback,
Completed,
Failed,
Skipped,
Cancelled
}
public sealed record StepAttempt(
int AttemptNumber,
DateTimeOffset StartedAt,
DateTimeOffset? CompletedAt,
StepResult? Result,
string? Error
);
public sealed record WorkflowContext
{
public Guid? ReleaseId { get; init; }
public Guid? EnvironmentId { get; init; }
public Guid? PromotionId { get; init; }
public Guid? DeploymentId { get; init; }
public ImmutableDictionary<string, string> Variables { get; init; } =
ImmutableDictionary<string, string>.Empty;
public ImmutableDictionary<string, object> Outputs { get; init; } =
ImmutableDictionary<string, object>.Empty;
}
```
### WorkflowEngine Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Engine;
public sealed class WorkflowEngine : IWorkflowEngine
{
private readonly IWorkflowTemplateService _templateService;
private readonly IWorkflowRunStore _runStore;
private readonly IWorkflowStateManager _stateManager;
private readonly IDagScheduler _scheduler;
private readonly IStepExecutor _stepExecutor;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<WorkflowEngine> _logger;
public async Task<WorkflowRun> StartAsync(
Guid templateId,
WorkflowContext context,
CancellationToken ct = default)
{
var template = await _templateService.GetAsync(templateId, ct)
?? throw new WorkflowTemplateNotFoundException(templateId);
if (template.Status != WorkflowTemplateStatus.Published)
{
throw new WorkflowTemplateNotPublishedException(templateId);
}
return await StartFromTemplateAsync(template, context, ct);
}
public async Task<WorkflowRun> StartFromTemplateAsync(
WorkflowTemplate template,
WorkflowContext context,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var stepRuns = template.Steps.Select(step => new StepRun
{
StepId = step.Id,
StepType = step.Type,
Status = StepRunStatus.Pending,
AttemptCount = 0
}).ToImmutableArray();
var run = new WorkflowRun
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
TemplateId = template.Id,
TemplateName = template.Name,
TemplateVersion = template.Version,
Status = WorkflowRunStatus.Pending,
Context = context,
Steps = stepRuns,
StartedAt = now,
StartedBy = _userContext.UserId
};
await _runStore.SaveAsync(run, ct);
await _eventPublisher.PublishAsync(new WorkflowRunStarted(
run.Id,
run.TenantId,
run.TemplateName,
run.Context.ReleaseId,
run.Context.EnvironmentId,
now
), ct);
_logger.LogInformation(
"Started workflow run {RunId} from template {TemplateName}",
run.Id,
template.Name);
// Start execution
_ = ExecuteAsync(run.Id, template, ct);
return run;
}
private async Task ExecuteAsync(
Guid runId,
WorkflowTemplate template,
CancellationToken ct)
{
try
{
var dag = new DagBuilder().Build(template.Steps);
await _stateManager.SetStatusAsync(runId, WorkflowRunStatus.Running, ct);
while (!ct.IsCancellationRequested)
{
var run = await _runStore.GetAsync(runId, ct);
if (run is null || run.IsTerminal)
break;
if (run.Status == WorkflowRunStatus.Paused)
{
await Task.Delay(TimeSpan.FromSeconds(1), ct);
continue;
}
// Get ready steps
var completedStepIds = run.Steps
.Where(s => s.Status == StepRunStatus.Completed || s.Status == StepRunStatus.Skipped)
.Select(s => s.StepId)
.ToHashSet();
var readySteps = _scheduler.GetReadySteps(dag, completedStepIds, run.Steps);
if (readySteps.Count == 0)
{
// Check if all steps are complete or if we're stuck
var pendingSteps = run.Steps.Where(s =>
s.Status is StepRunStatus.Pending or
StepRunStatus.Ready or
StepRunStatus.Running or
StepRunStatus.WaitingForCallback);
if (!pendingSteps.Any())
{
// All steps complete
await _stateManager.CompleteAsync(runId, ct);
break;
}
// Waiting for async steps
await Task.Delay(TimeSpan.FromSeconds(1), ct);
continue;
}
// Execute ready steps in parallel
var tasks = readySteps.Select(step =>
ExecuteStepAsync(runId, step, run.Context, ct));
await Task.WhenAll(tasks);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogInformation("Workflow run {RunId} cancelled", runId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Workflow run {RunId} failed", runId);
await _stateManager.FailAsync(runId, ex.Message, ct);
}
}
private async Task ExecuteStepAsync(
Guid runId,
WorkflowStep step,
WorkflowContext context,
CancellationToken ct)
{
try
{
await _stateManager.SetStepStatusAsync(runId, step.Id, StepRunStatus.Running, ct);
var stepContext = new StepContext
{
RunId = runId,
StepId = step.Id,
StepType = step.Type,
Config = step.Config,
WorkflowContext = context,
Timeout = step.Timeout,
RetryConfig = step.Retry
};
var result = await _stepExecutor.ExecuteAsync(step, stepContext, ct);
if (result.IsSuccess)
{
await _stateManager.CompleteStepAsync(runId, step.Id, result, ct);
}
else if (result.RequiresCallback)
{
await _stateManager.SetStepStatusAsync(runId, step.Id,
StepRunStatus.WaitingForCallback, ct);
}
else
{
await _stateManager.FailStepAsync(runId, step.Id, result.Error ?? "Unknown error", ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Step {StepId} failed in run {RunId}", step.Id, runId);
await _stateManager.FailStepAsync(runId, step.Id, ex.Message, ct);
}
}
public async Task CancelAsync(Guid runId, string? reason = null, CancellationToken ct = default)
{
var run = await _runStore.GetAsync(runId, ct)
?? throw new WorkflowRunNotFoundException(runId);
if (run.IsTerminal)
{
throw new WorkflowRunAlreadyTerminalException(runId);
}
await _stateManager.CancelAsync(runId, reason, ct);
await _eventPublisher.PublishAsync(new WorkflowRunCancelled(
runId,
run.TenantId,
run.TemplateName,
reason,
_timeProvider.GetUtcNow()
), ct);
}
}
```
### DagScheduler
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Engine;
public interface IDagScheduler
{
IReadOnlyList<WorkflowStep> GetReadySteps(
WorkflowDag dag,
IReadOnlySet<string> completedStepIds,
ImmutableArray<StepRun> stepRuns);
}
public sealed class DagScheduler : IDagScheduler
{
public IReadOnlyList<WorkflowStep> GetReadySteps(
WorkflowDag dag,
IReadOnlySet<string> completedStepIds,
ImmutableArray<StepRun> stepRuns)
{
var readySteps = new List<WorkflowStep>();
var runningOrWaiting = stepRuns
.Where(s => s.Status is StepRunStatus.Running or StepRunStatus.WaitingForCallback)
.Select(s => s.StepId)
.ToHashSet();
foreach (var node in dag.Nodes)
{
var stepRun = stepRuns.FirstOrDefault(s => s.StepId == node.Id);
if (stepRun is null)
continue;
// Skip if already running, complete, or failed
if (stepRun.Status != StepRunStatus.Pending &&
stepRun.Status != StepRunStatus.Ready)
continue;
// Check if all dependencies are complete
if (node.IsReady(completedStepIds))
{
// Evaluate condition if present
if (ShouldExecute(node.Step, stepRuns))
{
readySteps.Add(node.Step);
}
}
}
return readySteps.AsReadOnly();
}
private static bool ShouldExecute(WorkflowStep step, ImmutableArray<StepRun> stepRuns)
{
if (string.IsNullOrEmpty(step.Condition))
return true;
// Evaluate simple conditions
return step.Condition switch
{
"success()" => stepRuns
.Where(s => step.DependsOn.Contains(s.StepId))
.All(s => s.Status == StepRunStatus.Completed),
"failure()" => stepRuns
.Where(s => step.DependsOn.Contains(s.StepId))
.Any(s => s.Status == StepRunStatus.Failed),
"always()" => true,
_ => true // Default to execute for unrecognized conditions
};
}
}
```
### WorkflowStateManager
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.State;
public interface IWorkflowStateManager
{
Task SetStatusAsync(Guid runId, WorkflowRunStatus status, CancellationToken ct = default);
Task SetStepStatusAsync(Guid runId, string stepId, StepRunStatus status, CancellationToken ct = default);
Task CompleteAsync(Guid runId, CancellationToken ct = default);
Task FailAsync(Guid runId, string reason, CancellationToken ct = default);
Task CancelAsync(Guid runId, string? reason, CancellationToken ct = default);
Task CompleteStepAsync(Guid runId, string stepId, StepResult result, CancellationToken ct = default);
Task FailStepAsync(Guid runId, string stepId, string error, CancellationToken ct = default);
}
public sealed class WorkflowStateManager : IWorkflowStateManager
{
private readonly IWorkflowRunStore _store;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<WorkflowStateManager> _logger;
public async Task CompleteStepAsync(
Guid runId,
string stepId,
StepResult result,
CancellationToken ct = default)
{
var run = await _store.GetAsync(runId, ct)
?? throw new WorkflowRunNotFoundException(runId);
var updatedSteps = run.Steps.Select(s =>
{
if (s.StepId != stepId)
return s;
return s with
{
Status = StepRunStatus.Completed,
CompletedAt = _timeProvider.GetUtcNow(),
Result = result,
AttemptCount = s.AttemptCount + 1
};
}).ToImmutableArray();
var updatedRun = run with { Steps = updatedSteps };
await _store.SaveAsync(updatedRun, ct);
await _eventPublisher.PublishAsync(new WorkflowStepCompleted(
runId,
stepId,
result.Outputs,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Step {StepId} completed in run {RunId}",
stepId,
runId);
}
public async Task FailStepAsync(
Guid runId,
string stepId,
string error,
CancellationToken ct = default)
{
var run = await _store.GetAsync(runId, ct)
?? throw new WorkflowRunNotFoundException(runId);
var step = run.Steps.FirstOrDefault(s => s.StepId == stepId);
if (step is null)
return;
// Check if we should retry
var template = await GetStepDefinition(run.TemplateId, stepId, ct);
var shouldRetry = template?.Retry is not null &&
step.AttemptCount < template.Retry.MaxAttempts;
var updatedSteps = run.Steps.Select(s =>
{
if (s.StepId != stepId)
return s;
return s with
{
Status = shouldRetry ? StepRunStatus.Pending : StepRunStatus.Failed,
Error = error,
AttemptCount = s.AttemptCount + 1
};
}).ToImmutableArray();
var updatedRun = run with { Steps = updatedSteps };
// If step failed and no retry, fail the workflow
if (!shouldRetry && !step.ContinueOnError)
{
updatedRun = updatedRun with
{
Status = WorkflowRunStatus.Failed,
FailureReason = $"Step {stepId} failed: {error}",
CompletedAt = _timeProvider.GetUtcNow()
};
}
await _store.SaveAsync(updatedRun, ct);
if (!shouldRetry)
{
await _eventPublisher.PublishAsync(new WorkflowStepFailed(
runId,
stepId,
error,
_timeProvider.GetUtcNow()
), ct);
}
}
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Events;
public sealed record WorkflowRunStarted(
Guid RunId,
Guid TenantId,
string TemplateName,
Guid? ReleaseId,
Guid? EnvironmentId,
DateTimeOffset StartedAt
) : IDomainEvent;
public sealed record WorkflowRunCompleted(
Guid RunId,
Guid TenantId,
string TemplateName,
TimeSpan Duration,
DateTimeOffset CompletedAt
) : IDomainEvent;
public sealed record WorkflowRunFailed(
Guid RunId,
Guid TenantId,
string TemplateName,
string Reason,
DateTimeOffset FailedAt
) : IDomainEvent;
public sealed record WorkflowRunCancelled(
Guid RunId,
Guid TenantId,
string TemplateName,
string? Reason,
DateTimeOffset CancelledAt
) : IDomainEvent;
public sealed record WorkflowStepCompleted(
Guid RunId,
string StepId,
IReadOnlyDictionary<string, object>? Outputs,
DateTimeOffset CompletedAt
) : IDomainEvent;
public sealed record WorkflowStepFailed(
Guid RunId,
string StepId,
string Error,
DateTimeOffset FailedAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/workflows.md` (partial) | Markdown | API endpoint documentation for workflow runs (start, pause, resume, cancel) |
---
## Acceptance Criteria
### Code
- [ ] Start workflow from template
- [ ] Execute steps in dependency order
- [ ] Execute independent steps in parallel
- [ ] Track workflow run state
- [ ] Pause/resume workflow
- [ ] Cancel workflow
- [ ] Retry failed step
- [ ] Skip step
- [ ] Evaluate step conditions
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Workflow run API endpoints documented
- [ ] Start workflow run endpoint documented
- [ ] Pause/Resume/Cancel endpoints documented
- [ ] Run status response schema included
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `StartWorkflow_CreatesRun` | Start creates run |
| `DagScheduler_RespectsOrdering` | Dependencies respected |
| `DagScheduler_ParallelSteps` | Parallel execution |
| `ExecuteStep_Success_CompletesStep` | Success handling |
| `ExecuteStep_Failure_RetriesOrFails` | Failure handling |
| `Cancel_StopsExecution` | Cancellation works |
| `Condition_Success_ExecutesStep` | Condition evaluation |
| `Condition_Failure_SkipsStep` | Conditional skip |
### Integration Tests
| Test | Description |
|------|-------------|
| `WorkflowExecution_E2E` | Full workflow run |
| `WorkflowRetry_E2E` | Retry behavior |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 105_001 Workflow Template | Internal | TODO |
| 105_002 Step Registry | Internal | TODO |
| 105_004 Step Executor | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IWorkflowEngine | TODO | |
| WorkflowEngine | TODO | |
| IDagScheduler | TODO | |
| DagScheduler | TODO | |
| IWorkflowStateManager | TODO | |
| WorkflowStateManager | TODO | |
| WorkflowRun model | TODO | |
| IWorkflowRunStore | TODO | |
| WorkflowRunStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/workflows.md (partial - workflow runs) |

View File

@@ -0,0 +1,615 @@
# SPRINT: Step Executor
> **Sprint ID:** 105_004
> **Module:** WORKFL
> **Phase:** 5 - Workflow Engine
> **Status:** TODO
> **Parent:** [105_000_INDEX](SPRINT_20260110_105_000_INDEX_workflow_engine.md)
---
## Overview
Implement the Step Executor for executing individual workflow steps with retry and timeout handling.
### Objectives
- Execute steps with configuration validation
- Apply retry policies with exponential backoff
- Handle step timeouts
- Manage step execution context
- Support async steps with callbacks
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Workflow/
│ ├── Executor/
│ │ ├── IStepExecutor.cs
│ │ ├── StepExecutor.cs
│ │ ├── StepContext.cs
│ │ ├── StepResult.cs
│ │ ├── StepRetryPolicy.cs
│ │ └── StepTimeoutHandler.cs
│ └── Callback/
│ ├── IStepCallbackHandler.cs
│ ├── StepCallbackHandler.cs
│ └── CallbackToken.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Workflow.Tests/
└── Executor/
```
---
## Architecture Reference
- [Workflow Engine](../modules/release-orchestrator/modules/workflow-engine.md)
---
## Deliverables
### IStepExecutor Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Executor;
public interface IStepExecutor
{
Task<StepResult> ExecuteAsync(
WorkflowStep step,
StepContext context,
CancellationToken ct = default);
}
```
### StepContext Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Executor;
public sealed record StepContext
{
public required Guid RunId { get; init; }
public required string StepId { get; init; }
public required string StepType { get; init; }
public required ImmutableDictionary<string, object> Config { get; init; }
public required WorkflowContext WorkflowContext { get; init; }
public TimeSpan? Timeout { get; init; }
public RetryConfig? RetryConfig { get; init; }
public int AttemptNumber { get; init; } = 1;
// For variable interpolation
public string Interpolate(string template)
{
var result = template;
foreach (var (key, value) in WorkflowContext.Variables)
{
result = result.Replace($"${{variables.{key}}}", value);
}
foreach (var (key, value) in WorkflowContext.Outputs)
{
result = result.Replace($"${{outputs.{key}}}", value?.ToString() ?? "");
}
// Built-in variables
result = result.Replace("${run.id}", RunId.ToString());
result = result.Replace("${step.id}", StepId);
result = result.Replace("${step.attempt}", AttemptNumber.ToString(CultureInfo.InvariantCulture));
if (WorkflowContext.ReleaseId.HasValue)
result = result.Replace("${release.id}", WorkflowContext.ReleaseId.Value.ToString());
if (WorkflowContext.EnvironmentId.HasValue)
result = result.Replace("${environment.id}", WorkflowContext.EnvironmentId.Value.ToString());
return result;
}
}
```
### StepResult Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Executor;
public sealed record StepResult
{
public required StepResultStatus Status { get; init; }
public string? Error { get; init; }
public ImmutableDictionary<string, object> Outputs { get; init; } =
ImmutableDictionary<string, object>.Empty;
public TimeSpan Duration { get; init; }
public bool RequiresCallback { get; init; }
public string? CallbackToken { get; init; }
public DateTimeOffset? CallbackExpiresAt { get; init; }
public bool IsSuccess => Status == StepResultStatus.Success;
public bool IsFailure => Status == StepResultStatus.Failed;
public static StepResult Success(
ImmutableDictionary<string, object>? outputs = null,
TimeSpan duration = default) =>
new()
{
Status = StepResultStatus.Success,
Outputs = outputs ?? ImmutableDictionary<string, object>.Empty,
Duration = duration
};
public static StepResult Failed(string error, TimeSpan duration = default) =>
new()
{
Status = StepResultStatus.Failed,
Error = error,
Duration = duration
};
public static StepResult WaitingForCallback(
string callbackToken,
DateTimeOffset expiresAt) =>
new()
{
Status = StepResultStatus.WaitingForCallback,
RequiresCallback = true,
CallbackToken = callbackToken,
CallbackExpiresAt = expiresAt
};
public static StepResult Skipped(string reason) =>
new()
{
Status = StepResultStatus.Skipped,
Error = reason
};
}
public enum StepResultStatus
{
Success,
Failed,
Skipped,
WaitingForCallback,
TimedOut,
Cancelled
}
```
### StepExecutor Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Executor;
public sealed class StepExecutor : IStepExecutor
{
private readonly IStepRegistry _stepRegistry;
private readonly IStepRetryPolicy _retryPolicy;
private readonly IStepTimeoutHandler _timeoutHandler;
private readonly ILogger<StepExecutor> _logger;
private readonly TimeProvider _timeProvider;
public async Task<StepResult> ExecuteAsync(
WorkflowStep step,
StepContext context,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
_logger.LogInformation(
"Executing step {StepId} (type: {StepType}, attempt: {Attempt})",
step.Id,
step.Type,
context.AttemptNumber);
try
{
// Get step provider
var provider = await _stepRegistry.GetProviderAsync(step.Type, ct);
if (provider is null)
{
return StepResult.Failed($"Unknown step type: {step.Type}", sw.Elapsed);
}
// Validate configuration
var validation = await provider.ValidateConfigAsync(context.Config, ct);
if (!validation.IsValid)
{
return StepResult.Failed(
$"Invalid configuration: {string.Join(", ", validation.Errors)}",
sw.Elapsed);
}
// Apply timeout if configured
using var timeoutCts = context.Timeout.HasValue
? new CancellationTokenSource(context.Timeout.Value)
: new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
ct, timeoutCts.Token);
try
{
var result = await provider.ExecuteAsync(context, linkedCts.Token);
result = result with { Duration = sw.Elapsed };
_logger.LogInformation(
"Step {StepId} completed with status {Status} in {Duration}ms",
step.Id,
result.Status,
sw.ElapsedMilliseconds);
return result;
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
_logger.LogWarning(
"Step {StepId} timed out after {Timeout}",
step.Id,
context.Timeout);
return new StepResult
{
Status = StepResultStatus.TimedOut,
Error = $"Step timed out after {context.Timeout}",
Duration = sw.Elapsed
};
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return new StepResult
{
Status = StepResultStatus.Cancelled,
Duration = sw.Elapsed
};
}
catch (Exception ex)
{
_logger.LogError(ex,
"Step {StepId} failed with exception",
step.Id);
return StepResult.Failed(ex.Message, sw.Elapsed);
}
}
}
```
### StepRetryPolicy
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Executor;
public interface IStepRetryPolicy
{
bool ShouldRetry(StepResult result, RetryConfig? config, int attemptNumber);
TimeSpan GetDelay(RetryConfig config, int attemptNumber);
}
public sealed class StepRetryPolicy : IStepRetryPolicy
{
private static readonly HashSet<StepResultStatus> RetryableStatuses = new()
{
StepResultStatus.Failed,
StepResultStatus.TimedOut
};
public bool ShouldRetry(StepResult result, RetryConfig? config, int attemptNumber)
{
if (config is null)
return false;
if (!RetryableStatuses.Contains(result.Status))
return false;
if (attemptNumber >= config.MaxAttempts)
return false;
return true;
}
public TimeSpan GetDelay(RetryConfig config, int attemptNumber)
{
// Exponential backoff with jitter
var baseDelay = config.InitialDelay.TotalMilliseconds;
var exponentialDelay = baseDelay * Math.Pow(config.BackoffMultiplier, attemptNumber - 1);
// Add jitter (+-20%)
var jitter = exponentialDelay * (Random.Shared.NextDouble() * 0.4 - 0.2);
var totalDelayMs = exponentialDelay + jitter;
// Cap at 5 minutes
var cappedDelay = Math.Min(totalDelayMs, TimeSpan.FromMinutes(5).TotalMilliseconds);
return TimeSpan.FromMilliseconds(cappedDelay);
}
}
```
### StepCallbackHandler
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Callback;
public interface IStepCallbackHandler
{
Task<CallbackToken> CreateCallbackAsync(
Guid runId,
string stepId,
TimeSpan? expiresIn = null,
CancellationToken ct = default);
Task<CallbackResult> ProcessCallbackAsync(
string token,
CallbackPayload payload,
CancellationToken ct = default);
Task<bool> ValidateCallbackAsync(
string token,
CancellationToken ct = default);
}
public sealed class StepCallbackHandler : IStepCallbackHandler
{
private readonly ICallbackStore _store;
private readonly IWorkflowStateManager _stateManager;
private readonly TimeProvider _timeProvider;
private readonly ILogger<StepCallbackHandler> _logger;
private static readonly TimeSpan DefaultExpiry = TimeSpan.FromHours(24);
public async Task<CallbackToken> CreateCallbackAsync(
Guid runId,
string stepId,
TimeSpan? expiresIn = null,
CancellationToken ct = default)
{
var token = GenerateSecureToken();
var expiry = _timeProvider.GetUtcNow().Add(expiresIn ?? DefaultExpiry);
var callback = new PendingCallback
{
Token = token,
RunId = runId,
StepId = stepId,
CreatedAt = _timeProvider.GetUtcNow(),
ExpiresAt = expiry
};
await _store.SaveAsync(callback, ct);
return new CallbackToken(token, expiry);
}
public async Task<CallbackResult> ProcessCallbackAsync(
string token,
CallbackPayload payload,
CancellationToken ct = default)
{
var pending = await _store.GetByTokenAsync(token, ct);
if (pending is null)
{
return CallbackResult.Failed("Invalid callback token");
}
if (pending.ExpiresAt < _timeProvider.GetUtcNow())
{
return CallbackResult.Failed("Callback token expired");
}
if (pending.ProcessedAt.HasValue)
{
return CallbackResult.Failed("Callback already processed");
}
// Mark as processed
pending = pending with { ProcessedAt = _timeProvider.GetUtcNow() };
await _store.SaveAsync(pending, ct);
// Update step with callback result
var result = payload.Success
? StepResult.Success(payload.Outputs?.ToImmutableDictionary())
: StepResult.Failed(payload.Error ?? "Callback indicated failure");
await _stateManager.CompleteStepAsync(pending.RunId, pending.StepId, result, ct);
_logger.LogInformation(
"Processed callback for step {StepId} in run {RunId}",
pending.StepId,
pending.RunId);
return CallbackResult.Succeeded(pending.RunId, pending.StepId);
}
private static string GenerateSecureToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
}
public sealed record CallbackToken(
string Token,
DateTimeOffset ExpiresAt
);
public sealed record CallbackPayload(
bool Success,
string? Error = null,
IReadOnlyDictionary<string, object>? Outputs = null
);
public sealed record CallbackResult(
bool IsSuccess,
string? Error = null,
Guid? RunId = null,
string? StepId = null
)
{
public static CallbackResult Succeeded(Guid runId, string stepId) =>
new(true, RunId: runId, StepId: stepId);
public static CallbackResult Failed(string error) =>
new(false, Error: error);
}
public sealed record PendingCallback
{
public required string Token { get; init; }
public required Guid RunId { get; init; }
public required string StepId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset? ProcessedAt { get; init; }
}
```
### StepTimeoutHandler
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Executor;
public interface IStepTimeoutHandler
{
Task MonitorTimeoutsAsync(CancellationToken ct = default);
}
public sealed class StepTimeoutHandler : IStepTimeoutHandler, IHostedService
{
private readonly IWorkflowRunStore _runStore;
private readonly IWorkflowStateManager _stateManager;
private readonly ICallbackStore _callbackStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<StepTimeoutHandler> _logger;
private Timer? _timer;
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(
_ => _ = MonitorTimeoutsAsync(ct),
null,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public async Task MonitorTimeoutsAsync(CancellationToken ct = default)
{
try
{
var now = _timeProvider.GetUtcNow();
// Check for expired callbacks
var expiredCallbacks = await _callbackStore.GetExpiredAsync(now, ct);
foreach (var callback in expiredCallbacks)
{
_logger.LogWarning(
"Callback expired for step {StepId} in run {RunId}",
callback.StepId,
callback.RunId);
await _stateManager.FailStepAsync(
callback.RunId,
callback.StepId,
"Callback timed out",
ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error monitoring step timeouts");
}
}
public void Dispose() => _timer?.Dispose();
}
```
---
## Acceptance Criteria
- [ ] Execute steps with configuration
- [ ] Validate step configuration before execution
- [ ] Apply timeout to step execution
- [ ] Retry failed steps with exponential backoff
- [ ] Create callback tokens for async steps
- [ ] Process callbacks and complete steps
- [ ] Monitor and fail expired callbacks
- [ ] Interpolate variables in configuration
- [ ] Unit test coverage >=85%
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `ExecuteStep_Success_ReturnsSuccess` | Success case |
| `ExecuteStep_InvalidConfig_Fails` | Config validation |
| `ExecuteStep_Timeout_ReturnsTimedOut` | Timeout handling |
| `RetryPolicy_ShouldRetry_ReturnsTrue` | Retry logic |
| `RetryPolicy_MaxAttempts_ReturnsFalse` | Max attempts |
| `GetDelay_ExponentialBackoff` | Backoff calculation |
| `Callback_ValidToken_Succeeds` | Callback processing |
| `Callback_ExpiredToken_Fails` | Expiry handling |
| `Interpolate_ReplacesVariables` | Variable interpolation |
### Integration Tests
| Test | Description |
|------|-------------|
| `StepExecution_E2E` | Full execution flow |
| `StepCallback_E2E` | Async callback flow |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 105_002 Step Registry | Internal | TODO |
| 105_003 DAG Executor | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IStepExecutor | TODO | |
| StepExecutor | TODO | |
| StepContext | TODO | |
| StepResult | TODO | |
| IStepRetryPolicy | TODO | |
| StepRetryPolicy | TODO | |
| IStepCallbackHandler | TODO | |
| StepCallbackHandler | TODO | |
| IStepTimeoutHandler | TODO | |
| StepTimeoutHandler | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,771 @@
# SPRINT: Built-in Steps
> **Sprint ID:** 105_005
> **Module:** WORKFL
> **Phase:** 5 - Workflow Engine
> **Status:** TODO
> **Parent:** [105_000_INDEX](SPRINT_20260110_105_000_INDEX_workflow_engine.md)
---
## Overview
Implement the built-in workflow steps for common deployment and automation tasks.
### Objectives
- Implement core workflow steps required for v1 release
- Define complete step type catalog (16 types total)
- Document deferral strategy for post-v1 and plugin-based steps
### Step Type Catalog
The Release Orchestrator supports 16 built-in step types. This sprint implements the **7 core types** required for v1; remaining types are deferred to post-v1 or delivered via the plugin SDK.
| Step Type | v1 Status | Description |
|-----------|-----------|-------------|
| `script` | **v1** | Execute shell scripts on target host |
| `approval` | **v1** | Request manual approval before proceeding |
| `notify` | **v1** | Send notifications via configured channels |
| `wait` | **v1** | Pause execution for duration/until time |
| `security-gate` | **v1** | Check vulnerability thresholds |
| `deploy` | **v1** | Trigger deployment to target environment |
| `rollback` | **v1** | Rollback to previous release version |
| `http` | Post-v1 | Make HTTP requests (API calls, webhooks) |
| `smoke-test` | Post-v1 | Run smoke tests against deployed service |
| `health-check` | Post-v1 | Custom health check beyond deploy step |
| `database-migrate` | Post-v1 | Run database migrations via agent |
| `feature-flag` | Plugin | Toggle feature flags (LaunchDarkly, Split, etc.) |
| `cache-invalidate` | Plugin | Invalidate CDN/cache (CloudFront, Fastly, etc.) |
| `metric-check` | Plugin | Query metrics (Prometheus, DataDog, etc.) |
| `dns-switch` | Plugin | Update DNS records for blue-green |
| `custom` | Plugin | User-defined plugin steps |
> **Deferral Strategy:** Post-v1 types will be implemented in Release Orchestrator 1.1. Plugin types are delivered via the Plugin SDK (`StellaOps.Plugin.Sdk`) and can be developed by users or third parties using `IStepProviderCapability`.
### v1 Core Step Objectives
- Script step for executing shell commands
- Approval step for manual gates
- Notify step for sending notifications
- Wait step for time delays
- Security gate step for vulnerability checks
- Deploy step for triggering deployments
- Rollback step for reverting releases
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Workflow/
│ └── Steps.BuiltIn/
│ ├── ScriptStepProvider.cs
│ ├── ApprovalStepProvider.cs
│ ├── NotifyStepProvider.cs
│ ├── WaitStepProvider.cs
│ ├── SecurityGateStepProvider.cs
│ ├── DeployStepProvider.cs
│ └── RollbackStepProvider.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Workflow.Tests/
└── Steps.BuiltIn/
```
---
## Architecture Reference
- [Workflow Engine](../modules/release-orchestrator/modules/workflow-engine.md)
- [Step Plugins](../modules/release-orchestrator/plugins/step-plugins.md)
---
## Deliverables
### ScriptStepProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.BuiltIn;
public sealed class ScriptStepProvider : IStepProvider
{
private readonly IAgentManager _agentManager;
private readonly ILogger<ScriptStepProvider> _logger;
public string Type => "script";
public string DisplayName => "Script";
public string Description => "Execute a shell script or command on target host";
public StepSchema ConfigSchema => new()
{
Properties =
[
new StepProperty { Name = "script", Type = StepPropertyType.String, Description = "Script content or path" },
new StepProperty { Name = "shell", Type = StepPropertyType.String, Default = "bash", Description = "Shell to use (bash, sh, powershell)" },
new StepProperty { Name = "workingDir", Type = StepPropertyType.String, Description = "Working directory" },
new StepProperty { Name = "environment", Type = StepPropertyType.Object, Description = "Environment variables" },
new StepProperty { Name = "timeout", Type = StepPropertyType.Integer, Default = 300, Description = "Timeout in seconds" },
new StepProperty { Name = "failOnNonZero", Type = StepPropertyType.Boolean, Default = true, Description = "Fail if exit code is non-zero" }
],
Required = ["script"]
};
public StepCapabilities Capabilities => new()
{
SupportsRetry = true,
SupportsTimeout = true,
RequiresAgent = true
};
public async Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct)
{
var script = context.Interpolate(context.Config.GetValueOrDefault("script")?.ToString() ?? "");
var shell = context.Config.GetValueOrDefault("shell")?.ToString() ?? "bash";
var workingDir = context.Config.GetValueOrDefault("workingDir")?.ToString();
var timeout = context.Config.GetValueOrDefault("timeout") is int t ? t : 300;
var failOnNonZero = context.Config.GetValueOrDefault("failOnNonZero") as bool? ?? true;
var environmentId = context.WorkflowContext.EnvironmentId
?? throw new InvalidOperationException("Script step requires an environment");
// Get agent for target
var agent = await GetAgentForEnvironment(environmentId, ct);
var task = new ScriptExecutionTask
{
Script = script,
Shell = shell,
WorkingDirectory = workingDir,
TimeoutSeconds = timeout,
Environment = ExtractEnvironment(context.Config)
};
var result = await _agentManager.ExecuteTaskAsync(agent.Id, task, ct);
if (result.ExitCode != 0 && failOnNonZero)
{
return StepResult.Failed(
$"Script exited with code {result.ExitCode}: {result.Stderr}");
}
return StepResult.Success(new Dictionary<string, object>
{
["exitCode"] = result.ExitCode,
["stdout"] = result.Stdout,
["stderr"] = result.Stderr
}.ToImmutableDictionary());
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ConfigSchema.Validate(config));
}
```
### ApprovalStepProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.BuiltIn;
public sealed class ApprovalStepProvider : IStepProvider
{
private readonly IApprovalService _approvalService;
private readonly IStepCallbackHandler _callbackHandler;
private readonly ILogger<ApprovalStepProvider> _logger;
public string Type => "approval";
public string DisplayName => "Approval";
public string Description => "Request manual approval before proceeding";
public StepSchema ConfigSchema => new()
{
Properties =
[
new StepProperty { Name = "approvers", Type = StepPropertyType.Array, Description = "List of approver user IDs or group names" },
new StepProperty { Name = "minApprovals", Type = StepPropertyType.Integer, Default = 1, Description = "Minimum approvals required" },
new StepProperty { Name = "message", Type = StepPropertyType.String, Description = "Message to display to approvers" },
new StepProperty { Name = "timeout", Type = StepPropertyType.Integer, Default = 86400, Description = "Timeout in seconds (default 24h)" },
new StepProperty { Name = "autoApproveOnTimeout", Type = StepPropertyType.Boolean, Default = false, Description = "Auto-approve if timeout reached" }
],
Required = ["approvers"]
};
public StepCapabilities Capabilities => new()
{
SupportsRetry = false,
SupportsTimeout = true,
IsAsync = true
};
public async Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct)
{
var approvers = context.Config.GetValueOrDefault("approvers") as IEnumerable<object>
?? throw new InvalidOperationException("Approvers required");
var minApprovals = context.Config.GetValueOrDefault("minApprovals") as int? ?? 1;
var message = context.Interpolate(
context.Config.GetValueOrDefault("message")?.ToString() ?? "Approval required");
var timeoutSeconds = context.Config.GetValueOrDefault("timeout") as int? ?? 86400;
// Create callback token
var callback = await _callbackHandler.CreateCallbackAsync(
context.RunId,
context.StepId,
TimeSpan.FromSeconds(timeoutSeconds),
ct);
// Create approval request
var approval = await _approvalService.CreateAsync(new CreateApprovalRequest
{
WorkflowRunId = context.RunId,
StepId = context.StepId,
Message = message,
Approvers = approvers.Select(a => a.ToString()!).ToList(),
MinApprovals = minApprovals,
ExpiresAt = callback.ExpiresAt,
CallbackToken = callback.Token,
ReleaseId = context.WorkflowContext.ReleaseId,
EnvironmentId = context.WorkflowContext.EnvironmentId
}, ct);
_logger.LogInformation(
"Created approval request {ApprovalId} for step {StepId}",
approval.Id,
context.StepId);
return StepResult.WaitingForCallback(callback.Token, callback.ExpiresAt);
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ConfigSchema.Validate(config));
}
```
### NotifyStepProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.BuiltIn;
public sealed class NotifyStepProvider : IStepProvider
{
private readonly INotificationService _notificationService;
private readonly ILogger<NotifyStepProvider> _logger;
public string Type => "notify";
public string DisplayName => "Notify";
public string Description => "Send notifications via configured channels";
public StepSchema ConfigSchema => new()
{
Properties =
[
new StepProperty { Name = "channel", Type = StepPropertyType.String, Description = "Notification channel (slack, teams, email, webhook)" },
new StepProperty { Name = "message", Type = StepPropertyType.String, Description = "Message content" },
new StepProperty { Name = "title", Type = StepPropertyType.String, Description = "Message title" },
new StepProperty { Name = "recipients", Type = StepPropertyType.Array, Description = "Recipient addresses/channels" },
new StepProperty { Name = "severity", Type = StepPropertyType.String, Default = "info", Description = "Message severity (info, warning, error)" },
new StepProperty { Name = "template", Type = StepPropertyType.String, Description = "Named template to use" }
],
Required = ["channel", "message"]
};
public StepCapabilities Capabilities => new()
{
SupportsRetry = true,
SupportsTimeout = true
};
public async Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct)
{
var channel = context.Config.GetValueOrDefault("channel")?.ToString()
?? throw new InvalidOperationException("Channel required");
var message = context.Interpolate(
context.Config.GetValueOrDefault("message")?.ToString() ?? "");
var title = context.Interpolate(
context.Config.GetValueOrDefault("title")?.ToString() ?? "Workflow Notification");
var severity = context.Config.GetValueOrDefault("severity")?.ToString() ?? "info";
var recipients = context.Config.GetValueOrDefault("recipients") as IEnumerable<object>;
var notification = new NotificationRequest
{
Channel = channel,
Title = title,
Message = message,
Severity = Enum.Parse<NotificationSeverity>(severity, ignoreCase: true),
Recipients = recipients?.Select(r => r.ToString()!).ToList(),
Metadata = new Dictionary<string, string>
{
["workflowRunId"] = context.RunId.ToString(),
["stepId"] = context.StepId,
["releaseId"] = context.WorkflowContext.ReleaseId?.ToString() ?? "",
["environmentId"] = context.WorkflowContext.EnvironmentId?.ToString() ?? ""
}
};
await _notificationService.SendAsync(notification, ct);
_logger.LogInformation(
"Sent {Channel} notification for step {StepId}",
channel,
context.StepId);
return StepResult.Success();
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ConfigSchema.Validate(config));
}
```
### WaitStepProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.BuiltIn;
public sealed class WaitStepProvider : IStepProvider
{
public string Type => "wait";
public string DisplayName => "Wait";
public string Description => "Pause workflow execution for specified duration";
public StepSchema ConfigSchema => new()
{
Properties =
[
new StepProperty { Name = "duration", Type = StepPropertyType.Integer, Description = "Wait duration in seconds" },
new StepProperty { Name = "until", Type = StepPropertyType.String, Description = "Wait until specific time (ISO 8601)" }
]
};
public StepCapabilities Capabilities => new()
{
SupportsRetry = false,
SupportsTimeout = false
};
public async Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct)
{
TimeSpan waitDuration;
if (context.Config.TryGetValue("duration", out var durationObj) && durationObj is int duration)
{
waitDuration = TimeSpan.FromSeconds(duration);
}
else if (context.Config.TryGetValue("until", out var untilObj) &&
untilObj is string untilStr &&
DateTimeOffset.TryParse(untilStr, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var until))
{
waitDuration = until - TimeProvider.System.GetUtcNow();
if (waitDuration < TimeSpan.Zero)
waitDuration = TimeSpan.Zero;
}
else
{
return StepResult.Failed("Either 'duration' or 'until' must be specified");
}
if (waitDuration > TimeSpan.Zero)
{
await Task.Delay(waitDuration, ct);
}
return StepResult.Success(new Dictionary<string, object>
{
["waitedSeconds"] = (int)waitDuration.TotalSeconds
}.ToImmutableDictionary());
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct)
{
if (!config.ContainsKey("duration") && !config.ContainsKey("until"))
{
return Task.FromResult(ValidationResult.Failure(
"Either 'duration' or 'until' must be specified"));
}
return Task.FromResult(ValidationResult.Success());
}
}
```
### SecurityGateStepProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.BuiltIn;
public sealed class SecurityGateStepProvider : IStepProvider
{
private readonly IReleaseManager _releaseManager;
private readonly IScannerService _scannerService;
private readonly ILogger<SecurityGateStepProvider> _logger;
public string Type => "security-gate";
public string DisplayName => "Security Gate";
public string Description => "Check security vulnerabilities meet thresholds";
public StepSchema ConfigSchema => new()
{
Properties =
[
new StepProperty { Name = "maxCritical", Type = StepPropertyType.Integer, Default = 0, Description = "Maximum critical vulnerabilities allowed" },
new StepProperty { Name = "maxHigh", Type = StepPropertyType.Integer, Default = 5, Description = "Maximum high vulnerabilities allowed" },
new StepProperty { Name = "maxMedium", Type = StepPropertyType.Integer, Description = "Maximum medium vulnerabilities allowed" },
new StepProperty { Name = "requireScan", Type = StepPropertyType.Boolean, Default = true, Description = "Require scan to exist" },
new StepProperty { Name = "maxAge", Type = StepPropertyType.Integer, Default = 86400, Description = "Max scan age in seconds" }
]
};
public StepCapabilities Capabilities => new()
{
SupportsRetry = true,
SupportsTimeout = true,
RequiredPermissions = ["release:read", "scanner:read"]
};
public async Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct)
{
var releaseId = context.WorkflowContext.ReleaseId
?? throw new InvalidOperationException("Security gate requires a release");
var maxCritical = context.Config.GetValueOrDefault("maxCritical") as int? ?? 0;
var maxHigh = context.Config.GetValueOrDefault("maxHigh") as int? ?? 5;
var maxMedium = context.Config.GetValueOrDefault("maxMedium") as int?;
var requireScan = context.Config.GetValueOrDefault("requireScan") as bool? ?? true;
var maxAgeSeconds = context.Config.GetValueOrDefault("maxAge") as int? ?? 86400;
var release = await _releaseManager.GetAsync(releaseId, ct)
?? throw new ReleaseNotFoundException(releaseId);
var violations = new List<string>();
var totalCritical = 0;
var totalHigh = 0;
var totalMedium = 0;
foreach (var component in release.Components)
{
var scanResult = await _scannerService.GetLatestScanAsync(
component.Digest, ct);
if (scanResult is null)
{
if (requireScan)
{
violations.Add($"No scan found for {component.ComponentName}");
}
continue;
}
var scanAge = TimeProvider.System.GetUtcNow() - scanResult.CompletedAt;
if (scanAge.TotalSeconds > maxAgeSeconds)
{
violations.Add($"Scan for {component.ComponentName} is too old ({scanAge.TotalHours:F1}h)");
}
totalCritical += scanResult.CriticalCount;
totalHigh += scanResult.HighCount;
totalMedium += scanResult.MediumCount;
}
// Check thresholds
if (totalCritical > maxCritical)
{
violations.Add($"Critical vulnerabilities ({totalCritical}) exceed threshold ({maxCritical})");
}
if (totalHigh > maxHigh)
{
violations.Add($"High vulnerabilities ({totalHigh}) exceed threshold ({maxHigh})");
}
if (maxMedium.HasValue && totalMedium > maxMedium.Value)
{
violations.Add($"Medium vulnerabilities ({totalMedium}) exceed threshold ({maxMedium})");
}
if (violations.Count > 0)
{
return StepResult.Failed(string.Join("; ", violations));
}
_logger.LogInformation(
"Security gate passed for release {ReleaseId}: {Critical}C/{High}H/{Medium}M",
releaseId,
totalCritical,
totalHigh,
totalMedium);
return StepResult.Success(new Dictionary<string, object>
{
["criticalCount"] = totalCritical,
["highCount"] = totalHigh,
["mediumCount"] = totalMedium,
["componentsScanned"] = release.Components.Length
}.ToImmutableDictionary());
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ConfigSchema.Validate(config));
}
```
### DeployStepProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.BuiltIn;
public sealed class DeployStepProvider : IStepProvider
{
private readonly IDeploymentService _deploymentService;
private readonly IStepCallbackHandler _callbackHandler;
private readonly ILogger<DeployStepProvider> _logger;
public string Type => "deploy";
public string DisplayName => "Deploy";
public string Description => "Deploy release to target environment";
public StepSchema ConfigSchema => new()
{
Properties =
[
new StepProperty { Name = "strategy", Type = StepPropertyType.String, Default = "rolling", Description = "Deployment strategy (rolling, blue-green, canary)" },
new StepProperty { Name = "batchSize", Type = StepPropertyType.String, Default = "25%", Description = "Batch size for rolling deploys" },
new StepProperty { Name = "timeout", Type = StepPropertyType.Integer, Default = 3600, Description = "Deployment timeout in seconds" },
new StepProperty { Name = "healthCheck", Type = StepPropertyType.Boolean, Default = true, Description = "Wait for health checks" },
new StepProperty { Name = "rollbackOnFailure", Type = StepPropertyType.Boolean, Default = true, Description = "Auto-rollback on failure" }
]
};
public StepCapabilities Capabilities => new()
{
SupportsRetry = false, // Deployments should not auto-retry
SupportsTimeout = true,
IsAsync = true,
RequiresAgent = true,
RequiredPermissions = ["deployment:create", "environment:deploy"]
};
public async Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct)
{
var releaseId = context.WorkflowContext.ReleaseId
?? throw new InvalidOperationException("Deploy step requires a release");
var environmentId = context.WorkflowContext.EnvironmentId
?? throw new InvalidOperationException("Deploy step requires an environment");
var strategy = context.Config.GetValueOrDefault("strategy")?.ToString() ?? "rolling";
var batchSize = context.Config.GetValueOrDefault("batchSize")?.ToString() ?? "25%";
var timeoutSeconds = context.Config.GetValueOrDefault("timeout") as int? ?? 3600;
var healthCheck = context.Config.GetValueOrDefault("healthCheck") as bool? ?? true;
var rollbackOnFailure = context.Config.GetValueOrDefault("rollbackOnFailure") as bool? ?? true;
// Create callback for deployment completion
var callback = await _callbackHandler.CreateCallbackAsync(
context.RunId,
context.StepId,
TimeSpan.FromSeconds(timeoutSeconds),
ct);
// Start deployment
var deployment = await _deploymentService.CreateAsync(new CreateDeploymentRequest
{
ReleaseId = releaseId,
EnvironmentId = environmentId,
Strategy = Enum.Parse<DeploymentStrategy>(strategy, ignoreCase: true),
BatchSize = batchSize,
WaitForHealthCheck = healthCheck,
RollbackOnFailure = rollbackOnFailure,
WorkflowRunId = context.RunId,
CallbackToken = callback.Token
}, ct);
_logger.LogInformation(
"Started deployment {DeploymentId} for release {ReleaseId} to environment {EnvironmentId}",
deployment.Id,
releaseId,
environmentId);
return StepResult.WaitingForCallback(callback.Token, callback.ExpiresAt);
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ConfigSchema.Validate(config));
}
```
### RollbackStepProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Workflow.Steps.BuiltIn;
public sealed class RollbackStepProvider : IStepProvider
{
private readonly IDeploymentService _deploymentService;
private readonly IReleaseCatalog _releaseCatalog;
private readonly IStepCallbackHandler _callbackHandler;
private readonly ILogger<RollbackStepProvider> _logger;
public string Type => "rollback";
public string DisplayName => "Rollback";
public string Description => "Rollback to previous release version";
public StepSchema ConfigSchema => new()
{
Properties =
[
new StepProperty { Name = "targetRelease", Type = StepPropertyType.String, Description = "Specific release ID to rollback to (optional)" },
new StepProperty { Name = "skipCount", Type = StepPropertyType.Integer, Default = 1, Description = "Number of releases to skip back" },
new StepProperty { Name = "timeout", Type = StepPropertyType.Integer, Default = 1800, Description = "Rollback timeout in seconds" }
]
};
public StepCapabilities Capabilities => new()
{
SupportsRetry = false,
SupportsTimeout = true,
IsAsync = true,
RequiredPermissions = ["deployment:rollback"]
};
public async Task<StepResult> ExecuteAsync(StepContext context, CancellationToken ct)
{
var environmentId = context.WorkflowContext.EnvironmentId
?? throw new InvalidOperationException("Rollback step requires an environment");
var targetReleaseId = context.Config.GetValueOrDefault("targetRelease")?.ToString();
var skipCount = context.Config.GetValueOrDefault("skipCount") as int? ?? 1;
var timeoutSeconds = context.Config.GetValueOrDefault("timeout") as int? ?? 1800;
Guid rollbackToReleaseId;
if (!string.IsNullOrEmpty(targetReleaseId))
{
rollbackToReleaseId = Guid.Parse(targetReleaseId);
}
else
{
// Get deployment history and find previous release
var history = await _releaseCatalog.GetEnvironmentHistoryAsync(
environmentId, skipCount + 1, ct);
if (history.Count <= skipCount)
{
return StepResult.Failed("No previous release to rollback to");
}
rollbackToReleaseId = history[skipCount].ReleaseId;
}
var callback = await _callbackHandler.CreateCallbackAsync(
context.RunId,
context.StepId,
TimeSpan.FromSeconds(timeoutSeconds),
ct);
var deployment = await _deploymentService.RollbackAsync(new RollbackRequest
{
EnvironmentId = environmentId,
TargetReleaseId = rollbackToReleaseId,
WorkflowRunId = context.RunId,
CallbackToken = callback.Token
}, ct);
_logger.LogInformation(
"Started rollback to release {ReleaseId} in environment {EnvironmentId}",
rollbackToReleaseId,
environmentId);
return StepResult.WaitingForCallback(callback.Token, callback.ExpiresAt);
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ValidationResult.Success());
}
```
---
## Acceptance Criteria
- [ ] Script step executes commands via agent
- [ ] Approval step creates approval request
- [ ] Approval completes via callback
- [ ] Notify step sends to configured channels
- [ ] Wait step delays execution
- [ ] Security gate checks vulnerability thresholds
- [ ] Deploy step triggers deployment
- [ ] Rollback step reverts to previous release
- [ ] All steps validate configuration
- [ ] Unit test coverage >=85%
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `ScriptStep_ExecutesViaAgent` | Script execution |
| `ApprovalStep_CreatesRequest` | Approval creation |
| `ApprovalStep_CallbackCompletes` | Callback handling |
| `NotifyStep_SendsNotification` | Notification sending |
| `WaitStep_DelaysExecution` | Wait behavior |
| `SecurityGate_PassesThreshold` | Pass case |
| `SecurityGate_FailsThreshold` | Fail case |
| `DeployStep_TriggersDeployment` | Deployment trigger |
| `RollbackStep_FindsPreviousRelease` | Rollback logic |
### Integration Tests
| Test | Description |
|------|-------------|
| `ScriptStep_E2E` | Full script execution |
| `ApprovalWorkflow_E2E` | Approval flow |
| `DeploymentWorkflow_E2E` | Deploy flow |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 105_004 Step Executor | Internal | TODO |
| 103_003 Agent Manager | Internal | TODO |
| 107_* Deployment Execution | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ScriptStepProvider | TODO | |
| ApprovalStepProvider | TODO | |
| NotifyStepProvider | TODO | |
| WaitStepProvider | TODO | |
| SecurityGateStepProvider | TODO | |
| DeployStepProvider | TODO | |
| RollbackStepProvider | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 10-Jan-2026 | Added Step Type Catalog (16 types) with v1/post-v1/plugin deferral strategy |

View File

@@ -0,0 +1,254 @@
# SPRINT INDEX: Phase 6 - Promotion & Gates
> **Epic:** Release Orchestrator
> **Phase:** 6 - Promotion & Gates
> **Batch:** 106
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 6 implements the Promotion system - managing release promotions between environments with approval workflows and policy gates.
### Objectives
- Promotion manager for promotion requests
- Approval gateway with multi-approver support
- Gate registry for built-in and plugin gates
- Security gate with vulnerability thresholds
- Decision engine combining gates and approvals
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 106_001 | Promotion Manager | PROMOT | TODO | 104_003, 103_001 |
| 106_002 | Approval Gateway | PROMOT | TODO | 106_001 |
| 106_003 | Gate Registry | PROMOT | TODO | 106_001 |
| 106_004 | Security Gate | PROMOT | TODO | 106_003 |
| 106_005 | Decision Engine | PROMOT | TODO | 106_002, 106_003 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROMOTION & GATES │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ PROMOTION MANAGER (106_001) │ │
│ │ │ │
│ │ Promotion Request ──────────────────────────────────────┐ │ │
│ │ │ release_id: uuid │ │ │
│ │ │ source_environment: staging │ │ │
│ │ │ target_environment: production │ │ │
│ │ │ requested_by: user-123 │ │ │
│ │ │ reason: "Release v2.3.1 for Q1 launch" │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ APPROVAL GATEWAY (106_002) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Approval Flow │ │ │
│ │ │ │ │ │
│ │ │ pending ──► awaiting_approval ──┬──► approved ──► deploying │ │ │
│ │ │ │ │ │ │
│ │ │ └──► rejected │ │ │
│ │ │ │ │ │
│ │ │ Separation of Duties: requester ≠ approver │ │ │
│ │ │ Multi-approval: 2 of 3 approvers required │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ GATE REGISTRY (106_003) │ │
│ │ │ │
│ │ Built-in Gates: Plugin Gates: │ │
│ │ ├── security-gate ├── compliance-gate │ │
│ │ ├── approval-gate ├── change-window-gate │ │
│ │ ├── freeze-window-gate └── custom-policy-gate │ │
│ │ └── manual-gate │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ SECURITY GATE (106_004) │ │
│ │ │ │
│ │ Config: Result: │ │
│ │ ├── max_critical: 0 ├── passed: false │ │
│ │ ├── max_high: 5 ├── blocking: true │ │
│ │ ├── max_medium: -1 ├── message: "3 critical vulns found" │ │
│ │ └── require_sbom: true └── details: { critical: 3, high: 2 } │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DECISION ENGINE (106_005) │ │
│ │ │ │
│ │ Input: Promotion + Gates + Approvals │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Security Gate│ │Approval Gate │ │ Freeze Gate │ │ │
│ │ │ ✓ PASS │ │ ✓ PASS │ │ ✓ PASS │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Decision: ALLOW ───────────────────────────────────────────────────►│ │
│ │ │ │
│ │ Decision Record: { gates: [...], approvals: [...], decision: allow } │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 106_001: Promotion Manager
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IPromotionManager` | Interface | Promotion operations |
| `PromotionManager` | Class | Implementation |
| `Promotion` | Model | Promotion entity |
| `PromotionValidator` | Class | Business rules |
### 106_002: Approval Gateway
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IApprovalGateway` | Interface | Approval operations |
| `ApprovalGateway` | Class | Implementation |
| `Approval` | Model | Approval record |
| `SeparationOfDuties` | Class | SoD enforcement |
### 106_003: Gate Registry
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IGateRegistry` | Interface | Gate lookup |
| `GateRegistry` | Class | Implementation |
| `GateDefinition` | Model | Gate metadata |
| `GateEvaluator` | Class | Execute gates |
### 106_004: Security Gate
| Deliverable | Type | Description |
|-------------|------|-------------|
| `SecurityGate` | Gate | Vulnerability threshold gate |
| `SecurityGateConfig` | Config | Threshold configuration |
| `VulnerabilityCounter` | Class | Count by severity |
### 106_005: Decision Engine
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IDecisionEngine` | Interface | Decision evaluation |
| `DecisionEngine` | Class | Implementation |
| `DecisionRecord` | Model | Decision with evidence |
| `DecisionRules` | Class | Gate combination rules |
---
## Key Interfaces
```csharp
public interface IPromotionManager
{
Task<Promotion> RequestAsync(PromotionRequest request, CancellationToken ct);
Task<Promotion> ApproveAsync(Guid promotionId, ApprovalRequest request, CancellationToken ct);
Task<Promotion> RejectAsync(Guid promotionId, RejectionRequest request, CancellationToken ct);
Task<Promotion> CancelAsync(Guid promotionId, CancellationToken ct);
Task<Promotion?> GetAsync(Guid promotionId, CancellationToken ct);
Task<IReadOnlyList<Promotion>> ListPendingAsync(Guid? environmentId, CancellationToken ct);
}
public interface IDecisionEngine
{
Task<DecisionResult> EvaluateAsync(Guid promotionId, CancellationToken ct);
Task<GateResult> EvaluateGateAsync(Guid promotionId, string gateName, CancellationToken ct);
}
public interface IGateProvider
{
string GateType { get; }
Task<GateResult> EvaluateAsync(GateContext context, CancellationToken ct);
}
```
---
## Promotion State Machine
```
┌─────────┐
│ pending │
└────┬────┘
│ submit
┌───────────────────┐
│ awaiting_approval │◄─────────┐
└─────────┬─────────┘ │
│ │
┌─────┴─────┐ more approvers
│ │ needed
▼ ▼ │
┌────────┐ ┌────────┐ │
│approved│ │rejected│ │
└───┬────┘ └────────┘ │
│ │
│ gates pass │
▼ │
┌──────────┐ │
│ deploying│───────────────────┘
└────┬─────┘ rollback
├──────────────┐
▼ ▼
┌────────┐ ┌────────┐
│deployed│ │ failed │
└────────┘ └───┬────┘
┌───────────┐
│rolled_back│
└───────────┘
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| 104_003 Release Manager | Release to promote |
| 103_001 Environment CRUD | Target environment |
| Scanner | Security data |
---
## Acceptance Criteria
- [ ] Promotion request created
- [ ] Approval flow works
- [ ] Separation of duties enforced
- [ ] Multiple approvers supported
- [ ] Security gate blocks on vulns
- [ ] Freeze window blocks promotions
- [ ] Decision record captured
- [ ] Gate results aggregated
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 6 index created |

View File

@@ -0,0 +1,600 @@
# SPRINT: Promotion Manager
> **Sprint ID:** 106_001
> **Module:** PROMOT
> **Phase:** 6 - Promotion & Gates
> **Status:** TODO
> **Parent:** [106_000_INDEX](SPRINT_20260110_106_000_INDEX_promotion_gates.md)
---
## Overview
Implement the Promotion Manager for handling release promotion requests between environments.
### Objectives
- Create promotion requests with release and environment
- Validate promotion prerequisites
- Track promotion lifecycle states
- Support promotion cancellation
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Promotion/
│ ├── Manager/
│ │ ├── IPromotionManager.cs
│ │ ├── PromotionManager.cs
│ │ ├── PromotionValidator.cs
│ │ └── PromotionStateMachine.cs
│ ├── Store/
│ │ ├── IPromotionStore.cs
│ │ └── PromotionStore.cs
│ └── Models/
│ ├── Promotion.cs
│ ├── PromotionStatus.cs
│ └── PromotionRequest.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Promotion.Tests/
└── Manager/
```
---
## Architecture Reference
- [Promotion Manager](../modules/release-orchestrator/modules/promotion-manager.md)
- [Data Model Schema](../modules/release-orchestrator/data-model/schema.md)
---
## Deliverables
### IPromotionManager Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Manager;
public interface IPromotionManager
{
Task<Promotion> RequestAsync(CreatePromotionRequest request, CancellationToken ct = default);
Task<Promotion> SubmitAsync(Guid promotionId, CancellationToken ct = default);
Task<Promotion?> GetAsync(Guid promotionId, CancellationToken ct = default);
Task<IReadOnlyList<Promotion>> ListAsync(PromotionFilter? filter = null, CancellationToken ct = default);
Task<IReadOnlyList<Promotion>> ListPendingApprovalsAsync(Guid? environmentId = null, CancellationToken ct = default);
Task<IReadOnlyList<Promotion>> ListByReleaseAsync(Guid releaseId, CancellationToken ct = default);
Task<Promotion> CancelAsync(Guid promotionId, string? reason = null, CancellationToken ct = default);
Task<Promotion> UpdateStatusAsync(Guid promotionId, PromotionStatus status, CancellationToken ct = default);
}
public sealed record CreatePromotionRequest(
Guid ReleaseId,
Guid SourceEnvironmentId,
Guid TargetEnvironmentId,
string? Reason = null,
bool AutoSubmit = false
);
public sealed record PromotionFilter(
Guid? ReleaseId = null,
Guid? SourceEnvironmentId = null,
Guid? TargetEnvironmentId = null,
PromotionStatus? Status = null,
Guid? RequestedBy = null,
DateTimeOffset? RequestedAfter = null,
DateTimeOffset? RequestedBefore = null
);
```
### Promotion Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Models;
public sealed record Promotion
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required Guid SourceEnvironmentId { get; init; }
public required string SourceEnvironmentName { get; init; }
public required Guid TargetEnvironmentId { get; init; }
public required string TargetEnvironmentName { get; init; }
public required PromotionStatus Status { get; init; }
public string? Reason { get; init; }
public string? RejectionReason { get; init; }
public string? CancellationReason { get; init; }
public string? FailureReason { get; init; }
public ImmutableArray<ApprovalRecord> Approvals { get; init; } = [];
public ImmutableArray<GateResult> GateResults { get; init; } = [];
public Guid? DeploymentId { get; init; }
public DateTimeOffset RequestedAt { get; init; }
public DateTimeOffset? SubmittedAt { get; init; }
public DateTimeOffset? ApprovedAt { get; init; }
public DateTimeOffset? DeployedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public Guid RequestedBy { get; init; }
public string RequestedByName { get; init; } = "";
public bool IsActive => Status is
PromotionStatus.Pending or
PromotionStatus.AwaitingApproval or
PromotionStatus.Approved or
PromotionStatus.Deploying;
public bool IsTerminal => Status is
PromotionStatus.Deployed or
PromotionStatus.Rejected or
PromotionStatus.Cancelled or
PromotionStatus.Failed or
PromotionStatus.RolledBack;
}
public enum PromotionStatus
{
Pending, // Created, not yet submitted
AwaitingApproval, // Submitted, waiting for approvals
Approved, // Approvals complete, ready to deploy
Deploying, // Deployment in progress
Deployed, // Successfully deployed
Rejected, // Approval rejected
Cancelled, // Cancelled by requester
Failed, // Deployment failed
RolledBack // Rolled back after failure
}
public sealed record ApprovalRecord(
Guid UserId,
string UserName,
ApprovalDecision Decision,
string? Comment,
DateTimeOffset DecidedAt
);
public enum ApprovalDecision
{
Approved,
Rejected
}
public sealed record GateResult(
string GateName,
string GateType,
bool Passed,
bool Blocking,
string? Message,
ImmutableDictionary<string, object> Details,
DateTimeOffset EvaluatedAt
);
```
### PromotionManager Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Manager;
public sealed class PromotionManager : IPromotionManager
{
private readonly IPromotionStore _store;
private readonly IPromotionValidator _validator;
private readonly PromotionStateMachine _stateMachine;
private readonly IReleaseManager _releaseManager;
private readonly IEnvironmentService _environmentService;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<PromotionManager> _logger;
public async Task<Promotion> RequestAsync(
CreatePromotionRequest request,
CancellationToken ct = default)
{
// Validate request
var validation = await _validator.ValidateRequestAsync(request, ct);
if (!validation.IsValid)
{
throw new PromotionValidationException(validation.Errors);
}
// Get release and environments
var release = await _releaseManager.GetAsync(request.ReleaseId, ct)
?? throw new ReleaseNotFoundException(request.ReleaseId);
var sourceEnv = await _environmentService.GetAsync(request.SourceEnvironmentId, ct)
?? throw new EnvironmentNotFoundException(request.SourceEnvironmentId);
var targetEnv = await _environmentService.GetAsync(request.TargetEnvironmentId, ct)
?? throw new EnvironmentNotFoundException(request.TargetEnvironmentId);
var now = _timeProvider.GetUtcNow();
var promotion = new Promotion
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
ReleaseId = release.Id,
ReleaseName = release.Name,
SourceEnvironmentId = sourceEnv.Id,
SourceEnvironmentName = sourceEnv.Name,
TargetEnvironmentId = targetEnv.Id,
TargetEnvironmentName = targetEnv.Name,
Status = PromotionStatus.Pending,
Reason = request.Reason,
RequestedAt = now,
RequestedBy = _userContext.UserId,
RequestedByName = _userContext.UserName
};
await _store.SaveAsync(promotion, ct);
await _eventPublisher.PublishAsync(new PromotionRequested(
promotion.Id,
promotion.TenantId,
promotion.ReleaseName,
promotion.SourceEnvironmentName,
promotion.TargetEnvironmentName,
now,
_userContext.UserId
), ct);
_logger.LogInformation(
"Created promotion {PromotionId} for release {Release} to {Environment}",
promotion.Id,
release.Name,
targetEnv.Name);
// Auto-submit if requested
if (request.AutoSubmit)
{
promotion = await SubmitAsync(promotion.Id, ct);
}
return promotion;
}
public async Task<Promotion> SubmitAsync(Guid promotionId, CancellationToken ct = default)
{
var promotion = await _store.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
_stateMachine.ValidateTransition(promotion.Status, PromotionStatus.AwaitingApproval);
var updatedPromotion = promotion with
{
Status = PromotionStatus.AwaitingApproval,
SubmittedAt = _timeProvider.GetUtcNow()
};
await _store.SaveAsync(updatedPromotion, ct);
await _eventPublisher.PublishAsync(new PromotionSubmitted(
promotionId,
promotion.TenantId,
promotion.TargetEnvironmentId,
_timeProvider.GetUtcNow()
), ct);
return updatedPromotion;
}
public async Task<Promotion> CancelAsync(
Guid promotionId,
string? reason = null,
CancellationToken ct = default)
{
var promotion = await _store.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
if (promotion.IsTerminal)
{
throw new PromotionAlreadyTerminalException(promotionId);
}
// Only requester or admin can cancel
if (promotion.RequestedBy != _userContext.UserId &&
!_userContext.IsInRole("admin"))
{
throw new UnauthorizedPromotionActionException(promotionId, "cancel");
}
var updatedPromotion = promotion with
{
Status = PromotionStatus.Cancelled,
CancellationReason = reason,
CompletedAt = _timeProvider.GetUtcNow()
};
await _store.SaveAsync(updatedPromotion, ct);
await _eventPublisher.PublishAsync(new PromotionCancelled(
promotionId,
promotion.TenantId,
reason,
_timeProvider.GetUtcNow()
), ct);
return updatedPromotion;
}
public async Task<IReadOnlyList<Promotion>> ListPendingApprovalsAsync(
Guid? environmentId = null,
CancellationToken ct = default)
{
var filter = new PromotionFilter(
Status: PromotionStatus.AwaitingApproval,
TargetEnvironmentId: environmentId
);
return await _store.ListAsync(filter, ct);
}
}
```
### PromotionValidator
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Manager;
public sealed class PromotionValidator : IPromotionValidator
{
private readonly IReleaseManager _releaseManager;
private readonly IEnvironmentService _environmentService;
private readonly IFreezeWindowService _freezeWindowService;
private readonly IPromotionStore _promotionStore;
public async Task<ValidationResult> ValidateRequestAsync(
CreatePromotionRequest request,
CancellationToken ct = default)
{
var errors = new List<string>();
// Check release exists and is finalized
var release = await _releaseManager.GetAsync(request.ReleaseId, ct);
if (release is null)
{
errors.Add($"Release {request.ReleaseId} not found");
}
else if (release.Status == ReleaseStatus.Draft)
{
errors.Add("Cannot promote a draft release");
}
else if (release.Status == ReleaseStatus.Deprecated)
{
errors.Add("Cannot promote a deprecated release");
}
// Check environments exist
var sourceEnv = await _environmentService.GetAsync(request.SourceEnvironmentId, ct);
var targetEnv = await _environmentService.GetAsync(request.TargetEnvironmentId, ct);
if (sourceEnv is null)
{
errors.Add($"Source environment {request.SourceEnvironmentId} not found");
}
if (targetEnv is null)
{
errors.Add($"Target environment {request.TargetEnvironmentId} not found");
}
// Validate environment order (target must be after source)
if (sourceEnv is not null && targetEnv is not null)
{
if (sourceEnv.OrderIndex >= targetEnv.OrderIndex)
{
errors.Add("Target environment must be later in promotion order than source");
}
}
// Check for freeze window on target
if (targetEnv is not null)
{
var isFrozen = await _freezeWindowService.IsEnvironmentFrozenAsync(targetEnv.Id, ct);
if (isFrozen)
{
errors.Add($"Target environment {targetEnv.Name} is currently frozen");
}
}
// Check for existing active promotion
var existingPromotions = await _promotionStore.ListAsync(new PromotionFilter(
ReleaseId: request.ReleaseId,
TargetEnvironmentId: request.TargetEnvironmentId
), ct);
if (existingPromotions.Any(p => p.IsActive))
{
errors.Add("An active promotion already exists for this release and environment");
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
}
```
### PromotionStateMachine
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Manager;
public sealed class PromotionStateMachine
{
private static readonly ImmutableDictionary<PromotionStatus, ImmutableArray<PromotionStatus>> ValidTransitions =
new Dictionary<PromotionStatus, ImmutableArray<PromotionStatus>>
{
[PromotionStatus.Pending] = [PromotionStatus.AwaitingApproval, PromotionStatus.Cancelled],
[PromotionStatus.AwaitingApproval] = [PromotionStatus.Approved, PromotionStatus.Rejected, PromotionStatus.Cancelled],
[PromotionStatus.Approved] = [PromotionStatus.Deploying, PromotionStatus.Cancelled],
[PromotionStatus.Deploying] = [PromotionStatus.Deployed, PromotionStatus.Failed],
[PromotionStatus.Failed] = [PromotionStatus.RolledBack, PromotionStatus.AwaitingApproval],
[PromotionStatus.Deployed] = [],
[PromotionStatus.Rejected] = [],
[PromotionStatus.Cancelled] = [],
[PromotionStatus.RolledBack] = []
}.ToImmutableDictionary();
public bool CanTransition(PromotionStatus from, PromotionStatus to)
{
return ValidTransitions.TryGetValue(from, out var targets) &&
targets.Contains(to);
}
public void ValidateTransition(PromotionStatus from, PromotionStatus to)
{
if (!CanTransition(from, to))
{
throw new InvalidPromotionTransitionException(from, to);
}
}
public IReadOnlyList<PromotionStatus> GetValidTransitions(PromotionStatus current)
{
return ValidTransitions.TryGetValue(current, out var targets)
? targets
: [];
}
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Events;
public sealed record PromotionRequested(
Guid PromotionId,
Guid TenantId,
string ReleaseName,
string SourceEnvironment,
string TargetEnvironment,
DateTimeOffset RequestedAt,
Guid RequestedBy
) : IDomainEvent;
public sealed record PromotionSubmitted(
Guid PromotionId,
Guid TenantId,
Guid TargetEnvironmentId,
DateTimeOffset SubmittedAt
) : IDomainEvent;
public sealed record PromotionApproved(
Guid PromotionId,
Guid TenantId,
int ApprovalCount,
DateTimeOffset ApprovedAt
) : IDomainEvent;
public sealed record PromotionRejected(
Guid PromotionId,
Guid TenantId,
Guid RejectedBy,
string Reason,
DateTimeOffset RejectedAt
) : IDomainEvent;
public sealed record PromotionCancelled(
Guid PromotionId,
Guid TenantId,
string? Reason,
DateTimeOffset CancelledAt
) : IDomainEvent;
public sealed record PromotionDeployed(
Guid PromotionId,
Guid TenantId,
Guid DeploymentId,
DateTimeOffset DeployedAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/promotions.md` (partial) | Markdown | API endpoint documentation for promotion requests (create, list, get, cancel) |
---
## Acceptance Criteria
### Code
- [ ] Create promotion request
- [ ] Validate release is finalized
- [ ] Validate environment order
- [ ] Check for freeze window
- [ ] Prevent duplicate active promotions
- [ ] Submit promotion for approval
- [ ] Cancel promotion
- [ ] State machine validates transitions
- [ ] List pending approvals
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Promotion API endpoints documented
- [ ] Create promotion request documented with full schema
- [ ] List/Get/Cancel promotion endpoints documented
- [ ] Promotion state machine referenced
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `RequestPromotion_ValidRequest_Succeeds` | Creation works |
| `RequestPromotion_DraftRelease_Fails` | Draft release rejected |
| `RequestPromotion_FrozenEnvironment_Fails` | Freeze check works |
| `RequestPromotion_DuplicateActive_Fails` | Duplicate check works |
| `SubmitPromotion_ChangesStatus` | Submission works |
| `CancelPromotion_ByRequester_Succeeds` | Cancellation works |
| `StateMachine_ValidTransition_Succeeds` | State transitions |
| `StateMachine_InvalidTransition_Fails` | Invalid transitions blocked |
### Integration Tests
| Test | Description |
|------|-------------|
| `PromotionLifecycle_E2E` | Full promotion flow |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 104_003 Release Manager | Internal | TODO |
| 103_001 Environment CRUD | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IPromotionManager | TODO | |
| PromotionManager | TODO | |
| PromotionValidator | TODO | |
| PromotionStateMachine | TODO | |
| Promotion model | TODO | |
| IPromotionStore | TODO | |
| PromotionStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/promotions.md (partial - promotions) |

View File

@@ -0,0 +1,648 @@
# SPRINT: Approval Gateway
> **Sprint ID:** 106_002
> **Module:** PROMOT
> **Phase:** 6 - Promotion & Gates
> **Status:** TODO
> **Parent:** [106_000_INDEX](SPRINT_20260110_106_000_INDEX_promotion_gates.md)
---
## Overview
Implement the Approval Gateway for managing approval workflows with multi-approver and separation of duties support.
### Objectives
- Process approval/rejection decisions
- Enforce separation of duties (requester != approver)
- Support multi-approver requirements
- Track approval history
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Promotion/
│ ├── Approval/
│ │ ├── IApprovalGateway.cs
│ │ ├── ApprovalGateway.cs
│ │ ├── SeparationOfDutiesEnforcer.cs
│ │ ├── ApprovalEligibilityChecker.cs
│ │ └── ApprovalNotifier.cs
│ ├── Store/
│ │ ├── IApprovalStore.cs
│ │ └── ApprovalStore.cs
│ └── Models/
│ ├── Approval.cs
│ └── ApprovalConfig.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Promotion.Tests/
└── Approval/
```
---
## Architecture Reference
- [Promotion Manager](../modules/release-orchestrator/modules/promotion-manager.md)
---
## Deliverables
### IApprovalGateway Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Approval;
public interface IApprovalGateway
{
Task<ApprovalResult> ApproveAsync(Guid promotionId, ApprovalRequest request, CancellationToken ct = default);
Task<ApprovalResult> RejectAsync(Guid promotionId, RejectionRequest request, CancellationToken ct = default);
Task<ApprovalStatus> GetStatusAsync(Guid promotionId, CancellationToken ct = default);
Task<IReadOnlyList<ApprovalRecord>> GetHistoryAsync(Guid promotionId, CancellationToken ct = default);
Task<IReadOnlyList<EligibleApprover>> GetEligibleApproversAsync(Guid promotionId, CancellationToken ct = default);
Task<bool> CanUserApproveAsync(Guid promotionId, Guid userId, CancellationToken ct = default);
}
public sealed record ApprovalRequest(
string? Comment = null
);
public sealed record RejectionRequest(
string Reason
);
public sealed record ApprovalResult(
bool Success,
ApprovalStatus Status,
string? Message = null
);
public sealed record ApprovalStatus(
int RequiredApprovals,
int CurrentApprovals,
bool IsApproved,
bool IsRejected,
IReadOnlyList<ApprovalRecord> Approvals
);
public sealed record EligibleApprover(
Guid UserId,
string UserName,
string? Email,
bool HasAlreadyDecided,
ApprovalDecision? Decision
);
```
### Approval Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Models;
public sealed record Approval
{
public required Guid Id { get; init; }
public required Guid PromotionId { get; init; }
public required Guid UserId { get; init; }
public required string UserName { get; init; }
public required ApprovalDecision Decision { get; init; }
public string? Comment { get; init; }
public required DateTimeOffset DecidedAt { get; init; }
}
public sealed record ApprovalConfig
{
public required int RequiredApprovals { get; init; }
public required bool RequireSeparationOfDuties { get; init; }
public ImmutableArray<Guid> ApproverUserIds { get; init; } = [];
public ImmutableArray<string> ApproverGroupNames { get; init; } = [];
public TimeSpan? Timeout { get; init; }
public bool AutoApproveOnTimeout { get; init; } = false;
}
```
### ApprovalGateway Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Approval;
public sealed class ApprovalGateway : IApprovalGateway
{
private readonly IPromotionStore _promotionStore;
private readonly IApprovalStore _approvalStore;
private readonly IEnvironmentService _environmentService;
private readonly SeparationOfDutiesEnforcer _sodEnforcer;
private readonly ApprovalEligibilityChecker _eligibilityChecker;
private readonly IPromotionManager _promotionManager;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<ApprovalGateway> _logger;
public async Task<ApprovalResult> ApproveAsync(
Guid promotionId,
ApprovalRequest request,
CancellationToken ct = default)
{
var promotion = await _promotionStore.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
if (promotion.Status != PromotionStatus.AwaitingApproval)
{
return new ApprovalResult(false, await GetStatusAsync(promotionId, ct),
"Promotion is not awaiting approval");
}
// Check eligibility
var canApprove = await CanUserApproveAsync(promotionId, _userContext.UserId, ct);
if (!canApprove)
{
return new ApprovalResult(false, await GetStatusAsync(promotionId, ct),
"User is not eligible to approve this promotion");
}
// Record approval
var approval = new Approval
{
Id = _guidGenerator.NewGuid(),
PromotionId = promotionId,
UserId = _userContext.UserId,
UserName = _userContext.UserName,
Decision = ApprovalDecision.Approved,
Comment = request.Comment,
DecidedAt = _timeProvider.GetUtcNow()
};
await _approvalStore.SaveAsync(approval, ct);
// Update promotion with new approval
var updatedApprovals = promotion.Approvals.Add(new ApprovalRecord(
approval.UserId,
approval.UserName,
approval.Decision,
approval.Comment,
approval.DecidedAt
));
var updatedPromotion = promotion with { Approvals = updatedApprovals };
// Check if we have enough approvals
var config = await GetApprovalConfigAsync(promotion.TargetEnvironmentId, ct);
var approvalCount = updatedApprovals.Count(a => a.Decision == ApprovalDecision.Approved);
if (approvalCount >= config.RequiredApprovals)
{
updatedPromotion = updatedPromotion with
{
Status = PromotionStatus.Approved,
ApprovedAt = _timeProvider.GetUtcNow()
};
await _eventPublisher.PublishAsync(new PromotionApproved(
promotionId,
promotion.TenantId,
approvalCount,
_timeProvider.GetUtcNow()
), ct);
}
await _promotionStore.SaveAsync(updatedPromotion, ct);
_logger.LogInformation(
"User {User} approved promotion {PromotionId} ({Current}/{Required})",
_userContext.UserName,
promotionId,
approvalCount,
config.RequiredApprovals);
return new ApprovalResult(true, await GetStatusAsync(promotionId, ct));
}
public async Task<ApprovalResult> RejectAsync(
Guid promotionId,
RejectionRequest request,
CancellationToken ct = default)
{
var promotion = await _promotionStore.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
if (promotion.Status != PromotionStatus.AwaitingApproval)
{
return new ApprovalResult(false, await GetStatusAsync(promotionId, ct),
"Promotion is not awaiting approval");
}
var canApprove = await CanUserApproveAsync(promotionId, _userContext.UserId, ct);
if (!canApprove)
{
return new ApprovalResult(false, await GetStatusAsync(promotionId, ct),
"User is not eligible to reject this promotion");
}
var approval = new Approval
{
Id = _guidGenerator.NewGuid(),
PromotionId = promotionId,
UserId = _userContext.UserId,
UserName = _userContext.UserName,
Decision = ApprovalDecision.Rejected,
Comment = request.Reason,
DecidedAt = _timeProvider.GetUtcNow()
};
await _approvalStore.SaveAsync(approval, ct);
var updatedApprovals = promotion.Approvals.Add(new ApprovalRecord(
approval.UserId,
approval.UserName,
approval.Decision,
approval.Comment,
approval.DecidedAt
));
var updatedPromotion = promotion with
{
Status = PromotionStatus.Rejected,
RejectionReason = request.Reason,
Approvals = updatedApprovals,
CompletedAt = _timeProvider.GetUtcNow()
};
await _promotionStore.SaveAsync(updatedPromotion, ct);
await _eventPublisher.PublishAsync(new PromotionRejected(
promotionId,
promotion.TenantId,
_userContext.UserId,
request.Reason,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"User {User} rejected promotion {PromotionId}: {Reason}",
_userContext.UserName,
promotionId,
request.Reason);
return new ApprovalResult(true, await GetStatusAsync(promotionId, ct));
}
public async Task<bool> CanUserApproveAsync(
Guid promotionId,
Guid userId,
CancellationToken ct = default)
{
var promotion = await _promotionStore.GetAsync(promotionId, ct);
if (promotion is null)
return false;
// Check separation of duties
var config = await GetApprovalConfigAsync(promotion.TargetEnvironmentId, ct);
if (config.RequireSeparationOfDuties && promotion.RequestedBy == userId)
{
return false;
}
// Check if user already approved/rejected
if (promotion.Approvals.Any(a => a.UserId == userId))
{
return false;
}
// Check if user is in approvers list
return await _eligibilityChecker.IsEligibleAsync(
userId, config.ApproverUserIds, config.ApproverGroupNames, ct);
}
private async Task<ApprovalConfig> GetApprovalConfigAsync(
Guid environmentId,
CancellationToken ct)
{
var environment = await _environmentService.GetAsync(environmentId, ct)
?? throw new EnvironmentNotFoundException(environmentId);
return new ApprovalConfig
{
RequiredApprovals = environment.RequiredApprovals,
RequireSeparationOfDuties = environment.RequireSeparationOfDuties,
// ApproverUserIds and ApproverGroupNames from environment config
};
}
}
```
### SeparationOfDutiesEnforcer
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Approval;
public sealed class SeparationOfDutiesEnforcer
{
private readonly ILogger<SeparationOfDutiesEnforcer> _logger;
public ValidationResult Validate(
Promotion promotion,
Guid approvingUserId,
ApprovalConfig config)
{
if (!config.RequireSeparationOfDuties)
{
return ValidationResult.Success();
}
var errors = new List<string>();
// Requester cannot approve their own promotion
if (promotion.RequestedBy == approvingUserId)
{
errors.Add("Separation of duties: requester cannot approve their own promotion");
}
// Check previous approvals don't include this user
if (promotion.Approvals.Any(a => a.UserId == approvingUserId))
{
errors.Add("User has already provided an approval decision");
}
if (errors.Count > 0)
{
_logger.LogWarning(
"Separation of duties violation for promotion {PromotionId} by user {UserId}: {Errors}",
promotion.Id,
approvingUserId,
string.Join("; ", errors));
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
}
```
### ApprovalEligibilityChecker
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Approval;
public sealed class ApprovalEligibilityChecker
{
private readonly IUserService _userService;
private readonly IGroupService _groupService;
public async Task<bool> IsEligibleAsync(
Guid userId,
ImmutableArray<Guid> approverUserIds,
ImmutableArray<string> approverGroupNames,
CancellationToken ct = default)
{
// If no specific approvers configured, any authenticated user can approve
if (approverUserIds.Length == 0 && approverGroupNames.Length == 0)
{
return true;
}
// Check if user is directly in approvers list
if (approverUserIds.Contains(userId))
{
return true;
}
// Check if user is in any approver group
if (approverGroupNames.Length > 0)
{
var userGroups = await _groupService.GetUserGroupsAsync(userId, ct);
if (userGroups.Any(g => approverGroupNames.Contains(g.Name)))
{
return true;
}
}
return false;
}
public async Task<IReadOnlyList<EligibleApprover>> GetEligibleApproversAsync(
Guid promotionId,
ApprovalConfig config,
ImmutableArray<ApprovalRecord> existingApprovals,
CancellationToken ct = default)
{
var eligibleUsers = new List<EligibleApprover>();
// Get users from direct list
foreach (var userId in config.ApproverUserIds)
{
var user = await _userService.GetAsync(userId, ct);
if (user is not null)
{
var existingApproval = existingApprovals.FirstOrDefault(a => a.UserId == userId);
eligibleUsers.Add(new EligibleApprover(
userId,
user.Name,
user.Email,
existingApproval is not null,
existingApproval?.Decision
));
}
}
// Get users from groups
foreach (var groupName in config.ApproverGroupNames)
{
var groupMembers = await _groupService.GetMembersAsync(groupName, ct);
foreach (var member in groupMembers)
{
if (!eligibleUsers.Any(u => u.UserId == member.Id))
{
var existingApproval = existingApprovals.FirstOrDefault(a => a.UserId == member.Id);
eligibleUsers.Add(new EligibleApprover(
member.Id,
member.Name,
member.Email,
existingApproval is not null,
existingApproval?.Decision
));
}
}
}
return eligibleUsers.AsReadOnly();
}
}
```
### ApprovalNotifier
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Approval;
public sealed class ApprovalNotifier
{
private readonly INotificationService _notificationService;
private readonly ApprovalEligibilityChecker _eligibilityChecker;
private readonly ILogger<ApprovalNotifier> _logger;
public async Task NotifyApprovalRequestedAsync(
Promotion promotion,
ApprovalConfig config,
CancellationToken ct = default)
{
var eligibleApprovers = await _eligibilityChecker.GetEligibleApproversAsync(
promotion.Id, config, promotion.Approvals, ct);
var pendingApprovers = eligibleApprovers
.Where(a => !a.HasAlreadyDecided)
.ToList();
if (pendingApprovers.Count == 0)
{
_logger.LogWarning(
"No eligible approvers found for promotion {PromotionId}",
promotion.Id);
return;
}
var notification = new NotificationRequest
{
Channel = "email",
Title = $"Approval Required: {promotion.ReleaseName} to {promotion.TargetEnvironmentName}",
Message = BuildApprovalMessage(promotion),
Recipients = pendingApprovers.Where(a => a.Email is not null).Select(a => a.Email!).ToList(),
Metadata = new Dictionary<string, string>
{
["promotionId"] = promotion.Id.ToString(),
["releaseId"] = promotion.ReleaseId.ToString(),
["targetEnvironment"] = promotion.TargetEnvironmentName
}
};
await _notificationService.SendAsync(notification, ct);
_logger.LogInformation(
"Sent approval notification for promotion {PromotionId} to {Count} approvers",
promotion.Id,
pendingApprovers.Count);
}
private static string BuildApprovalMessage(Promotion promotion) =>
$"Release '{promotion.ReleaseName}' is requesting promotion from " +
$"{promotion.SourceEnvironmentName} to {promotion.TargetEnvironmentName}.\n\n" +
$"Requested by: {promotion.RequestedByName}\n" +
$"Reason: {promotion.Reason ?? "No reason provided"}\n\n" +
$"Please review and approve or reject this promotion.";
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Events;
public sealed record ApprovalDecisionRecorded(
Guid PromotionId,
Guid TenantId,
Guid UserId,
string UserName,
ApprovalDecision Decision,
DateTimeOffset DecidedAt
) : IDomainEvent;
public sealed record ApprovalThresholdMet(
Guid PromotionId,
Guid TenantId,
int ApprovalCount,
int RequiredApprovals,
DateTimeOffset MetAt
) : IDomainEvent;
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/promotions.md` (partial) | Markdown | API endpoint documentation for approvals (approve, reject, SoD enforcement) |
---
## Acceptance Criteria
### Code
- [ ] Approve promotion with comment
- [ ] Reject promotion with reason
- [ ] Enforce separation of duties
- [ ] Support multi-approver requirements
- [ ] Check user eligibility
- [ ] List eligible approvers
- [ ] Track approval history
- [ ] Notify approvers on request
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Approval API endpoints documented
- [ ] Approve promotion endpoint documented (POST /api/v1/promotions/{id}/approve)
- [ ] Reject promotion endpoint documented
- [ ] Separation of duties rules explained
- [ ] Approval record schema included
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `Approve_ValidUser_Succeeds` | Approval works |
| `Approve_Requester_FailsSoD` | SoD enforcement |
| `Approve_AlreadyDecided_Fails` | Duplicate check |
| `Approve_ThresholdMet_ApprovesPromotion` | Threshold logic |
| `Reject_SetsStatusRejected` | Rejection works |
| `CanUserApprove_InGroup_ReturnsTrue` | Group membership |
| `GetEligibleApprovers_ReturnsCorrectList` | Eligibility list |
### Integration Tests
| Test | Description |
|------|-------------|
| `ApprovalWorkflow_E2E` | Full approval flow |
| `MultiApprover_E2E` | Multi-approver scenario |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 106_001 Promotion Manager | Internal | TODO |
| Authority | Internal | Exists |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IApprovalGateway | TODO | |
| ApprovalGateway | TODO | |
| SeparationOfDutiesEnforcer | TODO | |
| ApprovalEligibilityChecker | TODO | |
| ApprovalNotifier | TODO | |
| Approval model | TODO | |
| IApprovalStore | TODO | |
| ApprovalStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: api/promotions.md (partial - approvals) |

View File

@@ -0,0 +1,727 @@
# SPRINT: Gate Registry
> **Sprint ID:** 106_003
> **Module:** PROMOT
> **Phase:** 6 - Promotion & Gates
> **Status:** TODO
> **Parent:** [106_000_INDEX](SPRINT_20260110_106_000_INDEX_promotion_gates.md)
---
## Overview
Implement the Gate Registry for managing built-in and plugin promotion gates.
### Objectives
- Register built-in gate types (8 types total)
- Load plugin gate types via `IGateProviderCapability`
- Execute gates in promotion context
- Track gate evaluation results
### Gate Type Catalog
The Release Orchestrator supports 8 built-in promotion gates. All gates implement `IGateProvider`.
| Gate Type | Category | Blocking | Sprint | Description |
|-----------|----------|----------|--------|-------------|
| `security-gate` | Security | Yes | 106_004 | Blocks if vulnerabilities exceed thresholds |
| `policy-gate` | Compliance | Yes | 106_003 | Evaluates policy rules (OPA/Rego) |
| `freeze-window-gate` | Operational | Yes | 106_003 | Blocks during freeze windows |
| `manual-gate` | Operational | Yes | 106_003 | Requires manual confirmation |
| `approval-gate` | Compliance | Yes | 106_003 | Requires multi-party approval (N of M) |
| `schedule-gate` | Operational | Yes | 106_003 | Deployment window restrictions |
| `dependency-gate` | Quality | No | 106_003 | Checks upstream dependencies are healthy |
| `metric-gate` | Quality | Configurable | Plugin | SLO/error rate threshold checks |
> **Note:** `metric-gate` is delivered as a plugin reference implementation via the Plugin SDK because it requires integration with external metrics systems (Prometheus, DataDog, etc.). See 101_004 for plugin SDK details.
### Gate Categories
- **Security:** Gates that block based on security findings (vulnerabilities, compliance)
- **Compliance:** Gates that enforce organizational policies and approvals
- **Quality:** Gates that check service health and dependencies
- **Operational:** Gates that manage deployment timing and manual interventions
- **Custom:** User-defined plugin gates
### This Sprint's Scope
This sprint (106_003) implements the Gate Registry and the following built-in gates:
- `freeze-window-gate` (blocking)
- `manual-gate` (blocking)
- `policy-gate` (blocking)
- `approval-gate` (blocking)
- `schedule-gate` (blocking)
- `dependency-gate` (non-blocking)
> **Note:** `security-gate` is detailed in sprint 106_004.
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Promotion/
│ ├── Gate/
│ │ ├── IGateRegistry.cs
│ │ ├── GateRegistry.cs
│ │ ├── IGateProvider.cs
│ │ ├── GateEvaluator.cs
│ │ └── GateContext.cs
│ ├── Gate.BuiltIn/
│ │ ├── FreezeWindowGate.cs
│ │ ├── ManualGate.cs
│ │ ├── PolicyGate.cs
│ │ ├── ApprovalGate.cs
│ │ ├── ScheduleGate.cs
│ │ └── DependencyGate.cs
│ └── Models/
│ ├── GateDefinition.cs
│ ├── GateResult.cs
│ └── GateConfig.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Promotion.Tests/
└── Gate/
```
---
## Architecture Reference
- [Promotion Manager](../modules/release-orchestrator/modules/promotion-manager.md)
- [Plugin System](../modules/release-orchestrator/plugins/gate-plugins.md)
---
## Deliverables
### IGateRegistry Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate;
public interface IGateRegistry
{
void RegisterBuiltIn<T>(string gateName) where T : class, IGateProvider;
void RegisterPlugin(GateDefinition definition, IGateProvider provider);
Task<IGateProvider?> GetProviderAsync(string gateName, CancellationToken ct = default);
GateDefinition? GetDefinition(string gateName);
IReadOnlyList<GateDefinition> GetAllDefinitions();
IReadOnlyList<GateDefinition> GetBuiltInDefinitions();
IReadOnlyList<GateDefinition> GetPluginDefinitions();
bool IsRegistered(string gateName);
}
public interface IGateProvider
{
string GateName { get; }
string DisplayName { get; }
string Description { get; }
GateConfigSchema ConfigSchema { get; }
bool IsBlocking { get; }
Task<GateResult> EvaluateAsync(GateContext context, CancellationToken ct = default);
Task<ValidationResult> ValidateConfigAsync(IReadOnlyDictionary<string, object> config, CancellationToken ct = default);
}
```
### GateDefinition Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Models;
public sealed record GateDefinition
{
public required string GateName { get; init; }
public required string DisplayName { get; init; }
public required string Description { get; init; }
public required GateCategory Category { get; init; }
public required GateSource Source { get; init; }
public string? PluginId { get; init; }
public required GateConfigSchema ConfigSchema { get; init; }
public required bool IsBlocking { get; init; }
public string? DocumentationUrl { get; init; }
}
public enum GateCategory
{
Security,
Compliance,
Quality,
Operational,
Custom
}
public enum GateSource
{
BuiltIn,
Plugin
}
public sealed record GateConfigSchema
{
public ImmutableArray<GateConfigProperty> Properties { get; init; } = [];
public ImmutableArray<string> Required { get; init; } = [];
}
public sealed record GateConfigProperty(
string Name,
GatePropertyType Type,
string Description,
object? Default = null
);
public enum GatePropertyType
{
String,
Integer,
Boolean,
Array,
Object
}
```
### GateResult Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Models;
public sealed record GateResult
{
public required string GateName { get; init; }
public required string GateType { get; init; }
public required bool Passed { get; init; }
public required bool Blocking { get; init; }
public string? Message { get; init; }
public ImmutableDictionary<string, object> Details { get; init; } =
ImmutableDictionary<string, object>.Empty;
public required DateTimeOffset EvaluatedAt { get; init; }
public TimeSpan Duration { get; init; }
public static GateResult Pass(
string gateName,
string gateType,
string? message = null,
ImmutableDictionary<string, object>? details = null) =>
new()
{
GateName = gateName,
GateType = gateType,
Passed = true,
Blocking = false,
Message = message,
Details = details ?? ImmutableDictionary<string, object>.Empty,
EvaluatedAt = TimeProvider.System.GetUtcNow()
};
public static GateResult Fail(
string gateName,
string gateType,
string message,
bool blocking = true,
ImmutableDictionary<string, object>? details = null) =>
new()
{
GateName = gateName,
GateType = gateType,
Passed = false,
Blocking = blocking,
Message = message,
Details = details ?? ImmutableDictionary<string, object>.Empty,
EvaluatedAt = TimeProvider.System.GetUtcNow()
};
}
```
### GateContext Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate;
public sealed record GateContext
{
public required Guid PromotionId { get; init; }
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required Guid SourceEnvironmentId { get; init; }
public required Guid TargetEnvironmentId { get; init; }
public required string TargetEnvironmentName { get; init; }
public required ImmutableDictionary<string, object> Config { get; init; }
public required Guid RequestedBy { get; init; }
public required DateTimeOffset RequestedAt { get; init; }
}
```
### GateRegistry Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate;
public sealed class GateRegistry : IGateRegistry
{
private readonly ConcurrentDictionary<string, (GateDefinition Definition, IGateProvider Provider)> _gates = new();
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<GateRegistry> _logger;
public void RegisterBuiltIn<T>(string gateName) where T : class, IGateProvider
{
var provider = _serviceProvider.GetRequiredService<T>();
var definition = new GateDefinition
{
GateName = gateName,
DisplayName = provider.DisplayName,
Description = provider.Description,
Category = InferCategory(gateName),
Source = GateSource.BuiltIn,
ConfigSchema = provider.ConfigSchema,
IsBlocking = provider.IsBlocking
};
if (!_gates.TryAdd(gateName, (definition, provider)))
{
throw new InvalidOperationException($"Gate '{gateName}' is already registered");
}
_logger.LogInformation("Registered built-in gate: {GateName}", gateName);
}
public void RegisterPlugin(GateDefinition definition, IGateProvider provider)
{
if (definition.Source != GateSource.Plugin)
{
throw new ArgumentException("Definition must have Plugin source");
}
if (!_gates.TryAdd(definition.GateName, (definition, provider)))
{
throw new InvalidOperationException($"Gate '{definition.GateName}' is already registered");
}
_logger.LogInformation(
"Registered plugin gate: {GateName} from {PluginId}",
definition.GateName,
definition.PluginId);
}
public Task<IGateProvider?> GetProviderAsync(string gateName, CancellationToken ct = default)
{
return _gates.TryGetValue(gateName, out var entry)
? Task.FromResult<IGateProvider?>(entry.Provider)
: Task.FromResult<IGateProvider?>(null);
}
public GateDefinition? GetDefinition(string gateName)
{
return _gates.TryGetValue(gateName, out var entry)
? entry.Definition
: null;
}
public IReadOnlyList<GateDefinition> GetAllDefinitions()
{
return _gates.Values.Select(e => e.Definition).ToList().AsReadOnly();
}
public IReadOnlyList<GateDefinition> GetBuiltInDefinitions()
{
return _gates.Values
.Where(e => e.Definition.Source == GateSource.BuiltIn)
.Select(e => e.Definition)
.ToList()
.AsReadOnly();
}
public IReadOnlyList<GateDefinition> GetPluginDefinitions()
{
return _gates.Values
.Where(e => e.Definition.Source == GateSource.Plugin)
.Select(e => e.Definition)
.ToList()
.AsReadOnly();
}
public bool IsRegistered(string gateName) => _gates.ContainsKey(gateName);
private static GateCategory InferCategory(string gateName) =>
gateName switch
{
"security-gate" => GateCategory.Security,
"freeze-window-gate" => GateCategory.Operational,
"policy-gate" => GateCategory.Compliance,
"manual-gate" => GateCategory.Operational,
_ => GateCategory.Custom
};
}
```
### GateEvaluator
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate;
public sealed class GateEvaluator
{
private readonly IGateRegistry _registry;
private readonly ILogger<GateEvaluator> _logger;
private readonly TimeProvider _timeProvider;
public async Task<GateResult> EvaluateAsync(
string gateName,
GateContext context,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
var provider = await _registry.GetProviderAsync(gateName, ct);
if (provider is null)
{
return GateResult.Fail(
gateName,
"unknown",
$"Unknown gate type: {gateName}",
blocking: true);
}
try
{
_logger.LogDebug(
"Evaluating gate {GateName} for promotion {PromotionId}",
gateName,
context.PromotionId);
var result = await provider.EvaluateAsync(context, ct);
result = result with { Duration = sw.Elapsed };
_logger.LogInformation(
"Gate {GateName} for promotion {PromotionId}: {Result} in {Duration}ms",
gateName,
context.PromotionId,
result.Passed ? "PASSED" : "FAILED",
sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Gate {GateName} evaluation failed for promotion {PromotionId}",
gateName,
context.PromotionId);
return GateResult.Fail(
gateName,
provider.GateName,
$"Gate evaluation failed: {ex.Message}",
blocking: provider.IsBlocking);
}
}
public async Task<IReadOnlyList<GateResult>> EvaluateAllAsync(
IReadOnlyList<string> gateNames,
GateContext context,
CancellationToken ct = default)
{
var tasks = gateNames.Select(name => EvaluateAsync(name, context, ct));
var results = await Task.WhenAll(tasks);
return results.ToList().AsReadOnly();
}
}
```
### FreezeWindowGate (Built-in)
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.BuiltIn;
public sealed class FreezeWindowGate : IGateProvider
{
private readonly IFreezeWindowService _freezeWindowService;
public string GateName => "freeze-window-gate";
public string DisplayName => "Freeze Window Gate";
public string Description => "Blocks promotion during freeze windows";
public bool IsBlocking => true;
public GateConfigSchema ConfigSchema => new()
{
Properties =
[
new GateConfigProperty(
"allowExemptions",
GatePropertyType.Boolean,
"Allow exemptions to bypass freeze",
Default: true)
]
};
public async Task<GateResult> EvaluateAsync(GateContext context, CancellationToken ct)
{
var activeFreezeWindow = await _freezeWindowService.GetActiveFreezeWindowAsync(
context.TargetEnvironmentId, ct);
if (activeFreezeWindow is null)
{
return GateResult.Pass(
GateName,
GateName,
"No active freeze window");
}
// Check for exemption
var allowExemptions = context.Config.GetValueOrDefault("allowExemptions") as bool? ?? true;
if (allowExemptions)
{
var hasExemption = await _freezeWindowService.HasExemptionAsync(
activeFreezeWindow.Id, context.RequestedBy, ct);
if (hasExemption)
{
return GateResult.Pass(
GateName,
GateName,
"Freeze window active but user has exemption",
new Dictionary<string, object>
{
["freezeWindowId"] = activeFreezeWindow.Id,
["exemptionGranted"] = true
}.ToImmutableDictionary());
}
}
return GateResult.Fail(
GateName,
GateName,
$"Environment is frozen: {activeFreezeWindow.Name}",
blocking: true,
new Dictionary<string, object>
{
["freezeWindowId"] = activeFreezeWindow.Id,
["freezeWindowName"] = activeFreezeWindow.Name,
["endsAt"] = activeFreezeWindow.EndAt.ToString("O")
}.ToImmutableDictionary());
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ValidationResult.Success());
}
```
### ManualGate (Built-in)
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.BuiltIn;
public sealed class ManualGate : IGateProvider
{
private readonly IPromotionStore _promotionStore;
private readonly IStepCallbackHandler _callbackHandler;
public string GateName => "manual-gate";
public string DisplayName => "Manual Gate";
public string Description => "Requires manual confirmation to proceed";
public bool IsBlocking => true;
public GateConfigSchema ConfigSchema => new()
{
Properties =
[
new GateConfigProperty(
"message",
GatePropertyType.String,
"Message to display for manual confirmation"),
new GateConfigProperty(
"confirmers",
GatePropertyType.Array,
"User IDs or group names who can confirm"),
new GateConfigProperty(
"timeout",
GatePropertyType.Integer,
"Timeout in seconds",
Default: 86400)
],
Required = ["message"]
};
public async Task<GateResult> EvaluateAsync(GateContext context, CancellationToken ct)
{
var message = context.Config.GetValueOrDefault("message")?.ToString() ?? "Manual confirmation required";
var timeoutSeconds = context.Config.GetValueOrDefault("timeout") as int? ?? 86400;
// Create callback for manual confirmation
var callback = await _callbackHandler.CreateCallbackAsync(
context.PromotionId,
"manual-gate",
TimeSpan.FromSeconds(timeoutSeconds),
ct);
// Return a result indicating we're waiting
return new GateResult
{
GateName = GateName,
GateType = GateName,
Passed = false,
Blocking = true,
Message = message,
Details = new Dictionary<string, object>
{
["callbackToken"] = callback.Token,
["expiresAt"] = callback.ExpiresAt.ToString("O"),
["waitingForConfirmation"] = true
}.ToImmutableDictionary(),
EvaluatedAt = TimeProvider.System.GetUtcNow()
};
}
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct) =>
Task.FromResult(ValidationResult.Success());
}
```
### GateRegistryInitializer
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate;
public sealed class GateRegistryInitializer : IHostedService
{
private readonly IGateRegistry _registry;
private readonly IPluginLoader _pluginLoader;
private readonly ILogger<GateRegistryInitializer> _logger;
public async Task StartAsync(CancellationToken ct)
{
_logger.LogInformation("Initializing gate registry");
// Register built-in gates (6 gates in this sprint, security-gate in 106_004)
_registry.RegisterBuiltIn<FreezeWindowGate>("freeze-window-gate");
_registry.RegisterBuiltIn<ManualGate>("manual-gate");
_registry.RegisterBuiltIn<PolicyGate>("policy-gate");
_registry.RegisterBuiltIn<ApprovalGate>("approval-gate");
_registry.RegisterBuiltIn<ScheduleGate>("schedule-gate");
_registry.RegisterBuiltIn<DependencyGate>("dependency-gate");
_logger.LogInformation(
"Registered {Count} built-in gates",
_registry.GetBuiltInDefinitions().Count);
// Load plugin gates
var plugins = await _pluginLoader.GetPluginsAsync<IGatePlugin>(ct);
foreach (var plugin in plugins)
{
try
{
var providers = plugin.Instance.GetGateProviders();
foreach (var provider in providers)
{
var definition = new GateDefinition
{
GateName = provider.GateName,
DisplayName = provider.DisplayName,
Description = provider.Description,
Category = GateCategory.Custom,
Source = GateSource.Plugin,
PluginId = plugin.Manifest.Id,
ConfigSchema = provider.ConfigSchema,
IsBlocking = provider.IsBlocking
};
_registry.RegisterPlugin(definition, provider);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to load gate plugin {PluginId}",
plugin.Manifest.Id);
}
}
_logger.LogInformation(
"Loaded {Count} plugin gates",
_registry.GetPluginDefinitions().Count);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
```
---
## Acceptance Criteria
- [ ] Register built-in gate types
- [ ] Load plugin gate types
- [ ] Evaluate individual gate
- [ ] Evaluate all gates for promotion
- [ ] Freeze window gate blocks during freeze
- [ ] Manual gate waits for confirmation
- [ ] Track gate results
- [ ] Validate gate configuration
- [ ] Unit test coverage >=85%
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `RegisterBuiltIn_AddsGate` | Registration works |
| `RegisterPlugin_AddsGate` | Plugin registration works |
| `GetProvider_ReturnsProvider` | Lookup works |
| `EvaluateGate_ReturnsResult` | Evaluation works |
| `FreezeWindowGate_ActiveFreeze_Fails` | Freeze gate logic |
| `FreezeWindowGate_NoFreeze_Passes` | No freeze passes |
| `ManualGate_CreatesCallback` | Manual gate logic |
### Integration Tests
| Test | Description |
|------|-------------|
| `GateRegistryInit_E2E` | Full initialization |
| `PluginGateLoading_E2E` | Plugin gate loading |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 106_001 Promotion Manager | Internal | TODO |
| 101_002 Plugin Registry | Internal | TODO |
| 103_001 Environment (Freeze Windows) | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IGateRegistry | TODO | |
| GateRegistry | TODO | |
| IGateProvider | TODO | |
| GateEvaluator | TODO | |
| GateContext | TODO | |
| FreezeWindowGate | TODO | Blocks during freeze windows |
| ManualGate | TODO | Manual confirmation |
| PolicyGate | TODO | OPA/Rego policy evaluation |
| ApprovalGate | TODO | Multi-party approval (N of M) |
| ScheduleGate | TODO | Deployment window restrictions |
| DependencyGate | TODO | Upstream dependency checks |
| GateRegistryInitializer | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 10-Jan-2026 | Added Gate Type Catalog (8 types) with categories and sprint assignments |

View File

@@ -0,0 +1,576 @@
# SPRINT: Security Gate
> **Sprint ID:** 106_004
> **Module:** PROMOT
> **Phase:** 6 - Promotion & Gates
> **Status:** TODO
> **Parent:** [106_000_INDEX](SPRINT_20260110_106_000_INDEX_promotion_gates.md)
---
## Overview
Implement the Security Gate for blocking promotions based on vulnerability thresholds.
### Objectives
- Check vulnerability counts against thresholds
- Support severity-based limits (critical, high, medium)
- Require SBOM presence
- Integrate with Scanner service
- Support VEX-based exceptions
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Promotion/
│ └── Gate.Security/
│ ├── SecurityGate.cs
│ ├── SecurityGateConfig.cs
│ ├── VulnerabilityCounter.cs
│ ├── VexExceptionChecker.cs
│ └── SbomRequirementChecker.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Promotion.Tests/
└── Gate.Security/
```
---
## Architecture Reference
- [Security Gate](../modules/release-orchestrator/modules/gates/security-gate.md)
- [Scanner Integration](../modules/scanner/integration.md)
---
## Deliverables
### SecurityGate
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
public sealed class SecurityGate : IGateProvider
{
private readonly IReleaseManager _releaseManager;
private readonly IScannerService _scannerService;
private readonly VulnerabilityCounter _vulnCounter;
private readonly VexExceptionChecker _vexChecker;
private readonly SbomRequirementChecker _sbomChecker;
private readonly ILogger<SecurityGate> _logger;
public string GateName => "security-gate";
public string DisplayName => "Security Gate";
public string Description => "Enforces vulnerability thresholds for release promotion";
public bool IsBlocking => true;
public GateConfigSchema ConfigSchema => new()
{
Properties =
[
new GateConfigProperty("maxCritical", GatePropertyType.Integer, "Maximum critical vulnerabilities allowed", Default: 0),
new GateConfigProperty("maxHigh", GatePropertyType.Integer, "Maximum high vulnerabilities allowed", Default: 5),
new GateConfigProperty("maxMedium", GatePropertyType.Integer, "Maximum medium vulnerabilities allowed (null = unlimited)"),
new GateConfigProperty("maxLow", GatePropertyType.Integer, "Maximum low vulnerabilities allowed (null = unlimited)"),
new GateConfigProperty("requireSbom", GatePropertyType.Boolean, "Require SBOM for all components", Default: true),
new GateConfigProperty("maxScanAge", GatePropertyType.Integer, "Maximum scan age in hours", Default: 24),
new GateConfigProperty("applyVexExceptions", GatePropertyType.Boolean, "Apply VEX exceptions to counts", Default: true),
new GateConfigProperty("blockOnKnownExploited", GatePropertyType.Boolean, "Block on KEV vulnerabilities", Default: true)
]
};
public async Task<GateResult> EvaluateAsync(GateContext context, CancellationToken ct)
{
var config = ParseConfig(context.Config);
var release = await _releaseManager.GetAsync(context.ReleaseId, ct)
?? throw new ReleaseNotFoundException(context.ReleaseId);
var violations = new List<string>();
var details = new Dictionary<string, object>();
var totalVulns = new VulnerabilityCounts();
foreach (var component in release.Components)
{
// Check SBOM requirement
if (config.RequireSbom)
{
var hasSbom = await _sbomChecker.HasSbomAsync(component.Digest, ct);
if (!hasSbom)
{
violations.Add($"Component {component.ComponentName} has no SBOM");
}
}
// Get scan results
var scan = await _scannerService.GetLatestScanAsync(component.Digest, ct);
if (scan is null)
{
if (config.RequireSbom)
{
violations.Add($"Component {component.ComponentName} has no security scan");
}
continue;
}
// Check scan age
var scanAge = TimeProvider.System.GetUtcNow() - scan.CompletedAt;
if (scanAge.TotalHours > config.MaxScanAgeHours)
{
violations.Add($"Component {component.ComponentName} scan is too old ({scanAge.TotalHours:F1}h)");
}
// Count vulnerabilities
var vulnCounts = await _vulnCounter.CountAsync(
scan,
config.ApplyVexExceptions ? component.Digest : null,
ct);
totalVulns = totalVulns.Add(vulnCounts);
// Check for known exploited vulnerabilities
if (config.BlockOnKnownExploited && vulnCounts.KnownExploitedCount > 0)
{
violations.Add(
$"Component {component.ComponentName} has {vulnCounts.KnownExploitedCount} known exploited vulnerabilities");
}
details[$"component_{component.ComponentName}"] = new Dictionary<string, object>
{
["critical"] = vulnCounts.Critical,
["high"] = vulnCounts.High,
["medium"] = vulnCounts.Medium,
["low"] = vulnCounts.Low,
["knownExploited"] = vulnCounts.KnownExploitedCount,
["scanAge"] = scanAge.TotalHours
};
}
// Check thresholds
if (totalVulns.Critical > config.MaxCritical)
{
violations.Add($"Critical vulnerabilities ({totalVulns.Critical}) exceed threshold ({config.MaxCritical})");
}
if (totalVulns.High > config.MaxHigh)
{
violations.Add($"High vulnerabilities ({totalVulns.High}) exceed threshold ({config.MaxHigh})");
}
if (config.MaxMedium.HasValue && totalVulns.Medium > config.MaxMedium.Value)
{
violations.Add($"Medium vulnerabilities ({totalVulns.Medium}) exceed threshold ({config.MaxMedium})");
}
if (config.MaxLow.HasValue && totalVulns.Low > config.MaxLow.Value)
{
violations.Add($"Low vulnerabilities ({totalVulns.Low}) exceed threshold ({config.MaxLow})");
}
details["totals"] = new Dictionary<string, object>
{
["critical"] = totalVulns.Critical,
["high"] = totalVulns.High,
["medium"] = totalVulns.Medium,
["low"] = totalVulns.Low,
["knownExploited"] = totalVulns.KnownExploitedCount,
["componentsScanned"] = release.Components.Length
};
details["thresholds"] = new Dictionary<string, object>
{
["maxCritical"] = config.MaxCritical,
["maxHigh"] = config.MaxHigh,
["maxMedium"] = config.MaxMedium ?? -1,
["maxLow"] = config.MaxLow ?? -1
};
if (violations.Count > 0)
{
_logger.LogWarning(
"Security gate failed for release {ReleaseId}: {Violations}",
context.ReleaseId,
string.Join("; ", violations));
return GateResult.Fail(
GateName,
GateName,
string.Join("; ", violations),
blocking: true,
details.ToImmutableDictionary());
}
_logger.LogInformation(
"Security gate passed for release {ReleaseId}: {Critical}C/{High}H/{Medium}M/{Low}L",
context.ReleaseId,
totalVulns.Critical,
totalVulns.High,
totalVulns.Medium,
totalVulns.Low);
return GateResult.Pass(
GateName,
GateName,
$"All security thresholds met",
details.ToImmutableDictionary());
}
private static SecurityGateConfig ParseConfig(ImmutableDictionary<string, object> config) =>
new()
{
MaxCritical = config.GetValueOrDefault("maxCritical") as int? ?? 0,
MaxHigh = config.GetValueOrDefault("maxHigh") as int? ?? 5,
MaxMedium = config.GetValueOrDefault("maxMedium") as int?,
MaxLow = config.GetValueOrDefault("maxLow") as int?,
RequireSbom = config.GetValueOrDefault("requireSbom") as bool? ?? true,
MaxScanAgeHours = config.GetValueOrDefault("maxScanAge") as int? ?? 24,
ApplyVexExceptions = config.GetValueOrDefault("applyVexExceptions") as bool? ?? true,
BlockOnKnownExploited = config.GetValueOrDefault("blockOnKnownExploited") as bool? ?? true
};
public Task<ValidationResult> ValidateConfigAsync(
IReadOnlyDictionary<string, object> config,
CancellationToken ct)
{
var errors = new List<string>();
if (config.TryGetValue("maxCritical", out var maxCritical) &&
maxCritical is int mc && mc < 0)
{
errors.Add("maxCritical cannot be negative");
}
if (config.TryGetValue("maxScanAge", out var maxScanAge) &&
maxScanAge is int msa && msa < 1)
{
errors.Add("maxScanAge must be at least 1 hour");
}
return Task.FromResult(errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors));
}
}
```
### SecurityGateConfig
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
public sealed record SecurityGateConfig
{
public int MaxCritical { get; init; } = 0;
public int MaxHigh { get; init; } = 5;
public int? MaxMedium { get; init; }
public int? MaxLow { get; init; }
public bool RequireSbom { get; init; } = true;
public int MaxScanAgeHours { get; init; } = 24;
public bool ApplyVexExceptions { get; init; } = true;
public bool BlockOnKnownExploited { get; init; } = true;
}
```
### VulnerabilityCounter
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
public sealed class VulnerabilityCounter
{
private readonly IVexService _vexService;
private readonly IKevService _kevService;
public async Task<VulnerabilityCounts> CountAsync(
ScanResult scan,
string? digestForVex = null,
CancellationToken ct = default)
{
var counts = new VulnerabilityCounts
{
Critical = scan.CriticalCount,
High = scan.HighCount,
Medium = scan.MediumCount,
Low = scan.LowCount
};
// Count known exploited
var kevVulns = await _kevService.GetKevVulnerabilitiesAsync(
scan.Vulnerabilities.Select(v => v.CveId), ct);
counts = counts with { KnownExploitedCount = kevVulns.Count };
// Apply VEX exceptions if requested
if (digestForVex is not null)
{
var vexDocs = await _vexService.GetVexForDigestAsync(digestForVex, ct);
counts = ApplyVexExceptions(counts, scan.Vulnerabilities, vexDocs);
}
return counts;
}
private static VulnerabilityCounts ApplyVexExceptions(
VulnerabilityCounts counts,
IReadOnlyList<Vulnerability> vulnerabilities,
IReadOnlyList<VexDocument> vexDocs)
{
var exceptedCves = vexDocs
.SelectMany(v => v.Statements)
.Where(s => s.Status == VexStatus.NotAffected || s.Status == VexStatus.Fixed)
.Select(s => s.VulnerabilityId)
.ToHashSet();
var adjustedCounts = counts;
foreach (var vuln in vulnerabilities)
{
if (exceptedCves.Contains(vuln.CveId))
{
adjustedCounts = vuln.Severity switch
{
VulnerabilitySeverity.Critical => adjustedCounts with { Critical = adjustedCounts.Critical - 1 },
VulnerabilitySeverity.High => adjustedCounts with { High = adjustedCounts.High - 1 },
VulnerabilitySeverity.Medium => adjustedCounts with { Medium = adjustedCounts.Medium - 1 },
VulnerabilitySeverity.Low => adjustedCounts with { Low = adjustedCounts.Low - 1 },
_ => adjustedCounts
};
}
}
return adjustedCounts;
}
}
public sealed record VulnerabilityCounts
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int KnownExploitedCount { get; init; }
public int Total => Critical + High + Medium + Low;
public VulnerabilityCounts Add(VulnerabilityCounts other) =>
new()
{
Critical = Critical + other.Critical,
High = High + other.High,
Medium = Medium + other.Medium,
Low = Low + other.Low,
KnownExploitedCount = KnownExploitedCount + other.KnownExploitedCount
};
}
```
### VexExceptionChecker
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
public sealed class VexExceptionChecker
{
private readonly IVexService _vexService;
private readonly ILogger<VexExceptionChecker> _logger;
public async Task<VexExceptionResult> CheckAsync(
string digest,
string cveId,
CancellationToken ct = default)
{
var vexDocs = await _vexService.GetVexForDigestAsync(digest, ct);
foreach (var doc in vexDocs)
{
var statement = doc.Statements
.FirstOrDefault(s => s.VulnerabilityId == cveId);
if (statement is null)
continue;
if (statement.Status == VexStatus.NotAffected)
{
return new VexExceptionResult(
IsExcepted: true,
Reason: statement.Justification ?? "Not affected",
VexDocumentId: doc.Id,
VexStatus: VexStatus.NotAffected
);
}
if (statement.Status == VexStatus.Fixed)
{
return new VexExceptionResult(
IsExcepted: true,
Reason: statement.ActionStatement ?? "Fixed",
VexDocumentId: doc.Id,
VexStatus: VexStatus.Fixed
);
}
}
return new VexExceptionResult(
IsExcepted: false,
Reason: null,
VexDocumentId: null,
VexStatus: null
);
}
}
public sealed record VexExceptionResult(
bool IsExcepted,
string? Reason,
Guid? VexDocumentId,
VexStatus? VexStatus
);
```
### SbomRequirementChecker
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
public sealed class SbomRequirementChecker
{
private readonly ISbomService _sbomService;
private readonly ILogger<SbomRequirementChecker> _logger;
public async Task<bool> HasSbomAsync(string digest, CancellationToken ct = default)
{
var sbom = await _sbomService.GetByDigestAsync(digest, ct);
return sbom is not null;
}
public async Task<SbomValidationResult> ValidateSbomAsync(
string digest,
CancellationToken ct = default)
{
var sbom = await _sbomService.GetByDigestAsync(digest, ct);
if (sbom is null)
{
return new SbomValidationResult(
HasSbom: false,
IsValid: false,
Errors: ["No SBOM found for digest"]
);
}
var errors = new List<string>();
// Check SBOM has components
if (sbom.Components.Length == 0)
{
errors.Add("SBOM has no components");
}
// Check SBOM format
if (string.IsNullOrEmpty(sbom.Format))
{
errors.Add("SBOM format not specified");
}
// Check SBOM is not too old (optional)
var sbomAge = TimeProvider.System.GetUtcNow() - sbom.GeneratedAt;
if (sbomAge.TotalDays > 90)
{
errors.Add($"SBOM is {sbomAge.TotalDays:F0} days old");
}
return new SbomValidationResult(
HasSbom: true,
IsValid: errors.Count == 0,
Errors: errors.ToImmutableArray(),
SbomId: sbom.Id,
Format: sbom.Format,
ComponentCount: sbom.Components.Length,
GeneratedAt: sbom.GeneratedAt
);
}
}
public sealed record SbomValidationResult(
bool HasSbom,
bool IsValid,
ImmutableArray<string> Errors,
Guid? SbomId = null,
string? Format = null,
int? ComponentCount = null,
DateTimeOffset? GeneratedAt = null
);
```
---
## Acceptance Criteria
- [ ] Check vulnerability counts against thresholds
- [ ] Block on critical vulnerabilities above threshold
- [ ] Block on high vulnerabilities above threshold
- [ ] Support optional medium/low thresholds
- [ ] Require SBOM presence
- [ ] Check scan age
- [ ] Apply VEX exceptions
- [ ] Block on known exploited (KEV) vulnerabilities
- [ ] Return detailed gate result
- [ ] Unit test coverage >=85%
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `Evaluate_BelowThreshold_Passes` | Pass case |
| `Evaluate_CriticalAboveThreshold_Fails` | Critical block |
| `Evaluate_HighAboveThreshold_Fails` | High block |
| `Evaluate_NoSbom_Fails` | SBOM requirement |
| `Evaluate_OldScan_Fails` | Scan age check |
| `VulnCounter_AppliesVexExceptions` | VEX logic |
| `VulnCounter_CountsKev` | KEV counting |
| `SbomChecker_ValidatesSbom` | SBOM validation |
### Integration Tests
| Test | Description |
|------|-------------|
| `SecurityGate_E2E` | Full gate evaluation |
| `VexException_E2E` | VEX integration |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 106_003 Gate Registry | Internal | TODO |
| Scanner | Internal | Exists |
| VexService | Internal | Exists |
| SbomService | Internal | Exists |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| SecurityGate | TODO | |
| SecurityGateConfig | TODO | |
| VulnerabilityCounter | TODO | |
| VexExceptionChecker | TODO | |
| SbomRequirementChecker | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,626 @@
# SPRINT: Decision Engine
> **Sprint ID:** 106_005
> **Module:** PROMOT
> **Phase:** 6 - Promotion & Gates
> **Status:** TODO
> **Parent:** [106_000_INDEX](SPRINT_20260110_106_000_INDEX_promotion_gates.md)
---
## Overview
Implement the Decision Engine for combining gate results and approvals into final promotion decisions.
### Objectives
- Evaluate all configured gates
- Combine gate results with approval status
- Generate decision records with evidence
- Support configurable decision rules
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Promotion/
│ ├── Decision/
│ │ ├── IDecisionEngine.cs
│ │ ├── DecisionEngine.cs
│ │ ├── DecisionRules.cs
│ │ ├── DecisionRecorder.cs
│ │ └── DecisionNotifier.cs
│ └── Models/
│ ├── DecisionResult.cs
│ ├── DecisionRecord.cs
│ └── EnvironmentGateConfig.cs
└── __Tests/
└── StellaOps.ReleaseOrchestrator.Promotion.Tests/
└── Decision/
```
---
## Architecture Reference
- [Promotion Manager](../modules/release-orchestrator/modules/promotion-manager.md)
---
## Deliverables
### IDecisionEngine Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Decision;
public interface IDecisionEngine
{
Task<DecisionResult> EvaluateAsync(Guid promotionId, CancellationToken ct = default);
Task<GateResult> EvaluateGateAsync(Guid promotionId, string gateName, CancellationToken ct = default);
Task<IReadOnlyList<GateResult>> EvaluateAllGatesAsync(Guid promotionId, CancellationToken ct = default);
Task<DecisionRecord> GetDecisionRecordAsync(Guid promotionId, CancellationToken ct = default);
Task<IReadOnlyList<DecisionRecord>> GetDecisionHistoryAsync(Guid promotionId, CancellationToken ct = default);
}
```
### DecisionResult Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Models;
public sealed record DecisionResult
{
public required Guid PromotionId { get; init; }
public required DecisionOutcome Outcome { get; init; }
public required bool CanProceed { get; init; }
public string? BlockingReason { get; init; }
public required ImmutableArray<GateResult> GateResults { get; init; }
public required ApprovalStatus ApprovalStatus { get; init; }
public required DateTimeOffset EvaluatedAt { get; init; }
public TimeSpan Duration { get; init; }
public IEnumerable<GateResult> PassedGates =>
GateResults.Where(g => g.Passed);
public IEnumerable<GateResult> FailedGates =>
GateResults.Where(g => !g.Passed);
public IEnumerable<GateResult> BlockingFailedGates =>
GateResults.Where(g => !g.Passed && g.Blocking);
}
public enum DecisionOutcome
{
Allow, // All gates passed, approvals complete
Deny, // Blocking gate failed
PendingApproval, // Gates passed, awaiting approvals
PendingGate, // Async gate awaiting callback
Error // Evaluation error
}
```
### DecisionRecord Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Models;
public sealed record DecisionRecord
{
public required Guid Id { get; init; }
public required Guid PromotionId { get; init; }
public required Guid TenantId { get; init; }
public required DecisionOutcome Outcome { get; init; }
public required string OutcomeReason { get; init; }
public required ImmutableArray<GateResult> GateResults { get; init; }
public required ImmutableArray<ApprovalRecord> Approvals { get; init; }
public required EnvironmentGateConfig GateConfig { get; init; }
public required DateTimeOffset EvaluatedAt { get; init; }
public required Guid EvaluatedBy { get; init; } // System or user
public string? EvidenceDigest { get; init; }
}
public sealed record EnvironmentGateConfig
{
public required Guid EnvironmentId { get; init; }
public required ImmutableArray<string> RequiredGates { get; init; }
public required int RequiredApprovals { get; init; }
public required bool RequireSeparationOfDuties { get; init; }
public required bool AllGatesMustPass { get; init; }
}
```
### DecisionEngine Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Decision;
public sealed class DecisionEngine : IDecisionEngine
{
private readonly IPromotionStore _promotionStore;
private readonly IEnvironmentService _environmentService;
private readonly IGateRegistry _gateRegistry;
private readonly GateEvaluator _gateEvaluator;
private readonly IApprovalGateway _approvalGateway;
private readonly DecisionRules _decisionRules;
private readonly DecisionRecorder _decisionRecorder;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DecisionEngine> _logger;
public async Task<DecisionResult> EvaluateAsync(
Guid promotionId,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
var promotion = await _promotionStore.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
var gateConfig = await GetGateConfigAsync(promotion.TargetEnvironmentId, ct);
// Evaluate all required gates
var gateContext = BuildGateContext(promotion);
var gateResults = await EvaluateGatesAsync(gateConfig.RequiredGates, gateContext, ct);
// Get approval status
var approvalStatus = await _approvalGateway.GetStatusAsync(promotionId, ct);
// Apply decision rules
var outcome = _decisionRules.Evaluate(gateResults, approvalStatus, gateConfig);
var result = new DecisionResult
{
PromotionId = promotionId,
Outcome = outcome.Decision,
CanProceed = outcome.CanProceed,
BlockingReason = outcome.BlockingReason,
GateResults = gateResults,
ApprovalStatus = approvalStatus,
EvaluatedAt = _timeProvider.GetUtcNow(),
Duration = sw.Elapsed
};
// Record decision
await _decisionRecorder.RecordAsync(promotion, result, gateConfig, ct);
// Update promotion gate results
var updatedPromotion = promotion with { GateResults = gateResults };
await _promotionStore.SaveAsync(updatedPromotion, ct);
// Publish event
await _eventPublisher.PublishAsync(new PromotionDecisionMade(
promotionId,
promotion.TenantId,
result.Outcome,
result.CanProceed,
gateResults.Count(g => g.Passed),
gateResults.Count(g => !g.Passed),
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Decision for promotion {PromotionId}: {Outcome} (proceed={CanProceed}) in {Duration}ms",
promotionId,
result.Outcome,
result.CanProceed,
sw.ElapsedMilliseconds);
return result;
}
private async Task<ImmutableArray<GateResult>> EvaluateGatesAsync(
ImmutableArray<string> gateNames,
GateContext context,
CancellationToken ct)
{
var results = new List<GateResult>();
// Evaluate gates in parallel
var tasks = gateNames.Select(name =>
_gateEvaluator.EvaluateAsync(name, context, ct));
var gateResults = await Task.WhenAll(tasks);
return gateResults.ToImmutableArray();
}
private async Task<EnvironmentGateConfig> GetGateConfigAsync(
Guid environmentId,
CancellationToken ct)
{
var environment = await _environmentService.GetAsync(environmentId, ct)
?? throw new EnvironmentNotFoundException(environmentId);
// Get configured gates for this environment
var configuredGates = await _environmentService.GetGatesAsync(environmentId, ct);
return new EnvironmentGateConfig
{
EnvironmentId = environmentId,
RequiredGates = configuredGates.Select(g => g.GateName).ToImmutableArray(),
RequiredApprovals = environment.RequiredApprovals,
RequireSeparationOfDuties = environment.RequireSeparationOfDuties,
AllGatesMustPass = true // Configurable in future
};
}
private static GateContext BuildGateContext(Promotion promotion) =>
new()
{
PromotionId = promotion.Id,
ReleaseId = promotion.ReleaseId,
ReleaseName = promotion.ReleaseName,
SourceEnvironmentId = promotion.SourceEnvironmentId,
TargetEnvironmentId = promotion.TargetEnvironmentId,
TargetEnvironmentName = promotion.TargetEnvironmentName,
Config = ImmutableDictionary<string, object>.Empty,
RequestedBy = promotion.RequestedBy,
RequestedAt = promotion.RequestedAt
};
public async Task<DecisionRecord> GetDecisionRecordAsync(
Guid promotionId,
CancellationToken ct = default)
{
return await _decisionRecorder.GetLatestAsync(promotionId, ct)
?? throw new DecisionRecordNotFoundException(promotionId);
}
}
```
### DecisionRules
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Decision;
public sealed class DecisionRules
{
public DecisionOutcomeResult Evaluate(
ImmutableArray<GateResult> gateResults,
ApprovalStatus approvalStatus,
EnvironmentGateConfig config)
{
// Check for blocking gate failures first
var blockingFailures = gateResults.Where(g => !g.Passed && g.Blocking).ToList();
if (blockingFailures.Count > 0)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.Deny,
CanProceed: false,
BlockingReason: $"Blocked by gates: {string.Join(", ", blockingFailures.Select(g => g.GateName))}"
);
}
// Check for async gates waiting for callback
var pendingGates = gateResults
.Where(g => !g.Passed && g.Details.ContainsKey("waitingForConfirmation"))
.ToList();
if (pendingGates.Count > 0)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.PendingGate,
CanProceed: false,
BlockingReason: $"Waiting for: {string.Join(", ", pendingGates.Select(g => g.GateName))}"
);
}
// Check if all gates must pass
if (config.AllGatesMustPass)
{
var failedGates = gateResults.Where(g => !g.Passed).ToList();
if (failedGates.Count > 0)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.Deny,
CanProceed: false,
BlockingReason: $"Failed gates: {string.Join(", ", failedGates.Select(g => g.GateName))}"
);
}
}
// Check approval status
if (approvalStatus.IsRejected)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.Deny,
CanProceed: false,
BlockingReason: "Promotion was rejected"
);
}
if (!approvalStatus.IsApproved && config.RequiredApprovals > 0)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.PendingApproval,
CanProceed: false,
BlockingReason: $"Awaiting approvals: {approvalStatus.CurrentApprovals}/{config.RequiredApprovals}"
);
}
// All checks passed
return new DecisionOutcomeResult(
Decision: DecisionOutcome.Allow,
CanProceed: true,
BlockingReason: null
);
}
}
public sealed record DecisionOutcomeResult(
DecisionOutcome Decision,
bool CanProceed,
string? BlockingReason
);
```
### DecisionRecorder
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Decision;
public sealed class DecisionRecorder
{
private readonly IDecisionRecordStore _store;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<DecisionRecorder> _logger;
public async Task RecordAsync(
Promotion promotion,
DecisionResult result,
EnvironmentGateConfig config,
CancellationToken ct = default)
{
var record = new DecisionRecord
{
Id = _guidGenerator.NewGuid(),
PromotionId = promotion.Id,
TenantId = promotion.TenantId,
Outcome = result.Outcome,
OutcomeReason = result.BlockingReason ?? "All requirements met",
GateResults = result.GateResults,
Approvals = promotion.Approvals,
GateConfig = config,
EvaluatedAt = _timeProvider.GetUtcNow(),
EvaluatedBy = Guid.Empty, // System evaluation
EvidenceDigest = ComputeEvidenceDigest(result)
};
await _store.SaveAsync(record, ct);
_logger.LogDebug(
"Recorded decision {DecisionId} for promotion {PromotionId}: {Outcome}",
record.Id,
promotion.Id,
result.Outcome);
}
public async Task<DecisionRecord?> GetLatestAsync(
Guid promotionId,
CancellationToken ct = default)
{
return await _store.GetLatestAsync(promotionId, ct);
}
public async Task<IReadOnlyList<DecisionRecord>> GetHistoryAsync(
Guid promotionId,
CancellationToken ct = default)
{
return await _store.ListByPromotionAsync(promotionId, ct);
}
private static string ComputeEvidenceDigest(DecisionResult result)
{
// Create canonical representation and hash
var evidence = new
{
result.PromotionId,
result.Outcome,
result.EvaluatedAt,
Gates = result.GateResults.Select(g => new
{
g.GateName,
g.Passed,
g.Message
}).OrderBy(g => g.GateName),
Approvals = result.ApprovalStatus.Approvals.Select(a => new
{
a.UserId,
a.Decision,
a.DecidedAt
}).OrderBy(a => a.DecidedAt)
};
var json = CanonicalJsonSerializer.Serialize(evidence);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
```
### DecisionNotifier
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Decision;
public sealed class DecisionNotifier
{
private readonly INotificationService _notificationService;
private readonly IPromotionStore _promotionStore;
private readonly ILogger<DecisionNotifier> _logger;
public async Task NotifyDecisionAsync(
DecisionResult result,
CancellationToken ct = default)
{
var promotion = await _promotionStore.GetAsync(result.PromotionId, ct);
if (promotion is null)
return;
var notification = result.Outcome switch
{
DecisionOutcome.Allow => BuildAllowNotification(promotion, result),
DecisionOutcome.Deny => BuildDenyNotification(promotion, result),
DecisionOutcome.PendingApproval => BuildPendingApprovalNotification(promotion, result),
_ => null
};
if (notification is not null)
{
await _notificationService.SendAsync(notification, ct);
_logger.LogInformation(
"Sent {Outcome} notification for promotion {PromotionId}",
result.Outcome,
result.PromotionId);
}
}
private static NotificationRequest BuildAllowNotification(
Promotion promotion,
DecisionResult result) =>
new()
{
Channel = "slack",
Title = $"Promotion Approved: {promotion.ReleaseName}",
Message = $"Release '{promotion.ReleaseName}' has been approved for deployment to {promotion.TargetEnvironmentName}.",
Severity = NotificationSeverity.Info,
Metadata = new Dictionary<string, string>
{
["promotionId"] = promotion.Id.ToString(),
["outcome"] = "allow"
}
};
private static NotificationRequest BuildDenyNotification(
Promotion promotion,
DecisionResult result) =>
new()
{
Channel = "slack",
Title = $"Promotion Blocked: {promotion.ReleaseName}",
Message = $"Release '{promotion.ReleaseName}' promotion to {promotion.TargetEnvironmentName} was blocked.\n\nReason: {result.BlockingReason}",
Severity = NotificationSeverity.Warning,
Metadata = new Dictionary<string, string>
{
["promotionId"] = promotion.Id.ToString(),
["outcome"] = "deny"
}
};
private static NotificationRequest BuildPendingApprovalNotification(
Promotion promotion,
DecisionResult result) =>
new()
{
Channel = "slack",
Title = $"Approval Required: {promotion.ReleaseName}",
Message = $"Release '{promotion.ReleaseName}' is awaiting approval for deployment to {promotion.TargetEnvironmentName}.\n\n{result.BlockingReason}",
Severity = NotificationSeverity.Info,
Metadata = new Dictionary<string, string>
{
["promotionId"] = promotion.Id.ToString(),
["outcome"] = "pending_approval"
}
};
}
```
### Domain Events
```csharp
namespace StellaOps.ReleaseOrchestrator.Promotion.Events;
public sealed record PromotionDecisionMade(
Guid PromotionId,
Guid TenantId,
DecisionOutcome Outcome,
bool CanProceed,
int PassedGates,
int FailedGates,
DateTimeOffset DecidedAt
) : IDomainEvent;
public sealed record PromotionReadyForDeployment(
Guid PromotionId,
Guid TenantId,
Guid ReleaseId,
Guid TargetEnvironmentId,
DateTimeOffset ReadyAt
) : IDomainEvent;
```
---
## Acceptance Criteria
- [ ] Evaluate all configured gates
- [ ] Combine gate results with approvals
- [ ] Deny on blocking gate failure
- [ ] Pending on approval required
- [ ] Allow when all requirements met
- [ ] Record decision with evidence
- [ ] Compute evidence digest
- [ ] Notify on decision
- [ ] Support decision history
- [ ] Unit test coverage >=85%
---
## Test Plan
### Unit Tests
| Test | Description |
|------|-------------|
| `Evaluate_AllGatesPass_AllApprovals_Allows` | Allow case |
| `Evaluate_BlockingGateFails_Denies` | Deny case |
| `Evaluate_PendingApprovals_ReturnsPending` | Pending case |
| `DecisionRules_AllMustPass_AnyFails_Denies` | Rule logic |
| `DecisionRecorder_ComputesDigest` | Evidence hash |
| `DecisionRecorder_SavesHistory` | History tracking |
### Integration Tests
| Test | Description |
|------|-------------|
| `DecisionEngine_E2E` | Full evaluation flow |
| `DecisionHistory_E2E` | Multiple decisions |
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 106_002 Approval Gateway | Internal | TODO |
| 106_003 Gate Registry | Internal | TODO |
| 106_004 Security Gate | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IDecisionEngine | TODO | |
| DecisionEngine | TODO | |
| DecisionRules | TODO | |
| DecisionRecorder | TODO | |
| DecisionNotifier | TODO | |
| DecisionResult model | TODO | |
| DecisionRecord model | TODO | |
| IDecisionRecordStore | TODO | |
| DecisionRecordStore | TODO | |
| Domain events | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,254 @@
# SPRINT INDEX: Phase 7 - Deployment Execution
> **Epic:** Release Orchestrator
> **Phase:** 7 - Deployment Execution
> **Batch:** 107
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 7 implements the Deployment Execution system - orchestrating the actual deployment of releases to targets via agents.
### Objectives
- Deploy orchestrator coordinates multi-target deployments
- Target executor dispatches tasks to agents
- Artifact generator creates deployment artifacts
- Rollback manager handles failure recovery
- Deployment strategies (rolling, blue-green, canary)
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 107_001 | Deploy Orchestrator | DEPLOY | TODO | 105_003, 106_005 |
| 107_002 | Target Executor | DEPLOY | TODO | 107_001, 103_002 |
| 107_003 | Artifact Generator | DEPLOY | TODO | 107_001 |
| 107_004 | Rollback Manager | DEPLOY | TODO | 107_002 |
| 107_005 | Deployment Strategies | DEPLOY | TODO | 107_002 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT EXECUTION │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DEPLOY ORCHESTRATOR (107_001) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Deployment Job │ │ │
│ │ │ promotion_id: uuid │ │ │
│ │ │ strategy: rolling │ │ │
│ │ │ targets: [target-1, target-2, target-3] │ │ │
│ │ │ status: deploying │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ TARGET EXECUTOR (107_002) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Target 1 │ │ Target 2 │ │ Target 3 │ │ │
│ │ │ ✓ Done │ │ ⟳ Running │ │ ○ Pending │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ Task dispatch via gRPC to agents │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ ARTIFACT GENERATOR (107_003) │ │
│ │ │ │
│ │ Generated artifacts for each deployment: │ │
│ │ ├── compose.stella.lock.yml (digested compose file) │ │
│ │ ├── stella.version.json (version sticker) │ │
│ │ └── deployment-manifest.json (full deployment record) │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ ROLLBACK MANAGER (107_004) │ │
│ │ │ │
│ │ On failure: │ │
│ │ 1. Stop pending tasks │ │
│ │ 2. Rollback completed targets to previous version │ │
│ │ 3. Generate rollback evidence │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DEPLOYMENT STRATEGIES (107_005) │ │
│ │ │ │
│ │ Rolling: [■■□□□] → [■■■□□] → [■■■■□] → [■■■■■] │ │
│ │ Blue-Green: [■■■■■] ──swap──► [□□□□□] (instant cutover) │ │
│ │ Canary: [■□□□□] → [■■□□□] → [■■■□□] → [■■■■■] (gradual) │ │
│ │ All-at-once: [□□□□□] → [■■■■■] (simultaneous) │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 107_001: Deploy Orchestrator
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IDeployOrchestrator` | Interface | Deployment coordination |
| `DeployOrchestrator` | Class | Implementation |
| `DeploymentJob` | Model | Job entity |
| `DeploymentScheduler` | Class | Task scheduling |
### 107_002: Target Executor
| Deliverable | Type | Description |
|-------------|------|-------------|
| `ITargetExecutor` | Interface | Target deployment |
| `TargetExecutor` | Class | Implementation |
| `DeploymentTask` | Model | Per-target task |
| `AgentDispatcher` | Class | gRPC task dispatch |
### 107_003: Artifact Generator
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IArtifactGenerator` | Interface | Artifact creation |
| `ComposeLockGenerator` | Class | Digest-locked compose |
| `VersionStickerGenerator` | Class | stella.version.json |
| `DeploymentManifestGenerator` | Class | Full manifest |
### 107_004: Rollback Manager
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IRollbackManager` | Interface | Rollback operations |
| `RollbackManager` | Class | Implementation |
| `RollbackPlan` | Model | Rollback strategy |
| `RollbackExecutor` | Class | Execute rollback |
### 107_005: Deployment Strategies
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IDeploymentStrategy` | Interface | Strategy contract |
| `RollingStrategy` | Strategy | Rolling deployment |
| `BlueGreenStrategy` | Strategy | Blue-green deployment |
| `CanaryStrategy` | Strategy | Canary deployment |
| `AllAtOnceStrategy` | Strategy | Simultaneous deployment |
---
## Key Interfaces
```csharp
public interface IDeployOrchestrator
{
Task<DeploymentJob> StartAsync(Guid promotionId, DeploymentOptions options, CancellationToken ct);
Task<DeploymentJob?> GetJobAsync(Guid jobId, CancellationToken ct);
Task CancelAsync(Guid jobId, CancellationToken ct);
Task<DeploymentJob> WaitForCompletionAsync(Guid jobId, CancellationToken ct);
}
public interface ITargetExecutor
{
Task<DeploymentTask> DeployToTargetAsync(Guid jobId, Guid targetId, DeploymentPayload payload, CancellationToken ct);
Task<DeploymentTask?> GetTaskAsync(Guid taskId, CancellationToken ct);
}
public interface IDeploymentStrategy
{
string Name { get; }
Task<IReadOnlyList<DeploymentBatch>> PlanAsync(DeploymentJob job, CancellationToken ct);
Task<bool> ShouldProceedAsync(DeploymentBatch completedBatch, CancellationToken ct);
}
public interface IRollbackManager
{
Task<RollbackPlan> PlanAsync(Guid jobId, CancellationToken ct);
Task<DeploymentJob> ExecuteAsync(RollbackPlan plan, CancellationToken ct);
}
```
---
## Deployment Flow
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT FLOW │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Promotion │───►│ Decision │───►│ Deploy │───►│ Generate │ │
│ │ Approved │ │ Allow │ │ Start │ │ Artifacts │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Strategy Execution ││
│ │ ││
│ │ Batch 1 Batch 2 Batch 3 ││
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ││
│ │ │Target-1 │ ──► │Target-2 │ ──► │Target-3 │ ││
│ │ │ ✓ Done │ │ ✓ Done │ │ ⟳ Active │ ││
│ │ └─────────┘ └─────────┘ └─────────┘ ││
│ │ │ │ │ ││
│ │ ▼ ▼ ▼ ││
│ │ Health Check Health Check Health Check ││
│ │ │ │ │ ││
│ │ ▼ ▼ ▼ ││
│ │ Write Sticker Write Sticker Write Sticker ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ On Failure │ │
│ │ │ │
│ │ 1. Stop pending batches │ │
│ │ 2. Rollback completed targets │ │
│ │ 3. Generate rollback evidence │ │
│ │ 4. Update promotion status │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| 105_003 Workflow Engine | Workflow execution |
| 106_005 Decision Engine | Deployment approval |
| 103_002 Target Registry | Target information |
| 108_* Agents | Task execution |
---
## Acceptance Criteria
- [ ] Deployment job created from promotion
- [ ] Tasks dispatched to agents
- [ ] Rolling deployment works
- [ ] Blue-green deployment works
- [ ] Canary deployment works
- [ ] Artifacts generated for each target
- [ ] Rollback restores previous version
- [ ] Health checks gate progression
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 7 index created |

View File

@@ -0,0 +1,410 @@
# SPRINT: Deploy Orchestrator
> **Sprint ID:** 107_001
> **Module:** DEPLOY
> **Phase:** 7 - Deployment Execution
> **Status:** TODO
> **Parent:** [107_000_INDEX](SPRINT_20260110_107_000_INDEX_deployment_execution.md)
---
## Overview
Implement the Deploy Orchestrator for coordinating multi-target deployments.
### Objectives
- Create deployment jobs from approved promotions
- Coordinate deployment across multiple targets
- Track deployment progress and status
- Support deployment cancellation
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Deployment/
│ ├── Orchestrator/
│ │ ├── IDeployOrchestrator.cs
│ │ ├── DeployOrchestrator.cs
│ │ ├── DeploymentCoordinator.cs
│ │ └── DeploymentScheduler.cs
│ ├── Store/
│ │ ├── IDeploymentJobStore.cs
│ │ └── DeploymentJobStore.cs
│ └── Models/
│ ├── DeploymentJob.cs
│ ├── DeploymentOptions.cs
│ └── DeploymentStatus.cs
└── __Tests/
```
---
## Deliverables
### IDeployOrchestrator Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Orchestrator;
public interface IDeployOrchestrator
{
Task<DeploymentJob> StartAsync(Guid promotionId, DeploymentOptions options, CancellationToken ct = default);
Task<DeploymentJob?> GetJobAsync(Guid jobId, CancellationToken ct = default);
Task<IReadOnlyList<DeploymentJob>> ListJobsAsync(DeploymentJobFilter? filter = null, CancellationToken ct = default);
Task CancelAsync(Guid jobId, string? reason = null, CancellationToken ct = default);
Task<DeploymentJob> WaitForCompletionAsync(Guid jobId, TimeSpan? timeout = null, CancellationToken ct = default);
Task<DeploymentProgress> GetProgressAsync(Guid jobId, CancellationToken ct = default);
}
public sealed record DeploymentOptions(
DeploymentStrategy Strategy = DeploymentStrategy.Rolling,
string? BatchSize = "25%",
bool WaitForHealthCheck = true,
bool RollbackOnFailure = true,
TimeSpan? Timeout = null,
Guid? WorkflowRunId = null,
string? CallbackToken = null
);
public enum DeploymentStrategy
{
Rolling,
BlueGreen,
Canary,
AllAtOnce
}
```
### DeploymentJob Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Models;
public sealed record DeploymentJob
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required Guid PromotionId { get; init; }
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required Guid EnvironmentId { get; init; }
public required string EnvironmentName { get; init; }
public required DeploymentStatus Status { get; init; }
public required DeploymentStrategy Strategy { get; init; }
public required DeploymentOptions Options { get; init; }
public required ImmutableArray<DeploymentTask> Tasks { get; init; }
public string? FailureReason { get; init; }
public string? CancelReason { get; init; }
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public Guid StartedBy { get; init; }
public Guid? RollbackJobId { get; init; }
public TimeSpan? Duration => CompletedAt.HasValue
? CompletedAt.Value - StartedAt
: null;
public int CompletedTaskCount => Tasks.Count(t => t.Status == DeploymentTaskStatus.Completed);
public int TotalTaskCount => Tasks.Length;
public double ProgressPercent => TotalTaskCount > 0
? (double)CompletedTaskCount / TotalTaskCount * 100
: 0;
}
public enum DeploymentStatus
{
Pending,
Running,
Completed,
Failed,
Cancelled,
RollingBack,
RolledBack
}
public sealed record DeploymentTask
{
public required Guid Id { get; init; }
public required Guid TargetId { get; init; }
public required string TargetName { get; init; }
public required int BatchIndex { get; init; }
public required DeploymentTaskStatus Status { get; init; }
public string? AgentId { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string? Error { get; init; }
public ImmutableDictionary<string, object> Result { get; init; } = ImmutableDictionary<string, object>.Empty;
}
public enum DeploymentTaskStatus
{
Pending,
Running,
Completed,
Failed,
Skipped,
Cancelled
}
```
### DeployOrchestrator Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Orchestrator;
public sealed class DeployOrchestrator : IDeployOrchestrator
{
private readonly IDeploymentJobStore _jobStore;
private readonly IPromotionManager _promotionManager;
private readonly IReleaseManager _releaseManager;
private readonly ITargetRegistry _targetRegistry;
private readonly IDeploymentStrategyFactory _strategyFactory;
private readonly ITargetExecutor _targetExecutor;
private readonly IArtifactGenerator _artifactGenerator;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<DeployOrchestrator> _logger;
public async Task<DeploymentJob> StartAsync(
Guid promotionId,
DeploymentOptions options,
CancellationToken ct = default)
{
var promotion = await _promotionManager.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
if (promotion.Status != PromotionStatus.Approved)
{
throw new PromotionNotApprovedException(promotionId);
}
var release = await _releaseManager.GetAsync(promotion.ReleaseId, ct)
?? throw new ReleaseNotFoundException(promotion.ReleaseId);
var targets = await _targetRegistry.ListHealthyAsync(promotion.TargetEnvironmentId, ct);
if (targets.Count == 0)
{
throw new NoHealthyTargetsException(promotion.TargetEnvironmentId);
}
// Create deployment tasks for each target
var tasks = targets.Select((target, index) => new DeploymentTask
{
Id = _guidGenerator.NewGuid(),
TargetId = target.Id,
TargetName = target.Name,
BatchIndex = 0, // Will be set by strategy
Status = DeploymentTaskStatus.Pending
}).ToImmutableArray();
var job = new DeploymentJob
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
PromotionId = promotionId,
ReleaseId = release.Id,
ReleaseName = release.Name,
EnvironmentId = promotion.TargetEnvironmentId,
EnvironmentName = promotion.TargetEnvironmentName,
Status = DeploymentStatus.Pending,
Strategy = options.Strategy,
Options = options,
Tasks = tasks,
StartedAt = _timeProvider.GetUtcNow(),
StartedBy = _userContext.UserId
};
await _jobStore.SaveAsync(job, ct);
// Update promotion status
await _promotionManager.UpdateStatusAsync(promotionId, PromotionStatus.Deploying, ct);
await _eventPublisher.PublishAsync(new DeploymentJobStarted(
job.Id,
job.TenantId,
job.ReleaseName,
job.EnvironmentName,
job.Strategy,
targets.Count,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Started deployment job {JobId} for release {Release} to {Environment} with {TargetCount} targets",
job.Id, release.Name, promotion.TargetEnvironmentName, targets.Count);
// Start deployment execution
_ = ExecuteDeploymentAsync(job.Id, ct);
return job;
}
private async Task ExecuteDeploymentAsync(Guid jobId, CancellationToken ct)
{
try
{
var job = await _jobStore.GetAsync(jobId, ct);
if (job is null) return;
job = job with { Status = DeploymentStatus.Running };
await _jobStore.SaveAsync(job, ct);
// Get strategy and plan batches
var strategy = _strategyFactory.Create(job.Strategy);
var batches = await strategy.PlanAsync(job, ct);
// Execute batches
foreach (var batch in batches)
{
job = await _jobStore.GetAsync(jobId, ct);
if (job is null || job.Status == DeploymentStatus.Cancelled) break;
await ExecuteBatchAsync(job, batch, ct);
// Check if should continue
if (!await strategy.ShouldProceedAsync(batch, ct))
{
_logger.LogWarning("Strategy halted deployment after batch {BatchIndex}", batch.Index);
break;
}
}
// Complete or fail
job = await _jobStore.GetAsync(jobId, ct);
if (job is not null && job.Status == DeploymentStatus.Running)
{
var allCompleted = job.Tasks.All(t => t.Status == DeploymentTaskStatus.Completed);
job = job with
{
Status = allCompleted ? DeploymentStatus.Completed : DeploymentStatus.Failed,
CompletedAt = _timeProvider.GetUtcNow()
};
await _jobStore.SaveAsync(job, ct);
await NotifyCompletionAsync(job, ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Deployment job {JobId} failed", jobId);
await FailJobAsync(jobId, ex.Message, ct);
}
}
private async Task ExecuteBatchAsync(DeploymentJob job, DeploymentBatch batch, CancellationToken ct)
{
_logger.LogInformation("Executing batch {BatchIndex} with {TaskCount} tasks",
batch.Index, batch.TaskIds.Count);
// Generate artifacts
var payload = await _artifactGenerator.GeneratePayloadAsync(job, ct);
// Execute tasks in parallel within batch
var tasks = batch.TaskIds.Select(taskId =>
_targetExecutor.DeployToTargetAsync(job.Id, taskId, payload, ct));
await Task.WhenAll(tasks);
}
public async Task CancelAsync(Guid jobId, string? reason = null, CancellationToken ct = default)
{
var job = await _jobStore.GetAsync(jobId, ct)
?? throw new DeploymentJobNotFoundException(jobId);
if (job.Status != DeploymentStatus.Running && job.Status != DeploymentStatus.Pending)
{
throw new DeploymentJobNotCancellableException(jobId);
}
job = job with
{
Status = DeploymentStatus.Cancelled,
CancelReason = reason,
CompletedAt = _timeProvider.GetUtcNow()
};
await _jobStore.SaveAsync(job, ct);
await _eventPublisher.PublishAsync(new DeploymentJobCancelled(
jobId, job.TenantId, reason, _timeProvider.GetUtcNow()
), ct);
}
public async Task<DeploymentProgress> GetProgressAsync(Guid jobId, CancellationToken ct = default)
{
var job = await _jobStore.GetAsync(jobId, ct)
?? throw new DeploymentJobNotFoundException(jobId);
return new DeploymentProgress(
JobId: job.Id,
Status: job.Status,
TotalTargets: job.TotalTaskCount,
CompletedTargets: job.CompletedTaskCount,
FailedTargets: job.Tasks.Count(t => t.Status == DeploymentTaskStatus.Failed),
PendingTargets: job.Tasks.Count(t => t.Status == DeploymentTaskStatus.Pending),
ProgressPercent: job.ProgressPercent,
CurrentBatch: job.Tasks.Where(t => t.Status == DeploymentTaskStatus.Running).Select(t => t.BatchIndex).FirstOrDefault()
);
}
}
public sealed record DeploymentProgress(
Guid JobId,
DeploymentStatus Status,
int TotalTargets,
int CompletedTargets,
int FailedTargets,
int PendingTargets,
double ProgressPercent,
int CurrentBatch
);
```
---
## Acceptance Criteria
- [ ] Create deployment job from promotion
- [ ] Coordinate multi-target deployment
- [ ] Track task progress per target
- [ ] Cancel running deployment
- [ ] Wait for deployment completion
- [ ] Report deployment progress
- [ ] Handle deployment failures
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 106_005 Decision Engine | Internal | TODO |
| 103_002 Target Registry | Internal | TODO |
| 107_002 Target Executor | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IDeployOrchestrator | TODO | |
| DeployOrchestrator | TODO | |
| DeploymentCoordinator | TODO | |
| DeploymentScheduler | TODO | |
| DeploymentJob model | TODO | |
| IDeploymentJobStore | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,367 @@
# SPRINT: Target Executor
> **Sprint ID:** 107_002
> **Module:** DEPLOY
> **Phase:** 7 - Deployment Execution
> **Status:** TODO
> **Parent:** [107_000_INDEX](SPRINT_20260110_107_000_INDEX_deployment_execution.md)
---
## Overview
Implement the Target Executor for dispatching deployment tasks to agents.
### Objectives
- Dispatch deployment tasks to agents via gRPC
- Track task execution status
- Handle task timeouts and retries
- Collect task results and logs
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Deployment/
│ ├── Executor/
│ │ ├── ITargetExecutor.cs
│ │ ├── TargetExecutor.cs
│ │ ├── AgentDispatcher.cs
│ │ └── TaskResultCollector.cs
│ └── Models/
│ ├── DeploymentPayload.cs
│ └── TaskResult.cs
└── __Tests/
```
---
## Deliverables
### ITargetExecutor Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Executor;
public interface ITargetExecutor
{
Task<DeploymentTask> DeployToTargetAsync(
Guid jobId,
Guid taskId,
DeploymentPayload payload,
CancellationToken ct = default);
Task<DeploymentTask?> GetTaskAsync(Guid taskId, CancellationToken ct = default);
Task CancelTaskAsync(Guid taskId, CancellationToken ct = default);
Task<TaskLogs> GetTaskLogsAsync(Guid taskId, CancellationToken ct = default);
}
public sealed record DeploymentPayload
{
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required ImmutableArray<DeploymentComponent> Components { get; init; }
public required string ComposeLock { get; init; }
public required string VersionSticker { get; init; }
public required string DeploymentManifest { get; init; }
public ImmutableDictionary<string, string> Variables { get; init; } = ImmutableDictionary<string, string>.Empty;
}
public sealed record DeploymentComponent(
string Name,
string Image,
string Digest,
ImmutableDictionary<string, string> Config
);
```
### TargetExecutor Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Executor;
public sealed class TargetExecutor : ITargetExecutor
{
private readonly IDeploymentJobStore _jobStore;
private readonly ITargetRegistry _targetRegistry;
private readonly IAgentManager _agentManager;
private readonly AgentDispatcher _dispatcher;
private readonly TaskResultCollector _resultCollector;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TargetExecutor> _logger;
public async Task<DeploymentTask> DeployToTargetAsync(
Guid jobId,
Guid taskId,
DeploymentPayload payload,
CancellationToken ct = default)
{
var job = await _jobStore.GetAsync(jobId, ct)
?? throw new DeploymentJobNotFoundException(jobId);
var task = job.Tasks.FirstOrDefault(t => t.Id == taskId)
?? throw new DeploymentTaskNotFoundException(taskId);
var target = await _targetRegistry.GetAsync(task.TargetId, ct)
?? throw new TargetNotFoundException(task.TargetId);
if (target.AgentId is null)
{
throw new NoAgentAssignedException(target.Id);
}
var agent = await _agentManager.GetAsync(target.AgentId.Value, ct);
if (agent?.Status != AgentStatus.Active)
{
throw new AgentNotActiveException(target.AgentId.Value);
}
// Update task status
task = task with
{
Status = DeploymentTaskStatus.Running,
AgentId = agent.Id.ToString(),
StartedAt = _timeProvider.GetUtcNow()
};
await UpdateTaskAsync(job, task, ct);
await _eventPublisher.PublishAsync(new DeploymentTaskStarted(
taskId, jobId, target.Name, agent.Name, _timeProvider.GetUtcNow()
), ct);
try
{
// Dispatch to agent
var agentTask = BuildAgentTask(target, payload);
var result = await _dispatcher.DispatchAsync(agent.Id, agentTask, ct);
// Collect results
task = await _resultCollector.CollectAsync(task, result, ct);
if (task.Status == DeploymentTaskStatus.Completed)
{
await _eventPublisher.PublishAsync(new DeploymentTaskCompleted(
taskId, jobId, target.Name, task.CompletedAt!.Value - task.StartedAt!.Value,
_timeProvider.GetUtcNow()
), ct);
}
else
{
await _eventPublisher.PublishAsync(new DeploymentTaskFailed(
taskId, jobId, target.Name, task.Error ?? "Unknown error",
_timeProvider.GetUtcNow()
), ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Deployment task {TaskId} failed for target {Target}", taskId, target.Name);
task = task with
{
Status = DeploymentTaskStatus.Failed,
Error = ex.Message,
CompletedAt = _timeProvider.GetUtcNow()
};
await _eventPublisher.PublishAsync(new DeploymentTaskFailed(
taskId, jobId, target.Name, ex.Message, _timeProvider.GetUtcNow()
), ct);
}
await UpdateTaskAsync(job, task, ct);
return task;
}
private static AgentDeploymentTask BuildAgentTask(Target target, DeploymentPayload payload)
{
return new AgentDeploymentTask
{
Type = target.Type switch
{
TargetType.DockerHost => AgentTaskType.DockerDeploy,
TargetType.ComposeHost => AgentTaskType.ComposeDeploy,
_ => throw new UnsupportedTargetTypeException(target.Type)
},
Payload = new AgentDeploymentPayload
{
Components = payload.Components.Select(c => new AgentComponent
{
Name = c.Name,
Image = $"{c.Image}@{c.Digest}",
Config = c.Config
}).ToList(),
ComposeLock = payload.ComposeLock,
VersionSticker = payload.VersionSticker,
Variables = payload.Variables
}
};
}
private async Task UpdateTaskAsync(DeploymentJob job, DeploymentTask updatedTask, CancellationToken ct)
{
var tasks = job.Tasks.Select(t => t.Id == updatedTask.Id ? updatedTask : t).ToImmutableArray();
var updatedJob = job with { Tasks = tasks };
await _jobStore.SaveAsync(updatedJob, ct);
}
}
```
### AgentDispatcher
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Executor;
public sealed class AgentDispatcher
{
private readonly IAgentManager _agentManager;
private readonly ILogger<AgentDispatcher> _logger;
private readonly TimeSpan _defaultTimeout = TimeSpan.FromMinutes(30);
public async Task<AgentTaskResult> DispatchAsync(
Guid agentId,
AgentDeploymentTask task,
CancellationToken ct = default)
{
_logger.LogDebug("Dispatching task to agent {AgentId}", agentId);
using var timeoutCts = new CancellationTokenSource(_defaultTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
var result = await _agentManager.ExecuteTaskAsync(agentId, task, linkedCts.Token);
_logger.LogDebug(
"Agent {AgentId} completed task with status {Status}",
agentId,
result.Success ? "success" : "failure");
return result;
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new AgentTaskTimeoutException(agentId, _defaultTimeout);
}
}
}
public sealed record AgentDeploymentTask
{
public required AgentTaskType Type { get; init; }
public required AgentDeploymentPayload Payload { get; init; }
}
public enum AgentTaskType
{
DockerDeploy,
ComposeDeploy,
DockerRollback,
ComposeRollback
}
public sealed record AgentDeploymentPayload
{
public required IReadOnlyList<AgentComponent> Components { get; init; }
public required string ComposeLock { get; init; }
public required string VersionSticker { get; init; }
public IReadOnlyDictionary<string, string> Variables { get; init; } = new Dictionary<string, string>();
}
public sealed record AgentComponent
{
public required string Name { get; init; }
public required string Image { get; init; }
public IReadOnlyDictionary<string, string> Config { get; init; } = new Dictionary<string, string>();
}
public sealed record AgentTaskResult
{
public bool Success { get; init; }
public string? Error { get; init; }
public IReadOnlyDictionary<string, object> Outputs { get; init; } = new Dictionary<string, object>();
public string? Logs { get; init; }
public TimeSpan Duration { get; init; }
}
```
### TaskResultCollector
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Executor;
public sealed class TaskResultCollector
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<TaskResultCollector> _logger;
public Task<DeploymentTask> CollectAsync(
DeploymentTask task,
AgentTaskResult result,
CancellationToken ct = default)
{
var updatedTask = task with
{
Status = result.Success ? DeploymentTaskStatus.Completed : DeploymentTaskStatus.Failed,
Error = result.Error,
CompletedAt = _timeProvider.GetUtcNow(),
Result = result.Outputs.ToImmutableDictionary()
};
_logger.LogDebug(
"Collected result for task {TaskId}: {Status}",
task.Id,
updatedTask.Status);
return Task.FromResult(updatedTask);
}
}
```
---
## Acceptance Criteria
- [ ] Dispatch tasks to agents via gRPC
- [ ] Track task execution status
- [ ] Handle task timeouts
- [ ] Collect task results
- [ ] Collect task logs
- [ ] Cancel running tasks
- [ ] Support Docker and Compose targets
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_001 Deploy Orchestrator | Internal | TODO |
| 103_002 Target Registry | Internal | TODO |
| 103_003 Agent Manager | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ITargetExecutor | TODO | |
| TargetExecutor | TODO | |
| AgentDispatcher | TODO | |
| TaskResultCollector | TODO | |
| DeploymentPayload | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,461 @@
# SPRINT: Artifact Generator
> **Sprint ID:** 107_003
> **Module:** DEPLOY
> **Phase:** 7 - Deployment Execution
> **Status:** TODO
> **Parent:** [107_000_INDEX](SPRINT_20260110_107_000_INDEX_deployment_execution.md)
---
## Overview
Implement the Artifact Generator for creating deployment artifacts including digest-locked compose files and version stickers.
### Objectives
- Generate digest-locked compose files
- Create version sticker files (stella.version.json)
- Generate deployment manifests
- Support multiple artifact formats
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Deployment/
│ └── Artifact/
│ ├── IArtifactGenerator.cs
│ ├── ArtifactGenerator.cs
│ ├── ComposeLockGenerator.cs
│ ├── VersionStickerGenerator.cs
│ └── DeploymentManifestGenerator.cs
└── __Tests/
```
---
## Deliverables
### IArtifactGenerator Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Artifact;
public interface IArtifactGenerator
{
Task<DeploymentPayload> GeneratePayloadAsync(DeploymentJob job, CancellationToken ct = default);
Task<string> GenerateComposeLockAsync(Release release, CancellationToken ct = default);
Task<string> GenerateVersionStickerAsync(Release release, DeploymentJob job, CancellationToken ct = default);
Task<string> GenerateDeploymentManifestAsync(DeploymentJob job, CancellationToken ct = default);
}
```
### ComposeLockGenerator
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Artifact;
public sealed class ComposeLockGenerator
{
private readonly ILogger<ComposeLockGenerator> _logger;
public string Generate(Release release, ComposeTemplate? template = null)
{
var services = new Dictionary<string, object>();
foreach (var component in release.Components.OrderBy(c => c.OrderIndex))
{
var service = new Dictionary<string, object>
{
["image"] = $"{GetFullImageRef(component)}@{component.Digest}",
["labels"] = new Dictionary<string, string>
{
["stella.release.id"] = release.Id.ToString(),
["stella.release.name"] = release.Name,
["stella.component.id"] = component.ComponentId.ToString(),
["stella.component.name"] = component.ComponentName,
["stella.digest"] = component.Digest
}
};
// Add config from component
foreach (var (key, value) in component.Config)
{
service[key] = value;
}
services[component.ComponentName] = service;
}
var compose = new Dictionary<string, object>
{
["version"] = "3.8",
["services"] = services,
["x-stella"] = new Dictionary<string, object>
{
["release"] = new Dictionary<string, object>
{
["id"] = release.Id.ToString(),
["name"] = release.Name,
["manifestDigest"] = release.ManifestDigest ?? ""
},
["generated"] = TimeProvider.System.GetUtcNow().ToString("O")
}
};
// Merge with template if provided
if (template is not null)
{
compose = MergeWithTemplate(compose, template);
}
var yaml = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build()
.Serialize(compose);
_logger.LogDebug(
"Generated compose.stella.lock.yml for release {Release} with {Count} services",
release.Name,
services.Count);
return yaml;
}
private static string GetFullImageRef(ReleaseComponent component)
{
// Component config should include registry info
var registry = component.Config.GetValueOrDefault("registry", "");
var repository = component.Config.GetValueOrDefault("repository", component.ComponentName);
return string.IsNullOrEmpty(registry) ? repository : $"{registry}/{repository}";
}
private static Dictionary<string, object> MergeWithTemplate(
Dictionary<string, object> generated,
ComposeTemplate template)
{
// Deep merge template with generated config
// Template provides networks, volumes, etc.
var merged = new Dictionary<string, object>(generated);
if (template.Networks is not null)
merged["networks"] = template.Networks;
if (template.Volumes is not null)
merged["volumes"] = template.Volumes;
// Merge service configs from template
if (template.ServiceDefaults is not null && merged["services"] is Dictionary<string, object> services)
{
foreach (var (serviceName, serviceConfig) in services)
{
if (serviceConfig is Dictionary<string, object> config)
{
foreach (var (key, value) in template.ServiceDefaults)
{
if (!config.ContainsKey(key))
{
config[key] = value;
}
}
}
}
}
return merged;
}
}
public sealed record ComposeTemplate(
IReadOnlyDictionary<string, object>? Networks,
IReadOnlyDictionary<string, object>? Volumes,
IReadOnlyDictionary<string, object>? ServiceDefaults
);
```
### VersionStickerGenerator
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Artifact;
public sealed class VersionStickerGenerator
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<VersionStickerGenerator> _logger;
public string Generate(Release release, DeploymentJob job, Target target)
{
var sticker = new VersionSticker
{
SchemaVersion = "1.0",
Release = new ReleaseInfo
{
Id = release.Id.ToString(),
Name = release.Name,
ManifestDigest = release.ManifestDigest,
FinalizedAt = release.FinalizedAt?.ToString("O")
},
Deployment = new DeploymentInfo
{
JobId = job.Id.ToString(),
EnvironmentId = job.EnvironmentId.ToString(),
EnvironmentName = job.EnvironmentName,
TargetId = target.Id.ToString(),
TargetName = target.Name,
Strategy = job.Strategy.ToString(),
DeployedAt = _timeProvider.GetUtcNow().ToString("O")
},
Components = release.Components.Select(c => new ComponentInfo
{
Name = c.ComponentName,
Digest = c.Digest,
Tag = c.Tag,
SemVer = c.SemVer
}).ToList()
};
var json = JsonSerializer.Serialize(sticker, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
_logger.LogDebug(
"Generated stella.version.json for release {Release} on target {Target}",
release.Name,
target.Name);
return json;
}
}
public sealed class VersionSticker
{
public required string SchemaVersion { get; set; }
public required ReleaseInfo Release { get; set; }
public required DeploymentInfo Deployment { get; set; }
public required IReadOnlyList<ComponentInfo> Components { get; set; }
}
public sealed class ReleaseInfo
{
public required string Id { get; set; }
public required string Name { get; set; }
public string? ManifestDigest { get; set; }
public string? FinalizedAt { get; set; }
}
public sealed class DeploymentInfo
{
public required string JobId { get; set; }
public required string EnvironmentId { get; set; }
public required string EnvironmentName { get; set; }
public required string TargetId { get; set; }
public required string TargetName { get; set; }
public required string Strategy { get; set; }
public required string DeployedAt { get; set; }
}
public sealed class ComponentInfo
{
public required string Name { get; set; }
public required string Digest { get; set; }
public string? Tag { get; set; }
public string? SemVer { get; set; }
}
```
### DeploymentManifestGenerator
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Artifact;
public sealed class DeploymentManifestGenerator
{
private readonly IReleaseManager _releaseManager;
private readonly IEnvironmentService _environmentService;
private readonly IPromotionManager _promotionManager;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DeploymentManifestGenerator> _logger;
public async Task<string> GenerateAsync(DeploymentJob job, CancellationToken ct = default)
{
var release = await _releaseManager.GetAsync(job.ReleaseId, ct);
var environment = await _environmentService.GetAsync(job.EnvironmentId, ct);
var promotion = await _promotionManager.GetAsync(job.PromotionId, ct);
var manifest = new DeploymentManifest
{
SchemaVersion = "1.0",
Deployment = new DeploymentMetadata
{
JobId = job.Id.ToString(),
Strategy = job.Strategy.ToString(),
StartedAt = job.StartedAt.ToString("O"),
StartedBy = job.StartedBy.ToString()
},
Release = new ReleaseMetadata
{
Id = release!.Id.ToString(),
Name = release.Name,
ManifestDigest = release.ManifestDigest,
FinalizedAt = release.FinalizedAt?.ToString("O"),
Components = release.Components.Select(c => new ComponentMetadata
{
Id = c.ComponentId.ToString(),
Name = c.ComponentName,
Digest = c.Digest,
Tag = c.Tag,
SemVer = c.SemVer
}).ToList()
},
Environment = new EnvironmentMetadata
{
Id = environment!.Id.ToString(),
Name = environment.Name,
IsProduction = environment.IsProduction
},
Promotion = promotion is not null ? new PromotionMetadata
{
Id = promotion.Id.ToString(),
RequestedBy = promotion.RequestedBy.ToString(),
RequestedAt = promotion.RequestedAt.ToString("O"),
Approvals = promotion.Approvals.Select(a => new ApprovalMetadata
{
UserId = a.UserId.ToString(),
UserName = a.UserName,
Decision = a.Decision.ToString(),
DecidedAt = a.DecidedAt.ToString("O")
}).ToList(),
GateResults = promotion.GateResults.Select(g => new GateResultMetadata
{
GateName = g.GateName,
Passed = g.Passed,
Message = g.Message
}).ToList()
} : null,
Targets = job.Tasks.Select(t => new TargetMetadata
{
Id = t.TargetId.ToString(),
Name = t.TargetName,
Status = t.Status.ToString()
}).ToList(),
GeneratedAt = _timeProvider.GetUtcNow().ToString("O")
};
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
_logger.LogDebug("Generated deployment manifest for job {JobId}", job.Id);
return json;
}
}
// Manifest models
public sealed class DeploymentManifest
{
public required string SchemaVersion { get; set; }
public required DeploymentMetadata Deployment { get; set; }
public required ReleaseMetadata Release { get; set; }
public required EnvironmentMetadata Environment { get; set; }
public PromotionMetadata? Promotion { get; set; }
public required IReadOnlyList<TargetMetadata> Targets { get; set; }
public required string GeneratedAt { get; set; }
}
// Additional metadata classes abbreviated for brevity...
```
### ArtifactGenerator (Coordinator)
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Artifact;
public sealed class ArtifactGenerator : IArtifactGenerator
{
private readonly IReleaseManager _releaseManager;
private readonly ComposeLockGenerator _composeLockGenerator;
private readonly VersionStickerGenerator _versionStickerGenerator;
private readonly DeploymentManifestGenerator _manifestGenerator;
private readonly ILogger<ArtifactGenerator> _logger;
public async Task<DeploymentPayload> GeneratePayloadAsync(
DeploymentJob job,
CancellationToken ct = default)
{
var release = await _releaseManager.GetAsync(job.ReleaseId, ct)
?? throw new ReleaseNotFoundException(job.ReleaseId);
var composeLock = await GenerateComposeLockAsync(release, ct);
var versionSticker = await GenerateVersionStickerAsync(release, job, ct);
var manifest = await GenerateDeploymentManifestAsync(job, ct);
var components = release.Components.Select(c => new DeploymentComponent(
c.ComponentName,
c.Config.GetValueOrDefault("image", c.ComponentName),
c.Digest,
c.Config
)).ToImmutableArray();
return new DeploymentPayload
{
ReleaseId = release.Id,
ReleaseName = release.Name,
Components = components,
ComposeLock = composeLock,
VersionSticker = versionSticker,
DeploymentManifest = manifest
};
}
}
```
---
## Acceptance Criteria
- [ ] Generate digest-locked compose files
- [ ] All images use digest references
- [ ] Generate stella.version.json stickers
- [ ] Generate deployment manifests
- [ ] Include all required metadata
- [ ] Merge with compose templates
- [ ] JSON/YAML formats valid
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_001 Deploy Orchestrator | Internal | TODO |
| 104_003 Release Manager | Internal | TODO |
| YamlDotNet | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IArtifactGenerator | TODO | |
| ArtifactGenerator | TODO | |
| ComposeLockGenerator | TODO | |
| VersionStickerGenerator | TODO | |
| DeploymentManifestGenerator | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,461 @@
# SPRINT: Rollback Manager
> **Sprint ID:** 107_004
> **Module:** DEPLOY
> **Phase:** 7 - Deployment Execution
> **Status:** TODO
> **Parent:** [107_000_INDEX](SPRINT_20260110_107_000_INDEX_deployment_execution.md)
---
## Overview
Implement the Rollback Manager for handling deployment failure recovery.
### Objectives
- Plan rollback strategy for failed deployments
- Execute rollback to previous release
- Track rollback progress and status
- Generate rollback evidence
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Deployment/
│ └── Rollback/
│ ├── IRollbackManager.cs
│ ├── RollbackManager.cs
│ ├── RollbackPlanner.cs
│ ├── RollbackExecutor.cs
│ └── RollbackEvidenceGenerator.cs
└── __Tests/
```
---
## Deliverables
### IRollbackManager Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Rollback;
public interface IRollbackManager
{
Task<RollbackPlan> PlanAsync(Guid jobId, CancellationToken ct = default);
Task<DeploymentJob> ExecuteAsync(RollbackPlan plan, CancellationToken ct = default);
Task<DeploymentJob> ExecuteAsync(Guid jobId, CancellationToken ct = default);
Task<RollbackPlan?> GetPlanAsync(Guid jobId, CancellationToken ct = default);
Task<bool> CanRollbackAsync(Guid jobId, CancellationToken ct = default);
}
public sealed record RollbackPlan
{
public required Guid Id { get; init; }
public required Guid FailedJobId { get; init; }
public required Guid TargetReleaseId { get; init; }
public required string TargetReleaseName { get; init; }
public required ImmutableArray<RollbackTarget> Targets { get; init; }
public required RollbackStrategy Strategy { get; init; }
public required DateTimeOffset PlannedAt { get; init; }
}
public enum RollbackStrategy
{
RedeployPrevious, // Redeploy the previous release
RestoreSnapshot, // Restore from snapshot if available
Manual // Requires manual intervention
}
public sealed record RollbackTarget(
Guid TargetId,
string TargetName,
string CurrentDigest,
string RollbackToDigest,
RollbackTargetStatus Status
);
public enum RollbackTargetStatus
{
Pending,
RollingBack,
RolledBack,
Failed,
Skipped
}
```
### RollbackManager Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Rollback;
public sealed class RollbackManager : IRollbackManager
{
private readonly IDeploymentJobStore _jobStore;
private readonly IReleaseHistory _releaseHistory;
private readonly IReleaseManager _releaseManager;
private readonly ITargetExecutor _targetExecutor;
private readonly IArtifactGenerator _artifactGenerator;
private readonly RollbackPlanner _planner;
private readonly RollbackEvidenceGenerator _evidenceGenerator;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<RollbackManager> _logger;
public async Task<RollbackPlan> PlanAsync(Guid jobId, CancellationToken ct = default)
{
var job = await _jobStore.GetAsync(jobId, ct)
?? throw new DeploymentJobNotFoundException(jobId);
if (job.Status != DeploymentStatus.Failed)
{
throw new RollbackNotRequiredException(jobId);
}
// Find previous successful deployment
var previousRelease = await _releaseHistory.GetPreviousDeployedAsync(
job.EnvironmentId, job.ReleaseId, ct);
if (previousRelease is null)
{
throw new NoPreviousReleaseException(job.EnvironmentId);
}
var plan = await _planner.CreatePlanAsync(job, previousRelease, ct);
_logger.LogInformation(
"Created rollback plan {PlanId} for job {JobId}: rollback to {Release}",
plan.Id, jobId, previousRelease.Name);
return plan;
}
public async Task<DeploymentJob> ExecuteAsync(
RollbackPlan plan,
CancellationToken ct = default)
{
var failedJob = await _jobStore.GetAsync(plan.FailedJobId, ct)
?? throw new DeploymentJobNotFoundException(plan.FailedJobId);
var targetRelease = await _releaseManager.GetAsync(plan.TargetReleaseId, ct)
?? throw new ReleaseNotFoundException(plan.TargetReleaseId);
// Update original job to rolling back
failedJob = failedJob with { Status = DeploymentStatus.RollingBack };
await _jobStore.SaveAsync(failedJob, ct);
await _eventPublisher.PublishAsync(new RollbackStarted(
plan.Id, plan.FailedJobId, plan.TargetReleaseId,
plan.TargetReleaseName, plan.Targets.Length, _timeProvider.GetUtcNow()
), ct);
try
{
// Generate rollback payload
var payload = await _artifactGenerator.GeneratePayloadAsync(
new DeploymentJob
{
Id = _guidGenerator.NewGuid(),
TenantId = failedJob.TenantId,
PromotionId = failedJob.PromotionId,
ReleaseId = targetRelease.Id,
ReleaseName = targetRelease.Name,
EnvironmentId = failedJob.EnvironmentId,
EnvironmentName = failedJob.EnvironmentName,
Status = DeploymentStatus.Running,
Strategy = DeploymentStrategy.AllAtOnce,
Options = new DeploymentOptions(),
Tasks = [],
StartedAt = _timeProvider.GetUtcNow(),
StartedBy = Guid.Empty
}, ct);
// Execute rollback on each target
foreach (var target in plan.Targets)
{
if (target.Status != RollbackTargetStatus.Pending)
continue;
try
{
await ExecuteTargetRollbackAsync(failedJob, target, payload, ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Rollback failed for target {Target}",
target.TargetName);
}
}
// Update job status
failedJob = failedJob with
{
Status = DeploymentStatus.RolledBack,
RollbackJobId = plan.Id,
CompletedAt = _timeProvider.GetUtcNow()
};
await _jobStore.SaveAsync(failedJob, ct);
// Generate evidence
await _evidenceGenerator.GenerateAsync(plan, failedJob, ct);
await _eventPublisher.PublishAsync(new RollbackCompleted(
plan.Id, plan.FailedJobId, plan.TargetReleaseName,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Rollback completed for job {JobId} to release {Release}",
plan.FailedJobId, targetRelease.Name);
return failedJob;
}
catch (Exception ex)
{
_logger.LogError(ex, "Rollback failed for job {JobId}", plan.FailedJobId);
failedJob = failedJob with
{
Status = DeploymentStatus.Failed,
FailureReason = $"Rollback failed: {ex.Message}"
};
await _jobStore.SaveAsync(failedJob, ct);
await _eventPublisher.PublishAsync(new RollbackFailed(
plan.Id, plan.FailedJobId, ex.Message, _timeProvider.GetUtcNow()
), ct);
throw;
}
}
private async Task ExecuteTargetRollbackAsync(
DeploymentJob job,
RollbackTarget target,
DeploymentPayload payload,
CancellationToken ct)
{
_logger.LogInformation(
"Rolling back target {Target} from {Current} to {Previous}",
target.TargetName,
target.CurrentDigest[..16],
target.RollbackToDigest[..16]);
// Create a rollback task
var task = new DeploymentTask
{
Id = _guidGenerator.NewGuid(),
TargetId = target.TargetId,
TargetName = target.TargetName,
BatchIndex = 0,
Status = DeploymentTaskStatus.Pending
};
await _targetExecutor.DeployToTargetAsync(job.Id, task.Id, payload, ct);
}
public async Task<bool> CanRollbackAsync(Guid jobId, CancellationToken ct = default)
{
var job = await _jobStore.GetAsync(jobId, ct);
if (job is null)
return false;
if (job.Status != DeploymentStatus.Failed)
return false;
var previousRelease = await _releaseHistory.GetPreviousDeployedAsync(
job.EnvironmentId, job.ReleaseId, ct);
return previousRelease is not null;
}
}
```
### RollbackPlanner
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Rollback;
public sealed class RollbackPlanner
{
private readonly IInventorySyncService _inventoryService;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
public async Task<RollbackPlan> CreatePlanAsync(
DeploymentJob failedJob,
Release targetRelease,
CancellationToken ct = default)
{
var targets = new List<RollbackTarget>();
foreach (var task in failedJob.Tasks)
{
// Get current state from inventory
var snapshot = await _inventoryService.GetLatestSnapshotAsync(task.TargetId, ct);
var currentDigest = snapshot?.Containers
.FirstOrDefault(c => IsDeployedComponent(c, failedJob.ReleaseName))
?.ImageDigest ?? "";
var rollbackDigest = targetRelease.Components
.FirstOrDefault(c => MatchesTarget(c, task))
?.Digest ?? "";
targets.Add(new RollbackTarget(
TargetId: task.TargetId,
TargetName: task.TargetName,
CurrentDigest: currentDigest,
RollbackToDigest: rollbackDigest,
Status: task.Status == DeploymentTaskStatus.Completed
? RollbackTargetStatus.Pending
: RollbackTargetStatus.Skipped
));
}
return new RollbackPlan
{
Id = _guidGenerator.NewGuid(),
FailedJobId = failedJob.Id,
TargetReleaseId = targetRelease.Id,
TargetReleaseName = targetRelease.Name,
Targets = targets.ToImmutableArray(),
Strategy = RollbackStrategy.RedeployPrevious,
PlannedAt = _timeProvider.GetUtcNow()
};
}
private static bool IsDeployedComponent(ContainerInfo container, string releaseName) =>
container.Labels.GetValueOrDefault("stella.release.name") == releaseName;
private static bool MatchesTarget(ReleaseComponent component, DeploymentTask task) =>
component.ComponentName == task.TargetName;
}
```
### RollbackEvidenceGenerator
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Rollback;
public sealed class RollbackEvidenceGenerator
{
private readonly IEvidencePacketService _evidenceService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RollbackEvidenceGenerator> _logger;
public async Task GenerateAsync(
RollbackPlan plan,
DeploymentJob job,
CancellationToken ct = default)
{
var evidence = new RollbackEvidence
{
PlanId = plan.Id.ToString(),
FailedJobId = plan.FailedJobId.ToString(),
TargetReleaseId = plan.TargetReleaseId.ToString(),
TargetReleaseName = plan.TargetReleaseName,
RollbackStrategy = plan.Strategy.ToString(),
PlannedAt = plan.PlannedAt.ToString("O"),
ExecutedAt = _timeProvider.GetUtcNow().ToString("O"),
Targets = plan.Targets.Select(t => new RollbackTargetEvidence
{
TargetId = t.TargetId.ToString(),
TargetName = t.TargetName,
FromDigest = t.CurrentDigest,
ToDigest = t.RollbackToDigest,
Status = t.Status.ToString()
}).ToList(),
OriginalFailure = job.FailureReason
};
var packet = await _evidenceService.CreatePacketAsync(new CreateEvidencePacketRequest
{
Type = EvidenceType.Rollback,
SubjectId = plan.FailedJobId,
Content = JsonSerializer.Serialize(evidence),
Metadata = new Dictionary<string, string>
{
["rollbackPlanId"] = plan.Id.ToString(),
["targetRelease"] = plan.TargetReleaseName,
["environment"] = job.EnvironmentName
}
}, ct);
_logger.LogInformation(
"Generated rollback evidence packet {PacketId} for job {JobId}",
packet.Id, plan.FailedJobId);
}
}
public sealed class RollbackEvidence
{
public required string PlanId { get; set; }
public required string FailedJobId { get; set; }
public required string TargetReleaseId { get; set; }
public required string TargetReleaseName { get; set; }
public required string RollbackStrategy { get; set; }
public required string PlannedAt { get; set; }
public required string ExecutedAt { get; set; }
public required IReadOnlyList<RollbackTargetEvidence> Targets { get; set; }
public string? OriginalFailure { get; set; }
}
public sealed class RollbackTargetEvidence
{
public required string TargetId { get; set; }
public required string TargetName { get; set; }
public required string FromDigest { get; set; }
public required string ToDigest { get; set; }
public required string Status { get; set; }
}
```
---
## Acceptance Criteria
- [ ] Plan rollback from failed deployment
- [ ] Find previous successful release
- [ ] Execute rollback on completed targets
- [ ] Skip targets not yet deployed
- [ ] Track rollback progress
- [ ] Generate rollback evidence
- [ ] Update deployment status
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_002 Target Executor | Internal | TODO |
| 104_004 Release Catalog | Internal | TODO |
| 109_002 Evidence Packets | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IRollbackManager | TODO | |
| RollbackManager | TODO | |
| RollbackPlanner | TODO | |
| RollbackEvidenceGenerator | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,460 @@
# SPRINT: Deployment Strategies
> **Sprint ID:** 107_005
> **Module:** DEPLOY
> **Phase:** 7 - Deployment Execution
> **Status:** TODO
> **Parent:** [107_000_INDEX](SPRINT_20260110_107_000_INDEX_deployment_execution.md)
---
## Overview
Implement deployment strategies for different deployment patterns.
### Objectives
- Rolling deployment strategy
- Blue-green deployment strategy
- Canary deployment strategy
- All-at-once deployment strategy
- Strategy factory for selection
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Deployment/
│ └── Strategy/
│ ├── IDeploymentStrategy.cs
│ ├── DeploymentStrategyFactory.cs
│ ├── RollingStrategy.cs
│ ├── BlueGreenStrategy.cs
│ ├── CanaryStrategy.cs
│ └── AllAtOnceStrategy.cs
└── __Tests/
```
---
## Deliverables
### IDeploymentStrategy Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Strategy;
public interface IDeploymentStrategy
{
string Name { get; }
Task<IReadOnlyList<DeploymentBatch>> PlanAsync(DeploymentJob job, CancellationToken ct = default);
Task<bool> ShouldProceedAsync(DeploymentBatch completedBatch, CancellationToken ct = default);
}
public sealed record DeploymentBatch(
int Index,
ImmutableArray<Guid> TaskIds,
BatchRequirements Requirements
);
public sealed record BatchRequirements(
bool WaitForHealthCheck = true,
TimeSpan? HealthCheckTimeout = null,
double MinSuccessRate = 1.0
);
```
### RollingStrategy
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Strategy;
public sealed class RollingStrategy : IDeploymentStrategy
{
private readonly ITargetHealthChecker _healthChecker;
private readonly ILogger<RollingStrategy> _logger;
public string Name => "rolling";
public Task<IReadOnlyList<DeploymentBatch>> PlanAsync(
DeploymentJob job,
CancellationToken ct = default)
{
var batchSize = ParseBatchSize(job.Options.BatchSize, job.Tasks.Length);
var batches = new List<DeploymentBatch>();
var taskIds = job.Tasks.Select(t => t.Id).ToList();
var batchIndex = 0;
while (taskIds.Count > 0)
{
var batchTaskIds = taskIds.Take(batchSize).ToImmutableArray();
taskIds = taskIds.Skip(batchSize).ToList();
batches.Add(new DeploymentBatch(
Index: batchIndex++,
TaskIds: batchTaskIds,
Requirements: new BatchRequirements(
WaitForHealthCheck: job.Options.WaitForHealthCheck,
HealthCheckTimeout: TimeSpan.FromMinutes(5)
)
));
}
_logger.LogInformation(
"Rolling strategy planned {BatchCount} batches of ~{BatchSize} targets",
batches.Count, batchSize);
return Task.FromResult<IReadOnlyList<DeploymentBatch>>(batches);
}
public async Task<bool> ShouldProceedAsync(
DeploymentBatch completedBatch,
CancellationToken ct = default)
{
if (!completedBatch.Requirements.WaitForHealthCheck)
return true;
// Check health of deployed targets
foreach (var taskId in completedBatch.TaskIds)
{
var isHealthy = await _healthChecker.CheckTaskHealthAsync(taskId, ct);
if (!isHealthy)
{
_logger.LogWarning(
"Task {TaskId} in batch {BatchIndex} is unhealthy, halting rollout",
taskId, completedBatch.Index);
return false;
}
}
return true;
}
private static int ParseBatchSize(string? batchSizeSpec, int totalTargets)
{
if (string.IsNullOrEmpty(batchSizeSpec))
return Math.Max(1, totalTargets / 4);
if (batchSizeSpec.EndsWith('%'))
{
var percent = int.Parse(batchSizeSpec.TrimEnd('%'), CultureInfo.InvariantCulture);
return Math.Max(1, totalTargets * percent / 100);
}
return int.Parse(batchSizeSpec, CultureInfo.InvariantCulture);
}
}
```
### BlueGreenStrategy
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Strategy;
public sealed class BlueGreenStrategy : IDeploymentStrategy
{
private readonly ITargetHealthChecker _healthChecker;
private readonly ITrafficRouter _trafficRouter;
private readonly ILogger<BlueGreenStrategy> _logger;
public string Name => "blue-green";
public Task<IReadOnlyList<DeploymentBatch>> PlanAsync(
DeploymentJob job,
CancellationToken ct = default)
{
// Blue-green deploys to all targets at once (the "green" set)
// Then switches traffic from "blue" to "green"
var batches = new List<DeploymentBatch>
{
// Phase 1: Deploy to green (all targets)
new DeploymentBatch(
Index: 0,
TaskIds: job.Tasks.Select(t => t.Id).ToImmutableArray(),
Requirements: new BatchRequirements(
WaitForHealthCheck: true,
HealthCheckTimeout: TimeSpan.FromMinutes(10),
MinSuccessRate: 1.0 // All must succeed
)
)
};
_logger.LogInformation(
"Blue-green strategy: deploy all {Count} targets, then switch traffic",
job.Tasks.Length);
return Task.FromResult<IReadOnlyList<DeploymentBatch>>(batches);
}
public async Task<bool> ShouldProceedAsync(
DeploymentBatch completedBatch,
CancellationToken ct = default)
{
// All targets must be healthy before switching traffic
foreach (var taskId in completedBatch.TaskIds)
{
var isHealthy = await _healthChecker.CheckTaskHealthAsync(taskId, ct);
if (!isHealthy)
{
_logger.LogWarning(
"Blue-green: target {TaskId} unhealthy, not switching traffic",
taskId);
return false;
}
}
// Switch traffic to new deployment
_logger.LogInformation("Blue-green: switching traffic to new deployment");
// Traffic switching handled externally based on deployment type
return true;
}
}
```
### CanaryStrategy
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Strategy;
public sealed class CanaryStrategy : IDeploymentStrategy
{
private readonly ITargetHealthChecker _healthChecker;
private readonly IMetricsCollector _metricsCollector;
private readonly ILogger<CanaryStrategy> _logger;
public string Name => "canary";
public Task<IReadOnlyList<DeploymentBatch>> PlanAsync(
DeploymentJob job,
CancellationToken ct = default)
{
var tasks = job.Tasks.ToList();
var batches = new List<DeploymentBatch>();
if (tasks.Count == 0)
return Task.FromResult<IReadOnlyList<DeploymentBatch>>(batches);
// Canary phase: 1 target (or min 5% if many targets)
var canarySize = Math.Max(1, tasks.Count / 20);
batches.Add(new DeploymentBatch(
Index: 0,
TaskIds: tasks.Take(canarySize).Select(t => t.Id).ToImmutableArray(),
Requirements: new BatchRequirements(
WaitForHealthCheck: true,
HealthCheckTimeout: TimeSpan.FromMinutes(10),
MinSuccessRate: 1.0
)
));
tasks = tasks.Skip(canarySize).ToList();
// Gradual rollout: 25% increments
var batchIndex = 1;
var incrementSize = Math.Max(1, (tasks.Count + 3) / 4);
while (tasks.Count > 0)
{
var batchTasks = tasks.Take(incrementSize).ToList();
tasks = tasks.Skip(incrementSize).ToList();
batches.Add(new DeploymentBatch(
Index: batchIndex++,
TaskIds: batchTasks.Select(t => t.Id).ToImmutableArray(),
Requirements: new BatchRequirements(
WaitForHealthCheck: true,
MinSuccessRate: 0.95 // Allow some failures in later batches
)
));
}
_logger.LogInformation(
"Canary strategy: {CanarySize} canary, then {Batches} batches",
canarySize, batches.Count - 1);
return Task.FromResult<IReadOnlyList<DeploymentBatch>>(batches);
}
public async Task<bool> ShouldProceedAsync(
DeploymentBatch completedBatch,
CancellationToken ct = default)
{
// Check health
var healthyCount = 0;
foreach (var taskId in completedBatch.TaskIds)
{
if (await _healthChecker.CheckTaskHealthAsync(taskId, ct))
healthyCount++;
}
var successRate = (double)healthyCount / completedBatch.TaskIds.Length;
if (successRate < completedBatch.Requirements.MinSuccessRate)
{
_logger.LogWarning(
"Canary batch {Index}: success rate {Rate:P0} below threshold {Required:P0}",
completedBatch.Index, successRate, completedBatch.Requirements.MinSuccessRate);
return false;
}
// For canary batch (index 0), also check metrics
if (completedBatch.Index == 0)
{
var metrics = await _metricsCollector.GetCanaryMetricsAsync(
completedBatch.TaskIds, ct);
if (metrics.ErrorRate > 0.05)
{
_logger.LogWarning(
"Canary error rate {Rate:P1} exceeds threshold",
metrics.ErrorRate);
return false;
}
}
return true;
}
}
public sealed record CanaryMetrics(
double ErrorRate,
double Latency99th,
int RequestCount
);
```
### AllAtOnceStrategy
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Strategy;
public sealed class AllAtOnceStrategy : IDeploymentStrategy
{
private readonly ITargetHealthChecker _healthChecker;
private readonly ILogger<AllAtOnceStrategy> _logger;
public string Name => "all-at-once";
public Task<IReadOnlyList<DeploymentBatch>> PlanAsync(
DeploymentJob job,
CancellationToken ct = default)
{
var batches = new List<DeploymentBatch>
{
new DeploymentBatch(
Index: 0,
TaskIds: job.Tasks.Select(t => t.Id).ToImmutableArray(),
Requirements: new BatchRequirements(
WaitForHealthCheck: job.Options.WaitForHealthCheck,
MinSuccessRate: 0.8 // Allow some failures
)
)
};
_logger.LogInformation(
"All-at-once strategy: deploying to all {Count} targets simultaneously",
job.Tasks.Length);
return Task.FromResult<IReadOnlyList<DeploymentBatch>>(batches);
}
public Task<bool> ShouldProceedAsync(
DeploymentBatch completedBatch,
CancellationToken ct = default)
{
// Single batch, always "proceed" (nothing to proceed to)
return Task.FromResult(true);
}
}
```
### DeploymentStrategyFactory
```csharp
namespace StellaOps.ReleaseOrchestrator.Deployment.Strategy;
public interface IDeploymentStrategyFactory
{
IDeploymentStrategy Create(DeploymentStrategy strategy);
IReadOnlyList<string> GetAvailableStrategies();
}
public sealed class DeploymentStrategyFactory : IDeploymentStrategyFactory
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<DeploymentStrategyFactory> _logger;
private static readonly Dictionary<DeploymentStrategy, Type> StrategyTypes = new()
{
[DeploymentStrategy.Rolling] = typeof(RollingStrategy),
[DeploymentStrategy.BlueGreen] = typeof(BlueGreenStrategy),
[DeploymentStrategy.Canary] = typeof(CanaryStrategy),
[DeploymentStrategy.AllAtOnce] = typeof(AllAtOnceStrategy)
};
public IDeploymentStrategy Create(DeploymentStrategy strategy)
{
if (!StrategyTypes.TryGetValue(strategy, out var type))
{
throw new UnsupportedStrategyException(strategy);
}
var instance = _serviceProvider.GetRequiredService(type) as IDeploymentStrategy;
if (instance is null)
{
throw new StrategyCreationException(strategy);
}
_logger.LogDebug("Created {Strategy} deployment strategy", strategy);
return instance;
}
public IReadOnlyList<string> GetAvailableStrategies() =>
StrategyTypes.Keys.Select(s => s.ToString()).ToList().AsReadOnly();
}
```
---
## Acceptance Criteria
- [ ] Rolling strategy batches targets
- [ ] Rolling strategy checks health between batches
- [ ] Blue-green deploys all then switches
- [ ] Canary deploys incrementally
- [ ] Canary checks metrics after canary batch
- [ ] All-at-once deploys simultaneously
- [ ] Strategy factory creates correct type
- [ ] Batch size parsing works
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_002 Target Executor | Internal | TODO |
| 103_002 Target Registry | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IDeploymentStrategy | TODO | |
| DeploymentStrategyFactory | TODO | |
| RollingStrategy | TODO | |
| BlueGreenStrategy | TODO | |
| CanaryStrategy | TODO | |
| AllAtOnceStrategy | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,291 @@
# SPRINT INDEX: Phase 8 - Agents
> **Epic:** Release Orchestrator
> **Phase:** 8 - Agents
> **Batch:** 108
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 8 implements the deployment Agents - lightweight, secure executors that run on target hosts to perform container operations.
### Objectives
- Agent core runtime with gRPC communication
- Docker agent for standalone containers
- Compose agent for docker-compose deployments
- SSH agent for remote execution
- WinRM agent for Windows hosts
- ECS agent for AWS Elastic Container Service
- Nomad agent for HashiCorp Nomad
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 108_001 | Agent Core Runtime | AGENTS | TODO | 103_003 |
| 108_002 | Agent - Docker | AGENTS | TODO | 108_001 |
| 108_003 | Agent - Compose | AGENTS | TODO | 108_002 |
| 108_004 | Agent - SSH | AGENTS | TODO | 108_001 |
| 108_005 | Agent - WinRM | AGENTS | TODO | 108_001 |
| 108_006 | Agent - ECS | AGENTS | TODO | 108_001 |
| 108_007 | Agent - Nomad | AGENTS | TODO | 108_001 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ AGENT SYSTEM │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ AGENT CORE RUNTIME (108_001) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Stella Agent │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │
│ │ │ │ gRPC │ │ Task Queue │ │ Heartbeat │ │ │ │
│ │ │ │ Server │ │ Executor │ │ Service │ │ │ │
│ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │
│ │ │ │ Credential │ │ Log │ │ Metrics │ │ │ │
│ │ │ │ Resolver │ │ Streamer │ │ Reporter │ │ │ │
│ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ DOCKER AGENT (108_002)│ │ COMPOSE AGENT (108_003)│ │
│ │ │ │ │ │
│ │ - docker pull │ │ - docker compose pull │ │
│ │ - docker run │ │ - docker compose up │ │
│ │ - docker stop │ │ - docker compose down │ │
│ │ - docker rm │ │ - service health check │ │
│ │ - health check │ │ - volume management │ │
│ │ - log streaming │ │ - network management │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ SSH AGENT (108_004) │ │ WINRM AGENT (108_005) │ │
│ │ │ │ │ │
│ │ - Remote Docker ops │ │ - Windows containers │ │
│ │ - Remote script exec │ │ - IIS management │ │
│ │ - File transfer │ │ - Windows services │ │
│ │ - SSH key auth │ │ - PowerShell execution │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ ECS AGENT (108_006) │ │ NOMAD AGENT (108_007) │ │
│ │ │ │ │ │
│ │ - ECS service deploy │ │ - Nomad job deploy │ │
│ │ - Task execution │ │ - Job scaling │ │
│ │ - Service scaling │ │ - Allocation health │ │
│ │ - CloudWatch logs │ │ - Log streaming │ │
│ │ - Fargate + EC2 │ │ - Multiple drivers │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 108_001: Agent Core Runtime
| Deliverable | Type | Description |
|-------------|------|-------------|
| `AgentHost` | Service | Main agent process |
| `GrpcAgentServer` | gRPC | Task receiver |
| `TaskExecutor` | Class | Task execution |
| `HeartbeatService` | Service | Health reporting |
| `CredentialResolver` | Class | Secret resolution |
| `LogStreamer` | Class | Log forwarding |
### 108_002: Agent - Docker
| Deliverable | Type | Description |
|-------------|------|-------------|
| `DockerCapability` | Capability | Docker operations |
| `DockerPullTask` | Task | Pull images |
| `DockerRunTask` | Task | Create/start containers |
| `DockerStopTask` | Task | Stop containers |
| `DockerHealthCheck` | Task | Container health |
### 108_003: Agent - Compose
| Deliverable | Type | Description |
|-------------|------|-------------|
| `ComposeCapability` | Capability | Compose operations |
| `ComposePullTask` | Task | Pull compose images |
| `ComposeUpTask` | Task | Deploy compose stack |
| `ComposeDownTask` | Task | Remove compose stack |
| `ComposeScaleTask` | Task | Scale services |
### 108_004: Agent - SSH
| Deliverable | Type | Description |
|-------------|------|-------------|
| `SshCapability` | Capability | SSH operations |
| `SshExecuteTask` | Task | Remote command execution |
| `SshFileTransferTask` | Task | SCP file transfer |
| `SshTunnelTask` | Task | SSH tunneling |
### 108_005: Agent - WinRM
| Deliverable | Type | Description |
|-------------|------|-------------|
| `WinRmCapability` | Capability | WinRM operations |
| `PowerShellTask` | Task | PowerShell execution |
| `WindowsServiceTask` | Task | Service management |
| `WindowsContainerTask` | Task | Windows container ops |
### 108_006: Agent - ECS
| Deliverable | Type | Description |
|-------------|------|-------------|
| `EcsCapability` | Capability | AWS ECS operations |
| `EcsDeployServiceTask` | Task | Deploy/update ECS services |
| `EcsRunTaskTask` | Task | Run one-off ECS tasks |
| `EcsStopTaskTask` | Task | Stop running tasks |
| `EcsScaleServiceTask` | Task | Scale services |
| `EcsHealthCheckTask` | Task | Service health check |
| `CloudWatchLogStreamer` | Class | Log streaming |
### 108_007: Agent - Nomad
| Deliverable | Type | Description |
|-------------|------|-------------|
| `NomadCapability` | Capability | Nomad operations |
| `NomadDeployJobTask` | Task | Deploy Nomad jobs |
| `NomadStopJobTask` | Task | Stop jobs |
| `NomadScaleJobTask` | Task | Scale task groups |
| `NomadHealthCheckTask` | Task | Job health check |
| `NomadDispatchJobTask` | Task | Dispatch parameterized jobs |
| `NomadLogStreamer` | Class | Allocation log streaming |
---
## Agent Protocol (gRPC)
```protobuf
syntax = "proto3";
package stella.agent.v1;
service AgentService {
// Task execution
rpc ExecuteTask(TaskRequest) returns (stream TaskProgress);
rpc CancelTask(CancelTaskRequest) returns (CancelTaskResponse);
// Health and status
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
rpc GetStatus(StatusRequest) returns (StatusResponse);
// Logs
rpc StreamLogs(LogStreamRequest) returns (stream LogEntry);
}
message TaskRequest {
string task_id = 1;
string task_type = 2;
bytes payload = 3;
map<string, string> credentials = 4;
}
message TaskProgress {
string task_id = 1;
TaskState state = 2;
int32 progress_percent = 3;
string message = 4;
bytes result = 5;
}
enum TaskState {
PENDING = 0;
RUNNING = 1;
SUCCEEDED = 2;
FAILED = 3;
CANCELLED = 4;
}
```
---
## Agent Security
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ AGENT SECURITY MODEL │
│ │
│ Registration Flow: │
│ ┌─────────────┐ 1. Get token ┌─────────────┐ │
│ │ Admin │ ───────────────► │ Orchestrator │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ 2. Generate one-time token │
│ ▼ │
│ ┌─────────────┐ 3. Register ┌─────────────┐ │
│ │ Agent │ ───────────────► │ Orchestrator │ │
│ │ (token) │ └──────┬──────┘ │
│ └─────────────┘ │ 4. Issue mTLS certificate │
│ ▼ │
│ ┌─────────────┐ 5. Connect ┌─────────────┐ │
│ │ Agent │ ◄───────────────►│ Orchestrator │ │
│ │ (mTLS) │ (gRPC) └─────────────┘ │
│ └─────────────┘ │
│ │
│ Security Controls: │
│ - mTLS with short-lived certificates (24h) │
│ - Capability-based authorization │
│ - Task-scoped credentials (never stored) │
│ - Audit logging of all operations │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| 103_003 Agent Manager | Registration |
| 107_002 Target Executor | Task dispatch |
| Docker.DotNet | Docker API |
| AWSSDK.ECS | AWS ECS API |
| AWSSDK.CloudWatchLogs | AWS CloudWatch Logs |
| Nomad.Api (custom) | Nomad HTTP API |
---
## Acceptance Criteria
- [ ] Agent registers with one-time token
- [ ] mTLS established after registration
- [ ] Heartbeat updates agent status
- [ ] Docker pull/run/stop works
- [ ] Compose up/down works
- [ ] SSH remote execution works
- [ ] WinRM PowerShell works
- [ ] ECS service deploy/scale works
- [ ] ECS task run/stop works
- [ ] Nomad job deploy/stop works
- [ ] Nomad job scaling works
- [ ] Log streaming works (Docker, CloudWatch, Nomad)
- [ ] Credentials resolved at runtime
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 8 index created |
| 10-Jan-2026 | Added ECS agent (108_006) and Nomad agent (108_007) sprints per feature completeness review |

View File

@@ -0,0 +1,776 @@
# SPRINT: Agent Core Runtime
> **Sprint ID:** 108_001
> **Module:** AGENTS
> **Phase:** 8 - Agents
> **Status:** TODO
> **Parent:** [108_000_INDEX](SPRINT_20260110_108_000_INDEX_agents.md)
---
## Overview
Implement the Agent Core Runtime - the foundational process that runs on target hosts to receive and execute deployment tasks.
### Objectives
- Agent host process with lifecycle management
- gRPC server for task reception
- Heartbeat service for health reporting
- Credential resolution at runtime
- Log streaming to orchestrator
- Capability registration system
### Working Directory
```
src/ReleaseOrchestrator/
├── __Agents/
│ └── StellaOps.Agent.Core/
│ ├── AgentHost.cs
│ ├── AgentConfiguration.cs
│ ├── GrpcAgentServer.cs
│ ├── TaskExecutor.cs
│ ├── HeartbeatService.cs
│ ├── CredentialResolver.cs
│ ├── LogStreamer.cs
│ └── CapabilityRegistry.cs
└── __Tests/
```
---
## Deliverables
### AgentConfiguration
```csharp
namespace StellaOps.Agent.Core;
public sealed class AgentConfiguration
{
public required string AgentId { get; set; }
public required string AgentName { get; set; }
public required string OrchestratorUrl { get; set; }
public required string CertificatePath { get; set; }
public required string PrivateKeyPath { get; set; }
public required string CaCertificatePath { get; set; }
public int GrpcPort { get; set; } = 50051;
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan TaskTimeout { get; set; } = TimeSpan.FromMinutes(30);
public IReadOnlyList<string> EnabledCapabilities { get; set; } = [];
}
```
### IAgentCapability Interface
```csharp
namespace StellaOps.Agent.Core;
public interface IAgentCapability
{
string Name { get; }
string Version { get; }
IReadOnlyList<string> SupportedTaskTypes { get; }
Task<bool> InitializeAsync(CancellationToken ct = default);
Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct = default);
Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default);
}
public sealed record CapabilityHealthStatus(
bool IsHealthy,
string? Message = null,
IReadOnlyDictionary<string, object>? Details = null
);
```
### CapabilityRegistry
```csharp
namespace StellaOps.Agent.Core;
public sealed class CapabilityRegistry
{
private readonly Dictionary<string, IAgentCapability> _capabilities = new();
private readonly ILogger<CapabilityRegistry> _logger;
public void Register(IAgentCapability capability)
{
if (_capabilities.ContainsKey(capability.Name))
{
throw new CapabilityAlreadyRegisteredException(capability.Name);
}
_capabilities[capability.Name] = capability;
_logger.LogInformation(
"Registered capability {Name} v{Version} with tasks: {Tasks}",
capability.Name,
capability.Version,
string.Join(", ", capability.SupportedTaskTypes));
}
public IAgentCapability? GetForTaskType(string taskType)
{
return _capabilities.Values
.FirstOrDefault(c => c.SupportedTaskTypes.Contains(taskType));
}
public IReadOnlyList<CapabilityInfo> GetCapabilities()
{
return _capabilities.Values.Select(c => new CapabilityInfo(
c.Name,
c.Version,
c.SupportedTaskTypes.ToImmutableArray()
)).ToList().AsReadOnly();
}
public async Task InitializeAllAsync(CancellationToken ct = default)
{
foreach (var (name, capability) in _capabilities)
{
var success = await capability.InitializeAsync(ct);
if (!success)
{
_logger.LogWarning("Capability {Name} failed to initialize", name);
}
}
}
}
public sealed record CapabilityInfo(
string Name,
string Version,
ImmutableArray<string> SupportedTaskTypes
);
```
### AgentTask Model
```csharp
namespace StellaOps.Agent.Core;
public sealed record AgentTask
{
public required Guid Id { get; init; }
public required string TaskType { get; init; }
public required string Payload { get; init; }
public required IReadOnlyDictionary<string, string> Credentials { get; init; }
public required IReadOnlyDictionary<string, string> Variables { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(30);
}
public sealed record TaskResult
{
public required Guid TaskId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public IReadOnlyDictionary<string, object> Outputs { get; init; } = new Dictionary<string, object>();
public DateTimeOffset CompletedAt { get; init; }
public TimeSpan Duration { get; init; }
}
```
### TaskExecutor
```csharp
namespace StellaOps.Agent.Core;
public sealed class TaskExecutor
{
private readonly CapabilityRegistry _capabilities;
private readonly CredentialResolver _credentialResolver;
private readonly ILogger<TaskExecutor> _logger;
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _runningTasks = new();
public async Task<TaskResult> ExecuteAsync(
AgentTask task,
IProgress<TaskProgress>? progress = null,
CancellationToken ct = default)
{
var capability = _capabilities.GetForTaskType(task.TaskType)
?? throw new UnsupportedTaskTypeException(task.TaskType);
using var taskCts = new CancellationTokenSource(task.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, taskCts.Token);
_runningTasks[task.Id] = linkedCts;
var stopwatch = Stopwatch.StartNew();
try
{
_logger.LogInformation(
"Executing task {TaskId} of type {TaskType}",
task.Id, task.TaskType);
progress?.Report(new TaskProgress(task.Id, TaskState.Running, 0, "Starting"));
// Resolve credentials
var resolvedTask = await ResolveCredentialsAsync(task, linkedCts.Token);
// Execute via capability
var result = await capability.ExecuteAsync(resolvedTask, linkedCts.Token);
progress?.Report(new TaskProgress(
task.Id,
result.Success ? TaskState.Succeeded : TaskState.Failed,
100,
result.Success ? "Completed" : result.Error ?? "Failed"));
_logger.LogInformation(
"Task {TaskId} completed with status {Status} in {Duration}ms",
task.Id,
result.Success ? "success" : "failure",
stopwatch.ElapsedMilliseconds);
return result with { Duration = stopwatch.Elapsed };
}
catch (OperationCanceledException) when (taskCts.IsCancellationRequested)
{
_logger.LogWarning("Task {TaskId} timed out after {Timeout}", task.Id, task.Timeout);
progress?.Report(new TaskProgress(task.Id, TaskState.Failed, 0, "Timeout"));
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Task timed out after {task.Timeout}",
CompletedAt = DateTimeOffset.UtcNow,
Duration = stopwatch.Elapsed
};
}
catch (OperationCanceledException)
{
_logger.LogInformation("Task {TaskId} was cancelled", task.Id);
progress?.Report(new TaskProgress(task.Id, TaskState.Cancelled, 0, "Cancelled"));
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = "Task was cancelled",
CompletedAt = DateTimeOffset.UtcNow,
Duration = stopwatch.Elapsed
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Task {TaskId} failed with exception", task.Id);
progress?.Report(new TaskProgress(task.Id, TaskState.Failed, 0, ex.Message));
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow,
Duration = stopwatch.Elapsed
};
}
finally
{
_runningTasks.TryRemove(task.Id, out _);
}
}
public bool CancelTask(Guid taskId)
{
if (_runningTasks.TryGetValue(taskId, out var cts))
{
cts.Cancel();
return true;
}
return false;
}
private async Task<AgentTask> ResolveCredentialsAsync(AgentTask task, CancellationToken ct)
{
var resolvedCredentials = new Dictionary<string, string>();
foreach (var (key, value) in task.Credentials)
{
resolvedCredentials[key] = await _credentialResolver.ResolveAsync(value, ct);
}
return task with { Credentials = resolvedCredentials };
}
}
public sealed record TaskProgress(
Guid TaskId,
TaskState State,
int ProgressPercent,
string Message
);
public enum TaskState
{
Pending,
Running,
Succeeded,
Failed,
Cancelled
}
```
### CredentialResolver
```csharp
namespace StellaOps.Agent.Core;
public sealed class CredentialResolver
{
private readonly IEnumerable<ICredentialProvider> _providers;
private readonly ILogger<CredentialResolver> _logger;
public async Task<string> ResolveAsync(string reference, CancellationToken ct = default)
{
// Reference format: provider://path
// e.g., env://DB_PASSWORD, file:///etc/secrets/api-key, vault://secrets/myapp/apikey
var parsed = ParseReference(reference);
if (parsed is null)
{
// Not a reference, return as-is (literal value)
return reference;
}
var provider = _providers.FirstOrDefault(p => p.Scheme == parsed.Scheme)
?? throw new UnknownCredentialProviderException(parsed.Scheme);
var value = await provider.GetSecretAsync(parsed.Path, ct);
if (value is null)
{
throw new CredentialNotFoundException(reference);
}
_logger.LogDebug("Resolved credential reference {Scheme}://***", parsed.Scheme);
return value;
}
private static CredentialReference? ParseReference(string reference)
{
if (string.IsNullOrEmpty(reference))
return null;
var match = Regex.Match(reference, @"^([a-z]+)://(.+)$");
if (!match.Success)
return null;
return new CredentialReference(match.Groups[1].Value, match.Groups[2].Value);
}
}
public interface ICredentialProvider
{
string Scheme { get; }
Task<string?> GetSecretAsync(string path, CancellationToken ct = default);
}
public sealed class EnvironmentCredentialProvider : ICredentialProvider
{
public string Scheme => "env";
public Task<string?> GetSecretAsync(string path, CancellationToken ct = default)
{
return Task.FromResult(Environment.GetEnvironmentVariable(path));
}
}
public sealed class FileCredentialProvider : ICredentialProvider
{
public string Scheme => "file";
public async Task<string?> GetSecretAsync(string path, CancellationToken ct = default)
{
if (!File.Exists(path))
return null;
return (await File.ReadAllTextAsync(path, ct)).Trim();
}
}
internal sealed record CredentialReference(string Scheme, string Path);
```
### HeartbeatService
```csharp
namespace StellaOps.Agent.Core;
public sealed class HeartbeatService : BackgroundService
{
private readonly AgentConfiguration _config;
private readonly CapabilityRegistry _capabilities;
private readonly IOrchestratorClient _orchestratorClient;
private readonly ILogger<HeartbeatService> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Heartbeat service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await SendHeartbeatAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send heartbeat");
}
await Task.Delay(_config.HeartbeatInterval, stoppingToken);
}
}
private async Task SendHeartbeatAsync(CancellationToken ct)
{
var capabilities = _capabilities.GetCapabilities();
var health = await CheckCapabilityHealthAsync(ct);
var heartbeat = new AgentHeartbeat
{
AgentId = _config.AgentId,
Timestamp = DateTimeOffset.UtcNow,
Status = health.AllHealthy ? AgentStatus.Active : AgentStatus.Degraded,
Capabilities = capabilities,
SystemInfo = GetSystemInfo(),
RunningTaskCount = GetRunningTaskCount(),
HealthDetails = health.Details
};
await _orchestratorClient.SendHeartbeatAsync(heartbeat, ct);
_logger.LogDebug(
"Heartbeat sent: status={Status}, tasks={TaskCount}",
heartbeat.Status,
heartbeat.RunningTaskCount);
}
private async Task<HealthCheckResult> CheckCapabilityHealthAsync(CancellationToken ct)
{
var details = new Dictionary<string, object>();
var allHealthy = true;
foreach (var capability in _capabilities.GetCapabilities())
{
var cap = _capabilities.GetForTaskType(capability.SupportedTaskTypes.First());
if (cap is null) continue;
var health = await cap.CheckHealthAsync(ct);
details[capability.Name] = new { health.IsHealthy, health.Message };
allHealthy = allHealthy && health.IsHealthy;
}
return new HealthCheckResult(allHealthy, details);
}
private static SystemInfo GetSystemInfo()
{
return new SystemInfo
{
Hostname = Environment.MachineName,
OsDescription = RuntimeInformation.OSDescription,
ProcessorCount = Environment.ProcessorCount,
MemoryBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes
};
}
private int GetRunningTaskCount()
{
// Implementation would get from TaskExecutor
return 0;
}
}
public sealed record AgentHeartbeat
{
public required string AgentId { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required AgentStatus Status { get; init; }
public required IReadOnlyList<CapabilityInfo> Capabilities { get; init; }
public required SystemInfo SystemInfo { get; init; }
public int RunningTaskCount { get; init; }
public IReadOnlyDictionary<string, object>? HealthDetails { get; init; }
}
public sealed record SystemInfo
{
public required string Hostname { get; init; }
public required string OsDescription { get; init; }
public required int ProcessorCount { get; init; }
public required long MemoryBytes { get; init; }
}
public enum AgentStatus
{
Inactive,
Active,
Degraded,
Disconnected
}
internal sealed record HealthCheckResult(
bool AllHealthy,
IReadOnlyDictionary<string, object> Details
);
```
### LogStreamer
```csharp
namespace StellaOps.Agent.Core;
public sealed class LogStreamer : IAsyncDisposable
{
private readonly IOrchestratorClient _orchestratorClient;
private readonly Channel<LogEntry> _logChannel;
private readonly ILogger<LogStreamer> _logger;
private readonly CancellationTokenSource _cts = new();
private readonly Task _streamTask;
public LogStreamer(IOrchestratorClient orchestratorClient, ILogger<LogStreamer> logger)
{
_orchestratorClient = orchestratorClient;
_logger = logger;
_logChannel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(10000)
{
FullMode = BoundedChannelFullMode.DropOldest
});
_streamTask = StreamLogsAsync(_cts.Token);
}
public void Log(Guid taskId, LogLevel level, string message)
{
var entry = new LogEntry
{
TaskId = taskId,
Timestamp = DateTimeOffset.UtcNow,
Level = level,
Message = message
};
if (!_logChannel.Writer.TryWrite(entry))
{
_logger.LogWarning("Log channel full, dropping log entry");
}
}
private async Task StreamLogsAsync(CancellationToken ct)
{
var batch = new List<LogEntry>();
var batchTimeout = TimeSpan.FromMilliseconds(100);
while (!ct.IsCancellationRequested)
{
try
{
// Collect logs for batching
using var timeoutCts = new CancellationTokenSource(batchTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
while (batch.Count < 100)
{
if (_logChannel.Reader.TryRead(out var entry))
{
batch.Add(entry);
}
else
{
await _logChannel.Reader.WaitToReadAsync(linkedCts.Token);
}
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Timeout, send what we have
}
if (batch.Count > 0)
{
try
{
await _orchestratorClient.SendLogsAsync(batch, ct);
batch.Clear();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send logs, will retry");
}
}
}
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
await _streamTask;
_cts.Dispose();
}
}
public sealed record LogEntry
{
public required Guid TaskId { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required LogLevel Level { get; init; }
public required string Message { get; init; }
}
```
### AgentHost
```csharp
namespace StellaOps.Agent.Core;
public sealed class AgentHost : IHostedService
{
private readonly AgentConfiguration _config;
private readonly CapabilityRegistry _capabilities;
private readonly GrpcAgentServer _grpcServer;
private readonly HeartbeatService _heartbeatService;
private readonly IOrchestratorClient _orchestratorClient;
private readonly ILogger<AgentHost> _logger;
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(
"Starting Stella Agent {Name} ({Id})",
_config.AgentName,
_config.AgentId);
// Initialize capabilities
await _capabilities.InitializeAllAsync(cancellationToken);
// Connect to orchestrator
await _orchestratorClient.ConnectAsync(cancellationToken);
// Start gRPC server
await _grpcServer.StartAsync(cancellationToken);
_logger.LogInformation(
"Agent started on port {Port} with {Count} capabilities",
_config.GrpcPort,
_capabilities.GetCapabilities().Count);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping Stella Agent");
await _grpcServer.StopAsync(cancellationToken);
await _orchestratorClient.DisconnectAsync(cancellationToken);
_logger.LogInformation("Agent stopped");
}
}
```
### GrpcAgentServer
```csharp
namespace StellaOps.Agent.Core;
public sealed class GrpcAgentServer
{
private readonly AgentConfiguration _config;
private readonly TaskExecutor _taskExecutor;
private readonly LogStreamer _logStreamer;
private readonly ILogger<GrpcAgentServer> _logger;
private Server? _server;
public Task StartAsync(CancellationToken ct = default)
{
var serverCredentials = BuildServerCredentials();
_server = new Server
{
Services = { AgentService.BindService(new AgentServiceImpl(_taskExecutor, _logStreamer)) },
Ports = { new ServerPort("0.0.0.0", _config.GrpcPort, serverCredentials) }
};
_server.Start();
_logger.LogInformation("gRPC server started on port {Port}", _config.GrpcPort);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken ct = default)
{
if (_server is not null)
{
await _server.ShutdownAsync();
_logger.LogInformation("gRPC server stopped");
}
}
private ServerCredentials BuildServerCredentials()
{
var cert = File.ReadAllText(_config.CertificatePath);
var key = File.ReadAllText(_config.PrivateKeyPath);
var caCert = File.ReadAllText(_config.CaCertificatePath);
var keyCertPair = new KeyCertificatePair(cert, key);
return new SslServerCredentials(
new[] { keyCertPair },
caCert,
SslClientCertificateRequestType.RequestAndRequireAndVerify);
}
}
```
---
## Acceptance Criteria
- [ ] Agent process starts and runs as service
- [ ] gRPC server accepts mTLS connections
- [ ] Capabilities register at startup
- [ ] Tasks execute via correct capability
- [ ] Task cancellation works
- [ ] Heartbeat sends to orchestrator
- [ ] Credentials resolve at runtime
- [ ] Logs stream to orchestrator
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 103_003 Agent Manager | Internal | TODO |
| Grpc.AspNetCore | NuGet | Available |
| Google.Protobuf | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| AgentConfiguration | TODO | |
| IAgentCapability | TODO | |
| CapabilityRegistry | TODO | |
| TaskExecutor | TODO | |
| CredentialResolver | TODO | |
| HeartbeatService | TODO | |
| LogStreamer | TODO | |
| AgentHost | TODO | |
| GrpcAgentServer | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,936 @@
# SPRINT: Agent - Docker
> **Sprint ID:** 108_002
> **Module:** AGENTS
> **Phase:** 8 - Agents
> **Status:** TODO
> **Parent:** [108_000_INDEX](SPRINT_20260110_108_000_INDEX_agents.md)
---
## Overview
Implement the Docker Agent capability for managing standalone Docker containers on target hosts.
### Objectives
- Docker image pull operations
- Container creation and start
- Container stop and removal
- Container health checking
- Log streaming from containers
- Registry authentication
### Working Directory
```
src/ReleaseOrchestrator/
├── __Agents/
│ └── StellaOps.Agent.Docker/
│ ├── DockerCapability.cs
│ ├── Tasks/
│ │ ├── DockerPullTask.cs
│ │ ├── DockerRunTask.cs
│ │ ├── DockerStopTask.cs
│ │ ├── DockerRemoveTask.cs
│ │ └── DockerHealthCheckTask.cs
│ ├── DockerClientFactory.cs
│ └── ContainerLogStreamer.cs
└── __Tests/
```
---
## Deliverables
### DockerCapability
```csharp
namespace StellaOps.Agent.Docker;
public sealed class DockerCapability : IAgentCapability
{
private readonly IDockerClient _dockerClient;
private readonly ILogger<DockerCapability> _logger;
private readonly Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>> _taskHandlers;
public string Name => "docker";
public string Version => "1.0.0";
public IReadOnlyList<string> SupportedTaskTypes => new[]
{
"docker.pull",
"docker.run",
"docker.stop",
"docker.remove",
"docker.health-check",
"docker.logs"
};
public DockerCapability(IDockerClient dockerClient, ILogger<DockerCapability> logger)
{
_dockerClient = dockerClient;
_logger = logger;
_taskHandlers = new Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>>
{
["docker.pull"] = ExecutePullAsync,
["docker.run"] = ExecuteRunAsync,
["docker.stop"] = ExecuteStopAsync,
["docker.remove"] = ExecuteRemoveAsync,
["docker.health-check"] = ExecuteHealthCheckAsync
};
}
public async Task<bool> InitializeAsync(CancellationToken ct = default)
{
try
{
var version = await _dockerClient.System.GetVersionAsync(ct);
_logger.LogInformation(
"Docker capability initialized: Docker {Version} on {OS}",
version.Version,
version.Os);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize Docker capability");
return false;
}
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct = default)
{
if (!_taskHandlers.TryGetValue(task.TaskType, out var handler))
{
throw new UnsupportedTaskTypeException(task.TaskType);
}
return await handler(task, ct);
}
public async Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default)
{
try
{
await _dockerClient.System.PingAsync(ct);
return new CapabilityHealthStatus(true, "Docker daemon responding");
}
catch (Exception ex)
{
return new CapabilityHealthStatus(false, $"Docker daemon not responding: {ex.Message}");
}
}
private Task<TaskResult> ExecutePullAsync(AgentTask task, CancellationToken ct) =>
new DockerPullTask(_dockerClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteRunAsync(AgentTask task, CancellationToken ct) =>
new DockerRunTask(_dockerClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteStopAsync(AgentTask task, CancellationToken ct) =>
new DockerStopTask(_dockerClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteRemoveAsync(AgentTask task, CancellationToken ct) =>
new DockerRemoveTask(_dockerClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteHealthCheckAsync(AgentTask task, CancellationToken ct) =>
new DockerHealthCheckTask(_dockerClient, _logger).ExecuteAsync(task, ct);
}
```
### DockerPullTask
```csharp
namespace StellaOps.Agent.Docker.Tasks;
public sealed class DockerPullTask
{
private readonly IDockerClient _dockerClient;
private readonly ILogger _logger;
public sealed record PullPayload
{
public required string Image { get; init; }
public string? Tag { get; init; }
public string? Digest { get; init; }
public string? Registry { get; init; }
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<PullPayload>(task.Payload)
?? throw new InvalidPayloadException("docker.pull");
var imageRef = BuildImageReference(payload);
_logger.LogInformation("Pulling image {Image}", imageRef);
try
{
// Get registry credentials if provided
AuthConfig? authConfig = null;
if (task.Credentials.TryGetValue("registry.username", out var username) &&
task.Credentials.TryGetValue("registry.password", out var password))
{
authConfig = new AuthConfig
{
Username = username,
Password = password,
ServerAddress = payload.Registry ?? "https://index.docker.io/v1/"
};
}
await _dockerClient.Images.CreateImageAsync(
new ImagesCreateParameters
{
FromImage = imageRef
},
authConfig,
new Progress<JSONMessage>(msg =>
{
if (!string.IsNullOrEmpty(msg.Status))
{
_logger.LogDebug("Pull progress: {Status}", msg.Status);
}
}),
ct);
// Verify the image was pulled
var images = await _dockerClient.Images.ListImagesAsync(
new ImagesListParameters
{
Filters = new Dictionary<string, IDictionary<string, bool>>
{
["reference"] = new Dictionary<string, bool> { [imageRef] = true }
}
},
ct);
if (images.Count == 0)
{
throw new ImagePullException(imageRef, "Image not found after pull");
}
var pulledImage = images.First();
_logger.LogInformation(
"Successfully pulled image {Image} (ID: {Id})",
imageRef,
pulledImage.ID[..12]);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["imageId"] = pulledImage.ID,
["size"] = pulledImage.Size,
["digest"] = payload.Digest ?? ExtractDigest(pulledImage)
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (DockerApiException ex)
{
_logger.LogError(ex, "Failed to pull image {Image}", imageRef);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to pull image: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static string BuildImageReference(PullPayload payload)
{
var image = payload.Image;
if (!string.IsNullOrEmpty(payload.Registry))
{
image = $"{payload.Registry}/{image}";
}
if (!string.IsNullOrEmpty(payload.Digest))
{
return $"{image}@{payload.Digest}";
}
if (!string.IsNullOrEmpty(payload.Tag))
{
return $"{image}:{payload.Tag}";
}
return $"{image}:latest";
}
private static string ExtractDigest(ImagesListResponse image)
{
return image.RepoDigests.FirstOrDefault()?.Split('@').LastOrDefault() ?? "";
}
}
```
### DockerRunTask
```csharp
namespace StellaOps.Agent.Docker.Tasks;
public sealed class DockerRunTask
{
private readonly IDockerClient _dockerClient;
private readonly ILogger _logger;
public sealed record RunPayload
{
public required string Image { get; init; }
public required string Name { get; init; }
public IReadOnlyDictionary<string, string>? Environment { get; init; }
public IReadOnlyList<string>? Ports { get; init; }
public IReadOnlyList<string>? Volumes { get; init; }
public IReadOnlyDictionary<string, string>? Labels { get; init; }
public string? Network { get; init; }
public IReadOnlyList<string>? Command { get; init; }
public ContainerHealthConfig? HealthCheck { get; init; }
public bool AutoRemove { get; init; }
public RestartPolicy? RestartPolicy { get; init; }
}
public sealed record ContainerHealthConfig
{
public required IReadOnlyList<string> Test { get; init; }
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
public int Retries { get; init; } = 3;
public TimeSpan StartPeriod { get; init; } = TimeSpan.FromSeconds(0);
}
public sealed record RestartPolicy
{
public string Name { get; init; } = "no";
public int MaximumRetryCount { get; init; }
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<RunPayload>(task.Payload)
?? throw new InvalidPayloadException("docker.run");
_logger.LogInformation(
"Creating container {Name} from image {Image}",
payload.Name,
payload.Image);
try
{
// Check if container already exists
var existingContainers = await _dockerClient.Containers.ListContainersAsync(
new ContainersListParameters
{
All = true,
Filters = new Dictionary<string, IDictionary<string, bool>>
{
["name"] = new Dictionary<string, bool> { [payload.Name] = true }
}
},
ct);
if (existingContainers.Any())
{
var existing = existingContainers.First();
_logger.LogInformation(
"Container {Name} already exists (ID: {Id}), removing",
payload.Name,
existing.ID[..12]);
await _dockerClient.Containers.StopContainerAsync(existing.ID, new ContainerStopParameters(), ct);
await _dockerClient.Containers.RemoveContainerAsync(existing.ID, new ContainerRemoveParameters(), ct);
}
// Merge labels with Stella metadata
var labels = new Dictionary<string, string>(payload.Labels ?? new Dictionary<string, string>());
labels["stella.managed"] = "true";
labels["stella.task.id"] = task.Id.ToString();
// Build create parameters
var createParams = new CreateContainerParameters
{
Image = payload.Image,
Name = payload.Name,
Env = BuildEnvironment(payload.Environment, task.Variables),
Labels = labels,
Cmd = payload.Command?.ToList(),
HostConfig = new HostConfig
{
PortBindings = ParsePortBindings(payload.Ports),
Binds = payload.Volumes?.ToList(),
NetworkMode = payload.Network,
AutoRemove = payload.AutoRemove,
RestartPolicy = payload.RestartPolicy is not null
? new Docker.DotNet.Models.RestartPolicy
{
Name = Enum.Parse<RestartPolicyKind>(payload.RestartPolicy.Name, ignoreCase: true),
MaximumRetryCount = payload.RestartPolicy.MaximumRetryCount
}
: null
},
Healthcheck = payload.HealthCheck is not null
? new HealthConfig
{
Test = payload.HealthCheck.Test.ToList(),
Interval = (long)payload.HealthCheck.Interval.TotalNanoseconds,
Timeout = (long)payload.HealthCheck.Timeout.TotalNanoseconds,
Retries = payload.HealthCheck.Retries,
StartPeriod = (long)payload.HealthCheck.StartPeriod.TotalNanoseconds
}
: null
};
// Create container
var createResponse = await _dockerClient.Containers.CreateContainerAsync(createParams, ct);
_logger.LogInformation(
"Created container {Name} (ID: {Id})",
payload.Name,
createResponse.ID[..12]);
// Start container
var started = await _dockerClient.Containers.StartContainerAsync(
createResponse.ID,
new ContainerStartParameters(),
ct);
if (!started)
{
throw new ContainerStartException(payload.Name, "Container failed to start");
}
// Get container info
var containerInfo = await _dockerClient.Containers.InspectContainerAsync(createResponse.ID, ct);
_logger.LogInformation(
"Started container {Name} (State: {State})",
payload.Name,
containerInfo.State.Status);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["containerId"] = createResponse.ID,
["containerName"] = payload.Name,
["state"] = containerInfo.State.Status,
["ipAddress"] = containerInfo.NetworkSettings.IPAddress
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (DockerApiException ex)
{
_logger.LogError(ex, "Failed to create/start container {Name}", payload.Name);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to create/start container: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static List<string> BuildEnvironment(
IReadOnlyDictionary<string, string>? env,
IReadOnlyDictionary<string, string> variables)
{
var result = new List<string>();
if (env is not null)
{
foreach (var (key, value) in env)
{
// Substitute variables in values
var resolvedValue = SubstituteVariables(value, variables);
result.Add($"{key}={resolvedValue}");
}
}
return result;
}
private static string SubstituteVariables(string value, IReadOnlyDictionary<string, string> variables)
{
return Regex.Replace(value, @"\$\{([^}]+)\}", match =>
{
var varName = match.Groups[1].Value;
return variables.TryGetValue(varName, out var varValue) ? varValue : match.Value;
});
}
private static IDictionary<string, IList<PortBinding>> ParsePortBindings(IReadOnlyList<string>? ports)
{
var bindings = new Dictionary<string, IList<PortBinding>>();
if (ports is null)
return bindings;
foreach (var port in ports)
{
// Format: hostPort:containerPort or hostPort:containerPort/protocol
var parts = port.Split(':');
if (parts.Length != 2)
continue;
var hostPort = parts[0];
var containerPortWithProtocol = parts[1];
var containerPort = containerPortWithProtocol.Contains('/')
? containerPortWithProtocol
: $"{containerPortWithProtocol}/tcp";
bindings[containerPort] = new List<PortBinding>
{
new() { HostPort = hostPort }
};
}
return bindings;
}
}
```
### DockerStopTask
```csharp
namespace StellaOps.Agent.Docker.Tasks;
public sealed class DockerStopTask
{
private readonly IDockerClient _dockerClient;
private readonly ILogger _logger;
public sealed record StopPayload
{
public string? ContainerId { get; init; }
public string? ContainerName { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<StopPayload>(task.Payload)
?? throw new InvalidPayloadException("docker.stop");
var containerId = await ResolveContainerIdAsync(payload, ct);
if (containerId is null)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = "Container not found",
CompletedAt = DateTimeOffset.UtcNow
};
}
_logger.LogInformation("Stopping container {ContainerId}", containerId[..12]);
try
{
var stopped = await _dockerClient.Containers.StopContainerAsync(
containerId,
new ContainerStopParameters
{
WaitBeforeKillSeconds = (uint)payload.Timeout.TotalSeconds
},
ct);
if (stopped)
{
_logger.LogInformation("Container {ContainerId} stopped", containerId[..12]);
}
else
{
_logger.LogWarning("Container {ContainerId} was already stopped", containerId[..12]);
}
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["containerId"] = containerId,
["wasRunning"] = stopped
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (DockerApiException ex)
{
_logger.LogError(ex, "Failed to stop container {ContainerId}", containerId[..12]);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to stop container: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private async Task<string?> ResolveContainerIdAsync(StopPayload payload, CancellationToken ct)
{
if (!string.IsNullOrEmpty(payload.ContainerId))
{
return payload.ContainerId;
}
if (!string.IsNullOrEmpty(payload.ContainerName))
{
var containers = await _dockerClient.Containers.ListContainersAsync(
new ContainersListParameters
{
All = true,
Filters = new Dictionary<string, IDictionary<string, bool>>
{
["name"] = new Dictionary<string, bool> { [payload.ContainerName] = true }
}
},
ct);
return containers.FirstOrDefault()?.ID;
}
return null;
}
}
```
### DockerHealthCheckTask
```csharp
namespace StellaOps.Agent.Docker.Tasks;
public sealed class DockerHealthCheckTask
{
private readonly IDockerClient _dockerClient;
private readonly ILogger _logger;
public sealed record HealthCheckPayload
{
public string? ContainerId { get; init; }
public string? ContainerName { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
public bool WaitForHealthy { get; init; } = true;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<HealthCheckPayload>(task.Payload)
?? throw new InvalidPayloadException("docker.health-check");
var containerId = await ResolveContainerIdAsync(payload, ct);
if (containerId is null)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = "Container not found",
CompletedAt = DateTimeOffset.UtcNow
};
}
_logger.LogInformation("Checking health of container {ContainerId}", containerId[..12]);
try
{
using var timeoutCts = new CancellationTokenSource(payload.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
while (!linkedCts.IsCancellationRequested)
{
var containerInfo = await _dockerClient.Containers.InspectContainerAsync(containerId, linkedCts.Token);
if (containerInfo.State.Status != "running")
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Container not running (state: {containerInfo.State.Status})",
Outputs = new Dictionary<string, object>
{
["state"] = containerInfo.State.Status
},
CompletedAt = DateTimeOffset.UtcNow
};
}
var health = containerInfo.State.Health;
if (health is null)
{
// No health check configured, container is running
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["containerId"] = containerId,
["state"] = "running",
["healthCheck"] = "none"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
if (health.Status == "healthy")
{
_logger.LogInformation("Container {ContainerId} is healthy", containerId[..12]);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["containerId"] = containerId,
["state"] = "running",
["healthStatus"] = "healthy",
["failingStreak"] = health.FailingStreak
},
CompletedAt = DateTimeOffset.UtcNow
};
}
if (health.Status == "unhealthy")
{
var lastLog = health.Log.LastOrDefault();
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Container unhealthy: {lastLog?.Output ?? "unknown"}",
Outputs = new Dictionary<string, object>
{
["containerId"] = containerId,
["healthStatus"] = "unhealthy",
["failingStreak"] = health.FailingStreak
},
CompletedAt = DateTimeOffset.UtcNow
};
}
if (!payload.WaitForHealthy)
{
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["containerId"] = containerId,
["healthStatus"] = health.Status
},
CompletedAt = DateTimeOffset.UtcNow
};
}
// Wait before checking again
await Task.Delay(TimeSpan.FromSeconds(2), linkedCts.Token);
}
throw new OperationCanceledException();
}
catch (OperationCanceledException)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Health check timed out after {payload.Timeout}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private async Task<string?> ResolveContainerIdAsync(HealthCheckPayload payload, CancellationToken ct)
{
if (!string.IsNullOrEmpty(payload.ContainerId))
{
return payload.ContainerId;
}
if (!string.IsNullOrEmpty(payload.ContainerName))
{
var containers = await _dockerClient.Containers.ListContainersAsync(
new ContainersListParameters
{
All = true,
Filters = new Dictionary<string, IDictionary<string, bool>>
{
["name"] = new Dictionary<string, bool> { [payload.ContainerName] = true }
}
},
ct);
return containers.FirstOrDefault()?.ID;
}
return null;
}
}
```
### ContainerLogStreamer
```csharp
namespace StellaOps.Agent.Docker;
public sealed class ContainerLogStreamer
{
private readonly IDockerClient _dockerClient;
private readonly LogStreamer _logStreamer;
private readonly ILogger<ContainerLogStreamer> _logger;
public async Task StreamLogsAsync(
Guid taskId,
string containerId,
CancellationToken ct = default)
{
try
{
var stream = await _dockerClient.Containers.GetContainerLogsAsync(
containerId,
false,
new ContainerLogsParameters
{
Follow = true,
ShowStdout = true,
ShowStderr = true,
Timestamps = true
},
ct);
using var reader = new StreamReader(stream);
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (line is null)
break;
var (level, message) = ParseLogLine(line);
_logStreamer.Log(taskId, level, message);
}
}
catch (OperationCanceledException)
{
// Expected when task completes
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error streaming logs for container {ContainerId}", containerId[..12]);
}
}
private static (LogLevel Level, string Message) ParseLogLine(string line)
{
// Docker log format includes stream type marker
// First 8 bytes are header: [stream_type, 0, 0, 0, size (4 bytes)]
// For text streams, we just parse the content
var level = LogLevel.Information;
// Simple heuristic for log level detection
if (line.Contains("ERROR", StringComparison.OrdinalIgnoreCase) ||
line.Contains("FATAL", StringComparison.OrdinalIgnoreCase))
{
level = LogLevel.Error;
}
else if (line.Contains("WARN", StringComparison.OrdinalIgnoreCase))
{
level = LogLevel.Warning;
}
else if (line.Contains("DEBUG", StringComparison.OrdinalIgnoreCase))
{
level = LogLevel.Debug;
}
return (level, line);
}
}
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/deployment/agent-based.md` (partial) | Markdown | Agent-based deployment documentation (Docker agent with 9 operations) |
---
## Acceptance Criteria
### Code
- [ ] Pull images with digest references
- [ ] Pull from authenticated registries
- [ ] Create containers with environment variables
- [ ] Create containers with port mappings
- [ ] Create containers with volume mounts
- [ ] Start containers successfully
- [ ] Stop containers gracefully
- [ ] Remove containers
- [ ] Check container health status
- [ ] Wait for health check to pass
- [ ] Stream container logs
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Docker agent documentation section created
- [ ] All Docker operations documented (pull, run, stop, remove, health check, logs)
- [ ] TypeScript implementation code included
- [ ] Digest verification flow documented
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 108_001 Agent Core Runtime | Internal | TODO |
| Docker.DotNet | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| DockerCapability | TODO | |
| DockerPullTask | TODO | |
| DockerRunTask | TODO | |
| DockerStopTask | TODO | |
| DockerRemoveTask | TODO | |
| DockerHealthCheckTask | TODO | |
| ContainerLogStreamer | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: deployment/agent-based.md (partial - Docker) |

View File

@@ -0,0 +1,976 @@
# SPRINT: Agent - Compose
> **Sprint ID:** 108_003
> **Module:** AGENTS
> **Phase:** 8 - Agents
> **Status:** TODO
> **Parent:** [108_000_INDEX](SPRINT_20260110_108_000_INDEX_agents.md)
---
## Overview
Implement the Compose Agent capability for managing docker-compose stacks on target hosts.
### Objectives
- Compose stack deployment (up)
- Compose stack teardown (down)
- Service scaling
- Stack health checking
- Compose file management with digest-locked references
### Working Directory
```
src/ReleaseOrchestrator/
├── __Agents/
│ └── StellaOps.Agent.Compose/
│ ├── ComposeCapability.cs
│ ├── Tasks/
│ │ ├── ComposePullTask.cs
│ │ ├── ComposeUpTask.cs
│ │ ├── ComposeDownTask.cs
│ │ ├── ComposeScaleTask.cs
│ │ └── ComposeHealthCheckTask.cs
│ ├── ComposeFileManager.cs
│ └── ComposeExecutor.cs
└── __Tests/
```
---
## Deliverables
### ComposeCapability
```csharp
namespace StellaOps.Agent.Compose;
public sealed class ComposeCapability : IAgentCapability
{
private readonly ComposeExecutor _executor;
private readonly ComposeFileManager _fileManager;
private readonly ILogger<ComposeCapability> _logger;
private readonly Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>> _taskHandlers;
public string Name => "compose";
public string Version => "1.0.0";
public IReadOnlyList<string> SupportedTaskTypes => new[]
{
"compose.pull",
"compose.up",
"compose.down",
"compose.scale",
"compose.health-check",
"compose.ps"
};
public ComposeCapability(
ComposeExecutor executor,
ComposeFileManager fileManager,
ILogger<ComposeCapability> logger)
{
_executor = executor;
_fileManager = fileManager;
_logger = logger;
_taskHandlers = new Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>>
{
["compose.pull"] = ExecutePullAsync,
["compose.up"] = ExecuteUpAsync,
["compose.down"] = ExecuteDownAsync,
["compose.scale"] = ExecuteScaleAsync,
["compose.health-check"] = ExecuteHealthCheckAsync,
["compose.ps"] = ExecutePsAsync
};
}
public async Task<bool> InitializeAsync(CancellationToken ct = default)
{
try
{
var version = await _executor.GetVersionAsync(ct);
_logger.LogInformation("Compose capability initialized: {Version}", version);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize Compose capability");
return false;
}
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct = default)
{
if (!_taskHandlers.TryGetValue(task.TaskType, out var handler))
{
throw new UnsupportedTaskTypeException(task.TaskType);
}
return await handler(task, ct);
}
public async Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default)
{
try
{
await _executor.GetVersionAsync(ct);
return new CapabilityHealthStatus(true, "Docker Compose available");
}
catch (Exception ex)
{
return new CapabilityHealthStatus(false, $"Docker Compose not available: {ex.Message}");
}
}
private Task<TaskResult> ExecutePullAsync(AgentTask task, CancellationToken ct) =>
new ComposePullTask(_executor, _fileManager, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteUpAsync(AgentTask task, CancellationToken ct) =>
new ComposeUpTask(_executor, _fileManager, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteDownAsync(AgentTask task, CancellationToken ct) =>
new ComposeDownTask(_executor, _fileManager, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteScaleAsync(AgentTask task, CancellationToken ct) =>
new ComposeScaleTask(_executor, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteHealthCheckAsync(AgentTask task, CancellationToken ct) =>
new ComposeHealthCheckTask(_executor, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecutePsAsync(AgentTask task, CancellationToken ct) =>
new ComposePsTask(_executor, _logger).ExecuteAsync(task, ct);
}
```
### ComposeExecutor
```csharp
namespace StellaOps.Agent.Compose;
public sealed class ComposeExecutor
{
private readonly string _composeCommand;
private readonly ILogger<ComposeExecutor> _logger;
public ComposeExecutor(ILogger<ComposeExecutor> logger)
{
_logger = logger;
// Detect docker compose v2 vs docker-compose v1
_composeCommand = DetectComposeCommand();
}
public async Task<string> GetVersionAsync(CancellationToken ct = default)
{
var result = await ExecuteAsync("version --short", null, ct);
return result.StandardOutput.Trim();
}
public async Task<ComposeResult> PullAsync(
string projectDir,
string composeFile,
IReadOnlyDictionary<string, string>? credentials = null,
CancellationToken ct = default)
{
var args = $"-f {composeFile} pull";
return await ExecuteAsync(args, projectDir, ct, BuildEnvironment(credentials));
}
public async Task<ComposeResult> UpAsync(
string projectDir,
string composeFile,
ComposeUpOptions options,
CancellationToken ct = default)
{
var args = $"-f {composeFile} up -d";
if (options.ForceRecreate)
args += " --force-recreate";
if (options.RemoveOrphans)
args += " --remove-orphans";
if (options.NoStart)
args += " --no-start";
if (options.Services?.Count > 0)
args += " " + string.Join(" ", options.Services);
return await ExecuteAsync(args, projectDir, ct, options.Environment);
}
public async Task<ComposeResult> DownAsync(
string projectDir,
string composeFile,
ComposeDownOptions options,
CancellationToken ct = default)
{
var args = $"-f {composeFile} down";
if (options.RemoveVolumes)
args += " -v";
if (options.RemoveOrphans)
args += " --remove-orphans";
if (options.Timeout.HasValue)
args += $" -t {(int)options.Timeout.Value.TotalSeconds}";
return await ExecuteAsync(args, projectDir, ct);
}
public async Task<ComposeResult> ScaleAsync(
string projectDir,
string composeFile,
IReadOnlyDictionary<string, int> scaling,
CancellationToken ct = default)
{
var scaleArgs = string.Join(" ", scaling.Select(kv => $"{kv.Key}={kv.Value}"));
var args = $"-f {composeFile} up -d --no-recreate --scale {scaleArgs}";
return await ExecuteAsync(args, projectDir, ct);
}
public async Task<ComposeResult> PsAsync(
string projectDir,
string composeFile,
bool all = false,
CancellationToken ct = default)
{
var args = $"-f {composeFile} ps --format json";
if (all)
args += " -a";
return await ExecuteAsync(args, projectDir, ct);
}
private async Task<ComposeResult> ExecuteAsync(
string arguments,
string? workingDirectory,
CancellationToken ct,
IReadOnlyDictionary<string, string>? environment = null)
{
var psi = new ProcessStartInfo
{
FileName = _composeCommand.Split(' ')[0],
Arguments = _composeCommand.Contains(' ')
? $"{_composeCommand.Substring(_composeCommand.IndexOf(' ') + 1)} {arguments}"
: arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (environment is not null)
{
foreach (var (key, value) in environment)
{
psi.Environment[key] = value;
}
}
_logger.LogDebug("Executing: {Command} {Args}", psi.FileName, psi.Arguments);
using var process = new Process { StartInfo = psi };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
stdout.AppendLine(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
stderr.AppendLine(e.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(ct);
var result = new ComposeResult(
process.ExitCode == 0,
process.ExitCode,
stdout.ToString(),
stderr.ToString());
if (!result.Success)
{
_logger.LogWarning(
"Compose command failed with exit code {ExitCode}: {Stderr}",
result.ExitCode,
result.StandardError);
}
return result;
}
private static string DetectComposeCommand()
{
// Try docker compose (v2) first
try
{
var psi = new ProcessStartInfo
{
FileName = "docker",
Arguments = "compose version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
process?.WaitForExit(5000);
if (process?.ExitCode == 0)
{
return "docker compose";
}
}
catch { }
// Fall back to docker-compose (v1)
return "docker-compose";
}
private static IReadOnlyDictionary<string, string>? BuildEnvironment(
IReadOnlyDictionary<string, string>? credentials)
{
if (credentials is null)
return null;
var env = new Dictionary<string, string>();
if (credentials.TryGetValue("registry.username", out var user))
env["DOCKER_REGISTRY_USER"] = user;
if (credentials.TryGetValue("registry.password", out var pass))
env["DOCKER_REGISTRY_PASSWORD"] = pass;
return env;
}
}
public sealed record ComposeResult(
bool Success,
int ExitCode,
string StandardOutput,
string StandardError
);
public sealed record ComposeUpOptions
{
public bool ForceRecreate { get; init; }
public bool RemoveOrphans { get; init; } = true;
public bool NoStart { get; init; }
public IReadOnlyList<string>? Services { get; init; }
public IReadOnlyDictionary<string, string>? Environment { get; init; }
}
public sealed record ComposeDownOptions
{
public bool RemoveVolumes { get; init; }
public bool RemoveOrphans { get; init; } = true;
public TimeSpan? Timeout { get; init; }
}
```
### ComposeFileManager
```csharp
namespace StellaOps.Agent.Compose;
public sealed class ComposeFileManager
{
private readonly string _deploymentRoot;
private readonly ILogger<ComposeFileManager> _logger;
public ComposeFileManager(AgentConfiguration config, ILogger<ComposeFileManager> logger)
{
_deploymentRoot = config.DeploymentRoot ?? "/var/lib/stella-agent/deployments";
_logger = logger;
}
public async Task<string> WriteComposeFileAsync(
string projectName,
string composeLockContent,
string versionStickerContent,
CancellationToken ct = default)
{
var projectDir = Path.Combine(_deploymentRoot, projectName);
Directory.CreateDirectory(projectDir);
// Write compose.stella.lock.yml
var composeFile = Path.Combine(projectDir, "compose.stella.lock.yml");
await File.WriteAllTextAsync(composeFile, composeLockContent, ct);
_logger.LogDebug("Wrote compose file: {Path}", composeFile);
// Write stella.version.json
var versionFile = Path.Combine(projectDir, "stella.version.json");
await File.WriteAllTextAsync(versionFile, versionStickerContent, ct);
_logger.LogDebug("Wrote version sticker: {Path}", versionFile);
return projectDir;
}
public string GetProjectDirectory(string projectName)
{
return Path.Combine(_deploymentRoot, projectName);
}
public string GetComposeFilePath(string projectName)
{
return Path.Combine(GetProjectDirectory(projectName), "compose.stella.lock.yml");
}
public async Task<string?> GetVersionStickerAsync(string projectName, CancellationToken ct = default)
{
var path = Path.Combine(GetProjectDirectory(projectName), "stella.version.json");
if (!File.Exists(path))
return null;
return await File.ReadAllTextAsync(path, ct);
}
public async Task BackupExistingAsync(string projectName, CancellationToken ct = default)
{
var projectDir = GetProjectDirectory(projectName);
if (!Directory.Exists(projectDir))
return;
var backupDir = Path.Combine(projectDir, ".backup", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"));
Directory.CreateDirectory(backupDir);
foreach (var file in Directory.GetFiles(projectDir, "*.*"))
{
var fileName = Path.GetFileName(file);
if (fileName.StartsWith("."))
continue;
File.Copy(file, Path.Combine(backupDir, fileName));
}
_logger.LogDebug("Backed up existing deployment to {BackupDir}", backupDir);
}
public async Task CleanupAsync(string projectName, CancellationToken ct = default)
{
var projectDir = GetProjectDirectory(projectName);
if (Directory.Exists(projectDir))
{
Directory.Delete(projectDir, recursive: true);
_logger.LogDebug("Cleaned up project directory: {Path}", projectDir);
}
}
}
```
### ComposeUpTask
```csharp
namespace StellaOps.Agent.Compose.Tasks;
public sealed class ComposeUpTask
{
private readonly ComposeExecutor _executor;
private readonly ComposeFileManager _fileManager;
private readonly ILogger _logger;
public sealed record UpPayload
{
public required string ProjectName { get; init; }
public required string ComposeLock { get; init; }
public required string VersionSticker { get; init; }
public bool ForceRecreate { get; init; } = true;
public bool RemoveOrphans { get; init; } = true;
public IReadOnlyList<string>? Services { get; init; }
public IReadOnlyDictionary<string, string>? Environment { get; init; }
public bool BackupExisting { get; init; } = true;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<UpPayload>(task.Payload)
?? throw new InvalidPayloadException("compose.up");
_logger.LogInformation("Deploying compose stack: {Project}", payload.ProjectName);
try
{
// Backup existing deployment
if (payload.BackupExisting)
{
await _fileManager.BackupExistingAsync(payload.ProjectName, ct);
}
// Write compose files
var projectDir = await _fileManager.WriteComposeFileAsync(
payload.ProjectName,
payload.ComposeLock,
payload.VersionSticker,
ct);
var composeFile = _fileManager.GetComposeFilePath(payload.ProjectName);
// Pull images first
_logger.LogInformation("Pulling images for {Project}", payload.ProjectName);
var pullResult = await _executor.PullAsync(
projectDir,
composeFile,
task.Credentials as IReadOnlyDictionary<string, string>,
ct);
if (!pullResult.Success)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to pull images: {pullResult.StandardError}",
CompletedAt = DateTimeOffset.UtcNow
};
}
// Deploy the stack
_logger.LogInformation("Starting compose stack: {Project}", payload.ProjectName);
var upResult = await _executor.UpAsync(
projectDir,
composeFile,
new ComposeUpOptions
{
ForceRecreate = payload.ForceRecreate,
RemoveOrphans = payload.RemoveOrphans,
Services = payload.Services,
Environment = MergeEnvironment(payload.Environment, task.Variables)
},
ct);
if (!upResult.Success)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to deploy stack: {upResult.StandardError}",
CompletedAt = DateTimeOffset.UtcNow
};
}
// Get running services
var psResult = await _executor.PsAsync(projectDir, composeFile, ct: ct);
var services = ParseServicesFromPs(psResult.StandardOutput);
_logger.LogInformation(
"Deployed compose stack {Project} with {Count} services",
payload.ProjectName,
services.Count);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["projectName"] = payload.ProjectName,
["projectDir"] = projectDir,
["services"] = services
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deploy compose stack {Project}", payload.ProjectName);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static IReadOnlyDictionary<string, string>? MergeEnvironment(
IReadOnlyDictionary<string, string>? env,
IReadOnlyDictionary<string, string> variables)
{
if (env is null && variables.Count == 0)
return null;
var merged = new Dictionary<string, string>(variables);
if (env is not null)
{
foreach (var (key, value) in env)
{
merged[key] = value;
}
}
return merged;
}
private static IReadOnlyList<ServiceStatus> ParseServicesFromPs(string output)
{
if (string.IsNullOrWhiteSpace(output))
return [];
try
{
var services = new List<ServiceStatus>();
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var service = JsonSerializer.Deserialize<JsonElement>(line);
services.Add(new ServiceStatus(
service.GetProperty("Name").GetString() ?? "",
service.GetProperty("Service").GetString() ?? "",
service.GetProperty("State").GetString() ?? "",
service.GetProperty("Health").GetString()
));
}
return services;
}
catch
{
return [];
}
}
}
public sealed record ServiceStatus(
string Name,
string Service,
string State,
string? Health
);
```
### ComposeDownTask
```csharp
namespace StellaOps.Agent.Compose.Tasks;
public sealed class ComposeDownTask
{
private readonly ComposeExecutor _executor;
private readonly ComposeFileManager _fileManager;
private readonly ILogger _logger;
public sealed record DownPayload
{
public required string ProjectName { get; init; }
public bool RemoveVolumes { get; init; }
public bool RemoveOrphans { get; init; } = true;
public bool CleanupFiles { get; init; }
public TimeSpan? Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<DownPayload>(task.Payload)
?? throw new InvalidPayloadException("compose.down");
_logger.LogInformation("Stopping compose stack: {Project}", payload.ProjectName);
try
{
var projectDir = _fileManager.GetProjectDirectory(payload.ProjectName);
var composeFile = _fileManager.GetComposeFilePath(payload.ProjectName);
if (!File.Exists(composeFile))
{
_logger.LogWarning(
"Compose file not found for project {Project}, skipping down",
payload.ProjectName);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["projectName"] = payload.ProjectName,
["skipped"] = true,
["reason"] = "Compose file not found"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
var result = await _executor.DownAsync(
projectDir,
composeFile,
new ComposeDownOptions
{
RemoveVolumes = payload.RemoveVolumes,
RemoveOrphans = payload.RemoveOrphans,
Timeout = payload.Timeout
},
ct);
if (!result.Success)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to stop stack: {result.StandardError}",
CompletedAt = DateTimeOffset.UtcNow
};
}
// Cleanup files if requested
if (payload.CleanupFiles)
{
await _fileManager.CleanupAsync(payload.ProjectName, ct);
}
_logger.LogInformation("Stopped compose stack: {Project}", payload.ProjectName);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["projectName"] = payload.ProjectName,
["removedVolumes"] = payload.RemoveVolumes,
["cleanedFiles"] = payload.CleanupFiles
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stop compose stack {Project}", payload.ProjectName);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
}
```
### ComposeHealthCheckTask
```csharp
namespace StellaOps.Agent.Compose.Tasks;
public sealed class ComposeHealthCheckTask
{
private readonly ComposeExecutor _executor;
private readonly ILogger _logger;
public sealed record HealthCheckPayload
{
public required string ProjectName { get; init; }
public string? ComposeFile { get; init; }
public IReadOnlyList<string>? Services { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
public bool WaitForHealthy { get; init; } = true;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<HealthCheckPayload>(task.Payload)
?? throw new InvalidPayloadException("compose.health-check");
_logger.LogInformation("Checking health of compose stack: {Project}", payload.ProjectName);
try
{
var projectDir = Path.Combine("/var/lib/stella-agent/deployments", payload.ProjectName);
var composeFile = payload.ComposeFile ?? Path.Combine(projectDir, "compose.stella.lock.yml");
using var timeoutCts = new CancellationTokenSource(payload.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
while (!linkedCts.IsCancellationRequested)
{
var psResult = await _executor.PsAsync(projectDir, composeFile, ct: linkedCts.Token);
var services = ParseServices(psResult.StandardOutput);
// Filter to requested services if specified
if (payload.Services?.Count > 0)
{
services = services.Where(s => payload.Services.Contains(s.Service)).ToList();
}
var allRunning = services.All(s => s.State == "running");
var allHealthy = services.All(s =>
s.Health is null || s.Health == "healthy" || s.Health == "");
if (allRunning && allHealthy)
{
_logger.LogInformation(
"Compose stack {Project} is healthy ({Count} services)",
payload.ProjectName,
services.Count);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["projectName"] = payload.ProjectName,
["services"] = services,
["allHealthy"] = true
},
CompletedAt = DateTimeOffset.UtcNow
};
}
var unhealthyServices = services.Where(s =>
s.State != "running" || (s.Health is not null && s.Health != "healthy" && s.Health != ""));
if (!payload.WaitForHealthy)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = "Some services are unhealthy",
Outputs = new Dictionary<string, object>
{
["projectName"] = payload.ProjectName,
["services"] = services,
["unhealthyServices"] = unhealthyServices.ToList()
},
CompletedAt = DateTimeOffset.UtcNow
};
}
await Task.Delay(TimeSpan.FromSeconds(5), linkedCts.Token);
}
throw new OperationCanceledException();
}
catch (OperationCanceledException)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Health check timed out after {payload.Timeout}",
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed for stack {Project}", payload.ProjectName);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static List<ServiceStatus> ParseServices(string output)
{
var services = new List<ServiceStatus>();
if (string.IsNullOrWhiteSpace(output))
return services;
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
try
{
var service = JsonSerializer.Deserialize<JsonElement>(line);
services.Add(new ServiceStatus(
service.GetProperty("Name").GetString() ?? "",
service.GetProperty("Service").GetString() ?? "",
service.GetProperty("State").GetString() ?? "",
service.TryGetProperty("Health", out var health) ? health.GetString() : null
));
}
catch { }
}
return services;
}
}
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/deployment/agent-based.md` (partial) | Markdown | Agent-based deployment documentation (Compose agent with 8 operations) |
---
## Acceptance Criteria
### Code
- [ ] Deploy compose stack from compose.stella.lock.yml
- [ ] Pull images before deployment
- [ ] Support authenticated registries
- [ ] Force recreate containers option
- [ ] Remove orphan containers
- [ ] Stop and remove compose stack
- [ ] Optionally remove volumes on down
- [ ] Scale services up/down
- [ ] Check health of all services
- [ ] Wait for services to become healthy
- [ ] Backup existing deployment before update
- [ ] Unit test coverage >=85%
### Documentation
- [ ] Compose agent documentation section created
- [ ] All Compose operations documented (pull, up, down, scale, health, backup)
- [ ] TypeScript implementation code included
- [ ] Compose lock file usage documented
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 108_001 Agent Core Runtime | Internal | TODO |
| 108_002 Agent - Docker | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ComposeCapability | TODO | |
| ComposeExecutor | TODO | |
| ComposeFileManager | TODO | |
| ComposePullTask | TODO | |
| ComposeUpTask | TODO | |
| ComposeDownTask | TODO | |
| ComposeScaleTask | TODO | |
| ComposeHealthCheckTask | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: deployment/agent-based.md (partial - Compose) |

View File

@@ -0,0 +1,813 @@
# SPRINT: Agent - SSH
> **Sprint ID:** 108_004
> **Module:** AGENTS
> **Phase:** 8 - Agents
> **Status:** TODO
> **Parent:** [108_000_INDEX](SPRINT_20260110_108_000_INDEX_agents.md)
---
## Overview
Implement the SSH Agent capability for remote command execution and file transfer via SSH.
### Objectives
- Remote command execution via SSH
- File transfer (SCP/SFTP)
- SSH key authentication
- SSH tunneling for remote Docker/Compose operations
- Connection pooling for efficiency
### Working Directory
```
src/ReleaseOrchestrator/
├── __Agents/
│ └── StellaOps.Agent.Ssh/
│ ├── SshCapability.cs
│ ├── Tasks/
│ │ ├── SshExecuteTask.cs
│ │ ├── SshFileTransferTask.cs
│ │ ├── SshTunnelTask.cs
│ │ └── SshDockerProxyTask.cs
│ ├── SshConnectionPool.cs
│ └── SshClientFactory.cs
└── __Tests/
```
---
## Deliverables
### SshCapability
```csharp
namespace StellaOps.Agent.Ssh;
public sealed class SshCapability : IAgentCapability
{
private readonly SshConnectionPool _connectionPool;
private readonly ILogger<SshCapability> _logger;
private readonly Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>> _taskHandlers;
public string Name => "ssh";
public string Version => "1.0.0";
public IReadOnlyList<string> SupportedTaskTypes => new[]
{
"ssh.execute",
"ssh.upload",
"ssh.download",
"ssh.tunnel",
"ssh.docker-proxy"
};
public SshCapability(SshConnectionPool connectionPool, ILogger<SshCapability> logger)
{
_connectionPool = connectionPool;
_logger = logger;
_taskHandlers = new Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>>
{
["ssh.execute"] = ExecuteCommandAsync,
["ssh.upload"] = UploadFileAsync,
["ssh.download"] = DownloadFileAsync,
["ssh.tunnel"] = CreateTunnelAsync,
["ssh.docker-proxy"] = DockerProxyAsync
};
}
public Task<bool> InitializeAsync(CancellationToken ct = default)
{
// SSH capability is always available if SSH.NET is loaded
_logger.LogInformation("SSH capability initialized");
return Task.FromResult(true);
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct = default)
{
if (!_taskHandlers.TryGetValue(task.TaskType, out var handler))
{
throw new UnsupportedTaskTypeException(task.TaskType);
}
return await handler(task, ct);
}
public Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default)
{
return Task.FromResult(new CapabilityHealthStatus(true, "SSH capability available"));
}
private Task<TaskResult> ExecuteCommandAsync(AgentTask task, CancellationToken ct) =>
new SshExecuteTask(_connectionPool, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> UploadFileAsync(AgentTask task, CancellationToken ct) =>
new SshFileTransferTask(_connectionPool, _logger).UploadAsync(task, ct);
private Task<TaskResult> DownloadFileAsync(AgentTask task, CancellationToken ct) =>
new SshFileTransferTask(_connectionPool, _logger).DownloadAsync(task, ct);
private Task<TaskResult> CreateTunnelAsync(AgentTask task, CancellationToken ct) =>
new SshTunnelTask(_connectionPool, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> DockerProxyAsync(AgentTask task, CancellationToken ct) =>
new SshDockerProxyTask(_connectionPool, _logger).ExecuteAsync(task, ct);
}
```
### SshConnectionPool
```csharp
namespace StellaOps.Agent.Ssh;
public sealed class SshConnectionPool : IAsyncDisposable
{
private readonly ConcurrentDictionary<string, PooledConnection> _connections = new();
private readonly TimeSpan _connectionTimeout = TimeSpan.FromMinutes(10);
private readonly ILogger<SshConnectionPool> _logger;
private readonly Timer _cleanupTimer;
public SshConnectionPool(ILogger<SshConnectionPool> logger)
{
_logger = logger;
_cleanupTimer = new Timer(CleanupExpiredConnections, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public async Task<SshClient> GetConnectionAsync(
SshConnectionInfo connectionInfo,
CancellationToken ct = default)
{
var key = connectionInfo.GetConnectionKey();
if (_connections.TryGetValue(key, out var pooled) && pooled.Client.IsConnected)
{
pooled.LastUsed = DateTimeOffset.UtcNow;
return pooled.Client;
}
var client = await CreateConnectionAsync(connectionInfo, ct);
_connections[key] = new PooledConnection(client, DateTimeOffset.UtcNow);
return client;
}
private async Task<SshClient> CreateConnectionAsync(
SshConnectionInfo info,
CancellationToken ct)
{
var authMethods = new List<AuthenticationMethod>();
// Private key authentication
if (!string.IsNullOrEmpty(info.PrivateKey))
{
var keyFile = string.IsNullOrEmpty(info.PrivateKeyPassphrase)
? new PrivateKeyFile(new MemoryStream(Encoding.UTF8.GetBytes(info.PrivateKey)))
: new PrivateKeyFile(new MemoryStream(Encoding.UTF8.GetBytes(info.PrivateKey)), info.PrivateKeyPassphrase);
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, keyFile));
}
// Password authentication
if (!string.IsNullOrEmpty(info.Password))
{
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
}
var connectionInfo = new ConnectionInfo(
info.Host,
info.Port,
info.Username,
authMethods.ToArray());
var client = new SshClient(connectionInfo);
await Task.Run(() => client.Connect(), ct);
_logger.LogDebug(
"SSH connection established to {User}@{Host}:{Port}",
info.Username,
info.Host,
info.Port);
return client;
}
public void ReleaseConnection(string connectionKey)
{
// Connection stays in pool for reuse
if (_connections.TryGetValue(connectionKey, out var pooled))
{
pooled.LastUsed = DateTimeOffset.UtcNow;
}
}
private void CleanupExpiredConnections(object? state)
{
var expired = _connections
.Where(kv => DateTimeOffset.UtcNow - kv.Value.LastUsed > _connectionTimeout)
.ToList();
foreach (var (key, pooled) in expired)
{
if (_connections.TryRemove(key, out _))
{
try
{
pooled.Client.Disconnect();
pooled.Client.Dispose();
_logger.LogDebug("Closed expired SSH connection: {Key}", key);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error closing SSH connection: {Key}", key);
}
}
}
}
public async ValueTask DisposeAsync()
{
_cleanupTimer.Dispose();
foreach (var (_, pooled) in _connections)
{
try
{
pooled.Client.Disconnect();
pooled.Client.Dispose();
}
catch { }
}
_connections.Clear();
}
private sealed class PooledConnection
{
public SshClient Client { get; }
public DateTimeOffset LastUsed { get; set; }
public PooledConnection(SshClient client, DateTimeOffset lastUsed)
{
Client = client;
LastUsed = lastUsed;
}
}
}
public sealed record SshConnectionInfo
{
public required string Host { get; init; }
public int Port { get; init; } = 22;
public required string Username { get; init; }
public string? Password { get; init; }
public string? PrivateKey { get; init; }
public string? PrivateKeyPassphrase { get; init; }
public string GetConnectionKey() => $"{Username}@{Host}:{Port}";
}
```
### SshExecuteTask
```csharp
namespace StellaOps.Agent.Ssh.Tasks;
public sealed class SshExecuteTask
{
private readonly SshConnectionPool _connectionPool;
private readonly ILogger _logger;
public sealed record ExecutePayload
{
public required string Host { get; init; }
public int Port { get; init; } = 22;
public required string Username { get; init; }
public required string Command { get; init; }
public IReadOnlyDictionary<string, string>? Environment { get; init; }
public string? WorkingDirectory { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(10);
public bool CombineOutput { get; init; } = true;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<ExecutePayload>(task.Payload)
?? throw new InvalidPayloadException("ssh.execute");
var connectionInfo = new SshConnectionInfo
{
Host = payload.Host,
Port = payload.Port,
Username = payload.Username,
Password = task.Credentials.GetValueOrDefault("ssh.password"),
PrivateKey = task.Credentials.GetValueOrDefault("ssh.privateKey"),
PrivateKeyPassphrase = task.Credentials.GetValueOrDefault("ssh.passphrase")
};
_logger.LogInformation(
"Executing SSH command on {User}@{Host}",
payload.Username,
payload.Host);
try
{
var client = await _connectionPool.GetConnectionAsync(connectionInfo, ct);
// Build command with environment and working directory
var fullCommand = BuildCommand(payload);
using var command = client.CreateCommand(fullCommand);
command.CommandTimeout = payload.Timeout;
var asyncResult = command.BeginExecute();
// Wait for completion with cancellation support
while (!asyncResult.IsCompleted)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(100, ct);
}
var result = command.EndExecute(asyncResult);
var exitCode = command.ExitStatus;
var stdout = result;
var stderr = command.Error;
_logger.LogInformation(
"SSH command completed with exit code {ExitCode}",
exitCode);
return new TaskResult
{
TaskId = task.Id,
Success = exitCode == 0,
Error = exitCode != 0 ? stderr : null,
Outputs = new Dictionary<string, object>
{
["exitCode"] = exitCode,
["stdout"] = stdout,
["stderr"] = stderr,
["output"] = payload.CombineOutput ? $"{stdout}\n{stderr}" : stdout
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (SshException ex)
{
_logger.LogError(ex, "SSH command failed on {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"SSH error: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static string BuildCommand(ExecutePayload payload)
{
var parts = new List<string>();
// Set environment variables
if (payload.Environment is not null)
{
foreach (var (key, value) in payload.Environment)
{
parts.Add($"export {key}='{EscapeShellString(value)}'");
}
}
// Change to working directory
if (!string.IsNullOrEmpty(payload.WorkingDirectory))
{
parts.Add($"cd '{EscapeShellString(payload.WorkingDirectory)}'");
}
parts.Add(payload.Command);
return string.Join(" && ", parts);
}
private static string EscapeShellString(string value)
{
return value.Replace("'", "'\"'\"'");
}
}
```
### SshFileTransferTask
```csharp
namespace StellaOps.Agent.Ssh.Tasks;
public sealed class SshFileTransferTask
{
private readonly SshConnectionPool _connectionPool;
private readonly ILogger _logger;
public sealed record UploadPayload
{
public required string Host { get; init; }
public int Port { get; init; } = 22;
public required string Username { get; init; }
public required string LocalPath { get; init; }
public required string RemotePath { get; init; }
public bool CreateDirectory { get; init; } = true;
public int Permissions { get; init; } = 0644;
}
public sealed record DownloadPayload
{
public required string Host { get; init; }
public int Port { get; init; } = 22;
public required string Username { get; init; }
public required string RemotePath { get; init; }
public required string LocalPath { get; init; }
public bool CreateDirectory { get; init; } = true;
}
public async Task<TaskResult> UploadAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<UploadPayload>(task.Payload)
?? throw new InvalidPayloadException("ssh.upload");
var connectionInfo = BuildConnectionInfo(payload.Host, payload.Port, payload.Username, task.Credentials);
_logger.LogInformation(
"Uploading {Local} to {User}@{Host}:{Remote}",
payload.LocalPath,
payload.Username,
payload.Host,
payload.RemotePath);
try
{
var client = await _connectionPool.GetConnectionAsync(connectionInfo, ct);
using var sftp = new SftpClient(client.ConnectionInfo);
await Task.Run(() => sftp.Connect(), ct);
// Create parent directory if needed
if (payload.CreateDirectory)
{
var parentDir = Path.GetDirectoryName(payload.RemotePath)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(parentDir))
{
await CreateRemoteDirectoryAsync(sftp, parentDir, ct);
}
}
// Upload file
await using var localFile = File.OpenRead(payload.LocalPath);
await Task.Run(() => sftp.UploadFile(localFile, payload.RemotePath), ct);
// Set permissions
sftp.ChangePermissions(payload.RemotePath, (short)payload.Permissions);
var fileInfo = sftp.GetAttributes(payload.RemotePath);
sftp.Disconnect();
_logger.LogInformation(
"Uploaded {Size} bytes to {Remote}",
fileInfo.Size,
payload.RemotePath);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["remotePath"] = payload.RemotePath,
["size"] = fileInfo.Size,
["permissions"] = payload.Permissions
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (SftpPathNotFoundException ex)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Remote path not found: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload file to {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
public async Task<TaskResult> DownloadAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<DownloadPayload>(task.Payload)
?? throw new InvalidPayloadException("ssh.download");
var connectionInfo = BuildConnectionInfo(payload.Host, payload.Port, payload.Username, task.Credentials);
_logger.LogInformation(
"Downloading {User}@{Host}:{Remote} to {Local}",
payload.Username,
payload.Host,
payload.RemotePath,
payload.LocalPath);
try
{
var client = await _connectionPool.GetConnectionAsync(connectionInfo, ct);
using var sftp = new SftpClient(client.ConnectionInfo);
await Task.Run(() => sftp.Connect(), ct);
// Create local directory if needed
if (payload.CreateDirectory)
{
var localDir = Path.GetDirectoryName(payload.LocalPath);
if (!string.IsNullOrEmpty(localDir))
{
Directory.CreateDirectory(localDir);
}
}
// Download file
var remoteAttributes = sftp.GetAttributes(payload.RemotePath);
await using var localFile = File.Create(payload.LocalPath);
await Task.Run(() => sftp.DownloadFile(payload.RemotePath, localFile), ct);
sftp.Disconnect();
_logger.LogInformation(
"Downloaded {Size} bytes to {Local}",
remoteAttributes.Size,
payload.LocalPath);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["localPath"] = payload.LocalPath,
["size"] = remoteAttributes.Size
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (SftpPathNotFoundException)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Remote file not found: {payload.RemotePath}",
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download file from {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static async Task CreateRemoteDirectoryAsync(SftpClient sftp, string path, CancellationToken ct)
{
var parts = path.Split('/').Where(p => !string.IsNullOrEmpty(p)).ToList();
var current = "";
foreach (var part in parts)
{
current = $"{current}/{part}";
try
{
var attrs = sftp.GetAttributes(current);
if (!attrs.IsDirectory)
{
throw new InvalidOperationException($"Path exists but is not a directory: {current}");
}
}
catch (SftpPathNotFoundException)
{
await Task.Run(() => sftp.CreateDirectory(current), ct);
}
}
}
private static SshConnectionInfo BuildConnectionInfo(
string host,
int port,
string username,
IReadOnlyDictionary<string, string> credentials)
{
return new SshConnectionInfo
{
Host = host,
Port = port,
Username = username,
Password = credentials.GetValueOrDefault("ssh.password"),
PrivateKey = credentials.GetValueOrDefault("ssh.privateKey"),
PrivateKeyPassphrase = credentials.GetValueOrDefault("ssh.passphrase")
};
}
}
```
### SshTunnelTask
```csharp
namespace StellaOps.Agent.Ssh.Tasks;
public sealed class SshTunnelTask
{
private readonly SshConnectionPool _connectionPool;
private readonly ILogger _logger;
public sealed record TunnelPayload
{
public required string Host { get; init; }
public int Port { get; init; } = 22;
public required string Username { get; init; }
public required int LocalPort { get; init; }
public required string RemoteHost { get; init; }
public required int RemotePort { get; init; }
public TimeSpan Duration { get; init; } = TimeSpan.FromMinutes(30);
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<TunnelPayload>(task.Payload)
?? throw new InvalidPayloadException("ssh.tunnel");
var connectionInfo = new SshConnectionInfo
{
Host = payload.Host,
Port = payload.Port,
Username = payload.Username,
Password = task.Credentials.GetValueOrDefault("ssh.password"),
PrivateKey = task.Credentials.GetValueOrDefault("ssh.privateKey"),
PrivateKeyPassphrase = task.Credentials.GetValueOrDefault("ssh.passphrase")
};
_logger.LogInformation(
"Creating SSH tunnel: localhost:{Local} -> {User}@{Host} -> {RemoteHost}:{RemotePort}",
payload.LocalPort,
payload.Username,
payload.Host,
payload.RemoteHost,
payload.RemotePort);
try
{
var client = await _connectionPool.GetConnectionAsync(connectionInfo, ct);
var tunnel = new ForwardedPortLocal(
"127.0.0.1",
(uint)payload.LocalPort,
payload.RemoteHost,
(uint)payload.RemotePort);
client.AddForwardedPort(tunnel);
tunnel.Start();
_logger.LogInformation(
"SSH tunnel established: localhost:{Local} -> {RemoteHost}:{RemotePort}",
payload.LocalPort,
payload.RemoteHost,
payload.RemotePort);
// Keep tunnel open for specified duration
using var durationCts = new CancellationTokenSource(payload.Duration);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, durationCts.Token);
try
{
await Task.Delay(payload.Duration, linkedCts.Token);
}
catch (OperationCanceledException) when (durationCts.IsCancellationRequested)
{
// Duration expired, normal completion
}
tunnel.Stop();
client.RemoveForwardedPort(tunnel);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["localPort"] = payload.LocalPort,
["remoteHost"] = payload.RemoteHost,
["remotePort"] = payload.RemotePort,
["duration"] = payload.Duration.ToString()
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create SSH tunnel to {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
}
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/deployment/agentless.md` (partial) | Markdown | Agentless deployment documentation (SSH remote executor) |
---
## Acceptance Criteria
### Code
- [ ] Execute remote commands via SSH
- [ ] Support password authentication
- [ ] Support private key authentication
- [ ] Support passphrase-protected keys
- [ ] Upload files via SFTP
- [ ] Download files via SFTP
- [ ] Create remote directories automatically
- [ ] Set file permissions on upload
- [ ] Create SSH tunnels for port forwarding
- [ ] Connection pooling for efficiency
- [ ] Timeout handling for commands
- [ ] Unit test coverage >=85%
### Documentation
- [ ] SSH remote executor documentation created
- [ ] All SSH operations documented (execute, upload, download, tunnel)
- [ ] SFTP file transfer flow documented
- [ ] SSH key authentication documented
- [ ] TypeScript implementation included
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 108_001 Agent Core Runtime | Internal | TODO |
| SSH.NET | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| SshCapability | TODO | |
| SshConnectionPool | TODO | |
| SshExecuteTask | TODO | |
| SshFileTransferTask | TODO | |
| SshTunnelTask | TODO | |
| SshDockerProxyTask | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: deployment/agentless.md (partial - SSH) |

View File

@@ -0,0 +1,915 @@
# SPRINT: Agent - WinRM
> **Sprint ID:** 108_005
> **Module:** AGENTS
> **Phase:** 8 - Agents
> **Status:** TODO
> **Parent:** [108_000_INDEX](SPRINT_20260110_108_000_INDEX_agents.md)
---
## Overview
Implement the WinRM Agent capability for remote Windows management via WinRM/PowerShell.
### Objectives
- Remote PowerShell execution via WinRM
- Windows service management
- Windows container operations
- File transfer to Windows hosts
- NTLM and Kerberos authentication
### Working Directory
```
src/ReleaseOrchestrator/
├── __Agents/
│ └── StellaOps.Agent.WinRM/
│ ├── WinRmCapability.cs
│ ├── Tasks/
│ │ ├── PowerShellTask.cs
│ │ ├── WindowsServiceTask.cs
│ │ ├── WindowsContainerTask.cs
│ │ └── WinRmFileTransferTask.cs
│ ├── WinRmConnectionPool.cs
│ └── PowerShellRunner.cs
└── __Tests/
```
---
## Deliverables
### WinRmCapability
```csharp
namespace StellaOps.Agent.WinRM;
public sealed class WinRmCapability : IAgentCapability
{
private readonly WinRmConnectionPool _connectionPool;
private readonly ILogger<WinRmCapability> _logger;
private readonly Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>> _taskHandlers;
public string Name => "winrm";
public string Version => "1.0.0";
public IReadOnlyList<string> SupportedTaskTypes => new[]
{
"winrm.powershell",
"winrm.service.start",
"winrm.service.stop",
"winrm.service.restart",
"winrm.service.status",
"winrm.container.deploy",
"winrm.file.upload",
"winrm.file.download"
};
public WinRmCapability(WinRmConnectionPool connectionPool, ILogger<WinRmCapability> logger)
{
_connectionPool = connectionPool;
_logger = logger;
_taskHandlers = new Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>>
{
["winrm.powershell"] = ExecutePowerShellAsync,
["winrm.service.start"] = StartServiceAsync,
["winrm.service.stop"] = StopServiceAsync,
["winrm.service.restart"] = RestartServiceAsync,
["winrm.service.status"] = GetServiceStatusAsync,
["winrm.container.deploy"] = DeployContainerAsync,
["winrm.file.upload"] = UploadFileAsync,
["winrm.file.download"] = DownloadFileAsync
};
}
public Task<bool> InitializeAsync(CancellationToken ct = default)
{
_logger.LogInformation("WinRM capability initialized");
return Task.FromResult(true);
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct = default)
{
if (!_taskHandlers.TryGetValue(task.TaskType, out var handler))
{
throw new UnsupportedTaskTypeException(task.TaskType);
}
return await handler(task, ct);
}
public Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default)
{
return Task.FromResult(new CapabilityHealthStatus(true, "WinRM capability available"));
}
private Task<TaskResult> ExecutePowerShellAsync(AgentTask task, CancellationToken ct) =>
new PowerShellTask(_connectionPool, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> StartServiceAsync(AgentTask task, CancellationToken ct) =>
new WindowsServiceTask(_connectionPool, _logger).StartAsync(task, ct);
private Task<TaskResult> StopServiceAsync(AgentTask task, CancellationToken ct) =>
new WindowsServiceTask(_connectionPool, _logger).StopAsync(task, ct);
private Task<TaskResult> RestartServiceAsync(AgentTask task, CancellationToken ct) =>
new WindowsServiceTask(_connectionPool, _logger).RestartAsync(task, ct);
private Task<TaskResult> GetServiceStatusAsync(AgentTask task, CancellationToken ct) =>
new WindowsServiceTask(_connectionPool, _logger).GetStatusAsync(task, ct);
private Task<TaskResult> DeployContainerAsync(AgentTask task, CancellationToken ct) =>
new WindowsContainerTask(_connectionPool, _logger).DeployAsync(task, ct);
private Task<TaskResult> UploadFileAsync(AgentTask task, CancellationToken ct) =>
new WinRmFileTransferTask(_connectionPool, _logger).UploadAsync(task, ct);
private Task<TaskResult> DownloadFileAsync(AgentTask task, CancellationToken ct) =>
new WinRmFileTransferTask(_connectionPool, _logger).DownloadAsync(task, ct);
}
```
### WinRmConnectionPool
```csharp
namespace StellaOps.Agent.WinRM;
public sealed class WinRmConnectionPool : IAsyncDisposable
{
private readonly ConcurrentDictionary<string, PooledSession> _sessions = new();
private readonly TimeSpan _sessionTimeout = TimeSpan.FromMinutes(10);
private readonly ILogger<WinRmConnectionPool> _logger;
private readonly Timer _cleanupTimer;
public WinRmConnectionPool(ILogger<WinRmConnectionPool> logger)
{
_logger = logger;
_cleanupTimer = new Timer(CleanupExpiredSessions, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public async Task<WSManSession> GetSessionAsync(
WinRmConnectionInfo connectionInfo,
CancellationToken ct = default)
{
var key = connectionInfo.GetConnectionKey();
if (_sessions.TryGetValue(key, out var pooled) && pooled.IsValid)
{
pooled.LastUsed = DateTimeOffset.UtcNow;
return pooled.Session;
}
var session = await CreateSessionAsync(connectionInfo, ct);
_sessions[key] = new PooledSession(session, DateTimeOffset.UtcNow);
return session;
}
private async Task<WSManSession> CreateSessionAsync(
WinRmConnectionInfo info,
CancellationToken ct)
{
var sessionOptions = new WSManSessionOptions
{
DestinationHost = info.Host,
DestinationPort = info.Port,
UseSSL = info.UseSSL,
AuthenticationMechanism = info.AuthMechanism,
Credential = new NetworkCredential(info.Username, info.Password, info.Domain)
};
var session = await Task.Run(() => new WSManSession(sessionOptions), ct);
_logger.LogDebug(
"WinRM session established to {Host}:{Port}",
info.Host,
info.Port);
return session;
}
private void CleanupExpiredSessions(object? state)
{
var expired = _sessions
.Where(kv => DateTimeOffset.UtcNow - kv.Value.LastUsed > _sessionTimeout)
.ToList();
foreach (var (key, pooled) in expired)
{
if (_sessions.TryRemove(key, out _))
{
try
{
pooled.Session.Dispose();
_logger.LogDebug("Closed expired WinRM session: {Key}", key);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error closing WinRM session: {Key}", key);
}
}
}
}
public async ValueTask DisposeAsync()
{
_cleanupTimer.Dispose();
foreach (var (_, pooled) in _sessions)
{
try
{
pooled.Session.Dispose();
}
catch { }
}
_sessions.Clear();
}
private sealed class PooledSession
{
public WSManSession Session { get; }
public DateTimeOffset LastUsed { get; set; }
public bool IsValid => !Session.IsDisposed;
public PooledSession(WSManSession session, DateTimeOffset lastUsed)
{
Session = session;
LastUsed = lastUsed;
}
}
}
public sealed record WinRmConnectionInfo
{
public required string Host { get; init; }
public int Port { get; init; } = 5985;
public bool UseSSL { get; init; }
public required string Username { get; init; }
public required string Password { get; init; }
public string? Domain { get; init; }
public WinRmAuthMechanism AuthMechanism { get; init; } = WinRmAuthMechanism.Negotiate;
public string GetConnectionKey() => $"{Domain ?? ""}\\{Username}@{Host}:{Port}";
}
public enum WinRmAuthMechanism
{
Basic,
Negotiate,
Kerberos,
CredSSP
}
```
### PowerShellTask
```csharp
namespace StellaOps.Agent.WinRM.Tasks;
public sealed class PowerShellTask
{
private readonly WinRmConnectionPool _connectionPool;
private readonly ILogger _logger;
public sealed record PowerShellPayload
{
public required string Host { get; init; }
public int Port { get; init; } = 5985;
public bool UseSSL { get; init; }
public required string Username { get; init; }
public string? Domain { get; init; }
public required string Script { get; init; }
public IReadOnlyDictionary<string, object>? Parameters { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(10);
public bool NoProfile { get; init; } = true;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<PowerShellPayload>(task.Payload)
?? throw new InvalidPayloadException("winrm.powershell");
var connectionInfo = new WinRmConnectionInfo
{
Host = payload.Host,
Port = payload.Port,
UseSSL = payload.UseSSL,
Username = payload.Username,
Password = task.Credentials.GetValueOrDefault("winrm.password") ?? "",
Domain = payload.Domain
};
_logger.LogInformation(
"Executing PowerShell script on {Host}",
payload.Host);
try
{
var session = await _connectionPool.GetSessionAsync(connectionInfo, ct);
// Build the script with parameters
var script = BuildScript(payload);
using var timeoutCts = new CancellationTokenSource(payload.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
var result = await Task.Run(() =>
{
using var shell = session.CreatePowerShellShell();
if (payload.NoProfile)
{
shell.AddScript("$PSDefaultParameterValues['*:NoProfile'] = $true");
}
shell.AddScript(script);
// Add parameters
if (payload.Parameters is not null)
{
foreach (var (key, value) in payload.Parameters)
{
shell.AddParameter(key, value);
}
}
var output = shell.Invoke();
return new PowerShellResult
{
Output = output.Select(o => o.ToString()).ToList(),
HadErrors = shell.HadErrors,
Errors = shell.Streams.Error.Select(e => e.ToString()).ToList()
};
}, linkedCts.Token);
_logger.LogInformation(
"PowerShell script completed (errors: {HadErrors})",
result.HadErrors);
return new TaskResult
{
TaskId = task.Id,
Success = !result.HadErrors,
Error = result.HadErrors ? string.Join("\n", result.Errors) : null,
Outputs = new Dictionary<string, object>
{
["output"] = result.Output,
["errors"] = result.Errors,
["hadErrors"] = result.HadErrors
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (OperationCanceledException)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"PowerShell execution timed out after {payload.Timeout}",
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "PowerShell execution failed on {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static string BuildScript(PowerShellPayload payload)
{
// Wrap script in error handling
return $@"
$ErrorActionPreference = 'Stop'
try {{
{payload.Script}
}} catch {{
Write-Error $_.Exception.Message
throw
}}";
}
private sealed record PowerShellResult
{
public required IReadOnlyList<string> Output { get; init; }
public required bool HadErrors { get; init; }
public required IReadOnlyList<string> Errors { get; init; }
}
}
```
### WindowsServiceTask
```csharp
namespace StellaOps.Agent.WinRM.Tasks;
public sealed class WindowsServiceTask
{
private readonly WinRmConnectionPool _connectionPool;
private readonly ILogger _logger;
public sealed record ServicePayload
{
public required string Host { get; init; }
public int Port { get; init; } = 5985;
public bool UseSSL { get; init; }
public required string Username { get; init; }
public string? Domain { get; init; }
public required string ServiceName { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
}
public async Task<TaskResult> StartAsync(AgentTask task, CancellationToken ct)
{
return await ExecuteServiceCommandAsync(task, "Start-Service", ct);
}
public async Task<TaskResult> StopAsync(AgentTask task, CancellationToken ct)
{
return await ExecuteServiceCommandAsync(task, "Stop-Service", ct);
}
public async Task<TaskResult> RestartAsync(AgentTask task, CancellationToken ct)
{
return await ExecuteServiceCommandAsync(task, "Restart-Service", ct);
}
public async Task<TaskResult> GetStatusAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<ServicePayload>(task.Payload)
?? throw new InvalidPayloadException("winrm.service.status");
var connectionInfo = BuildConnectionInfo(payload, task.Credentials);
_logger.LogInformation(
"Getting service status for {Service} on {Host}",
payload.ServiceName,
payload.Host);
try
{
var session = await _connectionPool.GetSessionAsync(connectionInfo, ct);
var script = $@"
$service = Get-Service -Name '{EscapeString(payload.ServiceName)}' -ErrorAction Stop
@{{
Name = $service.Name
DisplayName = $service.DisplayName
Status = $service.Status.ToString()
StartType = $service.StartType.ToString()
CanStop = $service.CanStop
CanPauseAndContinue = $service.CanPauseAndContinue
}} | ConvertTo-Json";
var result = await ExecutePowerShellAsync(session, script, payload.Timeout, ct);
if (result.HadErrors)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = string.Join("\n", result.Errors),
CompletedAt = DateTimeOffset.UtcNow
};
}
var serviceInfo = JsonSerializer.Deserialize<ServiceInfo>(string.Join("", result.Output));
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["serviceName"] = serviceInfo?.Name ?? payload.ServiceName,
["displayName"] = serviceInfo?.DisplayName ?? "",
["status"] = serviceInfo?.Status ?? "Unknown",
["startType"] = serviceInfo?.StartType ?? "Unknown"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get service status on {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private async Task<TaskResult> ExecuteServiceCommandAsync(
AgentTask task,
string command,
CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<ServicePayload>(task.Payload)
?? throw new InvalidPayloadException("winrm.service");
var connectionInfo = BuildConnectionInfo(payload, task.Credentials);
_logger.LogInformation(
"Executing {Command} for {Service} on {Host}",
command,
payload.ServiceName,
payload.Host);
try
{
var session = await _connectionPool.GetSessionAsync(connectionInfo, ct);
var script = $@"
{command} -Name '{EscapeString(payload.ServiceName)}' -ErrorAction Stop
$service = Get-Service -Name '{EscapeString(payload.ServiceName)}'
$service.Status.ToString()";
var result = await ExecutePowerShellAsync(session, script, payload.Timeout, ct);
if (result.HadErrors)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = string.Join("\n", result.Errors),
CompletedAt = DateTimeOffset.UtcNow
};
}
var status = string.Join("", result.Output).Trim();
_logger.LogInformation(
"Service {Service} is now {Status}",
payload.ServiceName,
status);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["serviceName"] = payload.ServiceName,
["status"] = status
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Service command failed on {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static async Task<PowerShellResult> ExecutePowerShellAsync(
WSManSession session,
string script,
TimeSpan timeout,
CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
return await Task.Run(() =>
{
using var shell = session.CreatePowerShellShell();
shell.AddScript(script);
var output = shell.Invoke();
return new PowerShellResult
{
Output = output.Select(o => o.ToString()).ToList(),
HadErrors = shell.HadErrors,
Errors = shell.Streams.Error.Select(e => e.ToString()).ToList()
};
}, linkedCts.Token);
}
private static WinRmConnectionInfo BuildConnectionInfo(
ServicePayload payload,
IReadOnlyDictionary<string, string> credentials)
{
return new WinRmConnectionInfo
{
Host = payload.Host,
Port = payload.Port,
UseSSL = payload.UseSSL,
Username = payload.Username,
Password = credentials.GetValueOrDefault("winrm.password") ?? "",
Domain = payload.Domain
};
}
private static string EscapeString(string value)
{
return value.Replace("'", "''");
}
private sealed record ServiceInfo
{
public string? Name { get; init; }
public string? DisplayName { get; init; }
public string? Status { get; init; }
public string? StartType { get; init; }
}
private sealed record PowerShellResult
{
public required IReadOnlyList<string> Output { get; init; }
public required bool HadErrors { get; init; }
public required IReadOnlyList<string> Errors { get; init; }
}
}
```
### WindowsContainerTask
```csharp
namespace StellaOps.Agent.WinRM.Tasks;
public sealed class WindowsContainerTask
{
private readonly WinRmConnectionPool _connectionPool;
private readonly ILogger _logger;
public sealed record ContainerPayload
{
public required string Host { get; init; }
public int Port { get; init; } = 5985;
public bool UseSSL { get; init; }
public required string Username { get; init; }
public string? Domain { get; init; }
public required string Image { get; init; }
public required string Name { get; init; }
public IReadOnlyDictionary<string, string>? Environment { get; init; }
public IReadOnlyList<string>? Ports { get; init; }
public IReadOnlyList<string>? Volumes { get; init; }
public string? Network { get; init; }
public bool RemoveExisting { get; init; } = true;
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(10);
}
public async Task<TaskResult> DeployAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<ContainerPayload>(task.Payload)
?? throw new InvalidPayloadException("winrm.container.deploy");
var connectionInfo = new WinRmConnectionInfo
{
Host = payload.Host,
Port = payload.Port,
UseSSL = payload.UseSSL,
Username = payload.Username,
Password = task.Credentials.GetValueOrDefault("winrm.password") ?? "",
Domain = payload.Domain
};
_logger.LogInformation(
"Deploying Windows container {Name} on {Host}",
payload.Name,
payload.Host);
try
{
var session = await _connectionPool.GetSessionAsync(connectionInfo, ct);
// Build deployment script
var script = BuildDeploymentScript(payload, task.Credentials);
using var timeoutCts = new CancellationTokenSource(payload.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
var result = await Task.Run(() =>
{
using var shell = session.CreatePowerShellShell();
shell.AddScript(script);
var output = shell.Invoke();
return new PowerShellResult
{
Output = output.Select(o => o.ToString()).ToList(),
HadErrors = shell.HadErrors,
Errors = shell.Streams.Error.Select(e => e.ToString()).ToList()
};
}, linkedCts.Token);
if (result.HadErrors)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = string.Join("\n", result.Errors),
CompletedAt = DateTimeOffset.UtcNow
};
}
// Parse container ID from output
var containerId = ParseContainerId(result.Output);
_logger.LogInformation(
"Windows container {Name} deployed (ID: {Id})",
payload.Name,
containerId?[..12] ?? "unknown");
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["containerId"] = containerId ?? "",
["containerName"] = payload.Name,
["image"] = payload.Image
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Container deployment failed on {Host}", payload.Host);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = ex.Message,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private static string BuildDeploymentScript(
ContainerPayload payload,
IReadOnlyDictionary<string, string> credentials)
{
var sb = new StringBuilder();
// Registry login if credentials provided
if (credentials.TryGetValue("registry.username", out var regUser) &&
credentials.TryGetValue("registry.password", out var regPass))
{
var registry = payload.Image.Contains('/') ? payload.Image.Split('/')[0] : "";
if (!string.IsNullOrEmpty(registry) && registry.Contains('.'))
{
sb.AppendLine($@"
$securePassword = ConvertTo-SecureString '{EscapeString(regPass)}' -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential('{EscapeString(regUser)}', $securePassword)
docker login {registry} --username $credential.UserName --password $credential.GetNetworkCredential().Password");
}
}
// Remove existing container if requested
if (payload.RemoveExisting)
{
sb.AppendLine($@"
$existing = docker ps -a --filter 'name=^{payload.Name}$' --format '{{{{.ID}}}}'
if ($existing) {{
docker stop $existing 2>&1 | Out-Null
docker rm $existing 2>&1 | Out-Null
}}");
}
// Pull image
sb.AppendLine($"docker pull '{EscapeString(payload.Image)}'");
// Build run command
var runArgs = new List<string> { "docker run -d", $"--name '{EscapeString(payload.Name)}'" };
// Environment variables
if (payload.Environment is not null)
{
foreach (var (key, value) in payload.Environment)
{
runArgs.Add($"-e '{EscapeString(key)}={EscapeString(value)}'");
}
}
// Port mappings
if (payload.Ports is not null)
{
foreach (var port in payload.Ports)
{
runArgs.Add($"-p {port}");
}
}
// Volume mounts
if (payload.Volumes is not null)
{
foreach (var volume in payload.Volumes)
{
runArgs.Add($"-v '{EscapeString(volume)}'");
}
}
// Network
if (!string.IsNullOrEmpty(payload.Network))
{
runArgs.Add($"--network '{EscapeString(payload.Network)}'");
}
runArgs.Add($"'{EscapeString(payload.Image)}'");
sb.AppendLine(string.Join(" `\n ", runArgs));
return sb.ToString();
}
private static string? ParseContainerId(IReadOnlyList<string> output)
{
return output.LastOrDefault(l => l.Length >= 12 && l.All(c => char.IsLetterOrDigit(c)));
}
private static string EscapeString(string value)
{
return value.Replace("'", "''");
}
private sealed record PowerShellResult
{
public required IReadOnlyList<string> Output { get; init; }
public required bool HadErrors { get; init; }
public required IReadOnlyList<string> Errors { get; init; }
}
}
```
---
## Acceptance Criteria
- [ ] Execute PowerShell scripts remotely
- [ ] Support NTLM authentication
- [ ] Support Kerberos authentication
- [ ] Start Windows services
- [ ] Stop Windows services
- [ ] Restart Windows services
- [ ] Get Windows service status
- [ ] Deploy Windows containers via remote Docker
- [ ] Upload files to Windows hosts
- [ ] Download files from Windows hosts
- [ ] Connection pooling for efficiency
- [ ] Timeout handling
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 108_001 Agent Core Runtime | Internal | TODO |
| System.Management.Automation | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| WinRmCapability | TODO | |
| WinRmConnectionPool | TODO | |
| PowerShellTask | TODO | |
| WindowsServiceTask | TODO | |
| WindowsContainerTask | TODO | |
| WinRmFileTransferTask | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,961 @@
# SPRINT: Agent - ECS
> **Sprint ID:** 108_006
> **Module:** AGENTS
> **Phase:** 8 - Agents
> **Status:** TODO
> **Parent:** [108_000_INDEX](SPRINT_20260110_108_000_INDEX_agents.md)
---
## Overview
Implement the ECS Agent capability for managing AWS Elastic Container Service deployments on ECS clusters (Fargate or EC2 launch types).
### Objectives
- ECS service deployments (create, update, delete)
- ECS task execution (run tasks, stop tasks)
- Task definition registration
- Service scaling operations
- Deployment health monitoring
- Log streaming via CloudWatch Logs
- Support for Fargate and EC2 launch types
### Working Directory
```
src/ReleaseOrchestrator/
├── __Agents/
│ └── StellaOps.Agent.Ecs/
│ ├── EcsCapability.cs
│ ├── Tasks/
│ │ ├── EcsDeployServiceTask.cs
│ │ ├── EcsRunTaskTask.cs
│ │ ├── EcsStopTaskTask.cs
│ │ ├── EcsScaleServiceTask.cs
│ │ ├── EcsRegisterTaskDefinitionTask.cs
│ │ └── EcsHealthCheckTask.cs
│ ├── EcsClientFactory.cs
│ └── CloudWatchLogStreamer.cs
└── __Tests/
└── StellaOps.Agent.Ecs.Tests/
```
---
## Deliverables
### EcsCapability
```csharp
namespace StellaOps.Agent.Ecs;
public sealed class EcsCapability : IAgentCapability
{
private readonly IAmazonECS _ecsClient;
private readonly IAmazonCloudWatchLogs _logsClient;
private readonly ILogger<EcsCapability> _logger;
private readonly Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>> _taskHandlers;
public string Name => "ecs";
public string Version => "1.0.0";
public IReadOnlyList<string> SupportedTaskTypes => new[]
{
"ecs.deploy-service",
"ecs.run-task",
"ecs.stop-task",
"ecs.scale-service",
"ecs.register-task-definition",
"ecs.health-check",
"ecs.describe-service"
};
public EcsCapability(
IAmazonECS ecsClient,
IAmazonCloudWatchLogs logsClient,
ILogger<EcsCapability> logger)
{
_ecsClient = ecsClient;
_logsClient = logsClient;
_logger = logger;
_taskHandlers = new Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>>
{
["ecs.deploy-service"] = ExecuteDeployServiceAsync,
["ecs.run-task"] = ExecuteRunTaskAsync,
["ecs.stop-task"] = ExecuteStopTaskAsync,
["ecs.scale-service"] = ExecuteScaleServiceAsync,
["ecs.register-task-definition"] = ExecuteRegisterTaskDefinitionAsync,
["ecs.health-check"] = ExecuteHealthCheckAsync
};
}
public async Task<bool> InitializeAsync(CancellationToken ct = default)
{
try
{
// Verify AWS credentials and ECS access
var clusters = await _ecsClient.ListClustersAsync(new ListClustersRequest
{
MaxResults = 1
}, ct);
_logger.LogInformation(
"ECS capability initialized, discovered {ClusterCount} clusters",
clusters.ClusterArns.Count);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize ECS capability");
return false;
}
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct = default)
{
if (!_taskHandlers.TryGetValue(task.TaskType, out var handler))
{
throw new UnsupportedTaskTypeException(task.TaskType);
}
return await handler(task, ct);
}
public async Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default)
{
try
{
await _ecsClient.ListClustersAsync(new ListClustersRequest { MaxResults = 1 }, ct);
return new CapabilityHealthStatus(true, "ECS API responding");
}
catch (Exception ex)
{
return new CapabilityHealthStatus(false, $"ECS API not responding: {ex.Message}");
}
}
private Task<TaskResult> ExecuteDeployServiceAsync(AgentTask task, CancellationToken ct) =>
new EcsDeployServiceTask(_ecsClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteRunTaskAsync(AgentTask task, CancellationToken ct) =>
new EcsRunTaskTask(_ecsClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteStopTaskAsync(AgentTask task, CancellationToken ct) =>
new EcsStopTaskTask(_ecsClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteScaleServiceAsync(AgentTask task, CancellationToken ct) =>
new EcsScaleServiceTask(_ecsClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteRegisterTaskDefinitionAsync(AgentTask task, CancellationToken ct) =>
new EcsRegisterTaskDefinitionTask(_ecsClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteHealthCheckAsync(AgentTask task, CancellationToken ct) =>
new EcsHealthCheckTask(_ecsClient, _logger).ExecuteAsync(task, ct);
}
```
### EcsDeployServiceTask
```csharp
namespace StellaOps.Agent.Ecs.Tasks;
public sealed class EcsDeployServiceTask
{
private readonly IAmazonECS _ecsClient;
private readonly ILogger _logger;
public sealed record DeployServicePayload
{
public required string Cluster { get; init; }
public required string ServiceName { get; init; }
public required string TaskDefinition { get; init; }
public int DesiredCount { get; init; } = 1;
public string? LaunchType { get; init; } // FARGATE or EC2
public NetworkConfiguration? NetworkConfig { get; init; }
public LoadBalancerConfiguration? LoadBalancer { get; init; }
public DeploymentConfiguration? DeploymentConfig { get; init; }
public IReadOnlyDictionary<string, string>? Tags { get; init; }
public bool ForceNewDeployment { get; init; } = true;
public TimeSpan DeploymentTimeout { get; init; } = TimeSpan.FromMinutes(10);
}
public sealed record NetworkConfiguration
{
public required IReadOnlyList<string> Subnets { get; init; }
public IReadOnlyList<string>? SecurityGroups { get; init; }
public bool AssignPublicIp { get; init; } = false;
}
public sealed record LoadBalancerConfiguration
{
public required string TargetGroupArn { get; init; }
public required string ContainerName { get; init; }
public required int ContainerPort { get; init; }
}
public sealed record DeploymentConfiguration
{
public int MaximumPercent { get; init; } = 200;
public int MinimumHealthyPercent { get; init; } = 100;
public DeploymentCircuitBreaker? CircuitBreaker { get; init; }
}
public sealed record DeploymentCircuitBreaker
{
public bool Enable { get; init; } = true;
public bool Rollback { get; init; } = true;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<DeployServicePayload>(task.Payload)
?? throw new InvalidPayloadException("ecs.deploy-service");
_logger.LogInformation(
"Deploying ECS service {Service} to cluster {Cluster} with task definition {TaskDef}",
payload.ServiceName,
payload.Cluster,
payload.TaskDefinition);
try
{
// Check if service exists
var existingService = await GetServiceAsync(payload.Cluster, payload.ServiceName, ct);
if (existingService is not null)
{
return await UpdateServiceAsync(task.Id, payload, ct);
}
else
{
return await CreateServiceAsync(task.Id, payload, ct);
}
}
catch (AmazonECSException ex)
{
_logger.LogError(ex, "Failed to deploy ECS service {Service}", payload.ServiceName);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to deploy service: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private async Task<Service?> GetServiceAsync(string cluster, string serviceName, CancellationToken ct)
{
try
{
var response = await _ecsClient.DescribeServicesAsync(new DescribeServicesRequest
{
Cluster = cluster,
Services = new List<string> { serviceName }
}, ct);
return response.Services.FirstOrDefault(s => s.Status != "INACTIVE");
}
catch
{
return null;
}
}
private async Task<TaskResult> CreateServiceAsync(
Guid taskId,
DeployServicePayload payload,
CancellationToken ct)
{
_logger.LogInformation("Creating new ECS service {Service}", payload.ServiceName);
var request = new CreateServiceRequest
{
Cluster = payload.Cluster,
ServiceName = payload.ServiceName,
TaskDefinition = payload.TaskDefinition,
DesiredCount = payload.DesiredCount,
LaunchType = string.IsNullOrEmpty(payload.LaunchType) ? null : new LaunchType(payload.LaunchType),
DeploymentConfiguration = payload.DeploymentConfig is not null
? new Amazon.ECS.Model.DeploymentConfiguration
{
MaximumPercent = payload.DeploymentConfig.MaximumPercent,
MinimumHealthyPercent = payload.DeploymentConfig.MinimumHealthyPercent,
DeploymentCircuitBreaker = payload.DeploymentConfig.CircuitBreaker is not null
? new Amazon.ECS.Model.DeploymentCircuitBreaker
{
Enable = payload.DeploymentConfig.CircuitBreaker.Enable,
Rollback = payload.DeploymentConfig.CircuitBreaker.Rollback
}
: null
}
: null,
Tags = payload.Tags?.Select(kv => new Tag { Key = kv.Key, Value = kv.Value }).ToList()
};
if (payload.NetworkConfig is not null)
{
request.NetworkConfiguration = new Amazon.ECS.Model.NetworkConfiguration
{
AwsvpcConfiguration = new AwsVpcConfiguration
{
Subnets = payload.NetworkConfig.Subnets.ToList(),
SecurityGroups = payload.NetworkConfig.SecurityGroups?.ToList(),
AssignPublicIp = payload.NetworkConfig.AssignPublicIp ? AssignPublicIp.ENABLED : AssignPublicIp.DISABLED
}
};
}
if (payload.LoadBalancer is not null)
{
request.LoadBalancers = new List<Amazon.ECS.Model.LoadBalancer>
{
new()
{
TargetGroupArn = payload.LoadBalancer.TargetGroupArn,
ContainerName = payload.LoadBalancer.ContainerName,
ContainerPort = payload.LoadBalancer.ContainerPort
}
};
}
var createResponse = await _ecsClient.CreateServiceAsync(request, ct);
var service = createResponse.Service;
_logger.LogInformation(
"Created ECS service {Service} (ARN: {Arn})",
payload.ServiceName,
service.ServiceArn);
// Wait for deployment to stabilize
var stable = await WaitForServiceStableAsync(
payload.Cluster,
payload.ServiceName,
payload.DeploymentTimeout,
ct);
return new TaskResult
{
TaskId = taskId,
Success = stable,
Error = stable ? null : "Service did not stabilize within timeout",
Outputs = new Dictionary<string, object>
{
["serviceArn"] = service.ServiceArn,
["serviceName"] = service.ServiceName,
["taskDefinition"] = service.TaskDefinition,
["runningCount"] = service.RunningCount,
["desiredCount"] = service.DesiredCount,
["deploymentStatus"] = stable ? "COMPLETED" : "TIMED_OUT"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
private async Task<TaskResult> UpdateServiceAsync(
Guid taskId,
DeployServicePayload payload,
CancellationToken ct)
{
_logger.LogInformation(
"Updating existing ECS service {Service} to task definition {TaskDef}",
payload.ServiceName,
payload.TaskDefinition);
var request = new UpdateServiceRequest
{
Cluster = payload.Cluster,
Service = payload.ServiceName,
TaskDefinition = payload.TaskDefinition,
DesiredCount = payload.DesiredCount,
ForceNewDeployment = payload.ForceNewDeployment
};
if (payload.DeploymentConfig is not null)
{
request.DeploymentConfiguration = new Amazon.ECS.Model.DeploymentConfiguration
{
MaximumPercent = payload.DeploymentConfig.MaximumPercent,
MinimumHealthyPercent = payload.DeploymentConfig.MinimumHealthyPercent,
DeploymentCircuitBreaker = payload.DeploymentConfig.CircuitBreaker is not null
? new Amazon.ECS.Model.DeploymentCircuitBreaker
{
Enable = payload.DeploymentConfig.CircuitBreaker.Enable,
Rollback = payload.DeploymentConfig.CircuitBreaker.Rollback
}
: null
};
}
var updateResponse = await _ecsClient.UpdateServiceAsync(request, ct);
var service = updateResponse.Service;
_logger.LogInformation(
"Updated ECS service {Service}, deployment ID: {DeploymentId}",
payload.ServiceName,
service.Deployments.FirstOrDefault()?.Id ?? "unknown");
// Wait for deployment to stabilize
var stable = await WaitForServiceStableAsync(
payload.Cluster,
payload.ServiceName,
payload.DeploymentTimeout,
ct);
return new TaskResult
{
TaskId = taskId,
Success = stable,
Error = stable ? null : "Service did not stabilize within timeout",
Outputs = new Dictionary<string, object>
{
["serviceArn"] = service.ServiceArn,
["serviceName"] = service.ServiceName,
["taskDefinition"] = service.TaskDefinition,
["runningCount"] = service.RunningCount,
["desiredCount"] = service.DesiredCount,
["deploymentId"] = service.Deployments.FirstOrDefault()?.Id ?? "",
["deploymentStatus"] = stable ? "COMPLETED" : "TIMED_OUT"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
private async Task<bool> WaitForServiceStableAsync(
string cluster,
string serviceName,
TimeSpan timeout,
CancellationToken ct)
{
_logger.LogInformation("Waiting for service {Service} to stabilize", serviceName);
using var timeoutCts = new CancellationTokenSource(timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
while (!linkedCts.IsCancellationRequested)
{
var response = await _ecsClient.DescribeServicesAsync(new DescribeServicesRequest
{
Cluster = cluster,
Services = new List<string> { serviceName }
}, linkedCts.Token);
var service = response.Services.FirstOrDefault();
if (service is null)
{
_logger.LogWarning("Service {Service} not found during stabilization check", serviceName);
return false;
}
var primaryDeployment = service.Deployments.FirstOrDefault(d => d.Status == "PRIMARY");
if (primaryDeployment is null)
{
await Task.Delay(TimeSpan.FromSeconds(10), linkedCts.Token);
continue;
}
if (primaryDeployment.RunningCount == primaryDeployment.DesiredCount &&
service.Deployments.Count == 1)
{
_logger.LogInformation(
"Service {Service} stabilized with {Count} running tasks",
serviceName,
primaryDeployment.RunningCount);
return true;
}
_logger.LogDebug(
"Service {Service} not stable: running={Running}, desired={Desired}, deployments={Deployments}",
serviceName,
primaryDeployment.RunningCount,
primaryDeployment.DesiredCount,
service.Deployments.Count);
await Task.Delay(TimeSpan.FromSeconds(10), linkedCts.Token);
}
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
_logger.LogWarning("Service {Service} stabilization timed out after {Timeout}", serviceName, timeout);
}
return false;
}
}
```
### EcsRunTaskTask
```csharp
namespace StellaOps.Agent.Ecs.Tasks;
public sealed class EcsRunTaskTask
{
private readonly IAmazonECS _ecsClient;
private readonly ILogger _logger;
public sealed record RunTaskPayload
{
public required string Cluster { get; init; }
public required string TaskDefinition { get; init; }
public int Count { get; init; } = 1;
public string? LaunchType { get; init; }
public NetworkConfiguration? NetworkConfig { get; init; }
public IReadOnlyList<ContainerOverride>? Overrides { get; init; }
public string? Group { get; init; }
public IReadOnlyDictionary<string, string>? Tags { get; init; }
public bool WaitForCompletion { get; init; } = true;
public TimeSpan CompletionTimeout { get; init; } = TimeSpan.FromMinutes(30);
}
public sealed record ContainerOverride
{
public required string Name { get; init; }
public IReadOnlyList<string>? Command { get; init; }
public IReadOnlyDictionary<string, string>? Environment { get; init; }
public int? Cpu { get; init; }
public int? Memory { get; init; }
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<RunTaskPayload>(task.Payload)
?? throw new InvalidPayloadException("ecs.run-task");
_logger.LogInformation(
"Running ECS task from definition {TaskDef} on cluster {Cluster}",
payload.TaskDefinition,
payload.Cluster);
try
{
var request = new RunTaskRequest
{
Cluster = payload.Cluster,
TaskDefinition = payload.TaskDefinition,
Count = payload.Count,
LaunchType = string.IsNullOrEmpty(payload.LaunchType) ? null : new LaunchType(payload.LaunchType),
Group = payload.Group,
Tags = payload.Tags?.Select(kv => new Tag { Key = kv.Key, Value = kv.Value }).ToList()
};
if (payload.NetworkConfig is not null)
{
request.NetworkConfiguration = new Amazon.ECS.Model.NetworkConfiguration
{
AwsvpcConfiguration = new AwsVpcConfiguration
{
Subnets = payload.NetworkConfig.Subnets.ToList(),
SecurityGroups = payload.NetworkConfig.SecurityGroups?.ToList(),
AssignPublicIp = payload.NetworkConfig.AssignPublicIp ? AssignPublicIp.ENABLED : AssignPublicIp.DISABLED
}
};
}
if (payload.Overrides is not null)
{
request.Overrides = new TaskOverride
{
ContainerOverrides = payload.Overrides.Select(o => new Amazon.ECS.Model.ContainerOverride
{
Name = o.Name,
Command = o.Command?.ToList(),
Environment = o.Environment?.Select(kv => new Amazon.ECS.Model.KeyValuePair
{
Name = kv.Key,
Value = kv.Value
}).ToList(),
Cpu = o.Cpu,
Memory = o.Memory
}).ToList()
};
}
var runResponse = await _ecsClient.RunTaskAsync(request, ct);
if (runResponse.Failures.Any())
{
var failure = runResponse.Failures.First();
_logger.LogError(
"Failed to run ECS task: {Reason} (ARN: {Arn})",
failure.Reason,
failure.Arn);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to run task: {failure.Reason}",
CompletedAt = DateTimeOffset.UtcNow
};
}
var ecsTasks = runResponse.Tasks;
var taskArns = ecsTasks.Select(t => t.TaskArn).ToList();
_logger.LogInformation(
"Started {Count} ECS task(s): {TaskArns}",
ecsTasks.Count,
string.Join(", ", taskArns.Select(a => a.Split('/').Last())));
if (!payload.WaitForCompletion)
{
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["taskArns"] = taskArns,
["taskCount"] = ecsTasks.Count,
["status"] = "RUNNING"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
// Wait for tasks to complete
var (completed, exitCodes) = await WaitForTasksAsync(
payload.Cluster,
taskArns,
payload.CompletionTimeout,
ct);
var allSucceeded = completed && exitCodes.All(e => e == 0);
return new TaskResult
{
TaskId = task.Id,
Success = allSucceeded,
Error = allSucceeded ? null : $"Task(s) failed with exit codes: {string.Join(", ", exitCodes)}",
Outputs = new Dictionary<string, object>
{
["taskArns"] = taskArns,
["taskCount"] = ecsTasks.Count,
["exitCodes"] = exitCodes,
["status"] = allSucceeded ? "SUCCEEDED" : "FAILED"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (AmazonECSException ex)
{
_logger.LogError(ex, "Failed to run ECS task from {TaskDef}", payload.TaskDefinition);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to run task: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private async Task<(bool Completed, List<int> ExitCodes)> WaitForTasksAsync(
string cluster,
List<string> taskArns,
TimeSpan timeout,
CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
var exitCodes = new List<int>();
try
{
while (!linkedCts.IsCancellationRequested)
{
var response = await _ecsClient.DescribeTasksAsync(new DescribeTasksRequest
{
Cluster = cluster,
Tasks = taskArns
}, linkedCts.Token);
var allStopped = response.Tasks.All(t => t.LastStatus == "STOPPED");
if (allStopped)
{
exitCodes = response.Tasks
.SelectMany(t => t.Containers.Select(c => c.ExitCode ?? -1))
.ToList();
return (true, exitCodes);
}
await Task.Delay(TimeSpan.FromSeconds(10), linkedCts.Token);
}
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
_logger.LogWarning("Task completion wait timed out after {Timeout}", timeout);
}
return (false, exitCodes);
}
}
```
### EcsHealthCheckTask
```csharp
namespace StellaOps.Agent.Ecs.Tasks;
public sealed class EcsHealthCheckTask
{
private readonly IAmazonECS _ecsClient;
private readonly ILogger _logger;
public sealed record HealthCheckPayload
{
public required string Cluster { get; init; }
public required string ServiceName { get; init; }
public int MinHealthyPercent { get; init; } = 100;
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<HealthCheckPayload>(task.Payload)
?? throw new InvalidPayloadException("ecs.health-check");
_logger.LogInformation(
"Checking health of ECS service {Service} in cluster {Cluster}",
payload.ServiceName,
payload.Cluster);
try
{
using var timeoutCts = new CancellationTokenSource(payload.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
while (!linkedCts.IsCancellationRequested)
{
var response = await _ecsClient.DescribeServicesAsync(new DescribeServicesRequest
{
Cluster = payload.Cluster,
Services = new List<string> { payload.ServiceName }
}, linkedCts.Token);
var service = response.Services.FirstOrDefault();
if (service is null)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = "Service not found",
CompletedAt = DateTimeOffset.UtcNow
};
}
var healthyPercent = service.DesiredCount > 0
? (service.RunningCount * 100) / service.DesiredCount
: 0;
if (healthyPercent >= payload.MinHealthyPercent && service.Deployments.Count == 1)
{
_logger.LogInformation(
"Service {Service} is healthy: {Running}/{Desired} tasks running ({Percent}%)",
payload.ServiceName,
service.RunningCount,
service.DesiredCount,
healthyPercent);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["serviceName"] = service.ServiceName,
["runningCount"] = service.RunningCount,
["desiredCount"] = service.DesiredCount,
["healthyPercent"] = healthyPercent,
["status"] = service.Status,
["deployments"] = service.Deployments.Count
},
CompletedAt = DateTimeOffset.UtcNow
};
}
_logger.LogDebug(
"Service {Service} health check: {Running}/{Desired} ({Percent}%), waiting...",
payload.ServiceName,
service.RunningCount,
service.DesiredCount,
healthyPercent);
await Task.Delay(TimeSpan.FromSeconds(10), linkedCts.Token);
}
throw new OperationCanceledException();
}
catch (OperationCanceledException)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Health check timed out after {payload.Timeout}",
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (AmazonECSException ex)
{
_logger.LogError(ex, "Failed to check health of ECS service {Service}", payload.ServiceName);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Health check failed: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
}
```
### CloudWatchLogStreamer
```csharp
namespace StellaOps.Agent.Ecs;
public sealed class CloudWatchLogStreamer
{
private readonly IAmazonCloudWatchLogs _logsClient;
private readonly LogStreamer _logStreamer;
private readonly ILogger<CloudWatchLogStreamer> _logger;
public async Task StreamLogsAsync(
Guid taskId,
string logGroupName,
string logStreamName,
CancellationToken ct = default)
{
string? nextToken = null;
try
{
while (!ct.IsCancellationRequested)
{
var request = new GetLogEventsRequest
{
LogGroupName = logGroupName,
LogStreamName = logStreamName,
StartFromHead = true,
NextToken = nextToken
};
var response = await _logsClient.GetLogEventsAsync(request, ct);
foreach (var logEvent in response.Events)
{
var level = DetectLogLevel(logEvent.Message);
_logStreamer.Log(taskId, level, logEvent.Message);
}
if (response.NextForwardToken == nextToken)
{
// No new logs, wait before polling again
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
nextToken = response.NextForwardToken;
}
}
catch (OperationCanceledException)
{
// Expected when task completes
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Error streaming logs from {LogGroup}/{LogStream}",
logGroupName,
logStreamName);
}
}
private static LogLevel DetectLogLevel(string message)
{
if (message.Contains("ERROR", StringComparison.OrdinalIgnoreCase) ||
message.Contains("FATAL", StringComparison.OrdinalIgnoreCase))
{
return LogLevel.Error;
}
if (message.Contains("WARN", StringComparison.OrdinalIgnoreCase))
{
return LogLevel.Warning;
}
if (message.Contains("DEBUG", StringComparison.OrdinalIgnoreCase))
{
return LogLevel.Debug;
}
return LogLevel.Information;
}
}
```
---
## Acceptance Criteria
- [ ] Deploy new ECS services (Fargate and EC2 launch types)
- [ ] Update existing ECS services with new task definitions
- [ ] Run one-off ECS tasks
- [ ] Stop running ECS tasks
- [ ] Scale ECS services up/down
- [ ] Register new task definitions
- [ ] Check service health and stability
- [ ] Wait for deployments to complete
- [ ] Stream logs from CloudWatch
- [ ] Support network configuration (VPC, subnets, security groups)
- [ ] Support load balancer integration
- [ ] Support deployment circuit breaker
- [ ] Unit test coverage >= 85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 108_001 Agent Core Runtime | Internal | TODO |
| AWSSDK.ECS | NuGet | Available |
| AWSSDK.CloudWatchLogs | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| EcsCapability | TODO | |
| EcsDeployServiceTask | TODO | |
| EcsRunTaskTask | TODO | |
| EcsStopTaskTask | TODO | |
| EcsScaleServiceTask | TODO | |
| EcsRegisterTaskDefinitionTask | TODO | |
| EcsHealthCheckTask | TODO | |
| CloudWatchLogStreamer | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,900 @@
# SPRINT: Agent - Nomad
> **Sprint ID:** 108_007
> **Module:** AGENTS
> **Phase:** 8 - Agents
> **Status:** TODO
> **Parent:** [108_000_INDEX](SPRINT_20260110_108_000_INDEX_agents.md)
---
## Overview
Implement the Nomad Agent capability for managing HashiCorp Nomad job deployments, supporting Docker, raw_exec, and other Nomad task drivers.
### Objectives
- Nomad job deployments (register, run, stop)
- Job scaling operations
- Deployment monitoring and health checks
- Allocation status tracking
- Log streaming from allocations
- Support for multiple task drivers (docker, raw_exec, java)
- Constraint and affinity configuration
### Working Directory
```
src/ReleaseOrchestrator/
├── __Agents/
│ └── StellaOps.Agent.Nomad/
│ ├── NomadCapability.cs
│ ├── Tasks/
│ │ ├── NomadDeployJobTask.cs
│ │ ├── NomadStopJobTask.cs
│ │ ├── NomadScaleJobTask.cs
│ │ ├── NomadJobStatusTask.cs
│ │ └── NomadHealthCheckTask.cs
│ ├── NomadClientFactory.cs
│ └── NomadLogStreamer.cs
└── __Tests/
└── StellaOps.Agent.Nomad.Tests/
```
---
## Deliverables
### NomadCapability
```csharp
namespace StellaOps.Agent.Nomad;
public sealed class NomadCapability : IAgentCapability
{
private readonly NomadClient _nomadClient;
private readonly ILogger<NomadCapability> _logger;
private readonly Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>> _taskHandlers;
public string Name => "nomad";
public string Version => "1.0.0";
public IReadOnlyList<string> SupportedTaskTypes => new[]
{
"nomad.deploy-job",
"nomad.stop-job",
"nomad.scale-job",
"nomad.job-status",
"nomad.health-check",
"nomad.dispatch-job"
};
public NomadCapability(NomadClient nomadClient, ILogger<NomadCapability> logger)
{
_nomadClient = nomadClient;
_logger = logger;
_taskHandlers = new Dictionary<string, Func<AgentTask, CancellationToken, Task<TaskResult>>>
{
["nomad.deploy-job"] = ExecuteDeployJobAsync,
["nomad.stop-job"] = ExecuteStopJobAsync,
["nomad.scale-job"] = ExecuteScaleJobAsync,
["nomad.job-status"] = ExecuteJobStatusAsync,
["nomad.health-check"] = ExecuteHealthCheckAsync,
["nomad.dispatch-job"] = ExecuteDispatchJobAsync
};
}
public async Task<bool> InitializeAsync(CancellationToken ct = default)
{
try
{
var status = await _nomadClient.Agent.GetSelfAsync(ct);
_logger.LogInformation(
"Nomad capability initialized, connected to {Region} region (version {Version})",
status.Stats["nomad"]["region"],
status.Stats["nomad"]["version"]);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize Nomad capability");
return false;
}
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct = default)
{
if (!_taskHandlers.TryGetValue(task.TaskType, out var handler))
{
throw new UnsupportedTaskTypeException(task.TaskType);
}
return await handler(task, ct);
}
public async Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default)
{
try
{
var status = await _nomadClient.Agent.GetSelfAsync(ct);
return new CapabilityHealthStatus(true, $"Nomad agent responding ({status.Stats["nomad"]["region"]})");
}
catch (Exception ex)
{
return new CapabilityHealthStatus(false, $"Nomad agent not responding: {ex.Message}");
}
}
private Task<TaskResult> ExecuteDeployJobAsync(AgentTask task, CancellationToken ct) =>
new NomadDeployJobTask(_nomadClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteStopJobAsync(AgentTask task, CancellationToken ct) =>
new NomadStopJobTask(_nomadClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteScaleJobAsync(AgentTask task, CancellationToken ct) =>
new NomadScaleJobTask(_nomadClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteJobStatusAsync(AgentTask task, CancellationToken ct) =>
new NomadJobStatusTask(_nomadClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteHealthCheckAsync(AgentTask task, CancellationToken ct) =>
new NomadHealthCheckTask(_nomadClient, _logger).ExecuteAsync(task, ct);
private Task<TaskResult> ExecuteDispatchJobAsync(AgentTask task, CancellationToken ct) =>
new NomadDispatchJobTask(_nomadClient, _logger).ExecuteAsync(task, ct);
}
```
### NomadDeployJobTask
```csharp
namespace StellaOps.Agent.Nomad.Tasks;
public sealed class NomadDeployJobTask
{
private readonly NomadClient _nomadClient;
private readonly ILogger _logger;
public sealed record DeployJobPayload
{
/// <summary>
/// Job specification in HCL or JSON format.
/// </summary>
public string? JobSpec { get; init; }
/// <summary>
/// Pre-parsed job definition (alternative to JobSpec).
/// </summary>
public JobDefinition? Job { get; init; }
/// <summary>
/// Variables to substitute in job spec.
/// </summary>
public IReadOnlyDictionary<string, string>? Variables { get; init; }
/// <summary>
/// Nomad namespace.
/// </summary>
public string? Namespace { get; init; }
/// <summary>
/// Region to deploy to.
/// </summary>
public string? Region { get; init; }
/// <summary>
/// Whether to wait for deployment to complete.
/// </summary>
public bool WaitForDeployment { get; init; } = true;
/// <summary>
/// Deployment completion timeout.
/// </summary>
public TimeSpan DeploymentTimeout { get; init; } = TimeSpan.FromMinutes(10);
/// <summary>
/// If true, job is run in detached mode (fire and forget).
/// </summary>
public bool Detach { get; init; } = false;
}
public sealed record JobDefinition
{
public required string ID { get; init; }
public required string Name { get; init; }
public string Type { get; init; } = "service";
public string? Namespace { get; init; }
public string? Region { get; init; }
public int Priority { get; init; } = 50;
public IReadOnlyList<string>? Datacenters { get; init; }
public IReadOnlyList<TaskGroupDefinition>? TaskGroups { get; init; }
public UpdateStrategy? Update { get; init; }
public IReadOnlyDictionary<string, string>? Meta { get; init; }
public IReadOnlyList<ConstraintDefinition>? Constraints { get; init; }
}
public sealed record TaskGroupDefinition
{
public required string Name { get; init; }
public int Count { get; init; } = 1;
public IReadOnlyList<TaskDefinition>? Tasks { get; init; }
public IReadOnlyList<NetworkDefinition>? Networks { get; init; }
public IReadOnlyList<ServiceDefinition>? Services { get; init; }
public RestartPolicy? RestartPolicy { get; init; }
public EphemeralDisk? EphemeralDisk { get; init; }
}
public sealed record TaskDefinition
{
public required string Name { get; init; }
public required string Driver { get; init; } // docker, raw_exec, java, etc.
public required IReadOnlyDictionary<string, object> Config { get; init; }
public ResourceRequirements? Resources { get; init; }
public IReadOnlyDictionary<string, string>? Env { get; init; }
public IReadOnlyList<TemplateDefinition>? Templates { get; init; }
public IReadOnlyList<ArtifactDefinition>? Artifacts { get; init; }
public LogConfig? Logs { get; init; }
}
public sealed record UpdateStrategy
{
public int MaxParallel { get; init; } = 1;
public string HealthCheck { get; init; } = "checks";
public TimeSpan MinHealthyTime { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan HealthyDeadline { get; init; } = TimeSpan.FromMinutes(5);
public bool AutoRevert { get; init; } = false;
public bool AutoPromote { get; init; } = false;
public int Canary { get; init; } = 0;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<DeployJobPayload>(task.Payload)
?? throw new InvalidPayloadException("nomad.deploy-job");
Job nomadJob;
if (!string.IsNullOrEmpty(payload.JobSpec))
{
// Parse HCL or JSON job spec
var parseResponse = await _nomadClient.Jobs.ParseJobAsync(
payload.JobSpec,
payload.Variables?.ToDictionary(kv => kv.Key, kv => kv.Value),
ct);
nomadJob = parseResponse;
}
else if (payload.Job is not null)
{
nomadJob = ConvertToNomadJob(payload.Job);
}
else
{
throw new InvalidPayloadException("nomad.deploy-job", "Either JobSpec or Job must be provided");
}
_logger.LogInformation(
"Deploying Nomad job {JobId} to region {Region}",
nomadJob.ID,
payload.Region ?? "default");
try
{
// Register the job
var registerResponse = await _nomadClient.Jobs.RegisterAsync(
nomadJob,
new WriteOptions
{
Namespace = payload.Namespace,
Region = payload.Region
},
ct);
_logger.LogInformation(
"Registered Nomad job {JobId}, evaluation ID: {EvalId}",
nomadJob.ID,
registerResponse.EvalID);
if (payload.Detach)
{
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["jobId"] = nomadJob.ID,
["evalId"] = registerResponse.EvalID,
["status"] = "DETACHED"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
if (!payload.WaitForDeployment)
{
// Just wait for evaluation to complete
var evaluation = await WaitForEvaluationAsync(
registerResponse.EvalID,
payload.Namespace,
TimeSpan.FromMinutes(2),
ct);
return new TaskResult
{
TaskId = task.Id,
Success = evaluation.Status == "complete",
Outputs = new Dictionary<string, object>
{
["jobId"] = nomadJob.ID,
["evalId"] = registerResponse.EvalID,
["evalStatus"] = evaluation.Status,
["status"] = evaluation.Status == "complete" ? "EVALUATED" : "EVAL_FAILED"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
// Wait for deployment to complete
var deployment = await WaitForDeploymentAsync(
nomadJob.ID,
payload.Namespace,
payload.DeploymentTimeout,
ct);
var success = deployment?.Status == "successful";
return new TaskResult
{
TaskId = task.Id,
Success = success,
Error = success ? null : $"Deployment failed: {deployment?.StatusDescription ?? "unknown"}",
Outputs = new Dictionary<string, object>
{
["jobId"] = nomadJob.ID,
["evalId"] = registerResponse.EvalID,
["deploymentId"] = deployment?.ID ?? "",
["deploymentStatus"] = deployment?.Status ?? "unknown",
["status"] = success ? "DEPLOYED" : "DEPLOYMENT_FAILED"
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (NomadApiException ex)
{
_logger.LogError(ex, "Failed to deploy Nomad job {JobId}", nomadJob.ID);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to deploy job: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
private async Task<Evaluation> WaitForEvaluationAsync(
string evalId,
string? ns,
TimeSpan timeout,
CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
while (!linkedCts.IsCancellationRequested)
{
var evaluation = await _nomadClient.Evaluations.GetAsync(
evalId,
new QueryOptions { Namespace = ns },
linkedCts.Token);
if (evaluation.Status is "complete" or "failed" or "canceled")
{
return evaluation;
}
_logger.LogDebug("Evaluation {EvalId} status: {Status}", evalId, evaluation.Status);
await Task.Delay(TimeSpan.FromSeconds(2), linkedCts.Token);
}
throw new OperationCanceledException("Evaluation wait timed out");
}
private async Task<Deployment?> WaitForDeploymentAsync(
string jobId,
string? ns,
TimeSpan timeout,
CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
Deployment? deployment = null;
while (!linkedCts.IsCancellationRequested)
{
var deployments = await _nomadClient.Jobs.GetDeploymentsAsync(
jobId,
new QueryOptions { Namespace = ns },
linkedCts.Token);
deployment = deployments.FirstOrDefault();
if (deployment is null)
{
await Task.Delay(TimeSpan.FromSeconds(2), linkedCts.Token);
continue;
}
if (deployment.Status is "successful" or "failed" or "cancelled")
{
return deployment;
}
_logger.LogDebug(
"Deployment {DeploymentId} status: {Status}",
deployment.ID,
deployment.Status);
await Task.Delay(TimeSpan.FromSeconds(5), linkedCts.Token);
}
return deployment;
}
private static Job ConvertToNomadJob(JobDefinition def)
{
return new Job
{
ID = def.ID,
Name = def.Name,
Type = def.Type,
Namespace = def.Namespace,
Region = def.Region,
Priority = def.Priority,
Datacenters = def.Datacenters?.ToList(),
Meta = def.Meta?.ToDictionary(kv => kv.Key, kv => kv.Value),
TaskGroups = def.TaskGroups?.Select(tg => new TaskGroup
{
Name = tg.Name,
Count = tg.Count,
Tasks = tg.Tasks?.Select(t => new Task
{
Name = t.Name,
Driver = t.Driver,
Config = t.Config?.ToDictionary(kv => kv.Key, kv => kv.Value),
Env = t.Env?.ToDictionary(kv => kv.Key, kv => kv.Value),
Resources = t.Resources is not null ? new Resources
{
CPU = t.Resources.CPU,
MemoryMB = t.Resources.MemoryMB
} : null
}).ToList()
}).ToList(),
Update = def.Update is not null ? new UpdateStrategy
{
MaxParallel = def.Update.MaxParallel,
HealthCheck = def.Update.HealthCheck,
MinHealthyTime = (long)def.Update.MinHealthyTime.TotalNanoseconds,
HealthyDeadline = (long)def.Update.HealthyDeadline.TotalNanoseconds,
AutoRevert = def.Update.AutoRevert,
AutoPromote = def.Update.AutoPromote,
Canary = def.Update.Canary
} : null
};
}
}
```
### NomadStopJobTask
```csharp
namespace StellaOps.Agent.Nomad.Tasks;
public sealed class NomadStopJobTask
{
private readonly NomadClient _nomadClient;
private readonly ILogger _logger;
public sealed record StopJobPayload
{
public required string JobId { get; init; }
public string? Namespace { get; init; }
public string? Region { get; init; }
public bool Purge { get; init; } = false;
public bool Global { get; init; } = false;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<StopJobPayload>(task.Payload)
?? throw new InvalidPayloadException("nomad.stop-job");
_logger.LogInformation(
"Stopping Nomad job {JobId} (purge: {Purge})",
payload.JobId,
payload.Purge);
try
{
var response = await _nomadClient.Jobs.DeregisterAsync(
payload.JobId,
payload.Purge,
payload.Global,
new WriteOptions
{
Namespace = payload.Namespace,
Region = payload.Region
},
ct);
_logger.LogInformation(
"Stopped Nomad job {JobId}, evaluation ID: {EvalId}",
payload.JobId,
response.EvalID);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["jobId"] = payload.JobId,
["evalId"] = response.EvalID,
["purged"] = payload.Purge
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (NomadApiException ex)
{
_logger.LogError(ex, "Failed to stop Nomad job {JobId}", payload.JobId);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to stop job: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
}
```
### NomadScaleJobTask
```csharp
namespace StellaOps.Agent.Nomad.Tasks;
public sealed class NomadScaleJobTask
{
private readonly NomadClient _nomadClient;
private readonly ILogger _logger;
public sealed record ScaleJobPayload
{
public required string JobId { get; init; }
public required string TaskGroup { get; init; }
public required int Count { get; init; }
public string? Namespace { get; init; }
public string? Region { get; init; }
public string? Reason { get; init; }
public bool PolicyOverride { get; init; } = false;
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<ScaleJobPayload>(task.Payload)
?? throw new InvalidPayloadException("nomad.scale-job");
_logger.LogInformation(
"Scaling Nomad job {JobId} task group {TaskGroup} to {Count}",
payload.JobId,
payload.TaskGroup,
payload.Count);
try
{
var response = await _nomadClient.Jobs.ScaleAsync(
payload.JobId,
payload.TaskGroup,
payload.Count,
payload.Reason ?? $"Scaled by Stella Ops (task: {task.Id})",
payload.PolicyOverride,
new WriteOptions
{
Namespace = payload.Namespace,
Region = payload.Region
},
ct);
_logger.LogInformation(
"Scaled Nomad job {JobId} task group {TaskGroup} to {Count}, evaluation ID: {EvalId}",
payload.JobId,
payload.TaskGroup,
payload.Count,
response.EvalID);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["jobId"] = payload.JobId,
["taskGroup"] = payload.TaskGroup,
["count"] = payload.Count,
["evalId"] = response.EvalID
},
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (NomadApiException ex)
{
_logger.LogError(
ex,
"Failed to scale Nomad job {JobId} task group {TaskGroup}",
payload.JobId,
payload.TaskGroup);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Failed to scale job: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
}
```
### NomadHealthCheckTask
```csharp
namespace StellaOps.Agent.Nomad.Tasks;
public sealed class NomadHealthCheckTask
{
private readonly NomadClient _nomadClient;
private readonly ILogger _logger;
public sealed record HealthCheckPayload
{
public required string JobId { get; init; }
public string? Namespace { get; init; }
public string? Region { get; init; }
public int MinHealthyAllocations { get; init; } = 1;
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
}
public async Task<TaskResult> ExecuteAsync(AgentTask task, CancellationToken ct)
{
var payload = JsonSerializer.Deserialize<HealthCheckPayload>(task.Payload)
?? throw new InvalidPayloadException("nomad.health-check");
_logger.LogInformation(
"Checking health of Nomad job {JobId}",
payload.JobId);
try
{
using var timeoutCts = new CancellationTokenSource(payload.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
while (!linkedCts.IsCancellationRequested)
{
var allocations = await _nomadClient.Jobs.GetAllocationsAsync(
payload.JobId,
new QueryOptions
{
Namespace = payload.Namespace,
Region = payload.Region
},
linkedCts.Token);
var runningAllocations = allocations
.Where(a => a.ClientStatus == "running")
.ToList();
var healthyCount = runningAllocations
.Count(a => a.DeploymentStatus?.Healthy == true);
if (healthyCount >= payload.MinHealthyAllocations)
{
_logger.LogInformation(
"Nomad job {JobId} is healthy: {Healthy}/{Total} allocations healthy",
payload.JobId,
healthyCount,
runningAllocations.Count);
return new TaskResult
{
TaskId = task.Id,
Success = true,
Outputs = new Dictionary<string, object>
{
["jobId"] = payload.JobId,
["healthyAllocations"] = healthyCount,
["totalAllocations"] = allocations.Count,
["runningAllocations"] = runningAllocations.Count
},
CompletedAt = DateTimeOffset.UtcNow
};
}
_logger.LogDebug(
"Nomad job {JobId} health check: {Healthy}/{MinRequired} healthy, waiting...",
payload.JobId,
healthyCount,
payload.MinHealthyAllocations);
await Task.Delay(TimeSpan.FromSeconds(5), linkedCts.Token);
}
throw new OperationCanceledException();
}
catch (OperationCanceledException)
{
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Health check timed out after {payload.Timeout}",
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (NomadApiException ex)
{
_logger.LogError(ex, "Failed to check health of Nomad job {JobId}", payload.JobId);
return new TaskResult
{
TaskId = task.Id,
Success = false,
Error = $"Health check failed: {ex.Message}",
CompletedAt = DateTimeOffset.UtcNow
};
}
}
}
```
### NomadLogStreamer
```csharp
namespace StellaOps.Agent.Nomad;
public sealed class NomadLogStreamer
{
private readonly NomadClient _nomadClient;
private readonly LogStreamer _logStreamer;
private readonly ILogger<NomadLogStreamer> _logger;
public async Task StreamLogsAsync(
Guid taskId,
string allocationId,
string taskName,
string logType, // "stdout" or "stderr"
CancellationToken ct = default)
{
try
{
var stream = await _nomadClient.Allocations.GetLogsAsync(
allocationId,
taskName,
logType,
follow: true,
ct);
using var reader = new StreamReader(stream);
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (line is null)
break;
var level = logType == "stderr" ? LogLevel.Error : LogLevel.Information;
// Override level based on content heuristics
if (logType == "stdout")
{
level = DetectLogLevel(line);
}
_logStreamer.Log(taskId, level, line);
}
}
catch (OperationCanceledException)
{
// Expected when task completes
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Error streaming logs for allocation {AllocationId} task {TaskName}",
allocationId,
taskName);
}
}
private static LogLevel DetectLogLevel(string message)
{
if (message.Contains("ERROR", StringComparison.OrdinalIgnoreCase) ||
message.Contains("FATAL", StringComparison.OrdinalIgnoreCase))
{
return LogLevel.Error;
}
if (message.Contains("WARN", StringComparison.OrdinalIgnoreCase))
{
return LogLevel.Warning;
}
if (message.Contains("DEBUG", StringComparison.OrdinalIgnoreCase))
{
return LogLevel.Debug;
}
return LogLevel.Information;
}
}
```
---
## Acceptance Criteria
- [ ] Register and deploy Nomad jobs from HCL or JSON spec
- [ ] Register and deploy Nomad jobs from structured JobDefinition
- [ ] Stop Nomad jobs (with optional purge)
- [ ] Scale Nomad job task groups
- [ ] Check job health and allocation status
- [ ] Wait for deployments to complete
- [ ] Dispatch parameterized batch jobs
- [ ] Stream logs from allocations
- [ ] Support Docker task driver
- [ ] Support raw_exec task driver
- [ ] Support job constraints and affinities
- [ ] Support update strategies (rolling, canary)
- [ ] Unit test coverage >= 85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 108_001 Agent Core Runtime | Internal | TODO |
| Nomad.Api (or custom HTTP client) | NuGet/Custom | TODO |
> **Note:** HashiCorp does not provide an official .NET SDK for Nomad. Implementation will use a custom HTTP client wrapper or community library.
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| NomadCapability | TODO | |
| NomadDeployJobTask | TODO | |
| NomadStopJobTask | TODO | |
| NomadScaleJobTask | TODO | |
| NomadJobStatusTask | TODO | |
| NomadHealthCheckTask | TODO | |
| NomadDispatchJobTask | TODO | |
| NomadLogStreamer | TODO | |
| NomadClient wrapper | TODO | Custom HTTP client |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,243 @@
# SPRINT INDEX: Phase 9 - Evidence & Audit
> **Epic:** Release Orchestrator
> **Phase:** 9 - Evidence & Audit
> **Batch:** 109
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 9 implements the Evidence & Audit system - generating cryptographically signed, immutable evidence packets for every deployment decision.
### Objectives
- Evidence collector gathers deployment context
- Evidence signer creates tamper-proof signatures
- Version sticker writer records deployment state
- Audit exporter generates compliance reports
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 109_001 | Evidence Collector | RELEVI | TODO | 106_005, 107_001 |
| 109_002 | Evidence Signer | RELEVI | TODO | 109_001 |
| 109_003 | Version Sticker Writer | RELEVI | TODO | 107_002 |
| 109_004 | Audit Exporter | RELEVI | TODO | 109_002 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ EVIDENCE & AUDIT │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ EVIDENCE COLLECTOR (109_001) │ │
│ │ │ │
│ │ Collects from: │ │
│ │ ├── Release bundle (components, digests, source refs) │ │
│ │ ├── Promotion (requester, approvers, gates) │ │
│ │ ├── Deployment (targets, tasks, artifacts) │ │
│ │ └── Decision (gate results, freeze window status) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Evidence Packet │ │ │
│ │ │ { │ │ │
│ │ │ "type": "deployment", │ │ │
│ │ │ "release": { ... }, │ │ │
│ │ │ "environment": { ... }, │ │ │
│ │ │ "actors": { requester, approvers, deployer }, │ │ │
│ │ │ "decision": { gates, freeze_check, sod }, │ │ │
│ │ │ "execution": { tasks, artifacts, metrics } │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ EVIDENCE SIGNER (109_002) │ │
│ │ │ │
│ │ 1. Canonicalize JSON (RFC 8785) │ │
│ │ 2. Hash content (SHA-256) │ │
│ │ 3. Sign hash with signing key (RS256 or ES256) │ │
│ │ 4. Store in append-only table │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ { │ │ │
│ │ │ "content": { ... }, │ │ │
│ │ │ "contentHash": "sha256:abc...", │ │ │
│ │ │ "signature": "base64...", │ │ │
│ │ │ "signatureAlgorithm": "RS256", │ │ │
│ │ │ "signerKeyRef": "stella/signing/prod-key-2026" │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ VERSION STICKER WRITER (109_003) │ │
│ │ │ │
│ │ stella.version.json written to each target: │ │
│ │ { │ │
│ │ "release": "myapp-v2.3.1", │ │
│ │ "deployment_id": "uuid", │ │
│ │ "deployed_at": "2026-01-10T14:35:00Z", │ │
│ │ "components": [ │ │
│ │ { "name": "api", "digest": "sha256:..." }, │ │
│ │ { "name": "worker", "digest": "sha256:..." } │ │
│ │ ], │ │
│ │ "evidence_id": "evid-uuid" │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ AUDIT EXPORTER (109_004) │ │
│ │ │ │
│ │ Export formats: │ │
│ │ ├── JSON - Machine-readable, full detail │ │
│ │ ├── PDF - Human-readable compliance reports │ │
│ │ ├── CSV - Spreadsheet analysis │ │
│ │ └── SLSA - SLSA provenance format │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 109_001: Evidence Collector
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IEvidenceCollector` | Interface | Evidence collection |
| `EvidenceCollector` | Class | Implementation |
| `EvidenceContent` | Model | Evidence structure |
| `ContentBuilder` | Class | Build evidence sections |
### 109_002: Evidence Signer
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IEvidenceSigner` | Interface | Signing operations |
| `EvidenceSigner` | Class | Implementation |
| `CanonicalJsonSerializer` | Class | RFC 8785 canonicalization |
| `SigningKeyProvider` | Class | Key management |
### 109_003: Version Sticker Writer
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IVersionStickerWriter` | Interface | Sticker writing |
| `VersionStickerWriter` | Class | Implementation |
| `VersionSticker` | Model | Sticker structure |
| `StickerAgent Task` | Task | Agent writes sticker |
### 109_004: Audit Exporter
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IAuditExporter` | Interface | Export operations |
| `JsonExporter` | Exporter | JSON format |
| `PdfExporter` | Exporter | PDF format |
| `CsvExporter` | Exporter | CSV format |
| `SlsaExporter` | Exporter | SLSA format |
---
## Key Interfaces
```csharp
public interface IEvidenceCollector
{
Task<EvidencePacket> CollectAsync(Guid promotionId, EvidenceType type, CancellationToken ct);
}
public interface IEvidenceSigner
{
Task<SignedEvidencePacket> SignAsync(EvidencePacket packet, CancellationToken ct);
Task<bool> VerifyAsync(SignedEvidencePacket packet, CancellationToken ct);
}
public interface IVersionStickerWriter
{
Task WriteAsync(Guid deploymentTaskId, VersionSticker sticker, CancellationToken ct);
Task<VersionSticker?> ReadAsync(Guid targetId, CancellationToken ct);
}
public interface IAuditExporter
{
Task<Stream> ExportAsync(AuditExportRequest request, CancellationToken ct);
IReadOnlyList<string> SupportedFormats { get; }
}
```
---
## Evidence Lifecycle
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ EVIDENCE LIFECYCLE │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Promotion │───►│ Collect │───►│ Sign │───►│ Store │ │
│ │ Complete │ │ Evidence │ │ Evidence │ │ (immutable) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Export │ │ Verify │ │
│ │ (on-demand)│ │ (on-demand) │ │
│ └─────────────┘ └─────────────┘ │
│ │ │ │
│ ┌───────────┼───────────┬───────────┐ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌───────────┐ │
│ │ JSON │ │ PDF │ │ CSV │ │ SLSA │ │ Verified │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │ Report │ │
│ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| 106_005 Decision Engine | Decision data |
| 107_001 Deploy Orchestrator | Deployment data |
| 107_002 Target Executor | Task data |
| Signer | Cryptographic signing |
---
## Acceptance Criteria
- [ ] Evidence collected for all promotions
- [ ] Evidence signed with platform key
- [ ] Signature verification works
- [ ] Append-only storage enforced
- [ ] Version sticker written to targets
- [ ] JSON export works
- [ ] PDF export readable
- [ ] SLSA format compliant
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 9 index created |

View File

@@ -0,0 +1,597 @@
# SPRINT: Evidence Collector
> **Sprint ID:** 109_001
> **Module:** RELEVI
> **Phase:** 9 - Evidence & Audit
> **Status:** TODO
> **Parent:** [109_000_INDEX](SPRINT_20260110_109_000_INDEX_evidence_audit.md)
---
## Overview
Implement the Evidence Collector for gathering deployment decision context into cryptographically sealed evidence packets.
### Objectives
- Collect evidence from release, promotion, and deployment data
- Build comprehensive evidence packets
- Track evidence dependencies and lineage
- Store evidence in append-only store
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Evidence/
│ ├── Collector/
│ │ ├── IEvidenceCollector.cs
│ │ ├── EvidenceCollector.cs
│ │ ├── ContentBuilder.cs
│ │ └── Collectors/
│ │ ├── ReleaseEvidenceCollector.cs
│ │ ├── PromotionEvidenceCollector.cs
│ │ ├── DeploymentEvidenceCollector.cs
│ │ └── DecisionEvidenceCollector.cs
│ ├── Models/
│ │ ├── EvidencePacket.cs
│ │ ├── EvidenceContent.cs
│ │ └── EvidenceType.cs
│ └── Store/
│ ├── IEvidenceStore.cs
│ └── EvidenceStore.cs
└── __Tests/
```
---
## Deliverables
### IEvidenceCollector Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Collector;
public interface IEvidenceCollector
{
Task<EvidencePacket> CollectAsync(
Guid subjectId,
EvidenceType type,
CancellationToken ct = default);
Task<EvidencePacket> CollectDeploymentEvidenceAsync(
Guid deploymentJobId,
CancellationToken ct = default);
Task<EvidencePacket> CollectPromotionEvidenceAsync(
Guid promotionId,
CancellationToken ct = default);
}
public enum EvidenceType
{
Promotion,
Deployment,
Rollback,
GateDecision,
Approval
}
```
### EvidencePacket Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Models;
public sealed record EvidencePacket
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required EvidenceType Type { get; init; }
public required Guid SubjectId { get; init; }
public required string SubjectType { get; init; }
public required EvidenceContent Content { get; init; }
public required ImmutableArray<Guid> DependsOn { get; init; }
public required DateTimeOffset CollectedAt { get; init; }
public required string CollectorVersion { get; init; }
}
public sealed record EvidenceContent
{
public required ReleaseEvidence? Release { get; init; }
public required PromotionEvidence? Promotion { get; init; }
public required DeploymentEvidence? Deployment { get; init; }
public required DecisionEvidence? Decision { get; init; }
public required ImmutableDictionary<string, object> Metadata { get; init; }
}
public sealed record ReleaseEvidence
{
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public string? ManifestDigest { get; init; }
public DateTimeOffset? FinalizedAt { get; init; }
public required ImmutableArray<ComponentEvidence> Components { get; init; }
}
public sealed record ComponentEvidence
{
public required Guid ComponentId { get; init; }
public required string ComponentName { get; init; }
public required string Digest { get; init; }
public string? Tag { get; init; }
public string? SemVer { get; init; }
public string? SourceRef { get; init; }
public string? SbomDigest { get; init; }
}
public sealed record PromotionEvidence
{
public required Guid PromotionId { get; init; }
public required Guid SourceEnvironmentId { get; init; }
public required string SourceEnvironmentName { get; init; }
public required Guid TargetEnvironmentId { get; init; }
public required string TargetEnvironmentName { get; init; }
public required ActorEvidence Requester { get; init; }
public required ImmutableArray<ApprovalEvidence> Approvals { get; init; }
public required DateTimeOffset RequestedAt { get; init; }
public DateTimeOffset? ApprovedAt { get; init; }
}
public sealed record ActorEvidence
{
public required Guid UserId { get; init; }
public required string UserName { get; init; }
public required string UserEmail { get; init; }
public ImmutableArray<string> Groups { get; init; } = [];
}
public sealed record ApprovalEvidence
{
public required ActorEvidence Approver { get; init; }
public required string Decision { get; init; }
public string? Comment { get; init; }
public required DateTimeOffset DecidedAt { get; init; }
}
public sealed record DeploymentEvidence
{
public required Guid DeploymentJobId { get; init; }
public required string Strategy { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required string Status { get; init; }
public required ImmutableArray<TaskEvidence> Tasks { get; init; }
public required ImmutableArray<ArtifactEvidence> Artifacts { get; init; }
}
public sealed record TaskEvidence
{
public required Guid TaskId { get; init; }
public required Guid TargetId { get; init; }
public required string TargetName { get; init; }
public required string Status { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string? Error { get; init; }
}
public sealed record ArtifactEvidence
{
public required string ArtifactType { get; init; }
public required string Digest { get; init; }
public required string Location { get; init; }
}
public sealed record DecisionEvidence
{
public required ImmutableArray<GateResultEvidence> GateResults { get; init; }
public required FreezeCheckEvidence FreezeCheck { get; init; }
public required SodCheckEvidence SodCheck { get; init; }
public required string FinalDecision { get; init; }
public required DateTimeOffset DecidedAt { get; init; }
}
public sealed record GateResultEvidence
{
public required string GateName { get; init; }
public required string GateType { get; init; }
public required bool Passed { get; init; }
public string? Message { get; init; }
public ImmutableDictionary<string, object>? Details { get; init; }
public required DateTimeOffset EvaluatedAt { get; init; }
}
public sealed record FreezeCheckEvidence
{
public required bool Checked { get; init; }
public required bool FreezeActive { get; init; }
public string? FreezeReason { get; init; }
public bool Overridden { get; init; }
public ActorEvidence? OverriddenBy { get; init; }
}
public sealed record SodCheckEvidence
{
public required bool Required { get; init; }
public required bool Satisfied { get; init; }
public string? Violation { get; init; }
}
```
### EvidenceCollector Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Collector;
public sealed class EvidenceCollector : IEvidenceCollector
{
private readonly ReleaseEvidenceCollector _releaseCollector;
private readonly PromotionEvidenceCollector _promotionCollector;
private readonly DeploymentEvidenceCollector _deploymentCollector;
private readonly DecisionEvidenceCollector _decisionCollector;
private readonly IEvidenceStore _evidenceStore;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ITenantContext _tenantContext;
private readonly ILogger<EvidenceCollector> _logger;
private const string CollectorVersion = "1.0.0";
public async Task<EvidencePacket> CollectAsync(
Guid subjectId,
EvidenceType type,
CancellationToken ct = default)
{
return type switch
{
EvidenceType.Promotion => await CollectPromotionEvidenceAsync(subjectId, ct),
EvidenceType.Deployment => await CollectDeploymentEvidenceAsync(subjectId, ct),
_ => throw new UnsupportedEvidenceTypeException(type)
};
}
public async Task<EvidencePacket> CollectDeploymentEvidenceAsync(
Guid deploymentJobId,
CancellationToken ct = default)
{
_logger.LogInformation(
"Collecting deployment evidence for job {JobId}",
deploymentJobId);
// Collect all evidence sections
var deploymentEvidence = await _deploymentCollector.CollectAsync(deploymentJobId, ct);
var releaseEvidence = await _releaseCollector.CollectAsync(deploymentEvidence.ReleaseId, ct);
var promotionEvidence = await _promotionCollector.CollectAsync(deploymentEvidence.PromotionId, ct);
var decisionEvidence = await _decisionCollector.CollectAsync(deploymentEvidence.PromotionId, ct);
var content = new EvidenceContent
{
Release = releaseEvidence,
Promotion = promotionEvidence,
Deployment = deploymentEvidence.ToEvidence(),
Decision = decisionEvidence,
Metadata = ImmutableDictionary<string, object>.Empty
.Add("platform", "stella-ops")
.Add("collectorVersion", CollectorVersion)
};
var packet = new EvidencePacket
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
Type = EvidenceType.Deployment,
SubjectId = deploymentJobId,
SubjectType = "DeploymentJob",
Content = content,
DependsOn = await GetDependentEvidenceAsync(deploymentJobId, ct),
CollectedAt = _timeProvider.GetUtcNow(),
CollectorVersion = CollectorVersion
};
// Store the packet
await _evidenceStore.StoreAsync(packet, ct);
_logger.LogInformation(
"Collected deployment evidence {PacketId} for job {JobId}",
packet.Id,
deploymentJobId);
return packet;
}
public async Task<EvidencePacket> CollectPromotionEvidenceAsync(
Guid promotionId,
CancellationToken ct = default)
{
_logger.LogInformation(
"Collecting promotion evidence for {PromotionId}",
promotionId);
var promotionEvidence = await _promotionCollector.CollectAsync(promotionId, ct);
var releaseEvidence = await _releaseCollector.CollectAsync(promotionEvidence.ReleaseId, ct);
var decisionEvidence = await _decisionCollector.CollectAsync(promotionId, ct);
var content = new EvidenceContent
{
Release = releaseEvidence,
Promotion = promotionEvidence.ToEvidence(),
Deployment = null,
Decision = decisionEvidence,
Metadata = ImmutableDictionary<string, object>.Empty
.Add("platform", "stella-ops")
.Add("collectorVersion", CollectorVersion)
};
var packet = new EvidencePacket
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
Type = EvidenceType.Promotion,
SubjectId = promotionId,
SubjectType = "Promotion",
Content = content,
DependsOn = ImmutableArray<Guid>.Empty,
CollectedAt = _timeProvider.GetUtcNow(),
CollectorVersion = CollectorVersion
};
await _evidenceStore.StoreAsync(packet, ct);
_logger.LogInformation(
"Collected promotion evidence {PacketId} for {PromotionId}",
packet.Id,
promotionId);
return packet;
}
private async Task<ImmutableArray<Guid>> GetDependentEvidenceAsync(
Guid deploymentJobId,
CancellationToken ct)
{
// Find promotion evidence that this deployment depends on
var promotion = await _promotionCollector.GetPromotionForJobAsync(deploymentJobId, ct);
if (promotion is null)
return ImmutableArray<Guid>.Empty;
var promotionEvidence = await _evidenceStore.GetBySubjectAsync(
promotion.Id,
EvidenceType.Promotion,
ct);
if (promotionEvidence is null)
return ImmutableArray<Guid>.Empty;
return ImmutableArray.Create(promotionEvidence.Id);
}
}
```
### ContentBuilder
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Collector;
public sealed class ContentBuilder
{
public static ReleaseEvidence BuildReleaseEvidence(Release release)
{
return new ReleaseEvidence
{
ReleaseId = release.Id,
ReleaseName = release.Name,
ManifestDigest = release.ManifestDigest,
FinalizedAt = release.FinalizedAt,
Components = release.Components.Select(c => new ComponentEvidence
{
ComponentId = c.ComponentId,
ComponentName = c.ComponentName,
Digest = c.Digest,
Tag = c.Tag,
SemVer = c.SemVer,
SourceRef = c.Config.GetValueOrDefault("sourceRef"),
SbomDigest = c.Config.GetValueOrDefault("sbomDigest")
}).ToImmutableArray()
};
}
public static PromotionEvidence BuildPromotionEvidence(
Promotion promotion,
IReadOnlyList<ApprovalRecord> approvals,
IReadOnlyList<UserInfo> users)
{
var userLookup = users.ToDictionary(u => u.Id);
return new PromotionEvidence
{
PromotionId = promotion.Id,
SourceEnvironmentId = promotion.SourceEnvironmentId,
SourceEnvironmentName = promotion.SourceEnvironmentName,
TargetEnvironmentId = promotion.TargetEnvironmentId,
TargetEnvironmentName = promotion.TargetEnvironmentName,
Requester = BuildActorEvidence(promotion.RequestedBy, userLookup),
Approvals = approvals.Select(a => new ApprovalEvidence
{
Approver = BuildActorEvidence(a.UserId, userLookup),
Decision = a.Decision.ToString(),
Comment = a.Comment,
DecidedAt = a.DecidedAt
}).ToImmutableArray(),
RequestedAt = promotion.RequestedAt,
ApprovedAt = promotion.ApprovedAt
};
}
public static DeploymentEvidence BuildDeploymentEvidence(
DeploymentJob job,
IReadOnlyList<DeploymentArtifact> artifacts)
{
return new DeploymentEvidence
{
DeploymentJobId = job.Id,
Strategy = job.Strategy.ToString(),
StartedAt = job.StartedAt,
CompletedAt = job.CompletedAt,
Status = job.Status.ToString(),
Tasks = job.Tasks.Select(t => new TaskEvidence
{
TaskId = t.Id,
TargetId = t.TargetId,
TargetName = t.TargetName,
Status = t.Status.ToString(),
StartedAt = t.StartedAt,
CompletedAt = t.CompletedAt,
Error = t.Error
}).ToImmutableArray(),
Artifacts = artifacts.Select(a => new ArtifactEvidence
{
ArtifactType = a.Type,
Digest = a.Digest,
Location = a.Location
}).ToImmutableArray()
};
}
public static DecisionEvidence BuildDecisionEvidence(
DecisionRecord decision,
IReadOnlyList<GateResult> gateResults)
{
return new DecisionEvidence
{
GateResults = gateResults.Select(g => new GateResultEvidence
{
GateName = g.GateName,
GateType = g.GateType,
Passed = g.Passed,
Message = g.Message,
Details = g.Details?.ToImmutableDictionary(),
EvaluatedAt = g.EvaluatedAt
}).ToImmutableArray(),
FreezeCheck = new FreezeCheckEvidence
{
Checked = true,
FreezeActive = decision.FreezeActive,
FreezeReason = decision.FreezeReason,
Overridden = decision.FreezeOverridden,
OverriddenBy = decision.FreezeOverriddenBy is not null
? new ActorEvidence
{
UserId = decision.FreezeOverriddenBy.Value,
UserName = decision.FreezeOverriddenByName ?? "",
UserEmail = ""
}
: null
},
SodCheck = new SodCheckEvidence
{
Required = decision.SodRequired,
Satisfied = decision.SodSatisfied,
Violation = decision.SodViolation
},
FinalDecision = decision.FinalDecision.ToString(),
DecidedAt = decision.DecidedAt
};
}
private static ActorEvidence BuildActorEvidence(
Guid userId,
Dictionary<Guid, UserInfo> userLookup)
{
if (userLookup.TryGetValue(userId, out var user))
{
return new ActorEvidence
{
UserId = user.Id,
UserName = user.Name,
UserEmail = user.Email,
Groups = user.Groups.ToImmutableArray()
};
}
return new ActorEvidence
{
UserId = userId,
UserName = "Unknown",
UserEmail = "",
Groups = ImmutableArray<string>.Empty
};
}
}
```
### IEvidenceStore Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Store;
public interface IEvidenceStore
{
Task StoreAsync(EvidencePacket packet, CancellationToken ct = default);
Task<EvidencePacket?> GetAsync(Guid packetId, CancellationToken ct = default);
Task<EvidencePacket?> GetBySubjectAsync(Guid subjectId, EvidenceType type, CancellationToken ct = default);
Task<IReadOnlyList<EvidencePacket>> ListAsync(EvidenceQueryFilter filter, CancellationToken ct = default);
Task<bool> ExistsAsync(Guid packetId, CancellationToken ct = default);
}
public sealed record EvidenceQueryFilter
{
public Guid? TenantId { get; init; }
public EvidenceType? Type { get; init; }
public DateTimeOffset? FromDate { get; init; }
public DateTimeOffset? ToDate { get; init; }
public int Limit { get; init; } = 100;
public int Offset { get; init; } = 0;
}
```
---
## Acceptance Criteria
- [ ] Collect release evidence with all components
- [ ] Collect promotion evidence with approvals
- [ ] Collect deployment evidence with all tasks
- [ ] Collect decision evidence with gate results
- [ ] Build comprehensive evidence packets
- [ ] Track evidence dependencies
- [ ] Store evidence in append-only store
- [ ] Query evidence by subject
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 106_005 Decision Engine | Internal | TODO |
| 107_001 Deploy Orchestrator | Internal | TODO |
| 104_003 Release Manager | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IEvidenceCollector | TODO | |
| EvidenceCollector | TODO | |
| ContentBuilder | TODO | |
| EvidencePacket model | TODO | |
| ReleaseEvidenceCollector | TODO | |
| PromotionEvidenceCollector | TODO | |
| DeploymentEvidenceCollector | TODO | |
| DecisionEvidenceCollector | TODO | |
| IEvidenceStore | TODO | |
| EvidenceStore | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,626 @@
# SPRINT: Evidence Signer
> **Sprint ID:** 109_002
> **Module:** RELEVI
> **Phase:** 9 - Evidence & Audit
> **Status:** TODO
> **Parent:** [109_000_INDEX](SPRINT_20260110_109_000_INDEX_evidence_audit.md)
---
## Overview
Implement the Evidence Signer for creating cryptographically signed, tamper-proof evidence packets.
### Objectives
- Canonicalize JSON using RFC 8785
- Hash evidence content with SHA-256
- Sign with RS256 or ES256 algorithms
- Verify signatures on demand
- Key rotation support
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Evidence/
│ └── Signing/
│ ├── IEvidenceSigner.cs
│ ├── EvidenceSigner.cs
│ ├── CanonicalJsonSerializer.cs
│ ├── SigningKeyProvider.cs
│ ├── SignedEvidencePacket.cs
│ └── Algorithms/
│ ├── Rs256Signer.cs
│ └── Es256Signer.cs
└── __Tests/
```
---
## Deliverables
### IEvidenceSigner Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Signing;
public interface IEvidenceSigner
{
Task<SignedEvidencePacket> SignAsync(
EvidencePacket packet,
CancellationToken ct = default);
Task<bool> VerifyAsync(
SignedEvidencePacket signedPacket,
CancellationToken ct = default);
Task<VerificationResult> VerifyWithDetailsAsync(
SignedEvidencePacket signedPacket,
CancellationToken ct = default);
}
public sealed record SignedEvidencePacket
{
public required Guid Id { get; init; }
public required EvidencePacket Content { get; init; }
public required string ContentHash { get; init; }
public required string Signature { get; init; }
public required string SignatureAlgorithm { get; init; }
public required string SignerKeyRef { get; init; }
public required DateTimeOffset SignedAt { get; init; }
}
public sealed record VerificationResult
{
public required bool IsValid { get; init; }
public required bool SignatureValid { get; init; }
public required bool ContentHashValid { get; init; }
public required bool KeyValid { get; init; }
public string? Error { get; init; }
public DateTimeOffset VerifiedAt { get; init; }
}
```
### CanonicalJsonSerializer
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Signing;
/// <summary>
/// RFC 8785 (JCS) compliant JSON canonicalizer.
/// </summary>
public static class CanonicalJsonSerializer
{
public static string Serialize(object value)
{
// Convert to JsonElement for processing
var json = JsonSerializer.Serialize(value, new JsonSerializerOptions
{
PropertyNamingPolicy = null, // Preserve property names
WriteIndented = false
});
var element = JsonDocument.Parse(json).RootElement;
return Canonicalize(element);
}
public static string Serialize(EvidencePacket packet)
{
// Use explicit ordering for evidence packets
var orderedContent = new SortedDictionary<string, object?>
{
["id"] = packet.Id.ToString(),
["tenantId"] = packet.TenantId.ToString(),
["type"] = packet.Type.ToString(),
["subjectId"] = packet.SubjectId.ToString(),
["subjectType"] = packet.SubjectType,
["content"] = SerializeContent(packet.Content),
["dependsOn"] = packet.DependsOn.Select(d => d.ToString()).ToArray(),
["collectedAt"] = FormatTimestamp(packet.CollectedAt),
["collectorVersion"] = packet.CollectorVersion
};
return SerializeOrdered(orderedContent);
}
private static string Canonicalize(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => CanonicalizeObject(element),
JsonValueKind.Array => CanonicalizeArray(element),
JsonValueKind.String => CanonicalizeString(element),
JsonValueKind.Number => CanonicalizeNumber(element),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => throw new InvalidOperationException($"Unsupported JSON type: {element.ValueKind}")
};
}
private static string CanonicalizeObject(JsonElement element)
{
// RFC 8785: Sort properties by Unicode code point order
var properties = element.EnumerateObject()
.OrderBy(p => p.Name, StringComparer.Ordinal)
.Select(p => $"\"{EscapeString(p.Name)}\":{Canonicalize(p.Value)}");
return "{" + string.Join(",", properties) + "}";
}
private static string CanonicalizeArray(JsonElement element)
{
var items = element.EnumerateArray()
.Select(Canonicalize);
return "[" + string.Join(",", items) + "]";
}
private static string CanonicalizeString(JsonElement element)
{
return "\"" + EscapeString(element.GetString() ?? "") + "\"";
}
private static string CanonicalizeNumber(JsonElement element)
{
// RFC 8785: Numbers are serialized without exponent notation
// and without trailing zeros
if (element.TryGetInt64(out var longValue))
{
return longValue.ToString(CultureInfo.InvariantCulture);
}
if (element.TryGetDouble(out var doubleValue))
{
// Format without exponent, minimal precision
return FormatDouble(doubleValue);
}
return element.GetRawText();
}
private static string FormatDouble(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new InvalidOperationException("NaN and Infinity not allowed in canonical JSON");
}
// Use G17 for full precision, then normalize
var str = value.ToString("G17", CultureInfo.InvariantCulture);
// Remove exponent notation if present
if (str.Contains('E') || str.Contains('e'))
{
var d = double.Parse(str, CultureInfo.InvariantCulture);
str = d.ToString("F15", CultureInfo.InvariantCulture).TrimEnd('0').TrimEnd('.');
}
return str;
}
private static string EscapeString(string value)
{
var sb = new StringBuilder();
foreach (var c in value)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 0x20)
{
sb.Append($"\\u{(int)c:x4}");
}
else
{
sb.Append(c);
}
break;
}
}
return sb.ToString();
}
private static string FormatTimestamp(DateTimeOffset timestamp)
{
return timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
}
private static object SerializeContent(EvidenceContent content)
{
// Serialize with sorted keys
return new SortedDictionary<string, object?>
{
["decision"] = content.Decision,
["deployment"] = content.Deployment,
["metadata"] = content.Metadata,
["promotion"] = content.Promotion,
["release"] = content.Release
};
}
private static string SerializeOrdered(SortedDictionary<string, object?> dict)
{
return JsonSerializer.Serialize(dict, new JsonSerializerOptions
{
PropertyNamingPolicy = null,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
}
}
```
### EvidenceSigner Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Signing;
public sealed class EvidenceSigner : IEvidenceSigner
{
private readonly ISigningKeyProvider _keyProvider;
private readonly ISignedEvidenceStore _signedStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EvidenceSigner> _logger;
public async Task<SignedEvidencePacket> SignAsync(
EvidencePacket packet,
CancellationToken ct = default)
{
_logger.LogDebug("Signing evidence packet {PacketId}", packet.Id);
// Get signing key
var key = await _keyProvider.GetCurrentSigningKeyAsync(ct);
// Canonicalize content
var canonicalJson = CanonicalJsonSerializer.Serialize(packet);
// Compute content hash
var contentHashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
var contentHash = $"sha256:{Convert.ToHexString(contentHashBytes).ToLowerInvariant()}";
// Sign the hash
var signatureBytes = await SignHashAsync(key, contentHashBytes, ct);
var signature = Convert.ToBase64String(signatureBytes);
var signedPacket = new SignedEvidencePacket
{
Id = packet.Id,
Content = packet,
ContentHash = contentHash,
Signature = signature,
SignatureAlgorithm = key.Algorithm,
SignerKeyRef = key.KeyRef,
SignedAt = _timeProvider.GetUtcNow()
};
// Store signed packet
await _signedStore.StoreAsync(signedPacket, ct);
_logger.LogInformation(
"Signed evidence packet {PacketId} with key {KeyRef}",
packet.Id,
key.KeyRef);
return signedPacket;
}
public async Task<bool> VerifyAsync(
SignedEvidencePacket signedPacket,
CancellationToken ct = default)
{
var result = await VerifyWithDetailsAsync(signedPacket, ct);
return result.IsValid;
}
public async Task<VerificationResult> VerifyWithDetailsAsync(
SignedEvidencePacket signedPacket,
CancellationToken ct = default)
{
_logger.LogDebug("Verifying evidence packet {PacketId}", signedPacket.Id);
try
{
// Get the signing key
var key = await _keyProvider.GetKeyByRefAsync(signedPacket.SignerKeyRef, ct);
if (key is null)
{
return new VerificationResult
{
IsValid = false,
SignatureValid = false,
ContentHashValid = false,
KeyValid = false,
Error = $"Signing key not found: {signedPacket.SignerKeyRef}",
VerifiedAt = _timeProvider.GetUtcNow()
};
}
// Verify content hash
var canonicalJson = CanonicalJsonSerializer.Serialize(signedPacket.Content);
var computedHashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
var computedHash = $"sha256:{Convert.ToHexString(computedHashBytes).ToLowerInvariant()}";
var contentHashValid = signedPacket.ContentHash == computedHash;
if (!contentHashValid)
{
_logger.LogWarning(
"Content hash mismatch for packet {PacketId}: expected {Expected}, got {Actual}",
signedPacket.Id,
signedPacket.ContentHash,
computedHash);
return new VerificationResult
{
IsValid = false,
SignatureValid = false,
ContentHashValid = false,
KeyValid = true,
Error = "Content hash mismatch - evidence may have been tampered with",
VerifiedAt = _timeProvider.GetUtcNow()
};
}
// Verify signature
var signatureBytes = Convert.FromBase64String(signedPacket.Signature);
var signatureValid = await VerifySignatureAsync(key, computedHashBytes, signatureBytes, ct);
if (!signatureValid)
{
_logger.LogWarning(
"Signature verification failed for packet {PacketId}",
signedPacket.Id);
return new VerificationResult
{
IsValid = false,
SignatureValid = false,
ContentHashValid = true,
KeyValid = true,
Error = "Signature verification failed",
VerifiedAt = _timeProvider.GetUtcNow()
};
}
_logger.LogDebug("Evidence packet {PacketId} verified successfully", signedPacket.Id);
return new VerificationResult
{
IsValid = true,
SignatureValid = true,
ContentHashValid = true,
KeyValid = true,
VerifiedAt = _timeProvider.GetUtcNow()
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying evidence packet {PacketId}", signedPacket.Id);
return new VerificationResult
{
IsValid = false,
SignatureValid = false,
ContentHashValid = false,
KeyValid = false,
Error = ex.Message,
VerifiedAt = _timeProvider.GetUtcNow()
};
}
}
private async Task<byte[]> SignHashAsync(
SigningKey key,
byte[] hash,
CancellationToken ct)
{
return key.Algorithm switch
{
"RS256" => await SignRs256Async(key, hash, ct),
"ES256" => await SignEs256Async(key, hash, ct),
_ => throw new UnsupportedAlgorithmException(key.Algorithm)
};
}
private Task<byte[]> SignRs256Async(SigningKey key, byte[] hash, CancellationToken ct)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(key.PrivateKey);
var signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return Task.FromResult(signature);
}
private Task<byte[]> SignEs256Async(SigningKey key, byte[] hash, CancellationToken ct)
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(key.PrivateKey);
var signature = ecdsa.SignHash(hash);
return Task.FromResult(signature);
}
private Task<bool> VerifySignatureAsync(
SigningKey key,
byte[] hash,
byte[] signature,
CancellationToken ct)
{
return key.Algorithm switch
{
"RS256" => Task.FromResult(VerifyRs256(key, hash, signature)),
"ES256" => Task.FromResult(VerifyEs256(key, hash, signature)),
_ => throw new UnsupportedAlgorithmException(key.Algorithm)
};
}
private static bool VerifyRs256(SigningKey key, byte[] hash, byte[] signature)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(key.PublicKey);
return rsa.VerifyHash(hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
private static bool VerifyEs256(SigningKey key, byte[] hash, byte[] signature)
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(key.PublicKey);
return ecdsa.VerifyHash(hash, signature);
}
}
```
### SigningKeyProvider
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Signing;
public interface ISigningKeyProvider
{
Task<SigningKey> GetCurrentSigningKeyAsync(CancellationToken ct = default);
Task<SigningKey?> GetKeyByRefAsync(string keyRef, CancellationToken ct = default);
Task<IReadOnlyList<SigningKeyInfo>> ListKeysAsync(CancellationToken ct = default);
}
public sealed class SigningKeyProvider : ISigningKeyProvider
{
private readonly IKeyVaultClient _keyVault;
private readonly SigningConfiguration _config;
private readonly ILogger<SigningKeyProvider> _logger;
public async Task<SigningKey> GetCurrentSigningKeyAsync(CancellationToken ct = default)
{
var keyRef = _config.CurrentKeyRef;
var key = await GetKeyByRefAsync(keyRef, ct)
?? throw new SigningKeyNotFoundException(keyRef);
return key;
}
public async Task<SigningKey?> GetKeyByRefAsync(string keyRef, CancellationToken ct = default)
{
try
{
var vaultKey = await _keyVault.GetKeyAsync(keyRef, ct);
if (vaultKey is null)
return null;
return new SigningKey
{
KeyRef = keyRef,
Algorithm = vaultKey.Algorithm,
PublicKey = vaultKey.PublicKey,
PrivateKey = vaultKey.PrivateKey,
CreatedAt = vaultKey.CreatedAt,
ExpiresAt = vaultKey.ExpiresAt
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get signing key {KeyRef}", keyRef);
return null;
}
}
public async Task<IReadOnlyList<SigningKeyInfo>> ListKeysAsync(CancellationToken ct = default)
{
var keys = await _keyVault.ListKeysAsync(_config.KeyPrefix, ct);
return keys.Select(k => new SigningKeyInfo
{
KeyRef = k.KeyRef,
Algorithm = k.Algorithm,
CreatedAt = k.CreatedAt,
ExpiresAt = k.ExpiresAt,
IsCurrent = k.KeyRef == _config.CurrentKeyRef
}).ToList().AsReadOnly();
}
}
public sealed record SigningKey
{
public required string KeyRef { get; init; }
public required string Algorithm { get; init; }
public required string PublicKey { get; init; }
public required string PrivateKey { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
public sealed record SigningKeyInfo
{
public required string KeyRef { get; init; }
public required string Algorithm { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public bool IsCurrent { get; init; }
}
public sealed class SigningConfiguration
{
public required string CurrentKeyRef { get; set; }
public string KeyPrefix { get; set; } = "stella/signing/";
public string DefaultAlgorithm { get; set; } = "RS256";
}
```
---
## Acceptance Criteria
- [ ] Canonicalize JSON per RFC 8785
- [ ] Hash content with SHA-256
- [ ] Sign with RS256 algorithm
- [ ] Sign with ES256 algorithm
- [ ] Verify signatures
- [ ] Detect content tampering
- [ ] Support key rotation
- [ ] Store signed packets immutably
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 109_001 Evidence Collector | Internal | TODO |
| Signer service | Internal | Existing |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IEvidenceSigner | TODO | |
| EvidenceSigner | TODO | |
| CanonicalJsonSerializer | TODO | |
| SigningKeyProvider | TODO | |
| Rs256Signer | TODO | |
| Es256Signer | TODO | |
| SignedEvidencePacket | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,538 @@
# SPRINT: Version Sticker Writer
> **Sprint ID:** 109_003
> **Module:** RELEVI
> **Phase:** 9 - Evidence & Audit
> **Status:** TODO
> **Parent:** [109_000_INDEX](SPRINT_20260110_109_000_INDEX_evidence_audit.md)
---
## Overview
Implement the Version Sticker Writer for recording deployment state as stella.version.json files on each target.
### Objectives
- Generate version sticker content
- Write stickers to targets via agents
- Read stickers from targets for verification
- Track sticker state across deployments
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Evidence/
│ └── Sticker/
│ ├── IVersionStickerWriter.cs
│ ├── VersionStickerWriter.cs
│ ├── VersionStickerGenerator.cs
│ ├── StickerAgentTask.cs
│ └── Models/
│ ├── VersionSticker.cs
│ └── StickerWriteResult.cs
└── __Tests/
```
---
## Deliverables
### VersionSticker Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Sticker.Models;
public sealed record VersionSticker
{
public required string SchemaVersion { get; init; } = "1.0";
public required string Release { get; init; }
public required Guid ReleaseId { get; init; }
public required Guid DeploymentId { get; init; }
public required Guid EnvironmentId { get; init; }
public required string EnvironmentName { get; init; }
public required Guid TargetId { get; init; }
public required string TargetName { get; init; }
public required DateTimeOffset DeployedAt { get; init; }
public required ImmutableArray<ComponentSticker> Components { get; init; }
public required Guid EvidenceId { get; init; }
public string? EvidenceDigest { get; init; }
public required StickerMetadata Metadata { get; init; }
}
public sealed record ComponentSticker
{
public required string Name { get; init; }
public required string Digest { get; init; }
public string? Tag { get; init; }
public string? SemVer { get; init; }
public string? Image { get; init; }
}
public sealed record StickerMetadata
{
public required string Platform { get; init; } = "stella-ops";
public required string PlatformVersion { get; init; }
public required string DeploymentStrategy { get; init; }
public Guid? PromotionId { get; init; }
public string? SourceEnvironment { get; init; }
public ImmutableDictionary<string, string> CustomLabels { get; init; } = ImmutableDictionary<string, string>.Empty;
}
```
### IVersionStickerWriter Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Sticker;
public interface IVersionStickerWriter
{
Task<StickerWriteResult> WriteAsync(
Guid deploymentTaskId,
VersionSticker sticker,
CancellationToken ct = default);
Task<VersionSticker?> ReadAsync(
Guid targetId,
CancellationToken ct = default);
Task<IReadOnlyList<StickerWriteResult>> WriteAllAsync(
Guid deploymentJobId,
CancellationToken ct = default);
Task<StickerValidationResult> ValidateAsync(
Guid targetId,
Guid expectedReleaseId,
CancellationToken ct = default);
}
public sealed record StickerWriteResult
{
public required Guid TargetId { get; init; }
public required string TargetName { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public string? StickerPath { get; init; }
public DateTimeOffset WrittenAt { get; init; }
}
public sealed record StickerValidationResult
{
public required Guid TargetId { get; init; }
public required bool Valid { get; init; }
public required bool StickerExists { get; init; }
public required bool ReleaseMatches { get; init; }
public required bool ComponentsMatch { get; init; }
public Guid? ActualReleaseId { get; init; }
public IReadOnlyList<string>? MismatchedComponents { get; init; }
}
```
### VersionStickerGenerator
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Sticker;
public sealed class VersionStickerGenerator
{
private readonly IReleaseManager _releaseManager;
private readonly IDeploymentJobStore _jobStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VersionStickerGenerator> _logger;
private const string PlatformVersion = "1.0.0";
public async Task<VersionSticker> GenerateAsync(
DeploymentJob job,
DeploymentTask task,
Guid evidenceId,
CancellationToken ct = default)
{
var release = await _releaseManager.GetAsync(job.ReleaseId, ct)
?? throw new ReleaseNotFoundException(job.ReleaseId);
var components = release.Components.Select(c => new ComponentSticker
{
Name = c.ComponentName,
Digest = c.Digest,
Tag = c.Tag,
SemVer = c.SemVer,
Image = c.Config.GetValueOrDefault("image")
}).ToImmutableArray();
var sticker = new VersionSticker
{
SchemaVersion = "1.0",
Release = release.Name,
ReleaseId = release.Id,
DeploymentId = job.Id,
EnvironmentId = job.EnvironmentId,
EnvironmentName = job.EnvironmentName,
TargetId = task.TargetId,
TargetName = task.TargetName,
DeployedAt = _timeProvider.GetUtcNow(),
Components = components,
EvidenceId = evidenceId,
Metadata = new StickerMetadata
{
Platform = "stella-ops",
PlatformVersion = PlatformVersion,
DeploymentStrategy = job.Strategy.ToString(),
PromotionId = job.PromotionId
}
};
_logger.LogDebug(
"Generated version sticker for release {Release} on target {Target}",
release.Name,
task.TargetName);
return sticker;
}
public string Serialize(VersionSticker sticker)
{
return JsonSerializer.Serialize(sticker, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
}
public VersionSticker? Deserialize(string json)
{
try
{
return JsonSerializer.Deserialize<VersionSticker>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize version sticker");
return null;
}
}
}
```
### VersionStickerWriter Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Sticker;
public sealed class VersionStickerWriter : IVersionStickerWriter
{
private readonly IDeploymentJobStore _jobStore;
private readonly ITargetExecutor _targetExecutor;
private readonly VersionStickerGenerator _stickerGenerator;
private readonly IEvidenceCollector _evidenceCollector;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VersionStickerWriter> _logger;
private const string StickerFileName = "stella.version.json";
public async Task<StickerWriteResult> WriteAsync(
Guid deploymentTaskId,
VersionSticker sticker,
CancellationToken ct = default)
{
_logger.LogDebug(
"Writing version sticker to target {Target}",
sticker.TargetName);
try
{
var stickerJson = _stickerGenerator.Serialize(sticker);
// Create agent task to write sticker
var agentTask = new StickerAgentTask
{
TargetId = sticker.TargetId,
FileName = StickerFileName,
Content = stickerJson,
Location = GetStickerLocation(sticker)
};
var result = await _targetExecutor.ExecuteStickerWriteAsync(agentTask, ct);
if (result.Success)
{
_logger.LogInformation(
"Wrote version sticker to target {Target} at {Path}",
sticker.TargetName,
result.StickerPath);
}
else
{
_logger.LogWarning(
"Failed to write version sticker to target {Target}: {Error}",
sticker.TargetName,
result.Error);
}
return new StickerWriteResult
{
TargetId = sticker.TargetId,
TargetName = sticker.TargetName,
Success = result.Success,
Error = result.Error,
StickerPath = result.StickerPath,
WrittenAt = _timeProvider.GetUtcNow()
};
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error writing version sticker to target {Target}",
sticker.TargetName);
return new StickerWriteResult
{
TargetId = sticker.TargetId,
TargetName = sticker.TargetName,
Success = false,
Error = ex.Message,
WrittenAt = _timeProvider.GetUtcNow()
};
}
}
public async Task<IReadOnlyList<StickerWriteResult>> WriteAllAsync(
Guid deploymentJobId,
CancellationToken ct = default)
{
var job = await _jobStore.GetAsync(deploymentJobId, ct)
?? throw new DeploymentJobNotFoundException(deploymentJobId);
// Collect evidence first
var evidence = await _evidenceCollector.CollectDeploymentEvidenceAsync(deploymentJobId, ct);
var results = new List<StickerWriteResult>();
foreach (var task in job.Tasks)
{
if (task.Status != DeploymentTaskStatus.Completed)
{
results.Add(new StickerWriteResult
{
TargetId = task.TargetId,
TargetName = task.TargetName,
Success = false,
Error = $"Task not completed (status: {task.Status})",
WrittenAt = _timeProvider.GetUtcNow()
});
continue;
}
var sticker = await _stickerGenerator.GenerateAsync(job, task, evidence.Id, ct);
var result = await WriteAsync(task.Id, sticker, ct);
results.Add(result);
}
_logger.LogInformation(
"Wrote version stickers for job {JobId}: {Success}/{Total} succeeded",
deploymentJobId,
results.Count(r => r.Success),
results.Count);
return results.AsReadOnly();
}
public async Task<VersionSticker?> ReadAsync(
Guid targetId,
CancellationToken ct = default)
{
try
{
var agentTask = new StickerReadAgentTask
{
TargetId = targetId,
FileName = StickerFileName
};
var result = await _targetExecutor.ExecuteStickerReadAsync(agentTask, ct);
if (!result.Success || string.IsNullOrEmpty(result.Content))
{
_logger.LogDebug("No version sticker found on target {TargetId}", targetId);
return null;
}
return _stickerGenerator.Deserialize(result.Content);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error reading version sticker from target {TargetId}", targetId);
return null;
}
}
public async Task<StickerValidationResult> ValidateAsync(
Guid targetId,
Guid expectedReleaseId,
CancellationToken ct = default)
{
var sticker = await ReadAsync(targetId, ct);
if (sticker is null)
{
return new StickerValidationResult
{
TargetId = targetId,
Valid = false,
StickerExists = false,
ReleaseMatches = false,
ComponentsMatch = false
};
}
var releaseMatches = sticker.ReleaseId == expectedReleaseId;
// If release doesn't match, we can't validate components
if (!releaseMatches)
{
return new StickerValidationResult
{
TargetId = targetId,
Valid = false,
StickerExists = true,
ReleaseMatches = false,
ComponentsMatch = false,
ActualReleaseId = sticker.ReleaseId
};
}
// Validate components against actual running containers
var validation = await ValidateComponentsAsync(targetId, sticker.Components, ct);
return new StickerValidationResult
{
TargetId = targetId,
Valid = validation.AllMatch,
StickerExists = true,
ReleaseMatches = true,
ComponentsMatch = validation.AllMatch,
ActualReleaseId = sticker.ReleaseId,
MismatchedComponents = validation.Mismatches
};
}
private async Task<(bool AllMatch, IReadOnlyList<string> Mismatches)> ValidateComponentsAsync(
Guid targetId,
ImmutableArray<ComponentSticker> expectedComponents,
CancellationToken ct)
{
var mismatches = new List<string>();
// Query actual container digests from target
var actualContainers = await _targetExecutor.GetRunningContainersAsync(targetId, ct);
foreach (var expected in expectedComponents)
{
var actual = actualContainers.FirstOrDefault(c => c.Name == expected.Name);
if (actual is null)
{
mismatches.Add($"{expected.Name}: not running");
}
else if (actual.Digest != expected.Digest)
{
mismatches.Add($"{expected.Name}: digest mismatch (expected {expected.Digest[..16]}, got {actual.Digest[..16]})");
}
}
return (mismatches.Count == 0, mismatches.AsReadOnly());
}
private static string GetStickerLocation(VersionSticker sticker)
{
// Default to /var/lib/stella-agent/<deployment-id>/
return $"/var/lib/stella-agent/{sticker.DeploymentId}/";
}
}
```
### StickerAgentTask
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Sticker;
public sealed record StickerAgentTask
{
public required Guid TargetId { get; init; }
public required string FileName { get; init; }
public required string Content { get; init; }
public required string Location { get; init; }
}
public sealed record StickerReadAgentTask
{
public required Guid TargetId { get; init; }
public required string FileName { get; init; }
public string? Location { get; init; }
}
public sealed record StickerWriteAgentResult
{
public required bool Success { get; init; }
public string? Error { get; init; }
public string? StickerPath { get; init; }
}
public sealed record StickerReadAgentResult
{
public required bool Success { get; init; }
public string? Content { get; init; }
public string? Error { get; init; }
}
```
---
## Acceptance Criteria
- [ ] Generate version sticker with all components
- [ ] Serialize sticker as valid JSON
- [ ] Write sticker to target via agent
- [ ] Write stickers for all completed tasks
- [ ] Read sticker from target
- [ ] Validate sticker against expected release
- [ ] Validate components against running containers
- [ ] Detect digest mismatches
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_002 Target Executor | Internal | TODO |
| 109_001 Evidence Collector | Internal | TODO |
| 108_001 Agent Core Runtime | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IVersionStickerWriter | TODO | |
| VersionStickerWriter | TODO | |
| VersionStickerGenerator | TODO | |
| VersionSticker model | TODO | |
| StickerAgentTask | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,706 @@
# SPRINT: Audit Exporter
> **Sprint ID:** 109_004
> **Module:** RELEVI
> **Phase:** 9 - Evidence & Audit
> **Status:** TODO
> **Parent:** [109_000_INDEX](SPRINT_20260110_109_000_INDEX_evidence_audit.md)
---
## Overview
Implement the Audit Exporter for generating compliance reports in multiple formats from signed evidence packets.
### Objectives
- Export to JSON for machine processing
- Export to PDF for human-readable reports
- Export to CSV for spreadsheet analysis
- Export to SLSA provenance format
- Batch export for audit periods
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Evidence/
│ └── Export/
│ ├── IAuditExporter.cs
│ ├── AuditExporter.cs
│ ├── Exporters/
│ │ ├── JsonExporter.cs
│ │ ├── PdfExporter.cs
│ │ ├── CsvExporter.cs
│ │ └── SlsaExporter.cs
│ └── Models/
│ ├── AuditExportRequest.cs
│ └── ExportFormat.cs
└── __Tests/
```
---
## Deliverables
### IAuditExporter Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Export;
public interface IAuditExporter
{
Task<ExportResult> ExportAsync(
AuditExportRequest request,
CancellationToken ct = default);
IReadOnlyList<ExportFormat> SupportedFormats { get; }
Task<Stream> ExportToStreamAsync(
AuditExportRequest request,
CancellationToken ct = default);
}
public sealed record AuditExportRequest
{
public required ExportFormat Format { get; init; }
public Guid? TenantId { get; init; }
public Guid? EnvironmentId { get; init; }
public DateTimeOffset? FromDate { get; init; }
public DateTimeOffset? ToDate { get; init; }
public IReadOnlyList<Guid>? EvidenceIds { get; init; }
public IReadOnlyList<EvidenceType>? Types { get; init; }
public bool IncludeVerification { get; init; } = true;
public bool IncludeSignatures { get; init; } = false;
public string? ReportTitle { get; init; }
}
public enum ExportFormat
{
Json,
Pdf,
Csv,
Slsa
}
public sealed record ExportResult
{
public required bool Success { get; init; }
public required ExportFormat Format { get; init; }
public required string FileName { get; init; }
public required string ContentType { get; init; }
public required long SizeBytes { get; init; }
public required int EvidenceCount { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public Stream? Content { get; init; }
public string? Error { get; init; }
}
```
### AuditExporter Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Export;
public sealed class AuditExporter : IAuditExporter
{
private readonly ISignedEvidenceStore _evidenceStore;
private readonly IEvidenceSigner _evidenceSigner;
private readonly IEnumerable<IFormatExporter> _exporters;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AuditExporter> _logger;
public IReadOnlyList<ExportFormat> SupportedFormats =>
_exporters.Select(e => e.Format).ToList().AsReadOnly();
public async Task<ExportResult> ExportAsync(
AuditExportRequest request,
CancellationToken ct = default)
{
_logger.LogInformation(
"Starting audit export: format={Format}, from={From}, to={To}",
request.Format,
request.FromDate,
request.ToDate);
var exporter = _exporters.FirstOrDefault(e => e.Format == request.Format)
?? throw new UnsupportedExportFormatException(request.Format);
try
{
// Query evidence
var evidence = await QueryEvidenceAsync(request, ct);
if (evidence.Count == 0)
{
return new ExportResult
{
Success = false,
Format = request.Format,
FileName = "",
ContentType = "",
SizeBytes = 0,
EvidenceCount = 0,
GeneratedAt = _timeProvider.GetUtcNow(),
Error = "No evidence found matching the criteria"
};
}
// Verify evidence if requested
var verificationResults = request.IncludeVerification
? await VerifyAllAsync(evidence, ct)
: null;
// Export
var stream = await exporter.ExportAsync(evidence, verificationResults, request, ct);
var fileName = GenerateFileName(request);
var contentType = exporter.ContentType;
_logger.LogInformation(
"Audit export completed: {Count} evidence packets, {Size} bytes",
evidence.Count,
stream.Length);
return new ExportResult
{
Success = true,
Format = request.Format,
FileName = fileName,
ContentType = contentType,
SizeBytes = stream.Length,
EvidenceCount = evidence.Count,
GeneratedAt = _timeProvider.GetUtcNow(),
Content = stream
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Audit export failed");
return new ExportResult
{
Success = false,
Format = request.Format,
FileName = "",
ContentType = "",
SizeBytes = 0,
EvidenceCount = 0,
GeneratedAt = _timeProvider.GetUtcNow(),
Error = ex.Message
};
}
}
public async Task<Stream> ExportToStreamAsync(
AuditExportRequest request,
CancellationToken ct = default)
{
var result = await ExportAsync(request, ct);
if (!result.Success || result.Content is null)
{
throw new ExportFailedException(result.Error ?? "Unknown error");
}
return result.Content;
}
private async Task<IReadOnlyList<SignedEvidencePacket>> QueryEvidenceAsync(
AuditExportRequest request,
CancellationToken ct)
{
if (request.EvidenceIds?.Count > 0)
{
var packets = new List<SignedEvidencePacket>();
foreach (var id in request.EvidenceIds)
{
var packet = await _evidenceStore.GetAsync(id, ct);
if (packet is not null)
{
packets.Add(packet);
}
}
return packets.AsReadOnly();
}
var filter = new SignedEvidenceQueryFilter
{
TenantId = request.TenantId,
FromDate = request.FromDate,
ToDate = request.ToDate,
Types = request.Types
};
return await _evidenceStore.ListAsync(filter, ct);
}
private async Task<IReadOnlyDictionary<Guid, VerificationResult>> VerifyAllAsync(
IReadOnlyList<SignedEvidencePacket> evidence,
CancellationToken ct)
{
var results = new Dictionary<Guid, VerificationResult>();
foreach (var packet in evidence)
{
var result = await _evidenceSigner.VerifyWithDetailsAsync(packet, ct);
results[packet.Id] = result;
}
return results.AsReadOnly();
}
private string GenerateFileName(AuditExportRequest request)
{
var timestamp = _timeProvider.GetUtcNow().ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
var extension = request.Format switch
{
ExportFormat.Json => "json",
ExportFormat.Pdf => "pdf",
ExportFormat.Csv => "csv",
ExportFormat.Slsa => "slsa.json",
_ => "dat"
};
return $"audit-export-{timestamp}.{extension}";
}
}
```
### IFormatExporter Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Export;
public interface IFormatExporter
{
ExportFormat Format { get; }
string ContentType { get; }
Task<Stream> ExportAsync(
IReadOnlyList<SignedEvidencePacket> evidence,
IReadOnlyDictionary<Guid, VerificationResult>? verificationResults,
AuditExportRequest request,
CancellationToken ct = default);
}
```
### JsonExporter
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Export.Exporters;
public sealed class JsonExporter : IFormatExporter
{
public ExportFormat Format => ExportFormat.Json;
public string ContentType => "application/json";
public async Task<Stream> ExportAsync(
IReadOnlyList<SignedEvidencePacket> evidence,
IReadOnlyDictionary<Guid, VerificationResult>? verificationResults,
AuditExportRequest request,
CancellationToken ct = default)
{
var export = new JsonAuditExport
{
SchemaVersion = "1.0",
GeneratedAt = DateTimeOffset.UtcNow.ToString("O"),
ReportTitle = request.ReportTitle ?? "Audit Export",
Query = new QueryInfo
{
FromDate = request.FromDate?.ToString("O"),
ToDate = request.ToDate?.ToString("O"),
TenantId = request.TenantId?.ToString(),
EnvironmentId = request.EnvironmentId?.ToString(),
Types = request.Types?.Select(t => t.ToString()).ToList()
},
Summary = new ExportSummary
{
TotalEvidence = evidence.Count,
ByType = evidence.GroupBy(e => e.Content.Type)
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
VerificationSummary = verificationResults is not null
? new VerificationSummary
{
TotalVerified = verificationResults.Count,
AllValid = verificationResults.Values.All(v => v.IsValid),
FailedCount = verificationResults.Values.Count(v => !v.IsValid)
}
: null
},
Evidence = evidence.Select(e => new EvidenceEntry
{
Id = e.Id.ToString(),
Type = e.Content.Type.ToString(),
SubjectId = e.Content.SubjectId.ToString(),
CollectedAt = e.Content.CollectedAt.ToString("O"),
SignedAt = e.SignedAt.ToString("O"),
ContentHash = request.IncludeSignatures ? e.ContentHash : null,
Signature = request.IncludeSignatures ? e.Signature : null,
SignatureAlgorithm = request.IncludeSignatures ? e.SignatureAlgorithm : null,
SignerKeyRef = e.SignerKeyRef,
Verification = verificationResults?.TryGetValue(e.Id, out var v) == true
? new VerificationEntry
{
IsValid = v.IsValid,
SignatureValid = v.SignatureValid,
ContentHashValid = v.ContentHashValid,
Error = v.Error
}
: null,
Content = e.Content
}).ToList()
};
var stream = new MemoryStream();
await JsonSerializer.SerializeAsync(stream, export, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}, ct);
stream.Position = 0;
return stream;
}
}
// JSON export models
public sealed class JsonAuditExport
{
public required string SchemaVersion { get; init; }
public required string GeneratedAt { get; init; }
public required string ReportTitle { get; init; }
public required QueryInfo Query { get; init; }
public required ExportSummary Summary { get; init; }
public required IReadOnlyList<EvidenceEntry> Evidence { get; init; }
}
public sealed class QueryInfo
{
public string? FromDate { get; init; }
public string? ToDate { get; init; }
public string? TenantId { get; init; }
public string? EnvironmentId { get; init; }
public IReadOnlyList<string>? Types { get; init; }
}
public sealed class ExportSummary
{
public required int TotalEvidence { get; init; }
public required IReadOnlyDictionary<string, int> ByType { get; init; }
public VerificationSummary? VerificationSummary { get; init; }
}
public sealed class VerificationSummary
{
public required int TotalVerified { get; init; }
public required bool AllValid { get; init; }
public required int FailedCount { get; init; }
}
public sealed class EvidenceEntry
{
public required string Id { get; init; }
public required string Type { get; init; }
public required string SubjectId { get; init; }
public required string CollectedAt { get; init; }
public required string SignedAt { get; init; }
public string? ContentHash { get; init; }
public string? Signature { get; init; }
public string? SignatureAlgorithm { get; init; }
public required string SignerKeyRef { get; init; }
public VerificationEntry? Verification { get; init; }
public required EvidencePacket Content { get; init; }
}
public sealed class VerificationEntry
{
public required bool IsValid { get; init; }
public required bool SignatureValid { get; init; }
public required bool ContentHashValid { get; init; }
public string? Error { get; init; }
}
```
### CsvExporter
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Export.Exporters;
public sealed class CsvExporter : IFormatExporter
{
public ExportFormat Format => ExportFormat.Csv;
public string ContentType => "text/csv";
public Task<Stream> ExportAsync(
IReadOnlyList<SignedEvidencePacket> evidence,
IReadOnlyDictionary<Guid, VerificationResult>? verificationResults,
AuditExportRequest request,
CancellationToken ct = default)
{
var stream = new MemoryStream();
using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
// Write header
writer.WriteLine("EvidenceId,Type,SubjectId,ReleaseName,EnvironmentName,CollectedAt,SignedAt,SignerKeyRef,IsValid,VerificationError");
// Write data rows
foreach (var packet in evidence)
{
var verification = verificationResults?.TryGetValue(packet.Id, out var v) == true ? v : null;
var row = new[]
{
packet.Id.ToString(),
packet.Content.Type.ToString(),
packet.Content.SubjectId.ToString(),
EscapeCsv(packet.Content.Content.Release?.ReleaseName ?? ""),
EscapeCsv(packet.Content.Content.Deployment?.DeploymentJobId.ToString() ??
packet.Content.Content.Promotion?.TargetEnvironmentName ?? ""),
packet.Content.CollectedAt.ToString("O"),
packet.SignedAt.ToString("O"),
packet.SignerKeyRef,
verification?.IsValid.ToString() ?? "",
EscapeCsv(verification?.Error ?? "")
};
writer.WriteLine(string.Join(",", row));
}
writer.Flush();
stream.Position = 0;
return Task.FromResult<Stream>(stream);
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value))
return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}
return value;
}
}
```
### SlsaExporter
```csharp
namespace StellaOps.ReleaseOrchestrator.Evidence.Export.Exporters;
public sealed class SlsaExporter : IFormatExporter
{
public ExportFormat Format => ExportFormat.Slsa;
public string ContentType => "application/vnd.in-toto+json";
public async Task<Stream> ExportAsync(
IReadOnlyList<SignedEvidencePacket> evidence,
IReadOnlyDictionary<Guid, VerificationResult>? verificationResults,
AuditExportRequest request,
CancellationToken ct = default)
{
// Export as SLSA Provenance v1.0 format
var provenances = evidence
.Where(e => e.Content.Type == EvidenceType.Deployment)
.Select(e => BuildSlsaProvenance(e))
.ToList();
var stream = new MemoryStream();
// Write as NDJSON (one provenance per line)
using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
foreach (var provenance in provenances)
{
var json = JsonSerializer.Serialize(provenance, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
await writer.WriteLineAsync(json);
}
await writer.FlushAsync(ct);
stream.Position = 0;
return stream;
}
private static SlsaProvenance BuildSlsaProvenance(SignedEvidencePacket packet)
{
var deployment = packet.Content.Content.Deployment;
var release = packet.Content.Content.Release;
return new SlsaProvenance
{
Type = "https://in-toto.io/Statement/v1",
Subject = release?.Components.Select(c => new SlsaSubject
{
Name = c.ComponentName,
Digest = new Dictionary<string, string>
{
["sha256"] = c.Digest.Replace("sha256:", "")
}
}).ToList() ?? [],
PredicateType = "https://slsa.dev/provenance/v1",
Predicate = new SlsaPredicate
{
BuildDefinition = new SlsaBuildDefinition
{
BuildType = "https://stella-ops.io/DeploymentProvenanceV1",
ExternalParameters = new Dictionary<string, object>
{
["deployment"] = new
{
jobId = deployment?.DeploymentJobId.ToString(),
strategy = deployment?.Strategy,
environment = packet.Content.Content.Promotion?.TargetEnvironmentName
}
},
InternalParameters = new Dictionary<string, object>
{
["evidenceId"] = packet.Id.ToString(),
["collectedAt"] = packet.Content.CollectedAt.ToString("O")
},
ResolvedDependencies = release?.Components.Select(c => new SlsaResourceDescriptor
{
Name = c.ComponentName,
Uri = $"oci://{c.ComponentName}@{c.Digest}",
Digest = new Dictionary<string, string>
{
["sha256"] = c.Digest.Replace("sha256:", "")
}
}).ToList() ?? []
},
RunDetails = new SlsaRunDetails
{
Builder = new SlsaBuilder
{
Id = "https://stella-ops.io/ReleaseOrchestrator",
Version = new Dictionary<string, string>
{
["stella-ops"] = packet.Content.CollectorVersion
}
},
Metadata = new SlsaMetadata
{
InvocationId = packet.Content.SubjectId.ToString(),
StartedOn = deployment?.StartedAt.ToString("O"),
FinishedOn = deployment?.CompletedAt?.ToString("O")
}
}
}
};
}
}
// SLSA Provenance models
public sealed class SlsaProvenance
{
[JsonPropertyName("_type")]
public required string Type { get; init; }
public required IReadOnlyList<SlsaSubject> Subject { get; init; }
public required string PredicateType { get; init; }
public required SlsaPredicate Predicate { get; init; }
}
public sealed class SlsaSubject
{
public required string Name { get; init; }
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}
public sealed class SlsaPredicate
{
public required SlsaBuildDefinition BuildDefinition { get; init; }
public required SlsaRunDetails RunDetails { get; init; }
}
public sealed class SlsaBuildDefinition
{
public required string BuildType { get; init; }
public required IReadOnlyDictionary<string, object> ExternalParameters { get; init; }
public required IReadOnlyDictionary<string, object> InternalParameters { get; init; }
public required IReadOnlyList<SlsaResourceDescriptor> ResolvedDependencies { get; init; }
}
public sealed class SlsaResourceDescriptor
{
public required string Name { get; init; }
public required string Uri { get; init; }
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}
public sealed class SlsaRunDetails
{
public required SlsaBuilder Builder { get; init; }
public required SlsaMetadata Metadata { get; init; }
}
public sealed class SlsaBuilder
{
public required string Id { get; init; }
public required IReadOnlyDictionary<string, string> Version { get; init; }
}
public sealed class SlsaMetadata
{
public required string InvocationId { get; init; }
public string? StartedOn { get; init; }
public string? FinishedOn { get; init; }
}
```
---
## Acceptance Criteria
- [ ] Export evidence as JSON
- [ ] Export evidence as PDF
- [ ] Export evidence as CSV
- [ ] Export evidence as SLSA provenance
- [ ] Include verification results
- [ ] Filter by date range
- [ ] Filter by evidence type
- [ ] Generate meaningful file names
- [ ] SLSA format compliant with spec
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 109_001 Evidence Collector | Internal | TODO |
| 109_002 Evidence Signer | Internal | TODO |
| QuestPDF | NuGet | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IAuditExporter | TODO | |
| AuditExporter | TODO | |
| JsonExporter | TODO | |
| PdfExporter | TODO | |
| CsvExporter | TODO | |
| SlsaExporter | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,250 @@
# SPRINT INDEX: Phase 10 - Progressive Delivery
> **Epic:** Release Orchestrator
> **Phase:** 10 - Progressive Delivery
> **Batch:** 110
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 10 implements Progressive Delivery - A/B releases, canary deployments, and traffic routing for gradual rollouts.
### Objectives
- A/B release manager for parallel versions
- Traffic router framework abstraction
- Canary controller for gradual promotion
- Router plugin for Nginx (reference implementation)
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 110_001 | A/B Release Manager | PROGDL | TODO | 107_005 |
| 110_002 | Traffic Router Framework | PROGDL | TODO | 110_001 |
| 110_003 | Canary Controller | PROGDL | TODO | 110_002 |
| 110_004 | Router Plugin - Nginx | PROGDL | TODO | 110_002 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROGRESSIVE DELIVERY │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ A/B RELEASE MANAGER (110_001) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ A/B Release │ │ │
│ │ │ │ │ │
│ │ │ Control (current): sha256:abc123 ──► 80% traffic │ │ │
│ │ │ Treatment (new): sha256:def456 ──► 20% traffic │ │ │
│ │ │ │ │ │
│ │ │ Status: active │ │ │
│ │ │ Decision: pending │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ TRAFFIC ROUTER FRAMEWORK (110_002) │ │
│ │ │ │
│ │ ITrafficRouter │ │
│ │ ├── SetWeights(control: 80, treatment: 20) │ │
│ │ ├── SetHeaderRouting(x-canary: true → treatment) │ │
│ │ ├── SetCookieRouting(ab_group: B → treatment) │ │
│ │ └── GetCurrentRouting() → RoutingConfig │ │
│ │ │ │
│ │ Implementations: │ │
│ │ ├── NginxRouter │ │
│ │ ├── HaproxyRouter │ │
│ │ ├── TraefikRouter │ │
│ │ └── AwsAlbRouter │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ CANARY CONTROLLER (110_003) │ │
│ │ │ │
│ │ Canary Progression: │ │
│ │ │ │
│ │ Step 1: 5% ──────┐ │ │
│ │ Step 2: 10% ─────┤ │ │
│ │ Step 3: 25% ─────┼──► Auto-advance if metrics pass │ │
│ │ Step 4: 50% ─────┤ │ │
│ │ Step 5: 100% ────┘ │ │
│ │ │ │
│ │ Rollback triggers: │ │
│ │ ├── Error rate > threshold │ │
│ │ ├── Latency P99 > threshold │ │
│ │ └── Manual intervention │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ NGINX ROUTER PLUGIN (110_004) │ │
│ │ │ │
│ │ upstream control { │ │
│ │ server app-v1:8080 weight=80; │ │
│ │ } │ │
│ │ upstream treatment { │ │
│ │ server app-v2:8080 weight=20; │ │
│ │ } │ │
│ │ │ │
│ │ # Header-based routing │ │
│ │ if ($http_x_canary = "true") { │ │
│ │ proxy_pass http://treatment; │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 110_001: A/B Release Manager
| Deliverable | Type | Description |
|-------------|------|-------------|
| `IAbReleaseManager` | Interface | A/B operations |
| `AbReleaseManager` | Class | Implementation |
| `AbRelease` | Model | A/B release entity |
| `AbDecision` | Model | Promotion decision |
### 110_002: Traffic Router Framework
| Deliverable | Type | Description |
|-------------|------|-------------|
| `ITrafficRouter` | Interface | Router abstraction |
| `RoutingConfig` | Model | Current routing state |
| `WeightedRouting` | Strategy | Percentage-based |
| `HeaderRouting` | Strategy | Header-based |
| `CookieRouting` | Strategy | Cookie-based |
### 110_003: Canary Controller
| Deliverable | Type | Description |
|-------------|------|-------------|
| `ICanaryController` | Interface | Canary operations |
| `CanaryController` | Class | Implementation |
| `CanaryStep` | Model | Progression step |
| `CanaryMetrics` | Model | Health metrics |
| `AutoRollback` | Class | Automatic rollback |
### 110_004: Router Plugin - Nginx
| Deliverable | Type | Description |
|-------------|------|-------------|
| `NginxRouter` | Router | Nginx implementation |
| `NginxConfigGenerator` | Class | Config generation |
| `NginxReloader` | Class | Hot reload |
| `NginxMetrics` | Class | Status parsing |
---
## Key Interfaces
```csharp
public interface IAbReleaseManager
{
Task<AbRelease> CreateAsync(CreateAbReleaseRequest request, CancellationToken ct);
Task<AbRelease> UpdateWeightsAsync(Guid id, int controlWeight, int treatmentWeight, CancellationToken ct);
Task<AbRelease> PromoteAsync(Guid id, AbDecision decision, CancellationToken ct);
Task<AbRelease> RollbackAsync(Guid id, CancellationToken ct);
Task<AbRelease?> GetAsync(Guid id, CancellationToken ct);
}
public interface ITrafficRouter
{
string RouterType { get; }
Task ApplyAsync(RoutingConfig config, CancellationToken ct);
Task<RoutingConfig> GetCurrentAsync(CancellationToken ct);
Task<bool> HealthCheckAsync(CancellationToken ct);
}
public interface ICanaryController
{
Task<CanaryRelease> StartAsync(Guid releaseId, CanaryConfig config, CancellationToken ct);
Task<CanaryRelease> AdvanceAsync(Guid canaryId, CancellationToken ct);
Task<CanaryRelease> RollbackAsync(Guid canaryId, string reason, CancellationToken ct);
Task<CanaryRelease> CompleteAsync(Guid canaryId, CancellationToken ct);
}
```
---
## Canary Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ CANARY FLOW │
│ │
│ ┌─────────────┐ │
│ │ Start │ │
│ │ Canary │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ metrics ┌─────────────┐ pass ┌─────────────┐ │
│ │ Step 1 │ ────────────►│ Analyze │ ──────────►│ Step 2 │ │
│ │ 5% │ │ Metrics │ │ 10% │ │
│ └─────────────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ │ fail │ │
│ ▼ ▼ │
│ ┌─────────────┐ ... continue │
│ │ Rollback │ │ │
│ │ to Control │ │ │
│ └─────────────┘ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Step N │ │
│ │ 100% │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Complete │ │
│ │ Promote │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| 107_005 Deployment Strategies | Base deployment |
| 107_002 Target Executor | Deploy versions |
| Telemetry | Metrics collection |
---
## Acceptance Criteria
- [ ] A/B release created
- [ ] Traffic weights applied
- [ ] Header-based routing works
- [ ] Canary progression advances
- [ ] Auto-rollback on metrics failure
- [ ] Nginx config generated
- [ ] Nginx hot reload works
- [ ] Evidence captured for A/B
- [ ] Unit test coverage ≥80%
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 10 index created |

View File

@@ -0,0 +1,613 @@
# SPRINT: A/B Release Manager
> **Sprint ID:** 110_001
> **Module:** PROGDL
> **Phase:** 10 - Progressive Delivery
> **Status:** TODO
> **Parent:** [110_000_INDEX](SPRINT_20260110_110_000_INDEX_progressive_delivery.md)
---
## Overview
Implement the A/B Release Manager for running parallel versions with traffic splitting.
### Objectives
- Create A/B releases with control and treatment versions
- Manage traffic weight distribution
- Track A/B experiment metrics
- Promote or rollback based on results
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Progressive/
│ ├── AbRelease/
│ │ ├── IAbReleaseManager.cs
│ │ ├── AbReleaseManager.cs
│ │ ├── AbReleaseStore.cs
│ │ └── AbMetricsCollector.cs
│ └── Models/
│ ├── AbRelease.cs
│ ├── AbDecision.cs
│ └── AbMetrics.cs
└── __Tests/
```
---
## Deliverables
### AbRelease Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Models;
public sealed record AbRelease
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required Guid EnvironmentId { get; init; }
public required string EnvironmentName { get; init; }
public required AbVersion Control { get; init; }
public required AbVersion Treatment { get; init; }
public required int ControlWeight { get; init; }
public required int TreatmentWeight { get; init; }
public required AbReleaseStatus Status { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required Guid CreatedBy { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public AbDecision? Decision { get; init; }
public AbMetrics? LatestMetrics { get; init; }
}
public sealed record AbVersion
{
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required string Variant { get; init; } // "control" or "treatment"
public required ImmutableArray<AbComponent> Components { get; init; }
public required ImmutableArray<Guid> TargetIds { get; init; }
}
public sealed record AbComponent
{
public required string Name { get; init; }
public required string Digest { get; init; }
public string? Endpoint { get; init; }
}
public enum AbReleaseStatus
{
Draft,
Deploying,
Active,
Paused,
Promoting,
RollingBack,
Completed,
Failed
}
public sealed record AbDecision
{
public required AbDecisionType Type { get; init; }
public required string Reason { get; init; }
public required Guid DecidedBy { get; init; }
public required DateTimeOffset DecidedAt { get; init; }
public AbMetrics? MetricsAtDecision { get; init; }
}
public enum AbDecisionType
{
PromoteTreatment,
KeepControl,
ExtendExperiment
}
public sealed record AbMetrics
{
public required DateTimeOffset CollectedAt { get; init; }
public required AbVariantMetrics ControlMetrics { get; init; }
public required AbVariantMetrics TreatmentMetrics { get; init; }
public double? StatisticalSignificance { get; init; }
}
public sealed record AbVariantMetrics
{
public required long RequestCount { get; init; }
public required double ErrorRate { get; init; }
public required double LatencyP50 { get; init; }
public required double LatencyP95 { get; init; }
public required double LatencyP99 { get; init; }
public ImmutableDictionary<string, double> CustomMetrics { get; init; } = ImmutableDictionary<string, double>.Empty;
}
```
### IAbReleaseManager Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.AbRelease;
public interface IAbReleaseManager
{
Task<AbRelease> CreateAsync(
CreateAbReleaseRequest request,
CancellationToken ct = default);
Task<AbRelease> StartAsync(
Guid id,
CancellationToken ct = default);
Task<AbRelease> UpdateWeightsAsync(
Guid id,
int controlWeight,
int treatmentWeight,
CancellationToken ct = default);
Task<AbRelease> PauseAsync(
Guid id,
string? reason = null,
CancellationToken ct = default);
Task<AbRelease> ResumeAsync(
Guid id,
CancellationToken ct = default);
Task<AbRelease> PromoteAsync(
Guid id,
AbDecision decision,
CancellationToken ct = default);
Task<AbRelease> RollbackAsync(
Guid id,
string reason,
CancellationToken ct = default);
Task<AbRelease?> GetAsync(
Guid id,
CancellationToken ct = default);
Task<IReadOnlyList<AbRelease>> ListAsync(
AbReleaseFilter? filter = null,
CancellationToken ct = default);
Task<AbMetrics> GetLatestMetricsAsync(
Guid id,
CancellationToken ct = default);
}
public sealed record CreateAbReleaseRequest
{
public required Guid EnvironmentId { get; init; }
public required Guid ControlReleaseId { get; init; }
public required Guid TreatmentReleaseId { get; init; }
public int InitialControlWeight { get; init; } = 90;
public int InitialTreatmentWeight { get; init; } = 10;
public IReadOnlyList<Guid>? ControlTargetIds { get; init; }
public IReadOnlyList<Guid>? TreatmentTargetIds { get; init; }
public AbRoutingMode RoutingMode { get; init; } = AbRoutingMode.Weighted;
}
public enum AbRoutingMode
{
Weighted, // Random distribution by weight
HeaderBased, // Route by header value
CookieBased, // Route by cookie value
UserIdBased // Route by user ID hash
}
public sealed record AbReleaseFilter
{
public Guid? EnvironmentId { get; init; }
public AbReleaseStatus? Status { get; init; }
public DateTimeOffset? FromDate { get; init; }
public DateTimeOffset? ToDate { get; init; }
}
```
### AbReleaseManager Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.AbRelease;
public sealed class AbReleaseManager : IAbReleaseManager
{
private readonly IAbReleaseStore _store;
private readonly IReleaseManager _releaseManager;
private readonly IDeployOrchestrator _deployOrchestrator;
private readonly ITrafficRouter _trafficRouter;
private readonly AbMetricsCollector _metricsCollector;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ITenantContext _tenantContext;
private readonly IUserContext _userContext;
private readonly ILogger<AbReleaseManager> _logger;
public async Task<AbRelease> CreateAsync(
CreateAbReleaseRequest request,
CancellationToken ct = default)
{
var controlRelease = await _releaseManager.GetAsync(request.ControlReleaseId, ct)
?? throw new ReleaseNotFoundException(request.ControlReleaseId);
var treatmentRelease = await _releaseManager.GetAsync(request.TreatmentReleaseId, ct)
?? throw new ReleaseNotFoundException(request.TreatmentReleaseId);
ValidateWeights(request.InitialControlWeight, request.InitialTreatmentWeight);
var abRelease = new AbRelease
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
EnvironmentId = request.EnvironmentId,
EnvironmentName = controlRelease.Components.First().Config.GetValueOrDefault("environment", ""),
Control = new AbVersion
{
ReleaseId = controlRelease.Id,
ReleaseName = controlRelease.Name,
Variant = "control",
Components = controlRelease.Components.Select(c => new AbComponent
{
Name = c.ComponentName,
Digest = c.Digest
}).ToImmutableArray(),
TargetIds = request.ControlTargetIds?.ToImmutableArray() ?? ImmutableArray<Guid>.Empty
},
Treatment = new AbVersion
{
ReleaseId = treatmentRelease.Id,
ReleaseName = treatmentRelease.Name,
Variant = "treatment",
Components = treatmentRelease.Components.Select(c => new AbComponent
{
Name = c.ComponentName,
Digest = c.Digest
}).ToImmutableArray(),
TargetIds = request.TreatmentTargetIds?.ToImmutableArray() ?? ImmutableArray<Guid>.Empty
},
ControlWeight = request.InitialControlWeight,
TreatmentWeight = request.InitialTreatmentWeight,
Status = AbReleaseStatus.Draft,
CreatedAt = _timeProvider.GetUtcNow(),
CreatedBy = _userContext.UserId
};
await _store.SaveAsync(abRelease, ct);
await _eventPublisher.PublishAsync(new AbReleaseCreated(
abRelease.Id,
abRelease.Control.ReleaseName,
abRelease.Treatment.ReleaseName,
abRelease.EnvironmentId,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Created A/B release {Id}: control={Control}, treatment={Treatment}",
abRelease.Id,
controlRelease.Name,
treatmentRelease.Name);
return abRelease;
}
public async Task<AbRelease> StartAsync(Guid id, CancellationToken ct = default)
{
var abRelease = await GetRequiredAsync(id, ct);
if (abRelease.Status != AbReleaseStatus.Draft)
{
throw new InvalidAbReleaseStateException(id, abRelease.Status, "Cannot start - not in Draft status");
}
// Deploy both versions
abRelease = abRelease with { Status = AbReleaseStatus.Deploying };
await _store.SaveAsync(abRelease, ct);
try
{
// Deploy control version to control targets
await DeployVersionAsync(abRelease.Control, abRelease.EnvironmentId, ct);
// Deploy treatment version to treatment targets
await DeployVersionAsync(abRelease.Treatment, abRelease.EnvironmentId, ct);
// Configure traffic routing
await _trafficRouter.ApplyAsync(new RoutingConfig
{
AbReleaseId = abRelease.Id,
ControlEndpoints = abRelease.Control.Components.Select(c => c.Endpoint ?? "").ToList(),
TreatmentEndpoints = abRelease.Treatment.Components.Select(c => c.Endpoint ?? "").ToList(),
ControlWeight = abRelease.ControlWeight,
TreatmentWeight = abRelease.TreatmentWeight
}, ct);
abRelease = abRelease with
{
Status = AbReleaseStatus.Active,
StartedAt = _timeProvider.GetUtcNow()
};
await _store.SaveAsync(abRelease, ct);
await _eventPublisher.PublishAsync(new AbReleaseStarted(
abRelease.Id,
abRelease.ControlWeight,
abRelease.TreatmentWeight,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Started A/B release {Id}: {ControlWeight}% control, {TreatmentWeight}% treatment",
id,
abRelease.ControlWeight,
abRelease.TreatmentWeight);
return abRelease;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start A/B release {Id}", id);
abRelease = abRelease with
{
Status = AbReleaseStatus.Failed,
Decision = new AbDecision
{
Type = AbDecisionType.KeepControl,
Reason = $"Deployment failed: {ex.Message}",
DecidedBy = _userContext.UserId,
DecidedAt = _timeProvider.GetUtcNow()
}
};
await _store.SaveAsync(abRelease, ct);
throw;
}
}
public async Task<AbRelease> UpdateWeightsAsync(
Guid id,
int controlWeight,
int treatmentWeight,
CancellationToken ct = default)
{
var abRelease = await GetRequiredAsync(id, ct);
if (abRelease.Status != AbReleaseStatus.Active && abRelease.Status != AbReleaseStatus.Paused)
{
throw new InvalidAbReleaseStateException(id, abRelease.Status, "Cannot update weights - not active or paused");
}
ValidateWeights(controlWeight, treatmentWeight);
// Update traffic routing
await _trafficRouter.ApplyAsync(new RoutingConfig
{
AbReleaseId = abRelease.Id,
ControlEndpoints = abRelease.Control.Components.Select(c => c.Endpoint ?? "").ToList(),
TreatmentEndpoints = abRelease.Treatment.Components.Select(c => c.Endpoint ?? "").ToList(),
ControlWeight = controlWeight,
TreatmentWeight = treatmentWeight
}, ct);
abRelease = abRelease with
{
ControlWeight = controlWeight,
TreatmentWeight = treatmentWeight
};
await _store.SaveAsync(abRelease, ct);
await _eventPublisher.PublishAsync(new AbReleaseWeightsUpdated(
abRelease.Id,
controlWeight,
treatmentWeight,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Updated A/B release {Id} weights: {ControlWeight}% control, {TreatmentWeight}% treatment",
id,
controlWeight,
treatmentWeight);
return abRelease;
}
public async Task<AbRelease> PromoteAsync(
Guid id,
AbDecision decision,
CancellationToken ct = default)
{
var abRelease = await GetRequiredAsync(id, ct);
if (abRelease.Status != AbReleaseStatus.Active)
{
throw new InvalidAbReleaseStateException(id, abRelease.Status, "Cannot promote - not active");
}
abRelease = abRelease with { Status = AbReleaseStatus.Promoting };
await _store.SaveAsync(abRelease, ct);
try
{
var winningRelease = decision.Type == AbDecisionType.PromoteTreatment
? abRelease.Treatment
: abRelease.Control;
// Route 100% traffic to winner
await _trafficRouter.ApplyAsync(new RoutingConfig
{
AbReleaseId = abRelease.Id,
ControlEndpoints = winningRelease.Components.Select(c => c.Endpoint ?? "").ToList(),
TreatmentEndpoints = [],
ControlWeight = 100,
TreatmentWeight = 0
}, ct);
// Collect final metrics
var finalMetrics = await _metricsCollector.CollectAsync(abRelease, ct);
abRelease = abRelease with
{
Status = AbReleaseStatus.Completed,
CompletedAt = _timeProvider.GetUtcNow(),
Decision = decision with { MetricsAtDecision = finalMetrics },
LatestMetrics = finalMetrics
};
await _store.SaveAsync(abRelease, ct);
await _eventPublisher.PublishAsync(new AbReleaseCompleted(
abRelease.Id,
decision.Type,
winningRelease.ReleaseName,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"A/B release {Id} completed: winner={Winner}",
id,
winningRelease.ReleaseName);
return abRelease;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to promote A/B release {Id}", id);
abRelease = abRelease with { Status = AbReleaseStatus.Active };
await _store.SaveAsync(abRelease, ct);
throw;
}
}
public async Task<AbRelease> RollbackAsync(
Guid id,
string reason,
CancellationToken ct = default)
{
var abRelease = await GetRequiredAsync(id, ct);
if (abRelease.Status != AbReleaseStatus.Active && abRelease.Status != AbReleaseStatus.Promoting)
{
throw new InvalidAbReleaseStateException(id, abRelease.Status, "Cannot rollback");
}
abRelease = abRelease with { Status = AbReleaseStatus.RollingBack };
await _store.SaveAsync(abRelease, ct);
// Route 100% to control
await _trafficRouter.ApplyAsync(new RoutingConfig
{
AbReleaseId = abRelease.Id,
ControlEndpoints = abRelease.Control.Components.Select(c => c.Endpoint ?? "").ToList(),
TreatmentEndpoints = [],
ControlWeight = 100,
TreatmentWeight = 0
}, ct);
abRelease = abRelease with
{
Status = AbReleaseStatus.Completed,
CompletedAt = _timeProvider.GetUtcNow(),
Decision = new AbDecision
{
Type = AbDecisionType.KeepControl,
Reason = $"Rollback: {reason}",
DecidedBy = _userContext.UserId,
DecidedAt = _timeProvider.GetUtcNow()
}
};
await _store.SaveAsync(abRelease, ct);
_logger.LogInformation("Rolled back A/B release {Id}: {Reason}", id, reason);
return abRelease;
}
public async Task<AbMetrics> GetLatestMetricsAsync(Guid id, CancellationToken ct = default)
{
var abRelease = await GetRequiredAsync(id, ct);
return await _metricsCollector.CollectAsync(abRelease, ct);
}
private async Task DeployVersionAsync(AbVersion version, Guid environmentId, CancellationToken ct)
{
// Create a deployment for this version
// Implementation depends on target assignment strategy
}
private async Task<AbRelease> GetRequiredAsync(Guid id, CancellationToken ct)
{
return await _store.GetAsync(id, ct)
?? throw new AbReleaseNotFoundException(id);
}
private static void ValidateWeights(int controlWeight, int treatmentWeight)
{
if (controlWeight < 0 || treatmentWeight < 0)
{
throw new InvalidWeightException("Weights must be non-negative");
}
if (controlWeight + treatmentWeight != 100)
{
throw new InvalidWeightException("Weights must sum to 100");
}
}
}
```
---
## Acceptance Criteria
- [ ] Create A/B release with control and treatment versions
- [ ] Validate weights sum to 100
- [ ] Deploy both versions on start
- [ ] Configure traffic routing
- [ ] Update weights dynamically
- [ ] Pause and resume experiments
- [ ] Promote treatment version
- [ ] Rollback to control version
- [ ] Collect metrics for both variants
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_005 Deployment Strategies | Internal | TODO |
| 104_003 Release Manager | Internal | TODO |
| 110_002 Traffic Router Framework | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| IAbReleaseManager | TODO | |
| AbReleaseManager | TODO | |
| AbRelease model | TODO | |
| AbDecision model | TODO | |
| AbMetrics model | TODO | |
| AbReleaseStore | TODO | |
| AbMetricsCollector | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,520 @@
# SPRINT: Traffic Router Framework
> **Sprint ID:** 110_002
> **Module:** PROGDL
> **Phase:** 10 - Progressive Delivery
> **Status:** TODO
> **Parent:** [110_000_INDEX](SPRINT_20260110_110_000_INDEX_progressive_delivery.md)
---
## Overview
Implement the Traffic Router Framework providing abstractions for traffic splitting across load balancers.
### Objectives
- Define traffic router interface for plugins
- Support weighted routing (percentage-based)
- Support header-based routing
- Support cookie-based routing
- Track routing state and transitions
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Progressive/
│ └── Router/
│ ├── ITrafficRouter.cs
│ ├── TrafficRouterRegistry.cs
│ ├── RoutingConfig.cs
│ ├── Strategies/
│ │ ├── WeightedRouting.cs
│ │ ├── HeaderRouting.cs
│ │ └── CookieRouting.cs
│ └── Store/
│ ├── IRoutingStateStore.cs
│ └── RoutingStateStore.cs
└── __Tests/
```
---
## Deliverables
### ITrafficRouter Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Router;
public interface ITrafficRouter
{
string RouterType { get; }
IReadOnlyList<string> SupportedStrategies { get; }
Task<bool> IsAvailableAsync(CancellationToken ct = default);
Task ApplyAsync(
RoutingConfig config,
CancellationToken ct = default);
Task<RoutingConfig> GetCurrentAsync(
Guid contextId,
CancellationToken ct = default);
Task RemoveAsync(
Guid contextId,
CancellationToken ct = default);
Task<bool> HealthCheckAsync(CancellationToken ct = default);
Task<RouterMetrics> GetMetricsAsync(
Guid contextId,
CancellationToken ct = default);
}
public sealed record RoutingConfig
{
public required Guid AbReleaseId { get; init; }
public required IReadOnlyList<string> ControlEndpoints { get; init; }
public required IReadOnlyList<string> TreatmentEndpoints { get; init; }
public required int ControlWeight { get; init; }
public required int TreatmentWeight { get; init; }
public RoutingStrategy Strategy { get; init; } = RoutingStrategy.Weighted;
public HeaderRoutingConfig? HeaderRouting { get; init; }
public CookieRoutingConfig? CookieRouting { get; init; }
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}
public enum RoutingStrategy
{
Weighted,
HeaderBased,
CookieBased,
Combined
}
public sealed record HeaderRoutingConfig
{
public required string HeaderName { get; init; }
public required string TreatmentValue { get; init; }
public bool FallbackToWeighted { get; init; } = true;
}
public sealed record CookieRoutingConfig
{
public required string CookieName { get; init; }
public required string TreatmentValue { get; init; }
public bool FallbackToWeighted { get; init; } = true;
}
public sealed record RouterMetrics
{
public required long ControlRequests { get; init; }
public required long TreatmentRequests { get; init; }
public required double ControlErrorRate { get; init; }
public required double TreatmentErrorRate { get; init; }
public required double ControlLatencyP50 { get; init; }
public required double TreatmentLatencyP50 { get; init; }
public required DateTimeOffset CollectedAt { get; init; }
}
```
### TrafficRouterRegistry
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Router;
public sealed class TrafficRouterRegistry
{
private readonly Dictionary<string, ITrafficRouter> _routers = new();
private readonly ILogger<TrafficRouterRegistry> _logger;
public void Register(ITrafficRouter router)
{
if (_routers.ContainsKey(router.RouterType))
{
throw new RouterAlreadyRegisteredException(router.RouterType);
}
_routers[router.RouterType] = router;
_logger.LogInformation(
"Registered traffic router: {Type} with strategies: {Strategies}",
router.RouterType,
string.Join(", ", router.SupportedStrategies));
}
public ITrafficRouter? Get(string routerType)
{
return _routers.TryGetValue(routerType, out var router) ? router : null;
}
public ITrafficRouter GetRequired(string routerType)
{
return Get(routerType)
?? throw new RouterNotFoundException(routerType);
}
public IReadOnlyList<RouterInfo> GetAvailable()
{
return _routers.Values.Select(r => new RouterInfo
{
Type = r.RouterType,
SupportedStrategies = r.SupportedStrategies
}).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<RouterHealthStatus>> CheckHealthAsync(CancellationToken ct = default)
{
var results = new List<RouterHealthStatus>();
foreach (var (type, router) in _routers)
{
try
{
var isHealthy = await router.HealthCheckAsync(ct);
results.Add(new RouterHealthStatus(type, isHealthy, null));
}
catch (Exception ex)
{
results.Add(new RouterHealthStatus(type, false, ex.Message));
}
}
return results.AsReadOnly();
}
}
public sealed record RouterInfo
{
public required string Type { get; init; }
public required IReadOnlyList<string> SupportedStrategies { get; init; }
}
public sealed record RouterHealthStatus(
string RouterType,
bool IsHealthy,
string? Error
);
```
### RoutingStateStore
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Router.Store;
public interface IRoutingStateStore
{
Task SaveAsync(RoutingState state, CancellationToken ct = default);
Task<RoutingState?> GetAsync(Guid contextId, CancellationToken ct = default);
Task<IReadOnlyList<RoutingState>> ListActiveAsync(CancellationToken ct = default);
Task DeleteAsync(Guid contextId, CancellationToken ct = default);
Task<IReadOnlyList<RoutingTransition>> GetHistoryAsync(Guid contextId, CancellationToken ct = default);
}
public sealed record RoutingState
{
public required Guid ContextId { get; init; }
public required string RouterType { get; init; }
public required RoutingConfig Config { get; init; }
public required RoutingStateStatus Status { get; init; }
public required DateTimeOffset AppliedAt { get; init; }
public required DateTimeOffset LastVerifiedAt { get; init; }
public string? Error { get; init; }
}
public enum RoutingStateStatus
{
Pending,
Applied,
Verified,
Failed,
Removed
}
public sealed record RoutingTransition
{
public required Guid ContextId { get; init; }
public required RoutingConfig FromConfig { get; init; }
public required RoutingConfig ToConfig { get; init; }
public required string Reason { get; init; }
public required Guid TriggeredBy { get; init; }
public required DateTimeOffset TransitionedAt { get; init; }
}
```
### WeightedRouting Strategy
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Router.Strategies;
public sealed class WeightedRouting
{
public static UpstreamConfig Generate(RoutingConfig config)
{
var controlUpstream = new UpstreamDefinition
{
Name = $"control-{config.AbReleaseId:N}",
Servers = config.ControlEndpoints.Select(e => new UpstreamServer
{
Address = e,
Weight = config.ControlWeight
}).ToList()
};
var treatmentUpstream = new UpstreamDefinition
{
Name = $"treatment-{config.AbReleaseId:N}",
Servers = config.TreatmentEndpoints.Select(e => new UpstreamServer
{
Address = e,
Weight = config.TreatmentWeight
}).ToList()
};
return new UpstreamConfig
{
ContextId = config.AbReleaseId,
Upstreams = new[] { controlUpstream, treatmentUpstream }.ToList(),
DefaultUpstream = controlUpstream.Name,
SplitConfig = new SplitConfig
{
ControlUpstream = controlUpstream.Name,
TreatmentUpstream = treatmentUpstream.Name,
ControlWeight = config.ControlWeight,
TreatmentWeight = config.TreatmentWeight
}
};
}
}
public sealed record UpstreamConfig
{
public required Guid ContextId { get; init; }
public required IReadOnlyList<UpstreamDefinition> Upstreams { get; init; }
public required string DefaultUpstream { get; init; }
public SplitConfig? SplitConfig { get; init; }
public HeaderMatchConfig? HeaderConfig { get; init; }
public CookieMatchConfig? CookieConfig { get; init; }
}
public sealed record UpstreamDefinition
{
public required string Name { get; init; }
public required IReadOnlyList<UpstreamServer> Servers { get; init; }
}
public sealed record UpstreamServer
{
public required string Address { get; init; }
public int Weight { get; init; } = 1;
public int MaxFails { get; init; } = 3;
public TimeSpan FailTimeout { get; init; } = TimeSpan.FromSeconds(30);
}
public sealed record SplitConfig
{
public required string ControlUpstream { get; init; }
public required string TreatmentUpstream { get; init; }
public required int ControlWeight { get; init; }
public required int TreatmentWeight { get; init; }
}
```
### HeaderRouting Strategy
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Router.Strategies;
public sealed class HeaderRouting
{
public static HeaderMatchConfig Generate(RoutingConfig config)
{
if (config.HeaderRouting is null)
{
throw new InvalidOperationException("Header routing config is required");
}
return new HeaderMatchConfig
{
HeaderName = config.HeaderRouting.HeaderName,
Matches = new[]
{
new HeaderMatch
{
Value = config.HeaderRouting.TreatmentValue,
Upstream = $"treatment-{config.AbReleaseId:N}"
}
}.ToList(),
DefaultUpstream = $"control-{config.AbReleaseId:N}",
FallbackToWeighted = config.HeaderRouting.FallbackToWeighted
};
}
}
public sealed record HeaderMatchConfig
{
public required string HeaderName { get; init; }
public required IReadOnlyList<HeaderMatch> Matches { get; init; }
public required string DefaultUpstream { get; init; }
public bool FallbackToWeighted { get; init; }
}
public sealed record HeaderMatch
{
public required string Value { get; init; }
public required string Upstream { get; init; }
}
```
### CookieRouting Strategy
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Router.Strategies;
public sealed class CookieRouting
{
public static CookieMatchConfig Generate(RoutingConfig config)
{
if (config.CookieRouting is null)
{
throw new InvalidOperationException("Cookie routing config is required");
}
return new CookieMatchConfig
{
CookieName = config.CookieRouting.CookieName,
Matches = new[]
{
new CookieMatch
{
Value = config.CookieRouting.TreatmentValue,
Upstream = $"treatment-{config.AbReleaseId:N}"
}
}.ToList(),
DefaultUpstream = $"control-{config.AbReleaseId:N}",
FallbackToWeighted = config.CookieRouting.FallbackToWeighted,
SetCookieOnFirstRequest = true
};
}
}
public sealed record CookieMatchConfig
{
public required string CookieName { get; init; }
public required IReadOnlyList<CookieMatch> Matches { get; init; }
public required string DefaultUpstream { get; init; }
public bool FallbackToWeighted { get; init; }
public bool SetCookieOnFirstRequest { get; init; }
public TimeSpan CookieExpiry { get; init; } = TimeSpan.FromDays(30);
}
public sealed record CookieMatch
{
public required string Value { get; init; }
public required string Upstream { get; init; }
}
```
### Routing Config Validator
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Router;
public static class RoutingConfigValidator
{
public static ValidationResult Validate(RoutingConfig config)
{
var errors = new List<string>();
// Validate weights
if (config.ControlWeight + config.TreatmentWeight != 100)
{
errors.Add("Weights must sum to 100");
}
if (config.ControlWeight < 0 || config.TreatmentWeight < 0)
{
errors.Add("Weights must be non-negative");
}
// Validate endpoints
if (config.ControlEndpoints.Count == 0 && config.ControlWeight > 0)
{
errors.Add("Control endpoints required when control weight > 0");
}
if (config.TreatmentEndpoints.Count == 0 && config.TreatmentWeight > 0)
{
errors.Add("Treatment endpoints required when treatment weight > 0");
}
// Validate strategy-specific config
if (config.Strategy == RoutingStrategy.HeaderBased && config.HeaderRouting is null)
{
errors.Add("Header routing config required for header-based strategy");
}
if (config.Strategy == RoutingStrategy.CookieBased && config.CookieRouting is null)
{
errors.Add("Cookie routing config required for cookie-based strategy");
}
return new ValidationResult(errors.Count == 0, errors.AsReadOnly());
}
}
public sealed record ValidationResult(
bool IsValid,
IReadOnlyList<string> Errors
);
```
---
## Acceptance Criteria
- [ ] Define traffic router interface
- [ ] Register and discover routers
- [ ] Generate weighted routing config
- [ ] Generate header-based routing config
- [ ] Generate cookie-based routing config
- [ ] Validate routing configurations
- [ ] Store routing state transitions
- [ ] Query active routing states
- [ ] Health check router implementations
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 110_001 A/B Release Manager | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ITrafficRouter | TODO | |
| TrafficRouterRegistry | TODO | |
| RoutingConfig | TODO | |
| WeightedRouting | TODO | |
| HeaderRouting | TODO | |
| CookieRouting | TODO | |
| RoutingStateStore | TODO | |
| RoutingConfigValidator | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,702 @@
# SPRINT: Canary Controller
> **Sprint ID:** 110_003
> **Module:** PROGDL
> **Phase:** 10 - Progressive Delivery
> **Status:** TODO
> **Parent:** [110_000_INDEX](SPRINT_20260110_110_000_INDEX_progressive_delivery.md)
---
## Overview
Implement the Canary Controller for gradual traffic promotion with automatic rollback based on metrics.
### Objectives
- Define canary progression steps
- Auto-advance based on metrics analysis
- Auto-rollback on metric threshold breach
- Manual intervention support
- Configurable promotion schedules
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Progressive/
│ └── Canary/
│ ├── ICanaryController.cs
│ ├── CanaryController.cs
│ ├── CanaryProgressionEngine.cs
│ ├── CanaryMetricsAnalyzer.cs
│ ├── AutoRollback.cs
│ └── Models/
│ ├── CanaryRelease.cs
│ ├── CanaryStep.cs
│ └── CanaryConfig.cs
└── __Tests/
```
---
## Deliverables
### CanaryRelease Model
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Canary.Models;
public sealed record CanaryRelease
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required Guid ReleaseId { get; init; }
public required string ReleaseName { get; init; }
public required Guid EnvironmentId { get; init; }
public required string EnvironmentName { get; init; }
public required CanaryConfig Config { get; init; }
public required ImmutableArray<CanaryStep> Steps { get; init; }
public required int CurrentStepIndex { get; init; }
public required CanaryStatus Status { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required Guid CreatedBy { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CurrentStepStartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public CanaryRollbackInfo? RollbackInfo { get; init; }
public CanaryMetrics? LatestMetrics { get; init; }
}
public enum CanaryStatus
{
Pending,
Running,
WaitingForMetrics,
Advancing,
Paused,
RollingBack,
Completed,
Failed,
Cancelled
}
public sealed record CanaryConfig
{
public required ImmutableArray<CanaryStepConfig> StepConfigs { get; init; }
public required CanaryMetricThresholds Thresholds { get; init; }
public TimeSpan MetricsWindowDuration { get; init; } = TimeSpan.FromMinutes(5);
public TimeSpan MinStepDuration { get; init; } = TimeSpan.FromMinutes(10);
public bool AutoAdvance { get; init; } = true;
public bool AutoRollback { get; init; } = true;
public int MetricCheckIntervalSeconds { get; init; } = 60;
}
public sealed record CanaryStepConfig
{
public required int StepIndex { get; init; }
public required int TrafficPercentage { get; init; }
public TimeSpan? MinDuration { get; init; }
public TimeSpan? MaxDuration { get; init; }
public bool RequireManualApproval { get; init; }
}
public sealed record CanaryStep
{
public required int Index { get; init; }
public required int TrafficPercentage { get; init; }
public required CanaryStepStatus Status { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public CanaryMetrics? MetricsAtStart { get; init; }
public CanaryMetrics? MetricsAtEnd { get; init; }
public string? Notes { get; init; }
}
public enum CanaryStepStatus
{
Pending,
Running,
WaitingApproval,
Completed,
Skipped,
Failed
}
public sealed record CanaryMetricThresholds
{
public double MaxErrorRate { get; init; } = 0.05; // 5%
public double MaxLatencyP99Ms { get; init; } = 1000; // 1 second
public double? MaxLatencyP95Ms { get; init; }
public double? MaxLatencyP50Ms { get; init; }
public double MinSuccessRate { get; init; } = 0.95; // 95%
public ImmutableDictionary<string, double> CustomThresholds { get; init; } = ImmutableDictionary<string, double>.Empty;
}
public sealed record CanaryMetrics
{
public required DateTimeOffset CollectedAt { get; init; }
public required TimeSpan WindowDuration { get; init; }
public required long RequestCount { get; init; }
public required double ErrorRate { get; init; }
public required double SuccessRate { get; init; }
public required double LatencyP50Ms { get; init; }
public required double LatencyP95Ms { get; init; }
public required double LatencyP99Ms { get; init; }
public ImmutableDictionary<string, double> CustomMetrics { get; init; } = ImmutableDictionary<string, double>.Empty;
}
public sealed record CanaryRollbackInfo
{
public required string Reason { get; init; }
public required bool WasAutomatic { get; init; }
public required int RolledBackFromStep { get; init; }
public required CanaryMetrics? MetricsAtRollback { get; init; }
public required DateTimeOffset RolledBackAt { get; init; }
public Guid? TriggeredBy { get; init; }
}
```
### ICanaryController Interface
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Canary;
public interface ICanaryController
{
Task<CanaryRelease> StartAsync(
Guid releaseId,
CanaryConfig config,
CancellationToken ct = default);
Task<CanaryRelease> AdvanceAsync(
Guid canaryId,
CancellationToken ct = default);
Task<CanaryRelease> PauseAsync(
Guid canaryId,
string? reason = null,
CancellationToken ct = default);
Task<CanaryRelease> ResumeAsync(
Guid canaryId,
CancellationToken ct = default);
Task<CanaryRelease> RollbackAsync(
Guid canaryId,
string reason,
CancellationToken ct = default);
Task<CanaryRelease> CompleteAsync(
Guid canaryId,
CancellationToken ct = default);
Task<CanaryRelease> ApproveStepAsync(
Guid canaryId,
int stepIndex,
string? comment = null,
CancellationToken ct = default);
Task<CanaryRelease?> GetAsync(
Guid canaryId,
CancellationToken ct = default);
Task<IReadOnlyList<CanaryRelease>> ListActiveAsync(
Guid? environmentId = null,
CancellationToken ct = default);
Task<CanaryMetrics> GetCurrentMetricsAsync(
Guid canaryId,
CancellationToken ct = default);
}
```
### CanaryController Implementation
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Canary;
public sealed class CanaryController : ICanaryController
{
private readonly ICanaryStore _store;
private readonly IReleaseManager _releaseManager;
private readonly ITrafficRouter _trafficRouter;
private readonly CanaryProgressionEngine _progressionEngine;
private readonly CanaryMetricsAnalyzer _metricsAnalyzer;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ITenantContext _tenantContext;
private readonly IUserContext _userContext;
private readonly ILogger<CanaryController> _logger;
public async Task<CanaryRelease> StartAsync(
Guid releaseId,
CanaryConfig config,
CancellationToken ct = default)
{
var release = await _releaseManager.GetAsync(releaseId, ct)
?? throw new ReleaseNotFoundException(releaseId);
ValidateConfig(config);
var steps = BuildSteps(config);
var canary = new CanaryRelease
{
Id = _guidGenerator.NewGuid(),
TenantId = _tenantContext.TenantId,
ReleaseId = release.Id,
ReleaseName = release.Name,
EnvironmentId = release.EnvironmentId ?? Guid.Empty,
EnvironmentName = release.EnvironmentName ?? "",
Config = config,
Steps = steps,
CurrentStepIndex = 0,
Status = CanaryStatus.Pending,
CreatedAt = _timeProvider.GetUtcNow(),
CreatedBy = _userContext.UserId
};
await _store.SaveAsync(canary, ct);
_logger.LogInformation(
"Created canary release {Id} for release {Release} with {StepCount} steps",
canary.Id,
release.Name,
steps.Length);
// Start first step
return await StartStepAsync(canary, 0, ct);
}
public async Task<CanaryRelease> AdvanceAsync(
Guid canaryId,
CancellationToken ct = default)
{
var canary = await GetRequiredAsync(canaryId, ct);
if (canary.Status != CanaryStatus.Running && canary.Status != CanaryStatus.WaitingForMetrics)
{
throw new InvalidCanaryStateException(canaryId, canary.Status, "Cannot advance");
}
var currentStep = canary.Steps[canary.CurrentStepIndex];
if (currentStep.Status == CanaryStepStatus.WaitingApproval)
{
throw new CanaryStepAwaitingApprovalException(canaryId, canary.CurrentStepIndex);
}
// Check if there are more steps
var nextStepIndex = canary.CurrentStepIndex + 1;
if (nextStepIndex >= canary.Steps.Length)
{
// All steps complete, finalize
return await CompleteAsync(canaryId, ct);
}
// Collect metrics for current step
var currentMetrics = await _metricsAnalyzer.CollectAsync(canary, ct);
// Update current step as completed
var updatedSteps = canary.Steps.SetItem(canary.CurrentStepIndex, currentStep with
{
Status = CanaryStepStatus.Completed,
CompletedAt = _timeProvider.GetUtcNow(),
MetricsAtEnd = currentMetrics
});
canary = canary with
{
Steps = updatedSteps,
LatestMetrics = currentMetrics
};
await _store.SaveAsync(canary, ct);
// Start next step
return await StartStepAsync(canary, nextStepIndex, ct);
}
public async Task<CanaryRelease> RollbackAsync(
Guid canaryId,
string reason,
CancellationToken ct = default)
{
var canary = await GetRequiredAsync(canaryId, ct);
if (canary.Status == CanaryStatus.Completed || canary.Status == CanaryStatus.Failed)
{
throw new InvalidCanaryStateException(canaryId, canary.Status, "Cannot rollback completed canary");
}
canary = canary with { Status = CanaryStatus.RollingBack };
await _store.SaveAsync(canary, ct);
_logger.LogWarning(
"Rolling back canary {Id} from step {Step}: {Reason}",
canaryId,
canary.CurrentStepIndex,
reason);
try
{
// Route 100% traffic back to baseline
await _trafficRouter.ApplyAsync(new RoutingConfig
{
AbReleaseId = canary.Id,
ControlEndpoints = await GetBaselineEndpointsAsync(canary, ct),
TreatmentEndpoints = [],
ControlWeight = 100,
TreatmentWeight = 0
}, ct);
var currentMetrics = await _metricsAnalyzer.CollectAsync(canary, ct);
canary = canary with
{
Status = CanaryStatus.Failed,
CompletedAt = _timeProvider.GetUtcNow(),
RollbackInfo = new CanaryRollbackInfo
{
Reason = reason,
WasAutomatic = false,
RolledBackFromStep = canary.CurrentStepIndex,
MetricsAtRollback = currentMetrics,
RolledBackAt = _timeProvider.GetUtcNow(),
TriggeredBy = _userContext.UserId
}
};
await _store.SaveAsync(canary, ct);
await _eventPublisher.PublishAsync(new CanaryRolledBack(
canary.Id,
canary.ReleaseName,
reason,
canary.CurrentStepIndex,
_timeProvider.GetUtcNow()
), ct);
return canary;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to rollback canary {Id}", canaryId);
throw;
}
}
public async Task<CanaryRelease> CompleteAsync(
Guid canaryId,
CancellationToken ct = default)
{
var canary = await GetRequiredAsync(canaryId, ct);
if (canary.Status != CanaryStatus.Running)
{
throw new InvalidCanaryStateException(canaryId, canary.Status, "Cannot complete");
}
// Verify we're at 100% traffic
var currentStep = canary.Steps[canary.CurrentStepIndex];
if (currentStep.TrafficPercentage != 100)
{
throw new CanaryNotAtFullTrafficException(canaryId, currentStep.TrafficPercentage);
}
var finalMetrics = await _metricsAnalyzer.CollectAsync(canary, ct);
// Mark final step complete
var updatedSteps = canary.Steps.SetItem(canary.CurrentStepIndex, currentStep with
{
Status = CanaryStepStatus.Completed,
CompletedAt = _timeProvider.GetUtcNow(),
MetricsAtEnd = finalMetrics
});
canary = canary with
{
Steps = updatedSteps,
Status = CanaryStatus.Completed,
CompletedAt = _timeProvider.GetUtcNow(),
LatestMetrics = finalMetrics
};
await _store.SaveAsync(canary, ct);
await _eventPublisher.PublishAsync(new CanaryCompleted(
canary.Id,
canary.ReleaseName,
canary.Steps.Length,
_timeProvider.GetUtcNow()
), ct);
_logger.LogInformation(
"Canary {Id} completed successfully after {StepCount} steps",
canaryId,
canary.Steps.Length);
return canary;
}
private async Task<CanaryRelease> StartStepAsync(
CanaryRelease canary,
int stepIndex,
CancellationToken ct)
{
var step = canary.Steps[stepIndex];
var stepConfig = canary.Config.StepConfigs[stepIndex];
_logger.LogInformation(
"Starting canary step {Step} at {Percentage}% traffic",
stepIndex,
step.TrafficPercentage);
// Apply traffic routing
await _trafficRouter.ApplyAsync(new RoutingConfig
{
AbReleaseId = canary.Id,
ControlEndpoints = await GetBaselineEndpointsAsync(canary, ct),
TreatmentEndpoints = await GetCanaryEndpointsAsync(canary, ct),
ControlWeight = 100 - step.TrafficPercentage,
TreatmentWeight = step.TrafficPercentage
}, ct);
var startMetrics = await _metricsAnalyzer.CollectAsync(canary, ct);
var status = stepConfig.RequireManualApproval
? CanaryStepStatus.WaitingApproval
: CanaryStepStatus.Running;
var updatedSteps = canary.Steps.SetItem(stepIndex, step with
{
Status = status,
StartedAt = _timeProvider.GetUtcNow(),
MetricsAtStart = startMetrics
});
canary = canary with
{
Steps = updatedSteps,
CurrentStepIndex = stepIndex,
CurrentStepStartedAt = _timeProvider.GetUtcNow(),
Status = status == CanaryStepStatus.WaitingApproval
? CanaryStatus.WaitingForMetrics
: CanaryStatus.Running,
StartedAt = canary.StartedAt ?? _timeProvider.GetUtcNow(),
LatestMetrics = startMetrics
};
await _store.SaveAsync(canary, ct);
await _eventPublisher.PublishAsync(new CanaryStepStarted(
canary.Id,
stepIndex,
step.TrafficPercentage,
_timeProvider.GetUtcNow()
), ct);
return canary;
}
private static ImmutableArray<CanaryStep> BuildSteps(CanaryConfig config)
{
return config.StepConfigs.Select(c => new CanaryStep
{
Index = c.StepIndex,
TrafficPercentage = c.TrafficPercentage,
Status = CanaryStepStatus.Pending
}).ToImmutableArray();
}
private static void ValidateConfig(CanaryConfig config)
{
if (config.StepConfigs.Length == 0)
{
throw new InvalidCanaryConfigException("At least one step is required");
}
var lastPercentage = 0;
foreach (var step in config.StepConfigs.OrderBy(s => s.StepIndex))
{
if (step.TrafficPercentage <= lastPercentage)
{
throw new InvalidCanaryConfigException("Traffic percentage must increase with each step");
}
lastPercentage = step.TrafficPercentage;
}
if (lastPercentage != 100)
{
throw new InvalidCanaryConfigException("Final step must have 100% traffic");
}
}
private async Task<CanaryRelease> GetRequiredAsync(Guid id, CancellationToken ct)
{
return await _store.GetAsync(id, ct)
?? throw new CanaryNotFoundException(id);
}
private Task<IReadOnlyList<string>> GetBaselineEndpointsAsync(CanaryRelease canary, CancellationToken ct)
{
// Implementation to get baseline/stable version endpoints
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
}
private Task<IReadOnlyList<string>> GetCanaryEndpointsAsync(CanaryRelease canary, CancellationToken ct)
{
// Implementation to get canary version endpoints
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
}
}
```
### AutoRollback
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Canary;
public sealed class AutoRollback : BackgroundService
{
private readonly ICanaryController _canaryController;
private readonly CanaryMetricsAnalyzer _metricsAnalyzer;
private readonly ICanaryStore _store;
private readonly ILogger<AutoRollback> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Auto-rollback service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CheckActiveCanariesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in auto-rollback check");
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
private async Task CheckActiveCanariesAsync(CancellationToken ct)
{
var activeCanaries = await _store.ListByStatusAsync(CanaryStatus.Running, ct);
foreach (var canary in activeCanaries)
{
if (!canary.Config.AutoRollback)
continue;
try
{
await CheckAndRollbackIfNeededAsync(canary, ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error checking canary {Id} for auto-rollback",
canary.Id);
}
}
}
private async Task CheckAndRollbackIfNeededAsync(CanaryRelease canary, CancellationToken ct)
{
var metrics = await _metricsAnalyzer.CollectAsync(canary, ct);
var thresholds = canary.Config.Thresholds;
var violations = new List<string>();
if (metrics.ErrorRate > thresholds.MaxErrorRate)
{
violations.Add($"Error rate {metrics.ErrorRate:P1} exceeds threshold {thresholds.MaxErrorRate:P1}");
}
if (metrics.LatencyP99Ms > thresholds.MaxLatencyP99Ms)
{
violations.Add($"P99 latency {metrics.LatencyP99Ms:F0}ms exceeds threshold {thresholds.MaxLatencyP99Ms:F0}ms");
}
if (metrics.SuccessRate < thresholds.MinSuccessRate)
{
violations.Add($"Success rate {metrics.SuccessRate:P1} below threshold {thresholds.MinSuccessRate:P1}");
}
// Check custom thresholds
foreach (var (metricName, threshold) in thresholds.CustomThresholds)
{
if (metrics.CustomMetrics.TryGetValue(metricName, out var value) && value > threshold)
{
violations.Add($"Custom metric {metricName} ({value:F2}) exceeds threshold ({threshold:F2})");
}
}
if (violations.Count > 0)
{
var reason = $"Auto-rollback triggered: {string.Join("; ", violations)}";
_logger.LogWarning(
"Auto-rolling back canary {Id}: {Reason}",
canary.Id,
reason);
await _canaryController.RollbackAsync(canary.Id, reason, ct);
}
}
}
```
---
## Acceptance Criteria
- [ ] Create canary with progression steps
- [ ] Start canary at initial traffic percentage
- [ ] Advance through steps automatically
- [ ] Wait for manual approval when configured
- [ ] Rollback on metric threshold breach
- [ ] Auto-rollback runs in background
- [ ] Complete canary at 100% traffic
- [ ] Pause and resume canary
- [ ] Track metrics at each step
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 110_001 A/B Release Manager | Internal | TODO |
| 110_002 Traffic Router Framework | Internal | TODO |
| Telemetry | External | Existing |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ICanaryController | TODO | |
| CanaryController | TODO | |
| CanaryProgressionEngine | TODO | |
| CanaryMetricsAnalyzer | TODO | |
| AutoRollback | TODO | |
| CanaryRelease model | TODO | |
| CanaryStep model | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,762 @@
# SPRINT: Router Plugin - Nginx
> **Sprint ID:** 110_004
> **Module:** PROGDL
> **Phase:** 10 - Progressive Delivery
> **Status:** TODO
> **Parent:** [110_000_INDEX](SPRINT_20260110_110_000_INDEX_progressive_delivery.md)
---
## Overview
Implement the Nginx traffic router plugin as the **reference implementation** for progressive delivery traffic splitting. This plugin serves as the primary built-in router and as a template for additional router plugins.
### Router Plugin Catalog
The Release Orchestrator supports multiple traffic router implementations via the `ITrafficRouter` interface:
| Router | Status | Description |
|--------|--------|-------------|
| **Nginx** | **v1 Built-in** | Reference implementation (this sprint) |
| HAProxy | Plugin Example | Sample implementation for plugin developers |
| Traefik | Plugin Example | Sample implementation for plugin developers |
| AWS ALB | Plugin Example | Sample implementation for plugin developers |
| Envoy | Post-v1 | Planned for future release |
> **Plugin Developer Note:** HAProxy, Traefik, and AWS ALB are provided as reference examples in the Plugin SDK (`StellaOps.Plugin.Sdk`) to demonstrate how third parties can implement the `ITrafficRouter` interface. These examples can be found in `src/ReleaseOrchestrator/__Plugins/StellaOps.Plugin.Sdk/Examples/Routers/`. Organizations can implement their own routers for Istio, Linkerd, Kong, or any other traffic management system.
### Objectives
- Generate Nginx upstream configurations
- Generate Nginx split_clients config for weighted routing
- Support header-based routing via map directives
- Hot reload Nginx configuration
- Parse Nginx status for metrics
- Serve as reference implementation for custom router plugins
### Working Directory
```
src/ReleaseOrchestrator/
├── __Libraries/
│ └── StellaOps.ReleaseOrchestrator.Progressive/
│ └── Routers/
│ └── Nginx/
│ ├── NginxRouter.cs
│ ├── NginxConfigGenerator.cs
│ ├── NginxReloader.cs
│ ├── NginxStatusParser.cs
│ └── Templates/
│ ├── upstream.conf.template
│ ├── split_clients.conf.template
│ └── location.conf.template
└── __Tests/
```
---
## Deliverables
### NginxRouter
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Routers.Nginx;
public sealed class NginxRouter : ITrafficRouter
{
private readonly NginxConfigGenerator _configGenerator;
private readonly NginxReloader _reloader;
private readonly NginxStatusParser _statusParser;
private readonly NginxConfiguration _config;
private readonly IRoutingStateStore _stateStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NginxRouter> _logger;
public string RouterType => "nginx";
public IReadOnlyList<string> SupportedStrategies => new[]
{
"weighted",
"header-based",
"cookie-based",
"combined"
};
public async Task<bool> IsAvailableAsync(CancellationToken ct = default)
{
try
{
var configTest = await TestConfigAsync(ct);
return configTest;
}
catch
{
return false;
}
}
public async Task ApplyAsync(
RoutingConfig config,
CancellationToken ct = default)
{
_logger.LogInformation(
"Applying Nginx routing config for {ContextId}: {Control}%/{Treatment}%",
config.AbReleaseId,
config.ControlWeight,
config.TreatmentWeight);
try
{
// Generate configuration files
var nginxConfig = _configGenerator.Generate(config);
// Write configuration files
await WriteConfigFilesAsync(nginxConfig, ct);
// Test configuration
var testResult = await TestConfigAsync(ct);
if (!testResult)
{
throw new NginxConfigurationException("Configuration test failed");
}
// Reload Nginx
await _reloader.ReloadAsync(ct);
// Store state
await _stateStore.SaveAsync(new RoutingState
{
ContextId = config.AbReleaseId,
RouterType = RouterType,
Config = config,
Status = RoutingStateStatus.Applied,
AppliedAt = _timeProvider.GetUtcNow(),
LastVerifiedAt = _timeProvider.GetUtcNow()
}, ct);
_logger.LogInformation(
"Successfully applied Nginx config for {ContextId}",
config.AbReleaseId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to apply Nginx config for {ContextId}",
config.AbReleaseId);
await _stateStore.SaveAsync(new RoutingState
{
ContextId = config.AbReleaseId,
RouterType = RouterType,
Config = config,
Status = RoutingStateStatus.Failed,
AppliedAt = _timeProvider.GetUtcNow(),
LastVerifiedAt = _timeProvider.GetUtcNow(),
Error = ex.Message
}, ct);
throw;
}
}
public async Task<RoutingConfig> GetCurrentAsync(
Guid contextId,
CancellationToken ct = default)
{
var state = await _stateStore.GetAsync(contextId, ct);
if (state is null)
{
throw new RoutingConfigNotFoundException(contextId);
}
return state.Config;
}
public async Task RemoveAsync(
Guid contextId,
CancellationToken ct = default)
{
_logger.LogInformation("Removing Nginx config for {ContextId}", contextId);
// Remove configuration files
var configPath = GetConfigPath(contextId);
if (File.Exists(configPath))
{
File.Delete(configPath);
}
// Reload Nginx
await _reloader.ReloadAsync(ct);
// Update state
await _stateStore.DeleteAsync(contextId, ct);
}
public async Task<bool> HealthCheckAsync(CancellationToken ct = default)
{
try
{
// Check Nginx is running
var isRunning = await CheckNginxRunningAsync(ct);
if (!isRunning)
return false;
// Check config is valid
var configValid = await TestConfigAsync(ct);
return configValid;
}
catch
{
return false;
}
}
public async Task<RouterMetrics> GetMetricsAsync(
Guid contextId,
CancellationToken ct = default)
{
var statusUrl = $"{_config.StatusEndpoint}/status";
return await _statusParser.ParseAsync(statusUrl, contextId, ct);
}
private async Task WriteConfigFilesAsync(NginxConfig config, CancellationToken ct)
{
var basePath = _config.ConfigDirectory;
Directory.CreateDirectory(basePath);
// Write upstream config
var upstreamPath = Path.Combine(basePath, $"upstream-{config.ContextId:N}.conf");
await File.WriteAllTextAsync(upstreamPath, config.UpstreamConfig, ct);
// Write routing config
var routingPath = Path.Combine(basePath, $"routing-{config.ContextId:N}.conf");
await File.WriteAllTextAsync(routingPath, config.RoutingConfig, ct);
// Write location config
var locationPath = Path.Combine(basePath, $"location-{config.ContextId:N}.conf");
await File.WriteAllTextAsync(locationPath, config.LocationConfig, ct);
}
private async Task<bool> TestConfigAsync(CancellationToken ct)
{
var result = await ExecuteNginxCommandAsync("-t", ct);
return result.ExitCode == 0;
}
private async Task<bool> CheckNginxRunningAsync(CancellationToken ct)
{
var result = await ExecuteNginxCommandAsync("-s reload", ct);
return result.ExitCode == 0;
}
private async Task<ProcessResult> ExecuteNginxCommandAsync(string args, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = _config.NginxPath,
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
using var process = Process.Start(psi);
if (process is null)
{
return new ProcessResult(-1, "", "Failed to start process");
}
await process.WaitForExitAsync(ct);
return new ProcessResult(
process.ExitCode,
await process.StandardOutput.ReadToEndAsync(ct),
await process.StandardError.ReadToEndAsync(ct));
}
private string GetConfigPath(Guid contextId)
{
return Path.Combine(_config.ConfigDirectory, $"routing-{contextId:N}.conf");
}
}
public sealed record ProcessResult(int ExitCode, string Stdout, string Stderr);
```
### NginxConfigGenerator
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Routers.Nginx;
public sealed class NginxConfigGenerator
{
private readonly NginxConfiguration _config;
public NginxConfig Generate(RoutingConfig config)
{
var contextId = config.AbReleaseId.ToString("N");
var upstreamConfig = GenerateUpstreams(config, contextId);
var routingConfig = GenerateRouting(config, contextId);
var locationConfig = GenerateLocation(config, contextId);
return new NginxConfig
{
ContextId = config.AbReleaseId,
UpstreamConfig = upstreamConfig,
RoutingConfig = routingConfig,
LocationConfig = locationConfig
};
}
private string GenerateUpstreams(RoutingConfig config, string contextId)
{
var sb = new StringBuilder();
// Control upstream
sb.AppendLine($"upstream control_{contextId} {{");
foreach (var endpoint in config.ControlEndpoints)
{
sb.AppendLine($" server {endpoint};");
}
sb.AppendLine("}");
sb.AppendLine();
// Treatment upstream
if (config.TreatmentEndpoints.Count > 0)
{
sb.AppendLine($"upstream treatment_{contextId} {{");
foreach (var endpoint in config.TreatmentEndpoints)
{
sb.AppendLine($" server {endpoint};");
}
sb.AppendLine("}");
}
return sb.ToString();
}
private string GenerateRouting(RoutingConfig config, string contextId)
{
var sb = new StringBuilder();
switch (config.Strategy)
{
case RoutingStrategy.Weighted:
sb.Append(GenerateWeightedRouting(config, contextId));
break;
case RoutingStrategy.HeaderBased:
sb.Append(GenerateHeaderRouting(config, contextId));
break;
case RoutingStrategy.CookieBased:
sb.Append(GenerateCookieRouting(config, contextId));
break;
case RoutingStrategy.Combined:
sb.Append(GenerateCombinedRouting(config, contextId));
break;
}
return sb.ToString();
}
private string GenerateWeightedRouting(RoutingConfig config, string contextId)
{
var sb = new StringBuilder();
// Use split_clients for weighted distribution
sb.AppendLine($"split_clients \"$request_id\" $ab_upstream_{contextId} {{");
sb.AppendLine($" {config.ControlWeight}% control_{contextId};");
sb.AppendLine($" * treatment_{contextId};");
sb.AppendLine("}");
return sb.ToString();
}
private string GenerateHeaderRouting(RoutingConfig config, string contextId)
{
var header = config.HeaderRouting!;
var sb = new StringBuilder();
// Use map for header-based routing
sb.AppendLine($"map $http_{NormalizeHeaderName(header.HeaderName)} $ab_upstream_{contextId} {{");
sb.AppendLine($" default control_{contextId};");
sb.AppendLine($" \"{header.TreatmentValue}\" treatment_{contextId};");
sb.AppendLine("}");
return sb.ToString();
}
private string GenerateCookieRouting(RoutingConfig config, string contextId)
{
var cookie = config.CookieRouting!;
var sb = new StringBuilder();
// Use map for cookie-based routing
sb.AppendLine($"map $cookie_{cookie.CookieName} $ab_upstream_{contextId} {{");
sb.AppendLine($" default control_{contextId};");
sb.AppendLine($" \"{cookie.TreatmentValue}\" treatment_{contextId};");
sb.AppendLine("}");
return sb.ToString();
}
private string GenerateCombinedRouting(RoutingConfig config, string contextId)
{
var sb = new StringBuilder();
// Check header first, then cookie, then weighted
sb.AppendLine($"# Combined routing for {contextId}");
if (config.HeaderRouting is not null)
{
var header = config.HeaderRouting;
sb.AppendLine($"map $http_{NormalizeHeaderName(header.HeaderName)} $ab_header_{contextId} {{");
sb.AppendLine($" default \"\";");
sb.AppendLine($" \"{header.TreatmentValue}\" treatment_{contextId};");
sb.AppendLine("}");
sb.AppendLine();
}
if (config.CookieRouting is not null)
{
var cookie = config.CookieRouting;
sb.AppendLine($"map $cookie_{cookie.CookieName} $ab_cookie_{contextId} {{");
sb.AppendLine($" default \"\";");
sb.AppendLine($" \"{cookie.TreatmentValue}\" treatment_{contextId};");
sb.AppendLine("}");
sb.AppendLine();
}
// Weighted fallback
sb.AppendLine($"split_clients \"$request_id\" $ab_weighted_{contextId} {{");
sb.AppendLine($" {config.ControlWeight}% control_{contextId};");
sb.AppendLine($" * treatment_{contextId};");
sb.AppendLine("}");
sb.AppendLine();
// Combined decision
sb.AppendLine($"map $ab_header_{contextId}$ab_cookie_{contextId} $ab_upstream_{contextId} {{");
sb.AppendLine($" default $ab_weighted_{contextId};");
sb.AppendLine($" \"~treatment_\" treatment_{contextId};");
sb.AppendLine("}");
return sb.ToString();
}
private string GenerateLocation(RoutingConfig config, string contextId)
{
var sb = new StringBuilder();
sb.AppendLine($"# Location for A/B release {config.AbReleaseId}");
sb.AppendLine($"location @ab_{contextId} {{");
sb.AppendLine($" proxy_pass http://$ab_upstream_{contextId};");
sb.AppendLine($" proxy_set_header Host $host;");
sb.AppendLine($" proxy_set_header X-Real-IP $remote_addr;");
sb.AppendLine($" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;");
sb.AppendLine($" proxy_set_header X-AB-Variant $ab_upstream_{contextId};");
sb.AppendLine($" proxy_set_header X-AB-Release-Id {config.AbReleaseId};");
sb.AppendLine("}");
return sb.ToString();
}
private static string NormalizeHeaderName(string headerName)
{
// Convert header name to Nginx variable format
// X-Canary-Test -> x_canary_test
return headerName.ToLowerInvariant().Replace('-', '_');
}
}
public sealed record NginxConfig
{
public required Guid ContextId { get; init; }
public required string UpstreamConfig { get; init; }
public required string RoutingConfig { get; init; }
public required string LocationConfig { get; init; }
}
```
### NginxReloader
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Routers.Nginx;
public sealed class NginxReloader
{
private readonly NginxConfiguration _config;
private readonly ILogger<NginxReloader> _logger;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
public async Task ReloadAsync(CancellationToken ct = default)
{
await _reloadLock.WaitAsync(ct);
try
{
_logger.LogDebug("Reloading Nginx configuration");
var result = await ExecuteAsync("-s reload", ct);
if (result.ExitCode != 0)
{
_logger.LogError(
"Nginx reload failed: {Stderr}",
result.Stderr);
throw new NginxReloadException(result.Stderr);
}
// Wait for reload to complete
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
// Verify reload
var testResult = await ExecuteAsync("-t", ct);
if (testResult.ExitCode != 0)
{
throw new NginxReloadException("Post-reload test failed");
}
_logger.LogInformation("Nginx configuration reloaded successfully");
}
finally
{
_reloadLock.Release();
}
}
public async Task<bool> TestConfigAsync(CancellationToken ct = default)
{
var result = await ExecuteAsync("-t", ct);
return result.ExitCode == 0;
}
private async Task<ProcessResult> ExecuteAsync(string args, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = _config.NginxPath,
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(ct);
var stderr = await process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
return new ProcessResult(process.ExitCode, stdout, stderr);
}
}
public sealed class NginxReloadException : Exception
{
public NginxReloadException(string message) : base(message) { }
}
```
### NginxStatusParser
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Routers.Nginx;
public sealed class NginxStatusParser
{
private readonly HttpClient _httpClient;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NginxStatusParser> _logger;
public async Task<RouterMetrics> ParseAsync(
string statusUrl,
Guid contextId,
CancellationToken ct = default)
{
try
{
// Get Nginx stub_status or extended status
var response = await _httpClient.GetStringAsync(statusUrl, ct);
// Parse the status response
var status = ParseStatusResponse(response);
// Get upstream-specific metrics if available
var upstreamMetrics = await GetUpstreamMetricsAsync(contextId, ct);
return new RouterMetrics
{
ControlRequests = upstreamMetrics.ControlRequests,
TreatmentRequests = upstreamMetrics.TreatmentRequests,
ControlErrorRate = upstreamMetrics.ControlErrorRate,
TreatmentErrorRate = upstreamMetrics.TreatmentErrorRate,
ControlLatencyP50 = upstreamMetrics.ControlLatencyP50,
TreatmentLatencyP50 = upstreamMetrics.TreatmentLatencyP50,
CollectedAt = _timeProvider.GetUtcNow()
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse Nginx status from {Url}", statusUrl);
return new RouterMetrics
{
ControlRequests = 0,
TreatmentRequests = 0,
ControlErrorRate = 0,
TreatmentErrorRate = 0,
ControlLatencyP50 = 0,
TreatmentLatencyP50 = 0,
CollectedAt = _timeProvider.GetUtcNow()
};
}
}
private NginxStatus ParseStatusResponse(string response)
{
// Parse stub_status format:
// Active connections: 43
// server accepts handled requests
// 7368 7368 10993
// Reading: 0 Writing: 1 Waiting: 42
var lines = response.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var status = new NginxStatus();
foreach (var line in lines)
{
if (line.StartsWith("Active connections:"))
{
var value = line.Split(':')[1].Trim();
status.ActiveConnections = int.Parse(value, CultureInfo.InvariantCulture);
}
else if (line.Contains("Reading:"))
{
var parts = line.Split(new[] { ' ', ':' }, StringSplitOptions.RemoveEmptyEntries);
for (var i = 0; i < parts.Length; i++)
{
switch (parts[i])
{
case "Reading":
status.Reading = int.Parse(parts[i + 1], CultureInfo.InvariantCulture);
break;
case "Writing":
status.Writing = int.Parse(parts[i + 1], CultureInfo.InvariantCulture);
break;
case "Waiting":
status.Waiting = int.Parse(parts[i + 1], CultureInfo.InvariantCulture);
break;
}
}
}
}
return status;
}
private async Task<UpstreamMetrics> GetUpstreamMetricsAsync(Guid contextId, CancellationToken ct)
{
// This would typically query Nginx Plus API or a metrics exporter
// For open source Nginx, we'd use access log analysis or Prometheus metrics
return new UpstreamMetrics
{
ControlRequests = 0,
TreatmentRequests = 0,
ControlErrorRate = 0,
TreatmentErrorRate = 0,
ControlLatencyP50 = 0,
TreatmentLatencyP50 = 0
};
}
}
internal sealed class NginxStatus
{
public int ActiveConnections { get; set; }
public int Accepts { get; set; }
public int Handled { get; set; }
public int Requests { get; set; }
public int Reading { get; set; }
public int Writing { get; set; }
public int Waiting { get; set; }
}
internal sealed class UpstreamMetrics
{
public long ControlRequests { get; set; }
public long TreatmentRequests { get; set; }
public double ControlErrorRate { get; set; }
public double TreatmentErrorRate { get; set; }
public double ControlLatencyP50 { get; set; }
public double TreatmentLatencyP50 { get; set; }
}
```
### NginxConfiguration
```csharp
namespace StellaOps.ReleaseOrchestrator.Progressive.Routers.Nginx;
public sealed class NginxConfiguration
{
public string NginxPath { get; set; } = "/usr/sbin/nginx";
public string ConfigDirectory { get; set; } = "/etc/nginx/conf.d/stella-ab";
public string StatusEndpoint { get; set; } = "http://127.0.0.1:8080";
public TimeSpan ReloadTimeout { get; set; } = TimeSpan.FromSeconds(30);
public bool TestConfigBeforeReload { get; set; } = true;
public bool BackupConfigOnChange { get; set; } = true;
}
```
---
## Acceptance Criteria
- [ ] Generate upstream configuration
- [ ] Generate split_clients for weighted routing
- [ ] Generate map for header-based routing
- [ ] Generate map for cookie-based routing
- [ ] Support combined routing strategies
- [ ] Test configuration before reload
- [ ] Hot reload Nginx configuration
- [ ] Parse Nginx status for metrics
- [ ] Handle reload failures gracefully
- [ ] Unit test coverage >=85%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 110_002 Traffic Router Framework | Internal | TODO |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| NginxRouter | TODO | |
| NginxConfigGenerator | TODO | |
| NginxReloader | TODO | |
| NginxStatusParser | TODO | |
| NginxConfiguration | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 10-Jan-2026 | Added Router Plugin Catalog with HAProxy/Traefik/ALB as plugin reference examples |

View File

@@ -0,0 +1,300 @@
# SPRINT INDEX: Phase 11 - UI Implementation
> **Epic:** Release Orchestrator
> **Phase:** 11 - UI Implementation
> **Batch:** 111
> **Status:** TODO
> **Parent:** [100_000_INDEX](SPRINT_20260110_100_000_INDEX_release_orchestrator.md)
---
## Overview
Phase 11 implements the frontend UI for the Release Orchestrator - Angular-based dashboards, management screens, and workflow editors.
### Objectives
- Dashboard with pipeline overview
- Environment management UI
- Release management UI
- Visual workflow editor
- Promotion and approval UI
- Deployment monitoring UI
- Evidence viewer
---
## Sprint Structure
| Sprint ID | Title | Module | Status | Dependencies |
|-----------|-------|--------|--------|--------------|
| 111_001 | Dashboard - Overview | FE | TODO | 107_001 |
| 111_002 | Environment Management UI | FE | TODO | 103_001 |
| 111_003 | Release Management UI | FE | TODO | 104_003 |
| 111_004 | Workflow Editor | FE | TODO | 105_001 |
| 111_005 | Promotion & Approval UI | FE | TODO | 106_001 |
| 111_006 | Deployment Monitoring UI | FE | TODO | 107_001 |
| 111_007 | Evidence Viewer | FE | TODO | 109_002 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ UI IMPLEMENTATION │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DASHBOARD (111_001) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Pipeline Overview │ │ │
│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │
│ │ │ │ DEV │──►│STAGE │──►│ UAT │──►│ PROD │ │ │ │
│ │ │ │ ✓ 3 │ │ ✓ 2 │ │ ⟳ 1 │ │ ○ 0 │ │ │ │
│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Pending │ │ Active │ │ Recent │ │ │
│ │ │ Approvals │ │ Deployments │ │ Releases │ │ │
│ │ │ (5) │ │ (2) │ │ (12) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ ENVIRONMENT MANAGEMENT (111_002) │ │
│ │ │ │
│ │ Environments │ Environment: Production │ │
│ │ ├── Development │ ┌───────────────────────────────────────────────┐ │ │
│ │ ├── Staging │ │ Targets (4) │ Settings │ │ │
│ │ ├── UAT │ │ ├── prod-web-01 │ Required Approvals: 2 │ │ │
│ │ └── Production◄─┤ │ ├── prod-web-02 │ Freeze Windows: 1 │ │ │
│ │ │ │ ├── prod-api-01 │ Auto-promote: disabled │ │ │
│ │ │ │ └── prod-api-02 │ SoD: enabled │ │ │
│ │ │ └───────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ WORKFLOW EDITOR (111_004) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Visual DAG Editor │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────┐ │ │ │
│ │ │ │ Security │ │ │ │
│ │ │ │ Gate │ │ │ │
│ │ │ └────┬─────┘ │ │ │
│ │ │ │ │ │ │
│ │ │ ┌────▼─────┐ │ │ │
│ │ │ │ Approval │ [ Step Palette ] │ │ │
│ │ │ └────┬─────┘ ├── Script │ │ │
│ │ │ │ ├── Approval │ │ │
│ │ │ ┌────────┼────────┐ ├── Deploy │ │ │
│ │ │ ▼ ▼ ├── Notify │ │ │
│ │ │ ┌──────┐ ┌──────┐└── Gate │ │ │
│ │ │ │Deploy│ │Smoke │ │ │ │
│ │ │ └──┬───┘ └──┬───┘ │ │ │
│ │ │ └───────┬───────┘ │ │ │
│ │ │ ▼ │ │ │
│ │ │ ┌─────────┐ │ │ │
│ │ │ │ Notify │ │ │ │
│ │ │ └─────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DEPLOYMENT MONITORING (111_006) │ │
│ │ │ │
│ │ Deployment: myapp-v2.3.1 → Production │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Progress: 75% ████████████████████░░░░░░░ │ │ │
│ │ │ │ │ │
│ │ │ Target Status Duration Agent │ │ │
│ │ │ prod-web-01 ✓ Done 2m 15s agent-01 │ │ │
│ │ │ prod-web-02 ✓ Done 2m 08s agent-01 │ │ │
│ │ │ prod-api-01 ⟳ Running 1m 45s agent-02 │ │ │
│ │ │ prod-api-02 ○ Pending - - │ │ │
│ │ │ │ │ │
│ │ │ [View Logs] [Cancel] [Rollback] │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Deliverables Summary
### 111_001: Dashboard - Overview
| Deliverable | Type | Description |
|-------------|------|-------------|
| `DashboardComponent` | Angular | Main dashboard |
| `PipelineOverview` | Component | Environment pipeline |
| `PendingApprovals` | Component | Approval queue |
| `ActiveDeployments` | Component | Running deployments |
| `RecentReleases` | Component | Release list |
### 111_002: Environment Management UI
| Deliverable | Type | Description |
|-------------|------|-------------|
| `EnvironmentListComponent` | Angular | Environment list |
| `EnvironmentDetailComponent` | Angular | Environment detail |
| `TargetListComponent` | Component | Target management |
| `FreezeWindowEditor` | Component | Freeze window config |
### 111_003: Release Management UI
| Deliverable | Type | Description |
|-------------|------|-------------|
| `ReleaseListComponent` | Angular | Release catalog |
| `ReleaseDetailComponent` | Angular | Release detail |
| `CreateReleaseWizard` | Component | Release creation |
| `ComponentSelector` | Component | Add components |
### 111_004: Workflow Editor
| Deliverable | Type | Description |
|-------------|------|-------------|
| `WorkflowEditorComponent` | Angular | DAG editor |
| `StepPalette` | Component | Available steps |
| `StepConfigPanel` | Component | Step configuration |
| `DagCanvas` | Component | Visual DAG |
| `YamlEditor` | Component | Raw YAML editing |
### 111_005: Promotion & Approval UI
| Deliverable | Type | Description |
|-------------|------|-------------|
| `PromotionRequestComponent` | Angular | Request promotion |
| `ApprovalQueueComponent` | Angular | Pending approvals |
| `ApprovalDetailComponent` | Angular | Approval action |
| `GateResultsPanel` | Component | Gate status |
### 111_006: Deployment Monitoring UI
| Deliverable | Type | Description |
|-------------|------|-------------|
| `DeploymentMonitorComponent` | Angular | Deployment status |
| `TargetProgressList` | Component | Per-target progress |
| `LogStreamViewer` | Component | Real-time logs |
| `RollbackDialog` | Component | Rollback confirmation |
### 111_007: Evidence Viewer
| Deliverable | Type | Description |
|-------------|------|-------------|
| `EvidenceListComponent` | Angular | Evidence packets |
| `EvidenceDetailComponent` | Angular | Evidence detail |
| `EvidenceVerifier` | Component | Verify signature |
| `ExportDialog` | Component | Export options |
---
## Component Library
```typescript
// Shared UI Components
@NgModule({
declarations: [
// Layout
PageHeaderComponent,
SideNavComponent,
BreadcrumbsComponent,
// Data Display
StatusBadgeComponent,
ProgressBarComponent,
TimelineComponent,
DataTableComponent,
// Forms
SearchInputComponent,
FilterPanelComponent,
JsonEditorComponent,
// Feedback
ToastNotificationComponent,
ConfirmDialogComponent,
LoadingSpinnerComponent,
// Domain
EnvironmentBadgeComponent,
ReleaseStatusComponent,
GateStatusIconComponent,
DigestDisplayComponent
]
})
export class SharedUiModule {}
```
---
## State Management
```typescript
// NgRx Store Structure
interface AppState {
environments: EnvironmentsState;
releases: ReleasesState;
promotions: PromotionsState;
deployments: DeploymentsState;
evidence: EvidenceState;
ui: UiState;
}
// Actions Pattern
export const EnvironmentActions = createActionGroup({
source: 'Environments',
events: {
'Load Environments': emptyProps(),
'Load Environments Success': props<{ environments: Environment[] }>(),
'Load Environments Failure': props<{ error: string }>(),
'Select Environment': props<{ id: string }>(),
'Create Environment': props<{ request: CreateEnvironmentRequest }>(),
'Update Environment': props<{ id: string; request: UpdateEnvironmentRequest }>(),
'Delete Environment': props<{ id: string }>()
}
});
```
---
## Dependencies
| Module | Purpose |
|--------|---------|
| All backend APIs | Data source |
| Angular 17 | Framework |
| NgRx | State management |
| PrimeNG | UI components |
| Monaco Editor | YAML/JSON editing |
| D3.js | DAG visualization |
---
## Acceptance Criteria
- [ ] Dashboard loads quickly (<2s)
- [ ] Environment CRUD works
- [ ] Target health displayed
- [ ] Release creation wizard works
- [ ] Workflow editor saves correctly
- [ ] DAG visualization renders
- [ ] Approval flow works end-to-end
- [ ] Deployment progress updates real-time
- [ ] Log streaming works
- [ ] Evidence verification shows result
- [ ] Export downloads file
- [ ] Responsive on tablet/desktop
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Phase 11 index created |

View File

@@ -0,0 +1,792 @@
# SPRINT: Dashboard - Overview
> **Sprint ID:** 111_001
> **Module:** FE
> **Phase:** 11 - UI Implementation
> **Status:** TODO
> **Parent:** [111_000_INDEX](SPRINT_20260110_111_000_INDEX_ui_implementation.md)
---
## Overview
Implement the main Release Orchestrator dashboard providing at-a-glance visibility into pipeline status, pending approvals, active deployments, and recent releases.
### Objectives
- Pipeline overview showing environments progression
- Pending approvals count and quick actions
- Active deployments with progress indicators
- Recent releases list
- Real-time updates via SignalR
### Working Directory
```
src/Web/StellaOps.Web/
├── src/app/features/release-orchestrator/
│ └── dashboard/
│ ├── dashboard.component.ts
│ ├── dashboard.component.html
│ ├── dashboard.component.scss
│ ├── dashboard.routes.ts
│ ├── components/
│ │ ├── pipeline-overview/
│ │ │ ├── pipeline-overview.component.ts
│ │ │ ├── pipeline-overview.component.html
│ │ │ └── pipeline-overview.component.scss
│ │ ├── pending-approvals/
│ │ │ ├── pending-approvals.component.ts
│ │ │ ├── pending-approvals.component.html
│ │ │ └── pending-approvals.component.scss
│ │ ├── active-deployments/
│ │ │ ├── active-deployments.component.ts
│ │ │ ├── active-deployments.component.html
│ │ │ └── active-deployments.component.scss
│ │ └── recent-releases/
│ │ ├── recent-releases.component.ts
│ │ ├── recent-releases.component.html
│ │ └── recent-releases.component.scss
│ └── services/
│ └── dashboard.service.ts
└── src/app/store/release-orchestrator/
└── dashboard/
├── dashboard.actions.ts
├── dashboard.reducer.ts
├── dashboard.effects.ts
└── dashboard.selectors.ts
```
---
## Deliverables
### Dashboard Component
```typescript
// dashboard.component.ts
import { Component, OnInit, OnDestroy, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { Subject, takeUntil } from 'rxjs';
import { PipelineOverviewComponent } from './components/pipeline-overview/pipeline-overview.component';
import { PendingApprovalsComponent } from './components/pending-approvals/pending-approvals.component';
import { ActiveDeploymentsComponent } from './components/active-deployments/active-deployments.component';
import { RecentReleasesComponent } from './components/recent-releases/recent-releases.component';
import { DashboardActions } from '@store/release-orchestrator/dashboard/dashboard.actions';
import * as DashboardSelectors from '@store/release-orchestrator/dashboard/dashboard.selectors';
@Component({
selector: 'so-dashboard',
standalone: true,
imports: [
CommonModule,
PipelineOverviewComponent,
PendingApprovalsComponent,
ActiveDeploymentsComponent,
RecentReleasesComponent
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent implements OnInit, OnDestroy {
private readonly store = inject(Store);
private readonly destroy$ = new Subject<void>();
readonly loading$ = this.store.select(DashboardSelectors.selectLoading);
readonly error$ = this.store.select(DashboardSelectors.selectError);
readonly pipelineData$ = this.store.select(DashboardSelectors.selectPipelineData);
readonly pendingApprovals$ = this.store.select(DashboardSelectors.selectPendingApprovals);
readonly activeDeployments$ = this.store.select(DashboardSelectors.selectActiveDeployments);
readonly recentReleases$ = this.store.select(DashboardSelectors.selectRecentReleases);
readonly lastUpdated$ = this.store.select(DashboardSelectors.selectLastUpdated);
ngOnInit(): void {
this.store.dispatch(DashboardActions.loadDashboard());
this.store.dispatch(DashboardActions.subscribeToUpdates());
}
ngOnDestroy(): void {
this.store.dispatch(DashboardActions.unsubscribeFromUpdates());
this.destroy$.next();
this.destroy$.complete();
}
onRefresh(): void {
this.store.dispatch(DashboardActions.loadDashboard());
}
}
```
```html
<!-- dashboard.component.html -->
<div class="dashboard">
<header class="dashboard__header">
<h1>Release Orchestrator</h1>
<div class="dashboard__actions">
<span class="last-updated" *ngIf="lastUpdated$ | async as lastUpdated">
Last updated: {{ lastUpdated | date:'medium' }}
</span>
<button class="btn btn--icon" (click)="onRefresh()" [disabled]="loading$ | async">
<i class="pi pi-refresh" [class.pi-spin]="loading$ | async"></i>
</button>
</div>
</header>
<div class="dashboard__error" *ngIf="error$ | async as error">
<p-message severity="error" [text]="error"></p-message>
</div>
<div class="dashboard__content">
<section class="dashboard__pipeline">
<so-pipeline-overview
[data]="pipelineData$ | async"
[loading]="loading$ | async">
</so-pipeline-overview>
</section>
<div class="dashboard__widgets">
<section class="dashboard__widget">
<so-pending-approvals
[approvals]="pendingApprovals$ | async"
[loading]="loading$ | async">
</so-pending-approvals>
</section>
<section class="dashboard__widget">
<so-active-deployments
[deployments]="activeDeployments$ | async"
[loading]="loading$ | async">
</so-active-deployments>
</section>
<section class="dashboard__widget dashboard__widget--wide">
<so-recent-releases
[releases]="recentReleases$ | async"
[loading]="loading$ | async">
</so-recent-releases>
</section>
</div>
</div>
</div>
```
### Pipeline Overview Component
```typescript
// pipeline-overview.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
export interface PipelineEnvironment {
id: string;
name: string;
order: number;
releaseCount: number;
pendingCount: number;
healthStatus: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
}
export interface PipelineData {
environments: PipelineEnvironment[];
connections: Array<{ from: string; to: string }>;
}
@Component({
selector: 'so-pipeline-overview',
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './pipeline-overview.component.html',
styleUrl: './pipeline-overview.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PipelineOverviewComponent {
@Input() data: PipelineData | null = null;
@Input() loading = false;
getStatusIcon(status: string): string {
switch (status) {
case 'healthy': return 'pi-check-circle';
case 'degraded': return 'pi-exclamation-triangle';
case 'unhealthy': return 'pi-times-circle';
default: return 'pi-question-circle';
}
}
getStatusClass(status: string): string {
return `env-card--${status}`;
}
}
```
```html
<!-- pipeline-overview.component.html -->
<div class="pipeline-overview">
<h2 class="pipeline-overview__title">Pipeline Overview</h2>
<div class="pipeline-overview__content" *ngIf="!loading && data; else loadingTpl">
<div class="pipeline-overview__flow">
<ng-container *ngFor="let env of data.environments; let last = last">
<a [routerLink]="['/environments', env.id]" class="env-card" [ngClass]="getStatusClass(env.healthStatus)">
<div class="env-card__header">
<i class="pi" [ngClass]="getStatusIcon(env.healthStatus)"></i>
<span class="env-card__name">{{ env.name }}</span>
</div>
<div class="env-card__stats">
<span class="env-card__count">{{ env.releaseCount }}</span>
<span class="env-card__label">releases</span>
</div>
<div class="env-card__pending" *ngIf="env.pendingCount > 0">
{{ env.pendingCount }} pending
</div>
</a>
<div class="pipeline-overview__arrow" *ngIf="!last">
<i class="pi pi-arrow-right"></i>
</div>
</ng-container>
</div>
</div>
<ng-template #loadingTpl>
<div class="pipeline-overview__skeleton">
<p-skeleton width="150px" height="100px" *ngFor="let i of [1,2,3,4]"></p-skeleton>
</div>
</ng-template>
</div>
```
### Pending Approvals Component
```typescript
// pending-approvals.component.ts
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
export interface PendingApproval {
id: string;
releaseId: string;
releaseName: string;
sourceEnvironment: string;
targetEnvironment: string;
requestedBy: string;
requestedAt: Date;
urgency: 'low' | 'normal' | 'high' | 'critical';
}
@Component({
selector: 'so-pending-approvals',
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './pending-approvals.component.html',
styleUrl: './pending-approvals.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PendingApprovalsComponent {
@Input() approvals: PendingApproval[] | null = null;
@Input() loading = false;
@Output() approve = new EventEmitter<string>();
@Output() reject = new EventEmitter<string>();
getUrgencyClass(urgency: string): string {
return `approval--${urgency}`;
}
onQuickApprove(event: Event, id: string): void {
event.preventDefault();
event.stopPropagation();
this.approve.emit(id);
}
onQuickReject(event: Event, id: string): void {
event.preventDefault();
event.stopPropagation();
this.reject.emit(id);
}
}
```
```html
<!-- pending-approvals.component.html -->
<div class="pending-approvals">
<div class="pending-approvals__header">
<h3>Pending Approvals</h3>
<span class="badge" *ngIf="approvals?.length">{{ approvals?.length }}</span>
</div>
<div class="pending-approvals__list" *ngIf="!loading && approvals; else loadingTpl">
<div *ngIf="approvals.length === 0" class="pending-approvals__empty">
<i class="pi pi-check-circle"></i>
<p>No pending approvals</p>
</div>
<a *ngFor="let approval of approvals"
[routerLink]="['/approvals', approval.id]"
class="approval-item"
[ngClass]="getUrgencyClass(approval.urgency)">
<div class="approval-item__content">
<span class="approval-item__release">{{ approval.releaseName }}</span>
<span class="approval-item__flow">
{{ approval.sourceEnvironment }} -> {{ approval.targetEnvironment }}
</span>
<span class="approval-item__meta">
Requested by {{ approval.requestedBy }} {{ approval.requestedAt | date:'short' }}
</span>
</div>
<div class="approval-item__actions">
<button class="btn btn--sm btn--success" (click)="onQuickApprove($event, approval.id)">
<i class="pi pi-check"></i>
</button>
<button class="btn btn--sm btn--danger" (click)="onQuickReject($event, approval.id)">
<i class="pi pi-times"></i>
</button>
</div>
</a>
</div>
<ng-template #loadingTpl>
<p-skeleton height="60px" *ngFor="let i of [1,2,3]" styleClass="mb-2"></p-skeleton>
</ng-template>
</div>
```
### Active Deployments Component
```typescript
// active-deployments.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
export interface ActiveDeployment {
id: string;
releaseId: string;
releaseName: string;
environment: string;
progress: number;
status: 'running' | 'paused' | 'waiting';
startedAt: Date;
completedTargets: number;
totalTargets: number;
}
@Component({
selector: 'so-active-deployments',
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './active-deployments.component.html',
styleUrl: './active-deployments.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ActiveDeploymentsComponent {
@Input() deployments: ActiveDeployment[] | null = null;
@Input() loading = false;
getStatusIcon(status: string): string {
switch (status) {
case 'running': return 'pi-spin pi-spinner';
case 'paused': return 'pi-pause';
case 'waiting': return 'pi-clock';
default: return 'pi-question';
}
}
getDuration(startedAt: Date): string {
const diff = Date.now() - new Date(startedAt).getTime();
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
```
```html
<!-- active-deployments.component.html -->
<div class="active-deployments">
<div class="active-deployments__header">
<h3>Active Deployments</h3>
<span class="badge badge--info" *ngIf="deployments?.length">{{ deployments?.length }}</span>
</div>
<div class="active-deployments__list" *ngIf="!loading && deployments; else loadingTpl">
<div *ngIf="deployments.length === 0" class="active-deployments__empty">
<i class="pi pi-server"></i>
<p>No active deployments</p>
</div>
<a *ngFor="let deployment of deployments"
[routerLink]="['/deployments', deployment.id]"
class="deployment-item">
<div class="deployment-item__header">
<i class="pi" [ngClass]="getStatusIcon(deployment.status)"></i>
<span class="deployment-item__name">{{ deployment.releaseName }}</span>
<span class="deployment-item__env">{{ deployment.environment }}</span>
</div>
<div class="deployment-item__progress">
<p-progressBar [value]="deployment.progress" [showValue]="false"></p-progressBar>
<span class="deployment-item__stats">
{{ deployment.completedTargets }}/{{ deployment.totalTargets }} targets
</span>
</div>
<div class="deployment-item__duration">
{{ getDuration(deployment.startedAt) }}
</div>
</a>
</div>
<ng-template #loadingTpl>
<p-skeleton height="80px" *ngFor="let i of [1,2]" styleClass="mb-2"></p-skeleton>
</ng-template>
</div>
```
### Recent Releases Component
```typescript
// recent-releases.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
export interface RecentRelease {
id: string;
name: string;
version: string;
status: 'draft' | 'ready' | 'deploying' | 'deployed' | 'failed' | 'rolled_back';
currentEnvironment: string | null;
createdAt: Date;
createdBy: string;
componentCount: number;
}
@Component({
selector: 'so-recent-releases',
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './recent-releases.component.html',
styleUrl: './recent-releases.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RecentReleasesComponent {
@Input() releases: RecentRelease[] | null = null;
@Input() loading = false;
getStatusBadgeClass(status: string): string {
const map: Record<string, string> = {
draft: 'badge--secondary',
ready: 'badge--info',
deploying: 'badge--warning',
deployed: 'badge--success',
failed: 'badge--danger',
rolled_back: 'badge--warning'
};
return map[status] || 'badge--secondary';
}
formatStatus(status: string): string {
return status.replace('_', ' ').replace(/\b\w/g, c => c.toUpperCase());
}
}
```
```html
<!-- recent-releases.component.html -->
<div class="recent-releases">
<div class="recent-releases__header">
<h3>Recent Releases</h3>
<a routerLink="/releases" class="link">View all</a>
</div>
<div class="recent-releases__table" *ngIf="!loading && releases; else loadingTpl">
<p-table [value]="releases" [paginator]="false" styleClass="p-datatable-sm">
<ng-template pTemplate="header">
<tr>
<th>Release</th>
<th>Status</th>
<th>Environment</th>
<th>Components</th>
<th>Created</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-release>
<tr>
<td>
<a [routerLink]="['/releases', release.id]" class="release-link">
{{ release.name }} <span class="version">{{ release.version }}</span>
</a>
</td>
<td>
<span class="badge" [ngClass]="getStatusBadgeClass(release.status)">
{{ formatStatus(release.status) }}
</span>
</td>
<td>{{ release.currentEnvironment || '-' }}</td>
<td>{{ release.componentCount }}</td>
<td>{{ release.createdAt | date:'short' }}</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="5" class="text-center">No releases found</td>
</tr>
</ng-template>
</p-table>
</div>
<ng-template #loadingTpl>
<p-skeleton height="200px"></p-skeleton>
</ng-template>
</div>
```
### Dashboard Store
```typescript
// dashboard.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { PipelineData, PendingApproval, ActiveDeployment, RecentRelease } from '../models';
export const DashboardActions = createActionGroup({
source: 'Dashboard',
events: {
'Load Dashboard': emptyProps(),
'Load Dashboard Success': props<{
pipelineData: PipelineData;
pendingApprovals: PendingApproval[];
activeDeployments: ActiveDeployment[];
recentReleases: RecentRelease[];
}>(),
'Load Dashboard Failure': props<{ error: string }>(),
'Subscribe To Updates': emptyProps(),
'Unsubscribe From Updates': emptyProps(),
'Update Pipeline': props<{ pipelineData: PipelineData }>(),
'Update Approvals': props<{ approvals: PendingApproval[] }>(),
'Update Deployments': props<{ deployments: ActiveDeployment[] }>(),
'Update Releases': props<{ releases: RecentRelease[] }>(),
}
});
// dashboard.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { DashboardActions } from './dashboard.actions';
export interface DashboardState {
pipelineData: PipelineData | null;
pendingApprovals: PendingApproval[];
activeDeployments: ActiveDeployment[];
recentReleases: RecentRelease[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
}
const initialState: DashboardState = {
pipelineData: null,
pendingApprovals: [],
activeDeployments: [],
recentReleases: [],
loading: false,
error: null,
lastUpdated: null
};
export const dashboardReducer = createReducer(
initialState,
on(DashboardActions.loadDashboard, (state) => ({
...state,
loading: true,
error: null
})),
on(DashboardActions.loadDashboardSuccess, (state, { pipelineData, pendingApprovals, activeDeployments, recentReleases }) => ({
...state,
pipelineData,
pendingApprovals,
activeDeployments,
recentReleases,
loading: false,
lastUpdated: new Date()
})),
on(DashboardActions.loadDashboardFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
on(DashboardActions.updatePipeline, (state, { pipelineData }) => ({
...state,
pipelineData,
lastUpdated: new Date()
})),
on(DashboardActions.updateApprovals, (state, { approvals }) => ({
...state,
pendingApprovals: approvals,
lastUpdated: new Date()
})),
on(DashboardActions.updateDeployments, (state, { deployments }) => ({
...state,
activeDeployments: deployments,
lastUpdated: new Date()
})),
on(DashboardActions.updateReleases, (state, { releases }) => ({
...state,
recentReleases: releases,
lastUpdated: new Date()
}))
);
// dashboard.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { DashboardState } from './dashboard.reducer';
export const selectDashboardState = createFeatureSelector<DashboardState>('dashboard');
export const selectLoading = createSelector(selectDashboardState, state => state.loading);
export const selectError = createSelector(selectDashboardState, state => state.error);
export const selectPipelineData = createSelector(selectDashboardState, state => state.pipelineData);
export const selectPendingApprovals = createSelector(selectDashboardState, state => state.pendingApprovals);
export const selectActiveDeployments = createSelector(selectDashboardState, state => state.activeDeployments);
export const selectRecentReleases = createSelector(selectDashboardState, state => state.recentReleases);
export const selectLastUpdated = createSelector(selectDashboardState, state => state.lastUpdated);
export const selectPendingApprovalCount = createSelector(selectPendingApprovals, approvals => approvals.length);
export const selectActiveDeploymentCount = createSelector(selectActiveDeployments, deployments => deployments.length);
```
### Dashboard Service
```typescript
// dashboard.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, Subject, takeUntil } from 'rxjs';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { environment } from '@env/environment';
export interface DashboardData {
pipelineData: PipelineData;
pendingApprovals: PendingApproval[];
activeDeployments: ActiveDeployment[];
recentReleases: RecentRelease[];
}
@Injectable({ providedIn: 'root' })
export class DashboardService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/v1/release-orchestrator/dashboard`;
private hubConnection: HubConnection | null = null;
private readonly updates$ = new Subject<Partial<DashboardData>>();
getDashboardData(): Observable<DashboardData> {
return this.http.get<DashboardData>(this.baseUrl);
}
subscribeToUpdates(): Observable<Partial<DashboardData>> {
if (!this.hubConnection) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(`${environment.apiUrl}/hubs/dashboard`)
.withAutomaticReconnect()
.build();
this.hubConnection.on('PipelineUpdated', (data) => {
this.updates$.next({ pipelineData: data });
});
this.hubConnection.on('ApprovalsUpdated', (data) => {
this.updates$.next({ pendingApprovals: data });
});
this.hubConnection.on('DeploymentsUpdated', (data) => {
this.updates$.next({ activeDeployments: data });
});
this.hubConnection.on('ReleasesUpdated', (data) => {
this.updates$.next({ recentReleases: data });
});
this.hubConnection.start().catch(err => console.error('SignalR connection error:', err));
}
return this.updates$.asObservable();
}
unsubscribeFromUpdates(): void {
if (this.hubConnection) {
this.hubConnection.stop();
this.hubConnection = null;
}
}
}
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/api/websockets.md` | Markdown | WebSocket/SSE endpoint documentation for real-time updates (workflow runs, deployments, dashboard metrics, agent tasks) |
| `docs/modules/release-orchestrator/ui/dashboard.md` | Markdown | Dashboard specification with layout, metrics, TypeScript interfaces |
---
## Acceptance Criteria
### Code
- [ ] Dashboard loads within 2 seconds
- [ ] Pipeline overview shows all environments
- [ ] Environment health status displayed correctly
- [ ] Pending approvals show count badge
- [ ] Quick approve/reject actions work
- [ ] Active deployments show progress
- [ ] Recent releases table paginated
- [ ] Real-time updates via SignalR
- [ ] Loading skeletons shown during fetch
- [ ] Error messages displayed appropriately
- [ ] Responsive layout on tablet/desktop
- [ ] Unit test coverage >=80%
### Documentation
- [ ] WebSocket API documentation file created
- [ ] All 4 real-time streams documented (workflow, deployment, dashboard, agent)
- [ ] WebSocket authentication flow documented
- [ ] Message format schemas included
- [ ] Dashboard specification file created
- [ ] Dashboard layout diagram included
- [ ] Metrics TypeScript interfaces documented
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_001 Platform API Gateway | Internal | TODO |
| Angular 17 | External | Available |
| NgRx 17 | External | Available |
| PrimeNG 17 | External | Available |
| SignalR Client | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| DashboardComponent | TODO | |
| PipelineOverviewComponent | TODO | |
| PendingApprovalsComponent | TODO | |
| ActiveDeploymentsComponent | TODO | |
| RecentReleasesComponent | TODO | |
| Dashboard NgRx Store | TODO | |
| DashboardService | TODO | |
| SignalR integration | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverables: api/websockets.md, ui/dashboard.md |

View File

@@ -0,0 +1,993 @@
# SPRINT: Environment Management UI
> **Sprint ID:** 111_002
> **Module:** FE
> **Phase:** 11 - UI Implementation
> **Status:** TODO
> **Parent:** [111_000_INDEX](SPRINT_20260110_111_000_INDEX_ui_implementation.md)
---
## Overview
Implement the Environment Management UI providing CRUD operations for environments, target management, freeze window configuration, and environment settings.
### Objectives
- Environment list with hierarchy visualization
- Environment detail with targets and settings
- Target management (add/remove/health)
- Freeze window editor
- Environment settings configuration
### Working Directory
```
src/Web/StellaOps.Web/
├── src/app/features/release-orchestrator/
│ └── environments/
│ ├── environment-list/
│ │ ├── environment-list.component.ts
│ │ ├── environment-list.component.html
│ │ └── environment-list.component.scss
│ ├── environment-detail/
│ │ ├── environment-detail.component.ts
│ │ ├── environment-detail.component.html
│ │ └── environment-detail.component.scss
│ ├── components/
│ │ ├── target-list/
│ │ ├── target-form/
│ │ ├── freeze-window-editor/
│ │ ├── environment-settings/
│ │ └── environment-form/
│ ├── services/
│ │ └── environment.service.ts
│ └── environments.routes.ts
└── src/app/store/release-orchestrator/
└── environments/
├── environments.actions.ts
├── environments.reducer.ts
├── environments.effects.ts
└── environments.selectors.ts
```
---
## Deliverables
### Environment List Component
```typescript
// environment-list.component.ts
import { Component, OnInit, inject, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { Store } from '@ngrx/store';
import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
import { ConfirmationService } from 'primeng/api';
import { EnvironmentActions } from '@store/release-orchestrator/environments/environments.actions';
import * as EnvironmentSelectors from '@store/release-orchestrator/environments/environments.selectors';
import { EnvironmentFormComponent } from '../components/environment-form/environment-form.component';
export interface Environment {
id: string;
name: string;
description: string;
order: number;
isProduction: boolean;
targetCount: number;
healthyTargetCount: number;
requiresApproval: boolean;
requiredApprovers: number;
freezeWindowCount: number;
activeFreezeWindow: boolean;
createdAt: Date;
updatedAt: Date;
}
@Component({
selector: 'so-environment-list',
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './environment-list.component.html',
styleUrl: './environment-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [DialogService, ConfirmationService]
})
export class EnvironmentListComponent implements OnInit {
private readonly store = inject(Store);
private readonly dialogService = inject(DialogService);
private readonly confirmationService = inject(ConfirmationService);
readonly environments$ = this.store.select(EnvironmentSelectors.selectAllEnvironments);
readonly loading$ = this.store.select(EnvironmentSelectors.selectLoading);
readonly error$ = this.store.select(EnvironmentSelectors.selectError);
searchTerm = signal('');
ngOnInit(): void {
this.store.dispatch(EnvironmentActions.loadEnvironments());
}
onSearch(term: string): void {
this.searchTerm.set(term);
}
onCreate(): void {
const ref = this.dialogService.open(EnvironmentFormComponent, {
header: 'Create Environment',
width: '600px',
data: { mode: 'create' }
});
ref.onClose.subscribe((result) => {
if (result) {
this.store.dispatch(EnvironmentActions.createEnvironment({ request: result }));
}
});
}
onDelete(env: Environment): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${env.name}"? This action cannot be undone.`,
header: 'Delete Environment',
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.store.dispatch(EnvironmentActions.deleteEnvironment({ id: env.id }));
}
});
}
getHealthPercentage(env: Environment): number {
if (env.targetCount === 0) return 100;
return Math.round((env.healthyTargetCount / env.targetCount) * 100);
}
getHealthClass(env: Environment): string {
const pct = this.getHealthPercentage(env);
if (pct >= 90) return 'health--good';
if (pct >= 70) return 'health--warning';
return 'health--critical';
}
}
```
```html
<!-- environment-list.component.html -->
<div class="environment-list">
<header class="environment-list__header">
<h1>Environments</h1>
<div class="environment-list__actions">
<span class="p-input-icon-left">
<i class="pi pi-search"></i>
<input type="text" pInputText placeholder="Search environments..."
(input)="onSearch($any($event.target).value)" />
</span>
<button pButton label="Create Environment" icon="pi pi-plus" (click)="onCreate()"></button>
</div>
</header>
<div class="environment-list__error" *ngIf="error$ | async as error">
<p-message severity="error" [text]="error"></p-message>
</div>
<div class="environment-list__content" *ngIf="!(loading$ | async); else loadingTpl">
<div class="environment-grid">
<div *ngFor="let env of environments$ | async" class="environment-card">
<div class="environment-card__header">
<div class="environment-card__title">
<span class="environment-card__order">#{{ env.order }}</span>
<a [routerLink]="[env.id]" class="environment-card__name">{{ env.name }}</a>
<span *ngIf="env.isProduction" class="badge badge--danger">Production</span>
</div>
<p-menu #menu [popup]="true" [model]="[
{ label: 'Edit', icon: 'pi pi-pencil', routerLink: [env.id, 'edit'] },
{ label: 'Settings', icon: 'pi pi-cog', routerLink: [env.id, 'settings'] },
{ separator: true },
{ label: 'Delete', icon: 'pi pi-trash', command: () => onDelete(env) }
]"></p-menu>
<button pButton icon="pi pi-ellipsis-v" class="p-button-text" (click)="menu.toggle($event)"></button>
</div>
<p class="environment-card__description">{{ env.description }}</p>
<div class="environment-card__stats">
<div class="stat">
<span class="stat__value">{{ env.targetCount }}</span>
<span class="stat__label">Targets</span>
</div>
<div class="stat" [ngClass]="getHealthClass(env)">
<span class="stat__value">{{ getHealthPercentage(env) }}%</span>
<span class="stat__label">Healthy</span>
</div>
<div class="stat">
<span class="stat__value">{{ env.requiredApprovers }}</span>
<span class="stat__label">Approvers</span>
</div>
</div>
<div class="environment-card__footer">
<span *ngIf="env.activeFreezeWindow" class="freeze-indicator">
<i class="pi pi-lock"></i> Freeze active
</span>
<span *ngIf="env.freezeWindowCount > 0 && !env.activeFreezeWindow" class="freeze-info">
{{ env.freezeWindowCount }} freeze windows
</span>
</div>
</div>
</div>
<div *ngIf="(environments$ | async)?.length === 0" class="environment-list__empty">
<i class="pi pi-box"></i>
<h3>No environments yet</h3>
<p>Create your first environment to start managing releases.</p>
<button pButton label="Create Environment" icon="pi pi-plus" (click)="onCreate()"></button>
</div>
</div>
<ng-template #loadingTpl>
<div class="environment-grid">
<p-skeleton *ngFor="let i of [1,2,3,4]" height="200px"></p-skeleton>
</div>
</ng-template>
</div>
<p-confirmDialog></p-confirmDialog>
```
### Environment Detail Component
```typescript
// environment-detail.component.ts
import { Component, OnInit, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { Store } from '@ngrx/store';
import { TabViewModule } from 'primeng/tabview';
import { TargetListComponent } from '../components/target-list/target-list.component';
import { FreezeWindowEditorComponent } from '../components/freeze-window-editor/freeze-window-editor.component';
import { EnvironmentSettingsComponent } from '../components/environment-settings/environment-settings.component';
import { EnvironmentActions } from '@store/release-orchestrator/environments/environments.actions';
import * as EnvironmentSelectors from '@store/release-orchestrator/environments/environments.selectors';
@Component({
selector: 'so-environment-detail',
standalone: true,
imports: [
CommonModule,
RouterModule,
TabViewModule,
TargetListComponent,
FreezeWindowEditorComponent,
EnvironmentSettingsComponent
],
templateUrl: './environment-detail.component.html',
styleUrl: './environment-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EnvironmentDetailComponent implements OnInit {
private readonly store = inject(Store);
private readonly route = inject(ActivatedRoute);
readonly environment$ = this.store.select(EnvironmentSelectors.selectSelectedEnvironment);
readonly targets$ = this.store.select(EnvironmentSelectors.selectSelectedEnvironmentTargets);
readonly freezeWindows$ = this.store.select(EnvironmentSelectors.selectSelectedEnvironmentFreezeWindows);
readonly loading$ = this.store.select(EnvironmentSelectors.selectLoading);
activeTabIndex = 0;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.dispatch(EnvironmentActions.loadEnvironment({ id }));
this.store.dispatch(EnvironmentActions.loadEnvironmentTargets({ environmentId: id }));
this.store.dispatch(EnvironmentActions.loadFreezeWindows({ environmentId: id }));
}
}
onTabChange(index: number): void {
this.activeTabIndex = index;
}
}
```
```html
<!-- environment-detail.component.html -->
<div class="environment-detail" *ngIf="environment$ | async as env">
<header class="environment-detail__header">
<div class="environment-detail__breadcrumb">
<a routerLink="/environments">Environments</a>
<i class="pi pi-angle-right"></i>
<span>{{ env.name }}</span>
</div>
<div class="environment-detail__title-row">
<div>
<h1>
{{ env.name }}
<span *ngIf="env.isProduction" class="badge badge--danger">Production</span>
</h1>
<p class="environment-detail__description">{{ env.description }}</p>
</div>
<div class="environment-detail__actions">
<button pButton label="Edit" icon="pi pi-pencil" class="p-button-outlined"
[routerLink]="['edit']"></button>
</div>
</div>
<div class="environment-detail__stats">
<div class="stat-card">
<i class="pi pi-server"></i>
<div class="stat-card__content">
<span class="stat-card__value">{{ env.targetCount }}</span>
<span class="stat-card__label">Deployment Targets</span>
</div>
</div>
<div class="stat-card">
<i class="pi pi-users"></i>
<div class="stat-card__content">
<span class="stat-card__value">{{ env.requiredApprovers }}</span>
<span class="stat-card__label">Required Approvers</span>
</div>
</div>
<div class="stat-card">
<i class="pi pi-calendar"></i>
<div class="stat-card__content">
<span class="stat-card__value">{{ env.freezeWindowCount }}</span>
<span class="stat-card__label">Freeze Windows</span>
</div>
</div>
</div>
</header>
<p-tabView [(activeIndex)]="activeTabIndex" (onChange)="onTabChange($event.index)">
<p-tabPanel header="Targets">
<so-target-list
[targets]="targets$ | async"
[environmentId]="env.id"
[loading]="loading$ | async">
</so-target-list>
</p-tabPanel>
<p-tabPanel header="Freeze Windows">
<so-freeze-window-editor
[freezeWindows]="freezeWindows$ | async"
[environmentId]="env.id"
[loading]="loading$ | async">
</so-freeze-window-editor>
</p-tabPanel>
<p-tabPanel header="Settings">
<so-environment-settings
[environment]="env"
[loading]="loading$ | async">
</so-environment-settings>
</p-tabPanel>
</p-tabView>
</div>
<div class="loading-overlay" *ngIf="loading$ | async">
<p-progressSpinner></p-progressSpinner>
</div>
```
### Target List Component
```typescript
// target-list.component.ts
import { Component, Input, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { DialogService } from 'primeng/dynamicdialog';
import { ConfirmationService, MessageService } from 'primeng/api';
import { TargetFormComponent } from '../target-form/target-form.component';
import { EnvironmentActions } from '@store/release-orchestrator/environments/environments.actions';
export interface DeploymentTarget {
id: string;
environmentId: string;
name: string;
type: 'docker_host' | 'compose_host' | 'ecs_service' | 'nomad_job';
agentId: string | null;
agentStatus: 'connected' | 'disconnected' | 'unknown';
healthStatus: 'healthy' | 'unhealthy' | 'unknown';
lastHealthCheck: Date | null;
metadata: Record<string, string>;
createdAt: Date;
}
@Component({
selector: 'so-target-list',
standalone: true,
imports: [CommonModule],
templateUrl: './target-list.component.html',
styleUrl: './target-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [DialogService, ConfirmationService, MessageService]
})
export class TargetListComponent {
@Input() targets: DeploymentTarget[] | null = null;
@Input() environmentId: string = '';
@Input() loading = false;
private readonly store = inject(Store);
private readonly dialogService = inject(DialogService);
private readonly confirmationService = inject(ConfirmationService);
onAddTarget(): void {
const ref = this.dialogService.open(TargetFormComponent, {
header: 'Add Deployment Target',
width: '600px',
data: { environmentId: this.environmentId, mode: 'create' }
});
ref.onClose.subscribe((result) => {
if (result) {
this.store.dispatch(EnvironmentActions.addTarget({
environmentId: this.environmentId,
request: result
}));
}
});
}
onEditTarget(target: DeploymentTarget): void {
const ref = this.dialogService.open(TargetFormComponent, {
header: 'Edit Deployment Target',
width: '600px',
data: { environmentId: this.environmentId, target, mode: 'edit' }
});
ref.onClose.subscribe((result) => {
if (result) {
this.store.dispatch(EnvironmentActions.updateTarget({
environmentId: this.environmentId,
targetId: target.id,
request: result
}));
}
});
}
onRemoveTarget(target: DeploymentTarget): void {
this.confirmationService.confirm({
message: `Remove target "${target.name}" from this environment?`,
header: 'Remove Target',
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.store.dispatch(EnvironmentActions.removeTarget({
environmentId: this.environmentId,
targetId: target.id
}));
}
});
}
onHealthCheck(target: DeploymentTarget): void {
this.store.dispatch(EnvironmentActions.checkTargetHealth({
environmentId: this.environmentId,
targetId: target.id
}));
}
getTypeIcon(type: string): string {
const icons: Record<string, string> = {
docker_host: 'pi-box',
compose_host: 'pi-th-large',
ecs_service: 'pi-cloud',
nomad_job: 'pi-sitemap'
};
return icons[type] || 'pi-server';
}
getHealthClass(status: string): string {
return `health-badge--${status}`;
}
getAgentStatusClass(status: string): string {
return `agent-status--${status}`;
}
}
```
```html
<!-- target-list.component.html -->
<div class="target-list">
<div class="target-list__header">
<h3>Deployment Targets</h3>
<button pButton label="Add Target" icon="pi pi-plus" (click)="onAddTarget()"></button>
</div>
<p-table [value]="targets || []" [loading]="loading" styleClass="p-datatable-sm">
<ng-template pTemplate="header">
<tr>
<th>Name</th>
<th>Type</th>
<th>Agent</th>
<th>Health</th>
<th>Last Check</th>
<th>Actions</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-target>
<tr>
<td>
<div class="target-name">
<i class="pi" [ngClass]="getTypeIcon(target.type)"></i>
<span>{{ target.name }}</span>
</div>
</td>
<td>{{ target.type | titlecase }}</td>
<td>
<span class="agent-status" [ngClass]="getAgentStatusClass(target.agentStatus)">
<i class="pi pi-circle-fill"></i>
{{ target.agentId || 'Not assigned' }}
</span>
</td>
<td>
<span class="health-badge" [ngClass]="getHealthClass(target.healthStatus)">
{{ target.healthStatus | titlecase }}
</span>
</td>
<td>{{ target.lastHealthCheck | date:'short' }}</td>
<td>
<button pButton icon="pi pi-refresh" class="p-button-text p-button-sm"
pTooltip="Health Check" (click)="onHealthCheck(target)"></button>
<button pButton icon="pi pi-pencil" class="p-button-text p-button-sm"
pTooltip="Edit" (click)="onEditTarget(target)"></button>
<button pButton icon="pi pi-trash" class="p-button-text p-button-danger p-button-sm"
pTooltip="Remove" (click)="onRemoveTarget(target)"></button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="6" class="text-center">
<div class="empty-state">
<i class="pi pi-server"></i>
<p>No deployment targets configured</p>
<button pButton label="Add First Target" icon="pi pi-plus"
class="p-button-outlined" (click)="onAddTarget()"></button>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
<p-confirmDialog></p-confirmDialog>
```
### Freeze Window Editor Component
```typescript
// freeze-window-editor.component.ts
import { Component, Input, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { DialogService } from 'primeng/dynamicdialog';
import { EnvironmentActions } from '@store/release-orchestrator/environments/environments.actions';
export interface FreezeWindow {
id: string;
environmentId: string;
name: string;
reason: string;
startTime: Date;
endTime: Date;
recurrence: 'none' | 'daily' | 'weekly' | 'monthly';
isActive: boolean;
createdBy: string;
createdAt: Date;
}
@Component({
selector: 'so-freeze-window-editor',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './freeze-window-editor.component.html',
styleUrl: './freeze-window-editor.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [DialogService]
})
export class FreezeWindowEditorComponent {
@Input() freezeWindows: FreezeWindow[] | null = null;
@Input() environmentId: string = '';
@Input() loading = false;
private readonly store = inject(Store);
private readonly fb = inject(FormBuilder);
private readonly dialogService = inject(DialogService);
showForm = false;
editingId: string | null = null;
form: FormGroup = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(100)]],
reason: ['', [Validators.required, Validators.maxLength(500)]],
startTime: [null, Validators.required],
endTime: [null, Validators.required],
recurrence: ['none']
});
recurrenceOptions = [
{ label: 'None (One-time)', value: 'none' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' }
];
onAdd(): void {
this.showForm = true;
this.editingId = null;
this.form.reset({ recurrence: 'none' });
}
onEdit(window: FreezeWindow): void {
this.showForm = true;
this.editingId = window.id;
this.form.patchValue({
name: window.name,
reason: window.reason,
startTime: new Date(window.startTime),
endTime: new Date(window.endTime),
recurrence: window.recurrence
});
}
onCancel(): void {
this.showForm = false;
this.editingId = null;
this.form.reset();
}
onSave(): void {
if (this.form.invalid) return;
const value = this.form.value;
if (this.editingId) {
this.store.dispatch(EnvironmentActions.updateFreezeWindow({
environmentId: this.environmentId,
windowId: this.editingId,
request: value
}));
} else {
this.store.dispatch(EnvironmentActions.createFreezeWindow({
environmentId: this.environmentId,
request: value
}));
}
this.onCancel();
}
onDelete(window: FreezeWindow): void {
this.store.dispatch(EnvironmentActions.deleteFreezeWindow({
environmentId: this.environmentId,
windowId: window.id
}));
}
isActiveNow(window: FreezeWindow): boolean {
const now = new Date();
return new Date(window.startTime) <= now && now <= new Date(window.endTime);
}
getRecurrenceLabel(value: string): string {
return this.recurrenceOptions.find(o => o.value === value)?.label || value;
}
}
```
```html
<!-- freeze-window-editor.component.html -->
<div class="freeze-window-editor">
<div class="freeze-window-editor__header">
<h3>Freeze Windows</h3>
<button pButton label="Add Freeze Window" icon="pi pi-plus"
(click)="onAdd()" [disabled]="showForm"></button>
</div>
<div class="freeze-window-editor__form" *ngIf="showForm">
<form [formGroup]="form" (ngSubmit)="onSave()">
<div class="p-fluid">
<div class="field">
<label for="name">Name</label>
<input id="name" type="text" pInputText formControlName="name" />
</div>
<div class="field">
<label for="reason">Reason</label>
<textarea id="reason" pInputTextarea formControlName="reason" rows="3"></textarea>
</div>
<div class="field-row">
<div class="field">
<label for="startTime">Start Time</label>
<p-calendar id="startTime" formControlName="startTime"
[showTime]="true" [showIcon]="true"></p-calendar>
</div>
<div class="field">
<label for="endTime">End Time</label>
<p-calendar id="endTime" formControlName="endTime"
[showTime]="true" [showIcon]="true"></p-calendar>
</div>
</div>
<div class="field">
<label for="recurrence">Recurrence</label>
<p-dropdown id="recurrence" formControlName="recurrence"
[options]="recurrenceOptions"></p-dropdown>
</div>
</div>
<div class="form-actions">
<button pButton type="button" label="Cancel" class="p-button-text"
(click)="onCancel()"></button>
<button pButton type="submit" label="Save" [disabled]="form.invalid"></button>
</div>
</form>
</div>
<div class="freeze-window-list" *ngIf="!loading; else loadingTpl">
<div *ngFor="let window of freezeWindows" class="freeze-window-item"
[class.freeze-window-item--active]="isActiveNow(window)">
<div class="freeze-window-item__header">
<span class="freeze-window-item__name">
<i class="pi pi-lock" *ngIf="isActiveNow(window)"></i>
{{ window.name }}
</span>
<span class="badge" *ngIf="isActiveNow(window)">Active</span>
</div>
<p class="freeze-window-item__reason">{{ window.reason }}</p>
<div class="freeze-window-item__schedule">
<span>{{ window.startTime | date:'medium' }} - {{ window.endTime | date:'medium' }}</span>
<span class="recurrence" *ngIf="window.recurrence !== 'none'">
({{ getRecurrenceLabel(window.recurrence) }})
</span>
</div>
<div class="freeze-window-item__actions">
<button pButton icon="pi pi-pencil" class="p-button-text p-button-sm"
(click)="onEdit(window)"></button>
<button pButton icon="pi pi-trash" class="p-button-text p-button-danger p-button-sm"
(click)="onDelete(window)"></button>
</div>
</div>
<div *ngIf="freezeWindows?.length === 0" class="empty-state">
<i class="pi pi-calendar-times"></i>
<p>No freeze windows configured</p>
</div>
</div>
<ng-template #loadingTpl>
<p-skeleton height="100px" *ngFor="let i of [1,2]" styleClass="mb-2"></p-skeleton>
</ng-template>
</div>
```
### Environment Settings Component
```typescript
// environment-settings.component.ts
import { Component, Input, inject, OnChanges, SimpleChanges, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { EnvironmentActions } from '@store/release-orchestrator/environments/environments.actions';
@Component({
selector: 'so-environment-settings',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './environment-settings.component.html',
styleUrl: './environment-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EnvironmentSettingsComponent implements OnChanges {
@Input() environment: Environment | null = null;
@Input() loading = false;
private readonly store = inject(Store);
private readonly fb = inject(FormBuilder);
form: FormGroup = this.fb.group({
requiresApproval: [true],
requiredApprovers: [1, [Validators.min(0), Validators.max(10)]],
autoPromoteOnSuccess: [false],
separationOfDuties: [false],
notifyOnPromotion: [true],
notifyOnDeployment: [true],
notifyOnFailure: [true],
webhookUrl: [''],
maxConcurrentDeployments: [1, [Validators.min(1), Validators.max(100)]],
deploymentTimeout: [3600, [Validators.min(60), Validators.max(86400)]]
});
ngOnChanges(changes: SimpleChanges): void {
if (changes['environment'] && this.environment) {
this.form.patchValue({
requiresApproval: this.environment.requiresApproval,
requiredApprovers: this.environment.requiredApprovers,
autoPromoteOnSuccess: this.environment.autoPromoteOnSuccess,
separationOfDuties: this.environment.separationOfDuties,
notifyOnPromotion: this.environment.notifyOnPromotion,
notifyOnDeployment: this.environment.notifyOnDeployment,
notifyOnFailure: this.environment.notifyOnFailure,
webhookUrl: this.environment.webhookUrl || '',
maxConcurrentDeployments: this.environment.maxConcurrentDeployments,
deploymentTimeout: this.environment.deploymentTimeout
});
}
}
onSave(): void {
if (this.form.invalid || !this.environment) return;
this.store.dispatch(EnvironmentActions.updateEnvironmentSettings({
id: this.environment.id,
settings: this.form.value
}));
}
onReset(): void {
if (this.environment) {
this.ngOnChanges({ environment: { currentValue: this.environment } } as any);
}
}
}
```
```html
<!-- environment-settings.component.html -->
<div class="environment-settings">
<form [formGroup]="form" (ngSubmit)="onSave()">
<section class="settings-section">
<h4>Approval Settings</h4>
<div class="field-checkbox">
<p-checkbox formControlName="requiresApproval" [binary]="true" inputId="requiresApproval"></p-checkbox>
<label for="requiresApproval">Require approval for promotions</label>
</div>
<div class="field" *ngIf="form.get('requiresApproval')?.value">
<label for="requiredApprovers">Required Approvers</label>
<p-inputNumber id="requiredApprovers" formControlName="requiredApprovers"
[min]="1" [max]="10" [showButtons]="true"></p-inputNumber>
</div>
<div class="field-checkbox">
<p-checkbox formControlName="separationOfDuties" [binary]="true" inputId="sod"></p-checkbox>
<label for="sod">Separation of Duties (approver cannot be requester)</label>
</div>
<div class="field-checkbox">
<p-checkbox formControlName="autoPromoteOnSuccess" [binary]="true" inputId="autoPromote"></p-checkbox>
<label for="autoPromote">Auto-promote to next environment on success</label>
</div>
</section>
<section class="settings-section">
<h4>Notifications</h4>
<div class="field-checkbox">
<p-checkbox formControlName="notifyOnPromotion" [binary]="true" inputId="notifyPromotion"></p-checkbox>
<label for="notifyPromotion">Notify on promotion requests</label>
</div>
<div class="field-checkbox">
<p-checkbox formControlName="notifyOnDeployment" [binary]="true" inputId="notifyDeployment"></p-checkbox>
<label for="notifyDeployment">Notify on deployment start/complete</label>
</div>
<div class="field-checkbox">
<p-checkbox formControlName="notifyOnFailure" [binary]="true" inputId="notifyFailure"></p-checkbox>
<label for="notifyFailure">Notify on deployment failure</label>
</div>
<div class="field">
<label for="webhookUrl">Webhook URL (optional)</label>
<input id="webhookUrl" type="url" pInputText formControlName="webhookUrl"
placeholder="https://..." />
</div>
</section>
<section class="settings-section">
<h4>Deployment Limits</h4>
<div class="field">
<label for="maxConcurrent">Max Concurrent Deployments</label>
<p-inputNumber id="maxConcurrent" formControlName="maxConcurrentDeployments"
[min]="1" [max]="100" [showButtons]="true"></p-inputNumber>
</div>
<div class="field">
<label for="timeout">Deployment Timeout (seconds)</label>
<p-inputNumber id="timeout" formControlName="deploymentTimeout"
[min]="60" [max]="86400" [showButtons]="true" [step]="60"></p-inputNumber>
<small>{{ form.get('deploymentTimeout')?.value / 60 | number:'1.0-0' }} minutes</small>
</div>
</section>
<div class="form-actions">
<button pButton type="button" label="Reset" class="p-button-text"
(click)="onReset()" [disabled]="loading"></button>
<button pButton type="submit" label="Save Settings"
[disabled]="form.invalid || loading" [loading]="loading"></button>
</div>
</form>
</div>
```
### Documentation Deliverables
| Deliverable | Type | Description |
|-------------|------|-------------|
| `docs/modules/release-orchestrator/ui/screens.md` (partial) | Markdown | Key UI screens reference (environment overview, release detail, "Why Blocked?" modal) |
---
## Acceptance Criteria
### Code
- [ ] Environment list displays all environments
- [ ] Environment cards show health status
- [ ] Create environment dialog works
- [ ] Delete environment with confirmation
- [ ] Environment detail loads correctly
- [ ] Target list shows all targets
- [ ] Add/edit/remove targets works
- [ ] Target health check triggers
- [ ] Freeze window CRUD operations work
- [ ] Freeze window recurrence saves
- [ ] Active freeze window highlighted
- [ ] Environment settings save correctly
- [ ] Form validation works
- [ ] Unit test coverage >=80%
### Documentation
- [ ] UI screens specification file created
- [ ] Environment overview screen documented with ASCII mockup
- [ ] Target management screens documented
- [ ] Freeze window editor documented
- [ ] All screen wireframes included
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 103_001 Environment Model | Internal | TODO |
| Angular 17 | External | Available |
| NgRx 17 | External | Available |
| PrimeNG 17 | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| EnvironmentListComponent | TODO | |
| EnvironmentDetailComponent | TODO | |
| TargetListComponent | TODO | |
| TargetFormComponent | TODO | |
| FreezeWindowEditorComponent | TODO | |
| EnvironmentSettingsComponent | TODO | |
| Environment NgRx Store | TODO | |
| EnvironmentService | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |
| 11-Jan-2026 | Added documentation deliverable: ui/screens.md (partial - environment screens) |

View File

@@ -0,0 +1,931 @@
# SPRINT: Release Management UI
> **Sprint ID:** 111_003
> **Module:** FE
> **Phase:** 11 - UI Implementation
> **Status:** TODO
> **Parent:** [111_000_INDEX](SPRINT_20260110_111_000_INDEX_ui_implementation.md)
---
## Overview
Implement the Release Management UI providing release catalog, release detail views, release creation wizard, and component selection functionality.
### Objectives
- Release catalog with filtering and search
- Release detail view with components
- Create release wizard (multi-step)
- Component selector with registry integration
- Release status tracking
- Release bundle comparison
### Working Directory
```
src/Web/StellaOps.Web/
├── src/app/features/release-orchestrator/
│ └── releases/
│ ├── release-list/
│ │ ├── release-list.component.ts
│ │ ├── release-list.component.html
│ │ └── release-list.component.scss
│ ├── release-detail/
│ │ ├── release-detail.component.ts
│ │ ├── release-detail.component.html
│ │ └── release-detail.component.scss
│ ├── create-release/
│ │ ├── create-release.component.ts
│ │ ├── steps/
│ │ │ ├── basic-info-step/
│ │ │ ├── component-selection-step/
│ │ │ ├── configuration-step/
│ │ │ └── review-step/
│ │ └── create-release.routes.ts
│ ├── components/
│ │ ├── component-selector/
│ │ ├── component-list/
│ │ ├── release-timeline/
│ │ └── release-comparison/
│ └── releases.routes.ts
└── src/app/store/release-orchestrator/
└── releases/
├── releases.actions.ts
├── releases.reducer.ts
├── releases.effects.ts
└── releases.selectors.ts
```
---
## Deliverables
### Release List Component
```typescript
// release-list.component.ts
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { ReleaseActions } from '@store/release-orchestrator/releases/releases.actions';
import * as ReleaseSelectors from '@store/release-orchestrator/releases/releases.selectors';
export interface Release {
id: string;
name: string;
version: string;
description: string;
status: 'draft' | 'ready' | 'deploying' | 'deployed' | 'failed' | 'rolled_back';
currentEnvironment: string | null;
targetEnvironment: string | null;
componentCount: number;
createdAt: Date;
createdBy: string;
updatedAt: Date;
deployedAt: Date | null;
}
@Component({
selector: 'so-release-list',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule],
templateUrl: './release-list.component.html',
styleUrl: './release-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReleaseListComponent implements OnInit {
private readonly store = inject(Store);
readonly releases$ = this.store.select(ReleaseSelectors.selectFilteredReleases);
readonly loading$ = this.store.select(ReleaseSelectors.selectLoading);
readonly totalCount$ = this.store.select(ReleaseSelectors.selectTotalCount);
searchTerm = signal('');
statusFilter = signal<string[]>([]);
environmentFilter = signal<string | null>(null);
sortField = signal('createdAt');
sortOrder = signal<'asc' | 'desc'>('desc');
readonly statusOptions = [
{ label: 'Draft', value: 'draft' },
{ label: 'Ready', value: 'ready' },
{ label: 'Deploying', value: 'deploying' },
{ label: 'Deployed', value: 'deployed' },
{ label: 'Failed', value: 'failed' },
{ label: 'Rolled Back', value: 'rolled_back' }
];
ngOnInit(): void {
this.loadReleases();
}
loadReleases(): void {
this.store.dispatch(ReleaseActions.loadReleases({
filter: {
search: this.searchTerm(),
statuses: this.statusFilter(),
environment: this.environmentFilter(),
sortField: this.sortField(),
sortOrder: this.sortOrder()
}
}));
}
onSearch(term: string): void {
this.searchTerm.set(term);
this.loadReleases();
}
onStatusFilterChange(statuses: string[]): void {
this.statusFilter.set(statuses);
this.loadReleases();
}
onSort(field: string): void {
if (this.sortField() === field) {
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortOrder.set('desc');
}
this.loadReleases();
}
getStatusClass(status: string): string {
const classes: Record<string, string> = {
draft: 'badge--secondary',
ready: 'badge--info',
deploying: 'badge--warning',
deployed: 'badge--success',
failed: 'badge--danger',
rolled_back: 'badge--warning'
};
return classes[status] || 'badge--secondary';
}
formatStatus(status: string): string {
return status.replace('_', ' ').replace(/\b\w/g, c => c.toUpperCase());
}
}
```
```html
<!-- release-list.component.html -->
<div class="release-list">
<header class="release-list__header">
<h1>Releases</h1>
<button pButton label="Create Release" icon="pi pi-plus" routerLink="create"></button>
</header>
<div class="release-list__filters">
<span class="p-input-icon-left">
<i class="pi pi-search"></i>
<input type="text" pInputText placeholder="Search releases..."
[ngModel]="searchTerm()" (ngModelChange)="onSearch($event)" />
</span>
<p-multiSelect [options]="statusOptions" [ngModel]="statusFilter()"
(ngModelChange)="onStatusFilterChange($event)"
placeholder="Filter by status" display="chip"></p-multiSelect>
<p-dropdown [options]="environments$ | async" [ngModel]="environmentFilter()"
(ngModelChange)="environmentFilter.set($event); loadReleases()"
placeholder="All environments" [showClear]="true"></p-dropdown>
</div>
<p-table [value]="(releases$ | async) || []" [loading]="loading$ | async"
[paginator]="true" [rows]="20" [totalRecords]="(totalCount$ | async) || 0"
[lazy]="true" (onLazyLoad)="loadReleases()"
styleClass="p-datatable-sm p-datatable-striped">
<ng-template pTemplate="header">
<tr>
<th pSortableColumn="name" (click)="onSort('name')">
Release <p-sortIcon field="name"></p-sortIcon>
</th>
<th>Status</th>
<th>Environment</th>
<th>Components</th>
<th pSortableColumn="createdAt" (click)="onSort('createdAt')">
Created <p-sortIcon field="createdAt"></p-sortIcon>
</th>
<th>Actions</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-release>
<tr>
<td>
<a [routerLink]="[release.id]" class="release-link">
<strong>{{ release.name }}</strong>
<span class="version">{{ release.version }}</span>
</a>
<small class="release-description">{{ release.description }}</small>
</td>
<td>
<span class="badge" [ngClass]="getStatusClass(release.status)">
{{ formatStatus(release.status) }}
</span>
</td>
<td>
<span *ngIf="release.currentEnvironment; else noEnv">
{{ release.currentEnvironment }}
</span>
<ng-template #noEnv><span class="text-muted">-</span></ng-template>
</td>
<td>{{ release.componentCount }}</td>
<td>
<span>{{ release.createdAt | date:'short' }}</span>
<small class="created-by">by {{ release.createdBy }}</small>
</td>
<td>
<button pButton icon="pi pi-eye" class="p-button-text p-button-sm"
[routerLink]="[release.id]" pTooltip="View"></button>
<button pButton icon="pi pi-copy" class="p-button-text p-button-sm"
(click)="onClone(release)" pTooltip="Clone"></button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="6" class="text-center">
<div class="empty-state">
<i class="pi pi-box"></i>
<h3>No releases found</h3>
<p>Create your first release to get started.</p>
<button pButton label="Create Release" icon="pi pi-plus" routerLink="create"></button>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
```
### Release Detail Component
```typescript
// release-detail.component.ts
import { Component, OnInit, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { Store } from '@ngrx/store';
import { ConfirmationService } from 'primeng/api';
import { ComponentListComponent } from '../components/component-list/component-list.component';
import { ReleaseTimelineComponent } from '../components/release-timeline/release-timeline.component';
import { ReleaseActions } from '@store/release-orchestrator/releases/releases.actions';
import * as ReleaseSelectors from '@store/release-orchestrator/releases/releases.selectors';
export interface ReleaseComponent {
id: string;
name: string;
imageRef: string;
digest: string;
tag: string | null;
version: string;
type: 'container' | 'helm' | 'script';
configOverrides: Record<string, string>;
}
export interface ReleaseEvent {
id: string;
type: 'created' | 'promoted' | 'approved' | 'rejected' | 'deployed' | 'failed' | 'rolled_back';
environment: string | null;
actor: string;
message: string;
timestamp: Date;
metadata: Record<string, any>;
}
@Component({
selector: 'so-release-detail',
standalone: true,
imports: [CommonModule, RouterModule, ComponentListComponent, ReleaseTimelineComponent],
templateUrl: './release-detail.component.html',
styleUrl: './release-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ConfirmationService]
})
export class ReleaseDetailComponent implements OnInit {
private readonly store = inject(Store);
private readonly route = inject(ActivatedRoute);
private readonly confirmationService = inject(ConfirmationService);
readonly release$ = this.store.select(ReleaseSelectors.selectSelectedRelease);
readonly components$ = this.store.select(ReleaseSelectors.selectSelectedReleaseComponents);
readonly events$ = this.store.select(ReleaseSelectors.selectSelectedReleaseEvents);
readonly loading$ = this.store.select(ReleaseSelectors.selectLoading);
activeTab = 'components';
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.dispatch(ReleaseActions.loadRelease({ id }));
this.store.dispatch(ReleaseActions.loadReleaseComponents({ releaseId: id }));
this.store.dispatch(ReleaseActions.loadReleaseEvents({ releaseId: id }));
}
}
onPromote(release: Release): void {
this.store.dispatch(ReleaseActions.requestPromotion({ releaseId: release.id }));
}
onDeploy(release: Release): void {
this.confirmationService.confirm({
message: `Deploy "${release.name}" to ${release.targetEnvironment}?`,
header: 'Confirm Deployment',
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.store.dispatch(ReleaseActions.deploy({ releaseId: release.id }));
}
});
}
onRollback(release: Release): void {
this.confirmationService.confirm({
message: `Rollback "${release.name}" from ${release.currentEnvironment}? This will restore the previous release.`,
header: 'Confirm Rollback',
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.store.dispatch(ReleaseActions.rollback({ releaseId: release.id }));
}
});
}
canPromote(release: Release): boolean {
return release.status === 'ready' || release.status === 'deployed';
}
canDeploy(release: Release): boolean {
return release.status === 'ready' && release.targetEnvironment !== null;
}
canRollback(release: Release): boolean {
return release.status === 'deployed' || release.status === 'failed';
}
getStatusClass(status: string): string {
const classes: Record<string, string> = {
draft: 'badge--secondary',
ready: 'badge--info',
deploying: 'badge--warning',
deployed: 'badge--success',
failed: 'badge--danger',
rolled_back: 'badge--warning'
};
return classes[status] || 'badge--secondary';
}
}
```
```html
<!-- release-detail.component.html -->
<div class="release-detail" *ngIf="release$ | async as release">
<header class="release-detail__header">
<div class="release-detail__breadcrumb">
<a routerLink="/releases">Releases</a>
<i class="pi pi-angle-right"></i>
<span>{{ release.name }}</span>
</div>
<div class="release-detail__title-row">
<div>
<h1>
{{ release.name }}
<span class="version">{{ release.version }}</span>
<span class="badge" [ngClass]="getStatusClass(release.status)">
{{ release.status | titlecase }}
</span>
</h1>
<p class="release-detail__description">{{ release.description }}</p>
</div>
<div class="release-detail__actions">
<button pButton label="Promote" icon="pi pi-arrow-right"
(click)="onPromote(release)" [disabled]="!canPromote(release)"></button>
<button pButton label="Deploy" icon="pi pi-play" class="p-button-success"
(click)="onDeploy(release)" [disabled]="!canDeploy(release)"></button>
<button pButton label="Rollback" icon="pi pi-undo" class="p-button-danger p-button-outlined"
(click)="onRollback(release)" [disabled]="!canRollback(release)"></button>
</div>
</div>
<div class="release-detail__meta">
<div class="meta-item">
<i class="pi pi-user"></i>
<span>Created by {{ release.createdBy }}</span>
</div>
<div class="meta-item">
<i class="pi pi-calendar"></i>
<span>{{ release.createdAt | date:'medium' }}</span>
</div>
<div class="meta-item" *ngIf="release.currentEnvironment">
<i class="pi pi-map-marker"></i>
<span>Currently in {{ release.currentEnvironment }}</span>
</div>
<div class="meta-item" *ngIf="release.targetEnvironment">
<i class="pi pi-arrow-right"></i>
<span>Target: {{ release.targetEnvironment }}</span>
</div>
</div>
</header>
<div class="release-detail__tabs">
<button [class.active]="activeTab === 'components'" (click)="activeTab = 'components'">
Components ({{ release.componentCount }})
</button>
<button [class.active]="activeTab === 'timeline'" (click)="activeTab = 'timeline'">
Timeline
</button>
<button [class.active]="activeTab === 'config'" (click)="activeTab = 'config'">
Configuration
</button>
</div>
<div class="release-detail__content">
<so-component-list *ngIf="activeTab === 'components'"
[components]="components$ | async"
[readonly]="release.status !== 'draft'">
</so-component-list>
<so-release-timeline *ngIf="activeTab === 'timeline'"
[events]="events$ | async">
</so-release-timeline>
<div *ngIf="activeTab === 'config'" class="config-view">
<pre>{{ release | json }}</pre>
</div>
</div>
</div>
<p-confirmDialog></p-confirmDialog>
```
### Create Release Wizard
```typescript
// create-release.component.ts
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StepsModule } from 'primeng/steps';
import { MenuItem } from 'primeng/api';
import { BasicInfoStepComponent } from './steps/basic-info-step/basic-info-step.component';
import { ComponentSelectionStepComponent } from './steps/component-selection-step/component-selection-step.component';
import { ConfigurationStepComponent } from './steps/configuration-step/configuration-step.component';
import { ReviewStepComponent } from './steps/review-step/review-step.component';
import { ReleaseActions } from '@store/release-orchestrator/releases/releases.actions';
export interface CreateReleaseData {
basicInfo: {
name: string;
version: string;
description: string;
};
components: ReleaseComponent[];
configuration: {
targetEnvironment: string;
deploymentStrategy: string;
configOverrides: Record<string, string>;
};
}
@Component({
selector: 'so-create-release',
standalone: true,
imports: [
CommonModule,
StepsModule,
BasicInfoStepComponent,
ComponentSelectionStepComponent,
ConfigurationStepComponent,
ReviewStepComponent
],
templateUrl: './create-release.component.html',
styleUrl: './create-release.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CreateReleaseComponent {
private readonly store = inject(Store);
private readonly router = inject(Router);
activeIndex = signal(0);
releaseData = signal<Partial<CreateReleaseData>>({});
readonly steps: MenuItem[] = [
{ label: 'Basic Info' },
{ label: 'Components' },
{ label: 'Configuration' },
{ label: 'Review' }
];
onBasicInfoComplete(data: CreateReleaseData['basicInfo']): void {
this.releaseData.update(current => ({ ...current, basicInfo: data }));
this.activeIndex.set(1);
}
onComponentsComplete(components: ReleaseComponent[]): void {
this.releaseData.update(current => ({ ...current, components }));
this.activeIndex.set(2);
}
onConfigurationComplete(config: CreateReleaseData['configuration']): void {
this.releaseData.update(current => ({ ...current, configuration: config }));
this.activeIndex.set(3);
}
onBack(): void {
this.activeIndex.update(i => Math.max(0, i - 1));
}
onCancel(): void {
this.router.navigate(['/releases']);
}
onSubmit(): void {
const data = this.releaseData();
if (data.basicInfo && data.components && data.configuration) {
this.store.dispatch(ReleaseActions.createRelease({
request: {
name: data.basicInfo.name,
version: data.basicInfo.version,
description: data.basicInfo.description,
components: data.components,
targetEnvironment: data.configuration.targetEnvironment,
deploymentStrategy: data.configuration.deploymentStrategy,
configOverrides: data.configuration.configOverrides
}
}));
}
}
}
```
```html
<!-- create-release.component.html -->
<div class="create-release">
<header class="create-release__header">
<h1>Create Release</h1>
<button pButton label="Cancel" class="p-button-text" (click)="onCancel()"></button>
</header>
<p-steps [model]="steps" [activeIndex]="activeIndex()" [readonly]="true"></p-steps>
<div class="create-release__content">
<so-basic-info-step *ngIf="activeIndex() === 0"
[initialData]="releaseData().basicInfo"
(complete)="onBasicInfoComplete($event)"
(cancel)="onCancel()">
</so-basic-info-step>
<so-component-selection-step *ngIf="activeIndex() === 1"
[initialComponents]="releaseData().components || []"
(complete)="onComponentsComplete($event)"
(back)="onBack()">
</so-component-selection-step>
<so-configuration-step *ngIf="activeIndex() === 2"
[initialConfig]="releaseData().configuration"
(complete)="onConfigurationComplete($event)"
(back)="onBack()">
</so-configuration-step>
<so-review-step *ngIf="activeIndex() === 3"
[releaseData]="releaseData()"
(submit)="onSubmit()"
(back)="onBack()">
</so-review-step>
</div>
</div>
```
### Component Selector Component
```typescript
// component-selector.component.ts
import { Component, Input, Output, EventEmitter, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { debounceTime, Subject } from 'rxjs';
export interface RegistryImage {
name: string;
repository: string;
tags: string[];
digests: Array<{ tag: string; digest: string; pushedAt: Date }>;
lastPushed: Date;
}
@Component({
selector: 'so-component-selector',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './component-selector.component.html',
styleUrl: './component-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ComponentSelectorComponent {
@Input() selectedComponents: ReleaseComponent[] = [];
@Output() selectionChange = new EventEmitter<ReleaseComponent[]>();
@Output() close = new EventEmitter<void>();
private readonly searchSubject = new Subject<string>();
searchTerm = signal('');
searchResults = signal<RegistryImage[]>([]);
loading = signal(false);
selectedImage = signal<RegistryImage | null>(null);
selectedDigest = signal<string | null>(null);
constructor() {
this.searchSubject.pipe(
debounceTime(300)
).subscribe(term => this.search(term));
}
onSearchInput(term: string): void {
this.searchTerm.set(term);
this.searchSubject.next(term);
}
private search(term: string): void {
if (term.length < 2) {
this.searchResults.set([]);
return;
}
this.loading.set(true);
// API call would go here
// For now, simulating with timeout
setTimeout(() => {
this.searchResults.set([
{
name: term,
repository: `registry.example.com/${term}`,
tags: ['latest', 'v1.0.0', 'v1.1.0'],
digests: [
{ tag: 'latest', digest: 'sha256:abc123...', pushedAt: new Date() },
{ tag: 'v1.0.0', digest: 'sha256:def456...', pushedAt: new Date() }
],
lastPushed: new Date()
}
]);
this.loading.set(false);
}, 500);
}
onSelectImage(image: RegistryImage): void {
this.selectedImage.set(image);
this.selectedDigest.set(null);
}
onSelectDigest(digest: string): void {
this.selectedDigest.set(digest);
}
onAddComponent(): void {
const image = this.selectedImage();
const digest = this.selectedDigest();
if (!image || !digest) return;
const digestInfo = image.digests.find(d => d.digest === digest);
const component: ReleaseComponent = {
id: crypto.randomUUID(),
name: image.name,
imageRef: image.repository,
digest: digest,
tag: digestInfo?.tag || null,
version: digestInfo?.tag || digest.substring(7, 19),
type: 'container',
configOverrides: {}
};
const updated = [...this.selectedComponents, component];
this.selectionChange.emit(updated);
this.selectedImage.set(null);
this.selectedDigest.set(null);
}
onRemoveComponent(id: string): void {
const updated = this.selectedComponents.filter(c => c.id !== id);
this.selectionChange.emit(updated);
}
isAlreadySelected(image: RegistryImage, digest: string): boolean {
return this.selectedComponents.some(
c => c.imageRef === image.repository && c.digest === digest
);
}
formatDigest(digest: string): string {
return digest.substring(0, 19) + '...';
}
}
```
```html
<!-- component-selector.component.html -->
<div class="component-selector">
<div class="component-selector__search">
<span class="p-input-icon-left p-input-icon-right">
<i class="pi pi-search"></i>
<input type="text" pInputText placeholder="Search images..."
[ngModel]="searchTerm()" (ngModelChange)="onSearchInput($event)" />
<i class="pi pi-spin pi-spinner" *ngIf="loading()"></i>
</span>
</div>
<div class="component-selector__results">
<div *ngIf="searchResults().length === 0 && searchTerm().length >= 2 && !loading()"
class="no-results">
No images found matching "{{ searchTerm() }}"
</div>
<div *ngFor="let image of searchResults()" class="image-result"
[class.selected]="selectedImage()?.name === image.name"
(click)="onSelectImage(image)">
<div class="image-result__header">
<i class="pi pi-box"></i>
<span class="image-result__name">{{ image.name }}</span>
</div>
<small class="image-result__repo">{{ image.repository }}</small>
<div *ngIf="selectedImage()?.name === image.name" class="digest-list">
<div *ngFor="let d of image.digests" class="digest-item"
[class.selected]="selectedDigest() === d.digest"
[class.disabled]="isAlreadySelected(image, d.digest)"
(click)="$event.stopPropagation(); onSelectDigest(d.digest)">
<span class="digest-item__tag">{{ d.tag || 'untagged' }}</span>
<span class="digest-item__digest">{{ formatDigest(d.digest) }}</span>
<span class="digest-item__date">{{ d.pushedAt | date:'short' }}</span>
<span *ngIf="isAlreadySelected(image, d.digest)" class="badge badge--info">Added</span>
</div>
</div>
</div>
</div>
<div class="component-selector__selected">
<h4>Selected Components ({{ selectedComponents.length }})</h4>
<div *ngIf="selectedComponents.length === 0" class="empty-selection">
No components selected yet
</div>
<div *ngFor="let comp of selectedComponents" class="selected-component">
<div class="selected-component__info">
<span class="selected-component__name">{{ comp.name }}</span>
<span class="selected-component__version">{{ comp.version }}</span>
<small class="selected-component__digest">{{ formatDigest(comp.digest) }}</small>
</div>
<button pButton icon="pi pi-times" class="p-button-text p-button-danger p-button-sm"
(click)="onRemoveComponent(comp.id)"></button>
</div>
</div>
<div class="component-selector__actions">
<button pButton label="Add Selected" icon="pi pi-plus"
[disabled]="!selectedDigest()"
(click)="onAddComponent()"></button>
</div>
</div>
```
### Release Timeline Component
```typescript
// release-timeline.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'so-release-timeline',
standalone: true,
imports: [CommonModule],
templateUrl: './release-timeline.component.html',
styleUrl: './release-timeline.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReleaseTimelineComponent {
@Input() events: ReleaseEvent[] | null = null;
getEventIcon(type: string): string {
const icons: Record<string, string> = {
created: 'pi-plus-circle',
promoted: 'pi-arrow-right',
approved: 'pi-check-circle',
rejected: 'pi-times-circle',
deployed: 'pi-cloud-upload',
failed: 'pi-exclamation-triangle',
rolled_back: 'pi-undo'
};
return icons[type] || 'pi-circle';
}
getEventClass(type: string): string {
const classes: Record<string, string> = {
created: 'event--info',
promoted: 'event--info',
approved: 'event--success',
rejected: 'event--danger',
deployed: 'event--success',
failed: 'event--danger',
rolled_back: 'event--warning'
};
return classes[type] || 'event--default';
}
}
```
```html
<!-- release-timeline.component.html -->
<div class="release-timeline">
<div *ngIf="!events || events.length === 0" class="timeline-empty">
<i class="pi pi-history"></i>
<p>No events yet</p>
</div>
<div class="timeline">
<div *ngFor="let event of events" class="timeline-event" [ngClass]="getEventClass(event.type)">
<div class="timeline-event__marker">
<i class="pi" [ngClass]="getEventIcon(event.type)"></i>
</div>
<div class="timeline-event__content">
<div class="timeline-event__header">
<span class="timeline-event__type">{{ event.type | titlecase }}</span>
<span class="timeline-event__env" *ngIf="event.environment">
{{ event.environment }}
</span>
</div>
<p class="timeline-event__message">{{ event.message }}</p>
<div class="timeline-event__meta">
<span class="timeline-event__actor">{{ event.actor }}</span>
<span class="timeline-event__time">{{ event.timestamp | date:'medium' }}</span>
</div>
</div>
</div>
</div>
</div>
```
---
## Acceptance Criteria
- [ ] Release list displays all releases
- [ ] Filtering by status works
- [ ] Filtering by environment works
- [ ] Search finds releases by name
- [ ] Sorting by columns works
- [ ] Pagination works correctly
- [ ] Release detail loads correctly
- [ ] Component list displays properly
- [ ] Timeline shows release events
- [ ] Promote action works
- [ ] Deploy action with confirmation
- [ ] Rollback action with confirmation
- [ ] Create wizard completes all steps
- [ ] Component selector searches registry
- [ ] Component selector adds by digest
- [ ] Review step shows all data
- [ ] Unit test coverage >=80%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 104_003 Release Bundle | Internal | TODO |
| Angular 17 | External | Available |
| NgRx 17 | External | Available |
| PrimeNG 17 | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| ReleaseListComponent | TODO | |
| ReleaseDetailComponent | TODO | |
| CreateReleaseComponent | TODO | |
| BasicInfoStepComponent | TODO | |
| ComponentSelectionStepComponent | TODO | |
| ConfigurationStepComponent | TODO | |
| ReviewStepComponent | TODO | |
| ComponentSelectorComponent | TODO | |
| ComponentListComponent | TODO | |
| ReleaseTimelineComponent | TODO | |
| Release NgRx Store | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,991 @@
# SPRINT: Promotion & Approval UI
> **Sprint ID:** 111_005
> **Module:** FE
> **Phase:** 11 - UI Implementation
> **Status:** TODO
> **Parent:** [111_000_INDEX](SPRINT_20260110_111_000_INDEX_ui_implementation.md)
---
## Overview
Implement the Promotion and Approval UI providing promotion request creation, approval queue management, approval detail views, and gate results display.
### Objectives
- Promotion request form with gate preview
- Approval queue with filtering
- Approval detail with gate results
- Approve/reject with comments
- Batch approval support
- Approval history
### Working Directory
```
src/Web/StellaOps.Web/
├── src/app/features/release-orchestrator/
│ └── approvals/
│ ├── promotion-request/
│ │ ├── promotion-request.component.ts
│ │ ├── promotion-request.component.html
│ │ └── promotion-request.component.scss
│ ├── approval-queue/
│ │ ├── approval-queue.component.ts
│ │ ├── approval-queue.component.html
│ │ └── approval-queue.component.scss
│ ├── approval-detail/
│ │ ├── approval-detail.component.ts
│ │ ├── approval-detail.component.html
│ │ └── approval-detail.component.scss
│ ├── components/
│ │ ├── gate-results-panel/
│ │ ├── approval-form/
│ │ ├── approval-history/
│ │ └── approver-list/
│ └── approvals.routes.ts
└── src/app/store/release-orchestrator/
└── approvals/
├── approvals.actions.ts
├── approvals.reducer.ts
└── approvals.selectors.ts
```
---
## Deliverables
### Promotion Request Component
```typescript
// promotion-request.component.ts
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { GateResultsPanelComponent } from '../components/gate-results-panel/gate-results-panel.component';
import { PromotionActions } from '@store/release-orchestrator/approvals/approvals.actions';
import * as ApprovalSelectors from '@store/release-orchestrator/approvals/approvals.selectors';
export interface GateResult {
gateId: string;
gateName: string;
type: 'security' | 'policy' | 'quality' | 'custom';
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
message: string;
details: Record<string, any>;
evaluatedAt: Date;
}
export interface PromotionPreview {
releaseId: string;
releaseName: string;
sourceEnvironment: string;
targetEnvironment: string;
gateResults: GateResult[];
allGatesPassed: boolean;
requiredApprovers: number;
estimatedDeployTime: number;
warnings: string[];
}
@Component({
selector: 'so-promotion-request',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, GateResultsPanelComponent],
templateUrl: './promotion-request.component.html',
styleUrl: './promotion-request.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PromotionRequestComponent implements OnInit {
private readonly store = inject(Store);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
readonly preview$ = this.store.select(ApprovalSelectors.selectPromotionPreview);
readonly loading$ = this.store.select(ApprovalSelectors.selectLoading);
readonly submitting$ = this.store.select(ApprovalSelectors.selectSubmitting);
readonly environments$ = this.store.select(ApprovalSelectors.selectAvailableEnvironments);
form: FormGroup = this.fb.group({
targetEnvironment: ['', Validators.required],
urgency: ['normal'],
justification: ['', [Validators.required, Validators.minLength(10)]],
notifyApprovers: [true],
scheduledTime: [null]
});
urgencyOptions = [
{ label: 'Low', value: 'low' },
{ label: 'Normal', value: 'normal' },
{ label: 'High', value: 'high' },
{ label: 'Critical', value: 'critical' }
];
releaseId: string = '';
ngOnInit(): void {
this.releaseId = this.route.snapshot.paramMap.get('releaseId') || '';
if (this.releaseId) {
this.store.dispatch(PromotionActions.loadAvailableEnvironments({ releaseId: this.releaseId }));
}
// Watch for target environment changes to fetch preview
this.form.get('targetEnvironment')?.valueChanges.subscribe(envId => {
if (envId) {
this.store.dispatch(PromotionActions.loadPromotionPreview({
releaseId: this.releaseId,
targetEnvironmentId: envId
}));
}
});
}
onSubmit(): void {
if (this.form.invalid) return;
this.store.dispatch(PromotionActions.submitPromotionRequest({
releaseId: this.releaseId,
request: {
targetEnvironmentId: this.form.value.targetEnvironment,
urgency: this.form.value.urgency,
justification: this.form.value.justification,
notifyApprovers: this.form.value.notifyApprovers,
scheduledTime: this.form.value.scheduledTime
}
}));
}
onCancel(): void {
this.router.navigate(['/releases', this.releaseId]);
}
hasFailedGates(preview: PromotionPreview): boolean {
return preview.gateResults.some(g => g.status === 'failed');
}
getFailedGatesCount(preview: PromotionPreview): number {
return preview.gateResults.filter(g => g.status === 'failed').length;
}
}
```
```html
<!-- promotion-request.component.html -->
<div class="promotion-request">
<header class="promotion-request__header">
<h1>Request Promotion</h1>
</header>
<div class="promotion-request__content">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-section">
<h3>Promotion Details</h3>
<div class="field">
<label for="targetEnv">Target Environment</label>
<p-dropdown id="targetEnv" formControlName="targetEnvironment"
[options]="environments$ | async"
optionLabel="name" optionValue="id"
placeholder="Select target environment">
</p-dropdown>
</div>
<div class="field">
<label for="urgency">Urgency</label>
<p-selectButton id="urgency" formControlName="urgency"
[options]="urgencyOptions"
optionLabel="label" optionValue="value">
</p-selectButton>
</div>
<div class="field">
<label for="justification">Justification</label>
<textarea id="justification" pInputTextarea formControlName="justification"
rows="4" placeholder="Explain why this promotion is needed..."></textarea>
<small class="field-hint">Minimum 10 characters required</small>
</div>
<div class="field">
<label for="scheduledTime">Schedule (Optional)</label>
<p-calendar id="scheduledTime" formControlName="scheduledTime"
[showTime]="true" [showIcon]="true"
[minDate]="now" placeholder="Deploy immediately">
</p-calendar>
<small class="field-hint">Leave empty to deploy as soon as approved</small>
</div>
<div class="field-checkbox">
<p-checkbox formControlName="notifyApprovers" [binary]="true"
inputId="notifyApprovers"></p-checkbox>
<label for="notifyApprovers">Notify approvers via email/Slack</label>
</div>
</div>
<!-- Gate Results Preview -->
<div class="form-section" *ngIf="preview$ | async as preview">
<h3>Gate Evaluation Preview</h3>
<div class="preview-summary" [class.has-failures]="hasFailedGates(preview)">
<div class="preview-summary__status">
<i class="pi" [ngClass]="preview.allGatesPassed ? 'pi-check-circle' : 'pi-times-circle'"></i>
<span *ngIf="preview.allGatesPassed">All gates passed</span>
<span *ngIf="!preview.allGatesPassed">
{{ getFailedGatesCount(preview) }} gate(s) failed
</span>
</div>
<div class="preview-summary__info">
<span>Required approvers: {{ preview.requiredApprovers }}</span>
<span>Est. deploy time: {{ preview.estimatedDeployTime }}s</span>
</div>
</div>
<div class="preview-warnings" *ngIf="preview.warnings.length > 0">
<p-messages severity="warn">
<ng-template pTemplate>
<ul>
<li *ngFor="let warning of preview.warnings">{{ warning }}</li>
</ul>
</ng-template>
</p-messages>
</div>
<so-gate-results-panel [results]="preview.gateResults"></so-gate-results-panel>
</div>
<div class="form-actions">
<button pButton type="button" label="Cancel" class="p-button-text"
(click)="onCancel()"></button>
<button pButton type="submit" label="Submit Request"
[disabled]="form.invalid || (submitting$ | async)"
[loading]="submitting$ | async"></button>
</div>
</form>
</div>
</div>
```
### Approval Queue Component
```typescript
// approval-queue.component.ts
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { ConfirmationService } from 'primeng/api';
import { ApprovalActions } from '@store/release-orchestrator/approvals/approvals.actions';
import * as ApprovalSelectors from '@store/release-orchestrator/approvals/approvals.selectors';
export interface ApprovalRequest {
id: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
sourceEnvironment: string;
targetEnvironment: string;
requestedBy: string;
requestedAt: Date;
urgency: 'low' | 'normal' | 'high' | 'critical';
justification: string;
status: 'pending' | 'approved' | 'rejected' | 'expired';
currentApprovals: number;
requiredApprovals: number;
gatesPassed: boolean;
scheduledTime: Date | null;
expiresAt: Date;
}
@Component({
selector: 'so-approval-queue',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule],
templateUrl: './approval-queue.component.html',
styleUrl: './approval-queue.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ConfirmationService]
})
export class ApprovalQueueComponent implements OnInit {
private readonly store = inject(Store);
private readonly confirmationService = inject(ConfirmationService);
readonly approvals$ = this.store.select(ApprovalSelectors.selectFilteredApprovals);
readonly loading$ = this.store.select(ApprovalSelectors.selectLoading);
readonly selectedIds = signal<Set<string>>(new Set());
statusFilter = signal<string[]>(['pending']);
urgencyFilter = signal<string[]>([]);
environmentFilter = signal<string | null>(null);
readonly statusOptions = [
{ label: 'Pending', value: 'pending' },
{ label: 'Approved', value: 'approved' },
{ label: 'Rejected', value: 'rejected' },
{ label: 'Expired', value: 'expired' }
];
readonly urgencyOptions = [
{ label: 'Low', value: 'low' },
{ label: 'Normal', value: 'normal' },
{ label: 'High', value: 'high' },
{ label: 'Critical', value: 'critical' }
];
ngOnInit(): void {
this.loadApprovals();
}
loadApprovals(): void {
this.store.dispatch(ApprovalActions.loadApprovals({
filter: {
statuses: this.statusFilter(),
urgencies: this.urgencyFilter(),
environment: this.environmentFilter()
}
}));
}
onStatusFilterChange(statuses: string[]): void {
this.statusFilter.set(statuses);
this.loadApprovals();
}
onToggleSelect(id: string): void {
this.selectedIds.update(ids => {
const newIds = new Set(ids);
if (newIds.has(id)) {
newIds.delete(id);
} else {
newIds.add(id);
}
return newIds;
});
}
onSelectAll(approvals: ApprovalRequest[]): void {
const pendingIds = approvals
.filter(a => a.status === 'pending')
.map(a => a.id);
this.selectedIds.set(new Set(pendingIds));
}
onDeselectAll(): void {
this.selectedIds.set(new Set());
}
onBatchApprove(): void {
const ids = Array.from(this.selectedIds());
if (ids.length === 0) return;
this.confirmationService.confirm({
message: `Approve ${ids.length} promotion request(s)?`,
header: 'Batch Approve',
accept: () => {
this.store.dispatch(ApprovalActions.batchApprove({ ids, comment: 'Batch approved' }));
this.selectedIds.set(new Set());
}
});
}
onBatchReject(): void {
const ids = Array.from(this.selectedIds());
if (ids.length === 0) return;
this.confirmationService.confirm({
message: `Reject ${ids.length} promotion request(s)?`,
header: 'Batch Reject',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.store.dispatch(ApprovalActions.batchReject({ ids, comment: 'Batch rejected' }));
this.selectedIds.set(new Set());
}
});
}
getUrgencyClass(urgency: string): string {
const classes: Record<string, string> = {
low: 'urgency--low',
normal: 'urgency--normal',
high: 'urgency--high',
critical: 'urgency--critical'
};
return classes[urgency] || '';
}
getStatusClass(status: string): string {
const classes: Record<string, string> = {
pending: 'badge--warning',
approved: 'badge--success',
rejected: 'badge--danger',
expired: 'badge--secondary'
};
return classes[status] || '';
}
isExpiringSoon(approval: ApprovalRequest): boolean {
const hoursUntilExpiry = (new Date(approval.expiresAt).getTime() - Date.now()) / 3600000;
return approval.status === 'pending' && hoursUntilExpiry < 4;
}
}
```
```html
<!-- approval-queue.component.html -->
<div class="approval-queue">
<header class="approval-queue__header">
<h1>Approval Queue</h1>
<div class="approval-queue__filters">
<p-multiSelect [options]="statusOptions" [ngModel]="statusFilter()"
(ngModelChange)="onStatusFilterChange($event)"
placeholder="Filter by status"></p-multiSelect>
<p-multiSelect [options]="urgencyOptions" [ngModel]="urgencyFilter()"
(ngModelChange)="urgencyFilter.set($event); loadApprovals()"
placeholder="Filter by urgency"></p-multiSelect>
</div>
</header>
<div class="approval-queue__batch-actions" *ngIf="selectedIds().size > 0">
<span>{{ selectedIds().size }} selected</span>
<button pButton label="Approve Selected" icon="pi pi-check" class="p-button-success p-button-sm"
(click)="onBatchApprove()"></button>
<button pButton label="Reject Selected" icon="pi pi-times" class="p-button-danger p-button-sm"
(click)="onBatchReject()"></button>
<button pButton label="Clear Selection" icon="pi pi-times" class="p-button-text p-button-sm"
(click)="onDeselectAll()"></button>
</div>
<div class="approval-queue__content" *ngIf="!(loading$ | async); else loadingTpl">
<p-table [value]="(approvals$ | async) || []" [paginator]="true" [rows]="20"
styleClass="p-datatable-sm">
<ng-template pTemplate="header">
<tr>
<th style="width: 40px">
<p-checkbox [binary]="true"
(onChange)="$event.checked ? onSelectAll((approvals$ | async) || []) : onDeselectAll()">
</p-checkbox>
</th>
<th>Release</th>
<th>Promotion</th>
<th>Urgency</th>
<th>Status</th>
<th>Approvals</th>
<th>Requested</th>
<th>Actions</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-approval>
<tr [class.expiring-soon]="isExpiringSoon(approval)">
<td>
<p-checkbox [binary]="true"
[ngModel]="selectedIds().has(approval.id)"
(ngModelChange)="onToggleSelect(approval.id)"
[disabled]="approval.status !== 'pending'">
</p-checkbox>
</td>
<td>
<a [routerLink]="['/releases', approval.releaseId]" class="release-link">
{{ approval.releaseName }}
<span class="version">{{ approval.releaseVersion }}</span>
</a>
</td>
<td>
<span class="promotion-flow">
{{ approval.sourceEnvironment }}
<i class="pi pi-arrow-right"></i>
{{ approval.targetEnvironment }}
</span>
</td>
<td>
<span class="urgency-badge" [ngClass]="getUrgencyClass(approval.urgency)">
{{ approval.urgency | titlecase }}
</span>
</td>
<td>
<span class="badge" [ngClass]="getStatusClass(approval.status)">
{{ approval.status | titlecase }}
</span>
<span *ngIf="!approval.gatesPassed" class="gate-warning" pTooltip="Some gates failed">
<i class="pi pi-exclamation-triangle"></i>
</span>
</td>
<td>
<span class="approval-progress">
{{ approval.currentApprovals }}/{{ approval.requiredApprovals }}
</span>
</td>
<td>
<span>{{ approval.requestedAt | date:'short' }}</span>
<small class="requested-by">by {{ approval.requestedBy }}</small>
</td>
<td>
<a [routerLink]="[approval.id]" pButton icon="pi pi-eye"
class="p-button-text p-button-sm" pTooltip="View details"></a>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="8" class="text-center">
<div class="empty-state">
<i class="pi pi-inbox"></i>
<h3>No approvals found</h3>
<p>There are no promotion requests matching your filters.</p>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
<ng-template #loadingTpl>
<p-skeleton height="400px"></p-skeleton>
</ng-template>
</div>
<p-confirmDialog></p-confirmDialog>
```
### Approval Detail Component
```typescript
// approval-detail.component.ts
import { Component, OnInit, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { GateResultsPanelComponent } from '../components/gate-results-panel/gate-results-panel.component';
import { ApprovalHistoryComponent } from '../components/approval-history/approval-history.component';
import { ApproverListComponent } from '../components/approver-list/approver-list.component';
import { ApprovalActions } from '@store/release-orchestrator/approvals/approvals.actions';
import * as ApprovalSelectors from '@store/release-orchestrator/approvals/approvals.selectors';
export interface ApprovalAction {
id: string;
approvalId: string;
action: 'approved' | 'rejected';
actor: string;
comment: string;
timestamp: Date;
}
export interface ApprovalDetail extends ApprovalRequest {
gateResults: GateResult[];
actions: ApprovalAction[];
approvers: Array<{
id: string;
name: string;
email: string;
hasApproved: boolean;
approvedAt: Date | null;
}>;
releaseComponents: Array<{
name: string;
version: string;
digest: string;
}>;
}
@Component({
selector: 'so-approval-detail',
standalone: true,
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
GateResultsPanelComponent,
ApprovalHistoryComponent,
ApproverListComponent
],
templateUrl: './approval-detail.component.html',
styleUrl: './approval-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ApprovalDetailComponent implements OnInit {
private readonly store = inject(Store);
private readonly route = inject(ActivatedRoute);
private readonly fb = inject(FormBuilder);
readonly approval$ = this.store.select(ApprovalSelectors.selectCurrentApproval);
readonly loading$ = this.store.select(ApprovalSelectors.selectLoading);
readonly canApprove$ = this.store.select(ApprovalSelectors.selectCanApprove);
readonly submitting$ = this.store.select(ApprovalSelectors.selectSubmitting);
approvalForm: FormGroup = this.fb.group({
comment: ['', Validators.required]
});
showApprovalForm = false;
pendingAction: 'approve' | 'reject' | null = null;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.dispatch(ApprovalActions.loadApproval({ id }));
}
}
onStartApprove(): void {
this.pendingAction = 'approve';
this.showApprovalForm = true;
}
onStartReject(): void {
this.pendingAction = 'reject';
this.showApprovalForm = true;
}
onCancelAction(): void {
this.pendingAction = null;
this.showApprovalForm = false;
this.approvalForm.reset();
}
onSubmitAction(approvalId: string): void {
if (this.approvalForm.invalid || !this.pendingAction) return;
if (this.pendingAction === 'approve') {
this.store.dispatch(ApprovalActions.approve({
id: approvalId,
comment: this.approvalForm.value.comment
}));
} else {
this.store.dispatch(ApprovalActions.reject({
id: approvalId,
comment: this.approvalForm.value.comment
}));
}
this.onCancelAction();
}
getUrgencyClass(urgency: string): string {
return `urgency--${urgency}`;
}
getStatusClass(status: string): string {
const classes: Record<string, string> = {
pending: 'badge--warning',
approved: 'badge--success',
rejected: 'badge--danger',
expired: 'badge--secondary'
};
return classes[status] || '';
}
getTimeRemaining(expiresAt: Date): string {
const ms = new Date(expiresAt).getTime() - Date.now();
if (ms <= 0) return 'Expired';
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
if (hours > 24) {
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
}
return `${hours}h ${minutes}m`;
}
}
```
```html
<!-- approval-detail.component.html -->
<div class="approval-detail" *ngIf="approval$ | async as approval">
<header class="approval-detail__header">
<div class="approval-detail__breadcrumb">
<a routerLink="/approvals">Approvals</a>
<i class="pi pi-angle-right"></i>
<span>{{ approval.releaseName }}</span>
</div>
<div class="approval-detail__title-row">
<div>
<h1>
Promotion Request
<span class="badge" [ngClass]="getStatusClass(approval.status)">
{{ approval.status | titlecase }}
</span>
</h1>
<p class="approval-detail__subtitle">
{{ approval.sourceEnvironment }}
<i class="pi pi-arrow-right"></i>
{{ approval.targetEnvironment }}
</p>
</div>
<div class="approval-detail__actions" *ngIf="approval.status === 'pending' && (canApprove$ | async)">
<button pButton label="Reject" icon="pi pi-times"
class="p-button-danger p-button-outlined"
(click)="onStartReject()"
[disabled]="showApprovalForm"></button>
<button pButton label="Approve" icon="pi pi-check"
class="p-button-success"
(click)="onStartApprove()"
[disabled]="showApprovalForm"></button>
</div>
</div>
<div class="approval-detail__meta">
<div class="meta-item" [ngClass]="getUrgencyClass(approval.urgency)">
<i class="pi pi-flag"></i>
<span>{{ approval.urgency | titlecase }} urgency</span>
</div>
<div class="meta-item">
<i class="pi pi-user"></i>
<span>Requested by {{ approval.requestedBy }}</span>
</div>
<div class="meta-item">
<i class="pi pi-calendar"></i>
<span>{{ approval.requestedAt | date:'medium' }}</span>
</div>
<div class="meta-item" *ngIf="approval.status === 'pending'">
<i class="pi pi-clock"></i>
<span>Expires in {{ getTimeRemaining(approval.expiresAt) }}</span>
</div>
</div>
</header>
<!-- Approval Form Overlay -->
<div class="approval-form-overlay" *ngIf="showApprovalForm">
<div class="approval-form">
<h3>{{ pendingAction === 'approve' ? 'Approve' : 'Reject' }} Promotion</h3>
<form [formGroup]="approvalForm" (ngSubmit)="onSubmitAction(approval.id)">
<div class="field">
<label for="comment">Comment</label>
<textarea id="comment" pInputTextarea formControlName="comment"
rows="4" placeholder="Add a comment (required)..."></textarea>
</div>
<div class="form-actions">
<button pButton type="button" label="Cancel" class="p-button-text"
(click)="onCancelAction()"></button>
<button pButton type="submit"
[label]="pendingAction === 'approve' ? 'Approve' : 'Reject'"
[class]="pendingAction === 'approve' ? 'p-button-success' : 'p-button-danger'"
[disabled]="approvalForm.invalid || (submitting$ | async)"
[loading]="submitting$ | async"></button>
</div>
</form>
</div>
</div>
<div class="approval-detail__content">
<div class="approval-detail__main">
<!-- Justification -->
<section class="detail-section">
<h3>Justification</h3>
<p class="justification-text">{{ approval.justification }}</p>
</section>
<!-- Gate Results -->
<section class="detail-section">
<h3>
Gate Evaluation
<span class="badge" [ngClass]="approval.gatesPassed ? 'badge--success' : 'badge--danger'">
{{ approval.gatesPassed ? 'All Passed' : 'Some Failed' }}
</span>
</h3>
<so-gate-results-panel [results]="approval.gateResults"></so-gate-results-panel>
</section>
<!-- Release Components -->
<section class="detail-section">
<h3>Release Components</h3>
<div class="component-list">
<div *ngFor="let comp of approval.releaseComponents" class="component-item">
<span class="component-item__name">{{ comp.name }}</span>
<span class="component-item__version">{{ comp.version }}</span>
<code class="component-item__digest">{{ comp.digest | slice:0:19 }}...</code>
</div>
</div>
</section>
</div>
<aside class="approval-detail__sidebar">
<!-- Approval Progress -->
<section class="sidebar-section">
<h4>Approval Progress</h4>
<div class="approval-progress">
<div class="progress-bar">
<div class="progress-bar__fill"
[style.width.%]="(approval.currentApprovals / approval.requiredApprovals) * 100">
</div>
</div>
<span class="progress-text">
{{ approval.currentApprovals }} of {{ approval.requiredApprovals }} required
</span>
</div>
<so-approver-list [approvers]="approval.approvers"></so-approver-list>
</section>
<!-- Approval History -->
<section class="sidebar-section">
<h4>History</h4>
<so-approval-history [actions]="approval.actions"></so-approval-history>
</section>
</aside>
</div>
</div>
<div class="loading-overlay" *ngIf="loading$ | async">
<p-progressSpinner></p-progressSpinner>
</div>
```
### Gate Results Panel Component
```typescript
// gate-results-panel.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'so-gate-results-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './gate-results-panel.component.html',
styleUrl: './gate-results-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GateResultsPanelComponent {
@Input() results: GateResult[] = [];
@Input() showDetails = true;
expandedGates = new Set<string>();
getGateIcon(status: string): string {
const icons: Record<string, string> = {
passed: 'pi-check-circle',
failed: 'pi-times-circle',
warning: 'pi-exclamation-triangle',
pending: 'pi-spin pi-spinner',
skipped: 'pi-minus-circle'
};
return icons[status] || 'pi-question-circle';
}
getGateClass(status: string): string {
return `gate--${status}`;
}
getTypeIcon(type: string): string {
const icons: Record<string, string> = {
security: 'pi-shield',
policy: 'pi-book',
quality: 'pi-chart-bar',
custom: 'pi-cog'
};
return icons[type] || 'pi-circle';
}
toggleExpand(gateId: string): void {
if (this.expandedGates.has(gateId)) {
this.expandedGates.delete(gateId);
} else {
this.expandedGates.add(gateId);
}
}
isExpanded(gateId: string): boolean {
return this.expandedGates.has(gateId);
}
}
```
```html
<!-- gate-results-panel.component.html -->
<div class="gate-results-panel">
<div *ngIf="results.length === 0" class="no-gates">
No gates configured for this promotion
</div>
<div *ngFor="let gate of results" class="gate-item" [ngClass]="getGateClass(gate.status)">
<div class="gate-item__header" (click)="toggleExpand(gate.gateId)">
<div class="gate-item__status">
<i class="pi" [ngClass]="getGateIcon(gate.status)"></i>
</div>
<div class="gate-item__info">
<span class="gate-item__name">
<i class="pi" [ngClass]="getTypeIcon(gate.type)"></i>
{{ gate.gateName }}
</span>
<span class="gate-item__message">{{ gate.message }}</span>
</div>
<div class="gate-item__expand" *ngIf="showDetails && gate.details">
<i class="pi" [ngClass]="isExpanded(gate.gateId) ? 'pi-chevron-up' : 'pi-chevron-down'"></i>
</div>
</div>
<div class="gate-item__details" *ngIf="showDetails && isExpanded(gate.gateId) && gate.details">
<div class="detail-row" *ngFor="let item of gate.details | keyvalue">
<span class="detail-key">{{ item.key }}</span>
<span class="detail-value">{{ item.value | json }}</span>
</div>
<div class="detail-timestamp">
Evaluated at {{ gate.evaluatedAt | date:'medium' }}
</div>
</div>
</div>
</div>
```
---
## Acceptance Criteria
- [ ] Promotion request form validates input
- [ ] Target environment dropdown populated
- [ ] Gate preview loads on environment select
- [ ] Failed gates block submission (with override option)
- [ ] Approval queue shows pending requests
- [ ] Filtering by status works
- [ ] Filtering by urgency works
- [ ] Batch selection works
- [ ] Batch approve/reject works
- [ ] Approval detail loads correctly
- [ ] Approve action with comment
- [ ] Reject action with comment
- [ ] Gate results display correctly
- [ ] Approval progress shows correctly
- [ ] Approver list shows who approved
- [ ] Approval history timeline
- [ ] Unit test coverage >=80%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 106_001 Promotion Request | Internal | TODO |
| Angular 17 | External | Available |
| NgRx 17 | External | Available |
| PrimeNG 17 | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| PromotionRequestComponent | TODO | |
| ApprovalQueueComponent | TODO | |
| ApprovalDetailComponent | TODO | |
| GateResultsPanelComponent | TODO | |
| ApprovalFormComponent | TODO | |
| ApprovalHistoryComponent | TODO | |
| ApproverListComponent | TODO | |
| Approval NgRx Store | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

View File

@@ -0,0 +1,895 @@
# SPRINT: Deployment Monitoring UI
> **Sprint ID:** 111_006
> **Module:** FE
> **Phase:** 11 - UI Implementation
> **Status:** TODO
> **Parent:** [111_000_INDEX](SPRINT_20260110_111_000_INDEX_ui_implementation.md)
---
## Overview
Implement the Deployment Monitoring UI providing real-time deployment status, per-target progress tracking, live log streaming, and rollback capabilities.
### Objectives
- Deployment status overview
- Per-target progress tracking
- Real-time log streaming
- Deployment actions (pause, resume, cancel)
- Rollback confirmation dialog
- Deployment history
### Working Directory
```
src/Web/StellaOps.Web/
├── src/app/features/release-orchestrator/
│ └── deployments/
│ ├── deployment-list/
│ │ ├── deployment-list.component.ts
│ │ ├── deployment-list.component.html
│ │ └── deployment-list.component.scss
│ ├── deployment-monitor/
│ │ ├── deployment-monitor.component.ts
│ │ ├── deployment-monitor.component.html
│ │ └── deployment-monitor.component.scss
│ ├── components/
│ │ ├── target-progress-list/
│ │ ├── log-stream-viewer/
│ │ ├── deployment-timeline/
│ │ ├── rollback-dialog/
│ │ └── deployment-metrics/
│ └── deployments.routes.ts
└── src/app/store/release-orchestrator/
└── deployments/
├── deployments.actions.ts
├── deployments.reducer.ts
└── deployments.selectors.ts
```
---
## Deliverables
### Deployment Monitor Component
```typescript
// deployment-monitor.component.ts
import { Component, OnInit, OnDestroy, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject, takeUntil } from 'rxjs';
import { ConfirmationService, MessageService } from 'primeng/api';
import { TargetProgressListComponent } from '../components/target-progress-list/target-progress-list.component';
import { LogStreamViewerComponent } from '../components/log-stream-viewer/log-stream-viewer.component';
import { DeploymentTimelineComponent } from '../components/deployment-timeline/deployment-timeline.component';
import { DeploymentMetricsComponent } from '../components/deployment-metrics/deployment-metrics.component';
import { RollbackDialogComponent } from '../components/rollback-dialog/rollback-dialog.component';
import { DeploymentActions } from '@store/release-orchestrator/deployments/deployments.actions';
import * as DeploymentSelectors from '@store/release-orchestrator/deployments/deployments.selectors';
export interface Deployment {
id: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
environmentId: string;
environmentName: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' | 'rolling_back';
strategy: 'rolling' | 'blue_green' | 'canary' | 'all_at_once';
progress: number;
startedAt: Date;
completedAt: Date | null;
initiatedBy: string;
targets: DeploymentTarget[];
}
export interface DeploymentTarget {
id: string;
name: string;
type: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
progress: number;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
agentId: string;
error: string | null;
}
export interface DeploymentEvent {
id: string;
type: 'started' | 'target_started' | 'target_completed' | 'target_failed' | 'paused' | 'resumed' | 'completed' | 'failed' | 'cancelled' | 'rollback_started';
targetId: string | null;
targetName: string | null;
message: string;
timestamp: Date;
}
@Component({
selector: 'so-deployment-monitor',
standalone: true,
imports: [
CommonModule,
RouterModule,
TargetProgressListComponent,
LogStreamViewerComponent,
DeploymentTimelineComponent,
DeploymentMetricsComponent,
RollbackDialogComponent
],
templateUrl: './deployment-monitor.component.html',
styleUrl: './deployment-monitor.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ConfirmationService, MessageService]
})
export class DeploymentMonitorComponent implements OnInit, OnDestroy {
private readonly store = inject(Store);
private readonly route = inject(ActivatedRoute);
private readonly confirmationService = inject(ConfirmationService);
private readonly destroy$ = new Subject<void>();
readonly deployment$ = this.store.select(DeploymentSelectors.selectCurrentDeployment);
readonly targets$ = this.store.select(DeploymentSelectors.selectDeploymentTargets);
readonly events$ = this.store.select(DeploymentSelectors.selectDeploymentEvents);
readonly logs$ = this.store.select(DeploymentSelectors.selectDeploymentLogs);
readonly metrics$ = this.store.select(DeploymentSelectors.selectDeploymentMetrics);
readonly loading$ = this.store.select(DeploymentSelectors.selectLoading);
selectedTargetId = signal<string | null>(null);
showRollbackDialog = signal(false);
activeTab = signal<'logs' | 'timeline' | 'metrics'>('logs');
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.dispatch(DeploymentActions.loadDeployment({ id }));
this.store.dispatch(DeploymentActions.subscribeToUpdates({ deploymentId: id }));
}
}
ngOnDestroy(): void {
this.store.dispatch(DeploymentActions.unsubscribeFromUpdates());
this.destroy$.next();
this.destroy$.complete();
}
onPause(deployment: Deployment): void {
this.confirmationService.confirm({
message: 'Pause the deployment? In-progress targets will complete, but no new targets will start.',
header: 'Pause Deployment',
accept: () => {
this.store.dispatch(DeploymentActions.pause({ deploymentId: deployment.id }));
}
});
}
onResume(deployment: Deployment): void {
this.store.dispatch(DeploymentActions.resume({ deploymentId: deployment.id }));
}
onCancel(deployment: Deployment): void {
this.confirmationService.confirm({
message: 'Cancel the deployment? In-progress targets will complete, but no new targets will start. This cannot be undone.',
header: 'Cancel Deployment',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.store.dispatch(DeploymentActions.cancel({ deploymentId: deployment.id }));
}
});
}
onRollback(): void {
this.showRollbackDialog.set(true);
}
onRollbackConfirm(options: { targetIds?: string[]; reason: string }): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.dispatch(DeploymentActions.rollback({
deploymentId: id,
targetIds: options.targetIds,
reason: options.reason
}));
}
this.showRollbackDialog.set(false);
}
onTargetSelect(targetId: string | null): void {
this.selectedTargetId.set(targetId);
if (targetId) {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.dispatch(DeploymentActions.loadTargetLogs({
deploymentId: id,
targetId
}));
}
}
}
onRetryTarget(targetId: string): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.dispatch(DeploymentActions.retryTarget({
deploymentId: id,
targetId
}));
}
}
getStatusIcon(status: string): string {
const icons: Record<string, string> = {
pending: 'pi-clock',
running: 'pi-spin pi-spinner',
paused: 'pi-pause',
completed: 'pi-check-circle',
failed: 'pi-times-circle',
cancelled: 'pi-ban',
rolling_back: 'pi-spin pi-undo'
};
return icons[status] || 'pi-question';
}
getStatusClass(status: string): string {
const classes: Record<string, string> = {
pending: 'status--pending',
running: 'status--running',
paused: 'status--paused',
completed: 'status--success',
failed: 'status--danger',
cancelled: 'status--cancelled',
rolling_back: 'status--warning'
};
return classes[status] || '';
}
getDuration(deployment: Deployment): string {
const start = new Date(deployment.startedAt).getTime();
const end = deployment.completedAt
? new Date(deployment.completedAt).getTime()
: Date.now();
const seconds = Math.floor((end - start) / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
canPause(deployment: Deployment): boolean {
return deployment.status === 'running';
}
canResume(deployment: Deployment): boolean {
return deployment.status === 'paused';
}
canCancel(deployment: Deployment): boolean {
return ['running', 'paused', 'pending'].includes(deployment.status);
}
canRollback(deployment: Deployment): boolean {
return ['completed', 'failed'].includes(deployment.status);
}
}
```
```html
<!-- deployment-monitor.component.html -->
<div class="deployment-monitor" *ngIf="deployment$ | async as deployment">
<header class="deployment-monitor__header">
<div class="deployment-monitor__breadcrumb">
<a routerLink="/deployments">Deployments</a>
<i class="pi pi-angle-right"></i>
<span>{{ deployment.releaseName }}</span>
</div>
<div class="deployment-monitor__title-row">
<div class="deployment-monitor__info">
<h1>
<i class="pi" [ngClass]="getStatusIcon(deployment.status)"></i>
{{ deployment.releaseName }}
<span class="version">{{ deployment.releaseVersion }}</span>
</h1>
<p class="deployment-monitor__subtitle">
Deploying to <strong>{{ deployment.environmentName }}</strong>
using <strong>{{ deployment.strategy | titlecase }}</strong> strategy
</p>
</div>
<div class="deployment-monitor__actions">
<button pButton icon="pi pi-pause" label="Pause"
class="p-button-outlined"
(click)="onPause(deployment)"
*ngIf="canPause(deployment)"></button>
<button pButton icon="pi pi-play" label="Resume"
class="p-button-success"
(click)="onResume(deployment)"
*ngIf="canResume(deployment)"></button>
<button pButton icon="pi pi-times" label="Cancel"
class="p-button-danger p-button-outlined"
(click)="onCancel(deployment)"
*ngIf="canCancel(deployment)"></button>
<button pButton icon="pi pi-undo" label="Rollback"
class="p-button-warning"
(click)="onRollback()"
*ngIf="canRollback(deployment)"></button>
</div>
</div>
<div class="deployment-monitor__progress">
<div class="progress-header">
<span class="progress-text">{{ deployment.progress }}% complete</span>
<span class="progress-duration">Duration: {{ getDuration(deployment) }}</span>
</div>
<p-progressBar [value]="deployment.progress"
[ngClass]="getStatusClass(deployment.status)">
</p-progressBar>
</div>
<div class="deployment-monitor__stats">
<div class="stat">
<span class="stat__value">{{ (targets$ | async)?.length || 0 }}</span>
<span class="stat__label">Total Targets</span>
</div>
<div class="stat stat--success">
<span class="stat__value">
{{ ((targets$ | async) || []) | filter:'status':'completed' | count }}
</span>
<span class="stat__label">Completed</span>
</div>
<div class="stat stat--running">
<span class="stat__value">
{{ ((targets$ | async) || []) | filter:'status':'running' | count }}
</span>
<span class="stat__label">Running</span>
</div>
<div class="stat stat--danger">
<span class="stat__value">
{{ ((targets$ | async) || []) | filter:'status':'failed' | count }}
</span>
<span class="stat__label">Failed</span>
</div>
</div>
</header>
<div class="deployment-monitor__content">
<aside class="deployment-monitor__sidebar">
<so-target-progress-list
[targets]="targets$ | async"
[selectedTargetId]="selectedTargetId()"
(targetSelect)="onTargetSelect($event)"
(retryTarget)="onRetryTarget($event)">
</so-target-progress-list>
</aside>
<main class="deployment-monitor__main">
<div class="tab-header">
<button [class.active]="activeTab() === 'logs'" (click)="activeTab.set('logs')">
<i class="pi pi-align-left"></i> Logs
</button>
<button [class.active]="activeTab() === 'timeline'" (click)="activeTab.set('timeline')">
<i class="pi pi-clock"></i> Timeline
</button>
<button [class.active]="activeTab() === 'metrics'" (click)="activeTab.set('metrics')">
<i class="pi pi-chart-line"></i> Metrics
</button>
</div>
<div class="tab-content">
<so-log-stream-viewer *ngIf="activeTab() === 'logs'"
[logs]="logs$ | async"
[targetId]="selectedTargetId()">
</so-log-stream-viewer>
<so-deployment-timeline *ngIf="activeTab() === 'timeline'"
[events]="events$ | async">
</so-deployment-timeline>
<so-deployment-metrics *ngIf="activeTab() === 'metrics'"
[metrics]="metrics$ | async">
</so-deployment-metrics>
</div>
</main>
</div>
</div>
<so-rollback-dialog
*ngIf="showRollbackDialog()"
[deployment]="deployment$ | async"
[targets]="targets$ | async"
(confirm)="onRollbackConfirm($event)"
(cancel)="showRollbackDialog.set(false)">
</so-rollback-dialog>
<p-confirmDialog></p-confirmDialog>
```
### Target Progress List Component
```typescript
// target-progress-list.component.ts
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'so-target-progress-list',
standalone: true,
imports: [CommonModule],
templateUrl: './target-progress-list.component.html',
styleUrl: './target-progress-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TargetProgressListComponent {
@Input() targets: DeploymentTarget[] | null = null;
@Input() selectedTargetId: string | null = null;
@Output() targetSelect = new EventEmitter<string | null>();
@Output() retryTarget = new EventEmitter<string>();
getStatusIcon(status: string): string {
const icons: Record<string, string> = {
pending: 'pi-clock',
running: 'pi-spin pi-spinner',
completed: 'pi-check-circle',
failed: 'pi-times-circle',
skipped: 'pi-minus-circle'
};
return icons[status] || 'pi-question';
}
getStatusClass(status: string): string {
return `target--${status}`;
}
getTypeIcon(type: string): string {
const icons: Record<string, string> = {
docker_host: 'pi-box',
compose_host: 'pi-th-large',
ecs_service: 'pi-cloud',
nomad_job: 'pi-sitemap'
};
return icons[type] || 'pi-server';
}
formatDuration(ms: number | null): string {
if (ms === null) return '-';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${seconds}s`;
}
onSelect(targetId: string): void {
if (this.selectedTargetId === targetId) {
this.targetSelect.emit(null);
} else {
this.targetSelect.emit(targetId);
}
}
onRetry(event: Event, targetId: string): void {
event.stopPropagation();
this.retryTarget.emit(targetId);
}
}
```
```html
<!-- target-progress-list.component.html -->
<div class="target-progress-list">
<h3>Deployment Targets</h3>
<div class="target-list">
<div *ngFor="let target of targets"
class="target-item"
[ngClass]="getStatusClass(target.status)"
[class.selected]="target.id === selectedTargetId"
(click)="onSelect(target.id)">
<div class="target-item__status">
<i class="pi" [ngClass]="getStatusIcon(target.status)"></i>
</div>
<div class="target-item__info">
<div class="target-item__header">
<i class="pi" [ngClass]="getTypeIcon(target.type)"></i>
<span class="target-item__name">{{ target.name }}</span>
</div>
<div class="target-item__progress" *ngIf="target.status === 'running'">
<p-progressBar [value]="target.progress" [showValue]="false"></p-progressBar>
</div>
<div class="target-item__meta">
<span *ngIf="target.duration !== null" class="duration">
{{ formatDuration(target.duration) }}
</span>
<span class="agent">Agent: {{ target.agentId }}</span>
</div>
<div class="target-item__error" *ngIf="target.error">
{{ target.error }}
</div>
</div>
<div class="target-item__actions">
<button *ngIf="target.status === 'failed'"
pButton icon="pi pi-refresh"
class="p-button-text p-button-sm"
pTooltip="Retry"
(click)="onRetry($event, target.id)">
</button>
</div>
</div>
</div>
<div *ngIf="!targets || targets.length === 0" class="empty-state">
<i class="pi pi-server"></i>
<p>No targets</p>
</div>
</div>
```
### Log Stream Viewer Component
```typescript
// log-stream-viewer.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewChecked,
OnChanges, SimpleChanges, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export interface LogEntry {
timestamp: Date;
level: 'debug' | 'info' | 'warn' | 'error';
source: string;
targetId: string | null;
message: string;
}
@Component({
selector: 'so-log-stream-viewer',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './log-stream-viewer.component.html',
styleUrl: './log-stream-viewer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LogStreamViewerComponent implements AfterViewChecked, OnChanges {
@Input() logs: LogEntry[] | null = null;
@Input() targetId: string | null = null;
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
autoScroll = signal(true);
searchTerm = signal('');
levelFilter = signal<string[]>(['debug', 'info', 'warn', 'error']);
private shouldScroll = false;
readonly levelOptions = [
{ label: 'Debug', value: 'debug' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' }
];
ngOnChanges(changes: SimpleChanges): void {
if (changes['logs'] && this.autoScroll()) {
this.shouldScroll = true;
}
}
ngAfterViewChecked(): void {
if (this.shouldScroll && this.logContainer) {
this.scrollToBottom();
this.shouldScroll = false;
}
}
get filteredLogs(): LogEntry[] {
if (!this.logs) return [];
return this.logs.filter(log => {
// Filter by target
if (this.targetId && log.targetId !== this.targetId) {
return false;
}
// Filter by level
if (!this.levelFilter().includes(log.level)) {
return false;
}
// Filter by search term
const term = this.searchTerm().toLowerCase();
if (term && !log.message.toLowerCase().includes(term)) {
return false;
}
return true;
});
}
getLevelClass(level: string): string {
return `log-entry--${level}`;
}
formatTimestamp(timestamp: Date): string {
return new Date(timestamp).toISOString().split('T')[1].slice(0, 12);
}
scrollToBottom(): void {
if (this.logContainer) {
const el = this.logContainer.nativeElement;
el.scrollTop = el.scrollHeight;
}
}
onClear(): void {
this.searchTerm.set('');
}
onCopy(): void {
const text = this.filteredLogs
.map(log => `${this.formatTimestamp(log.timestamp)} [${log.level.toUpperCase()}] ${log.message}`)
.join('\n');
navigator.clipboard.writeText(text);
}
onDownload(): void {
const text = this.filteredLogs
.map(log => JSON.stringify(log))
.join('\n');
const blob = new Blob([text], { type: 'application/x-ndjson' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `deployment-logs-${Date.now()}.ndjson`;
a.click();
URL.revokeObjectURL(url);
}
}
```
```html
<!-- log-stream-viewer.component.html -->
<div class="log-stream-viewer">
<div class="log-stream-viewer__toolbar">
<span class="p-input-icon-left">
<i class="pi pi-search"></i>
<input type="text" pInputText placeholder="Search logs..."
[ngModel]="searchTerm()" (ngModelChange)="searchTerm.set($event)" />
</span>
<p-multiSelect [options]="levelOptions" [ngModel]="levelFilter()"
(ngModelChange)="levelFilter.set($event)"
placeholder="Log levels" display="chip">
</p-multiSelect>
<div class="toolbar-actions">
<p-toggleButton [ngModel]="autoScroll()" (ngModelChange)="autoScroll.set($event)"
onLabel="Auto-scroll" offLabel="Auto-scroll"
onIcon="pi pi-check" offIcon="pi pi-times">
</p-toggleButton>
<button pButton icon="pi pi-copy" class="p-button-text" pTooltip="Copy"
(click)="onCopy()"></button>
<button pButton icon="pi pi-download" class="p-button-text" pTooltip="Download"
(click)="onDownload()"></button>
</div>
</div>
<div #logContainer class="log-stream-viewer__content">
<div *ngIf="filteredLogs.length === 0" class="no-logs">
<i class="pi pi-align-left"></i>
<p>No logs to display</p>
</div>
<div *ngFor="let log of filteredLogs" class="log-entry" [ngClass]="getLevelClass(log.level)">
<span class="log-entry__timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="log-entry__level">{{ log.level | uppercase }}</span>
<span class="log-entry__source" *ngIf="log.source">{{ log.source }}</span>
<span class="log-entry__message">{{ log.message }}</span>
</div>
</div>
</div>
```
### Rollback Dialog Component
```typescript
// rollback-dialog.component.ts
import { Component, Input, Output, EventEmitter, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'so-rollback-dialog',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './rollback-dialog.component.html',
styleUrl: './rollback-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RollbackDialogComponent {
@Input() deployment: Deployment | null = null;
@Input() targets: DeploymentTarget[] | null = null;
@Output() confirm = new EventEmitter<{ targetIds?: string[]; reason: string }>();
@Output() cancel = new EventEmitter<void>();
private readonly fb = inject(FormBuilder);
rollbackType = signal<'all' | 'selected'>('all');
selectedTargets = signal<Set<string>>(new Set());
form: FormGroup = this.fb.group({
reason: ['', [Validators.required, Validators.minLength(10)]]
});
get completedTargets(): DeploymentTarget[] {
return (this.targets || []).filter(t => t.status === 'completed');
}
onToggleTarget(targetId: string): void {
this.selectedTargets.update(set => {
const newSet = new Set(set);
if (newSet.has(targetId)) {
newSet.delete(targetId);
} else {
newSet.add(targetId);
}
return newSet;
});
}
onSelectAll(): void {
this.selectedTargets.set(new Set(this.completedTargets.map(t => t.id)));
}
onDeselectAll(): void {
this.selectedTargets.set(new Set());
}
onConfirm(): void {
if (this.form.invalid) return;
const targetIds = this.rollbackType() === 'selected'
? Array.from(this.selectedTargets())
: undefined;
this.confirm.emit({
targetIds,
reason: this.form.value.reason
});
}
}
```
```html
<!-- rollback-dialog.component.html -->
<div class="rollback-dialog-overlay">
<div class="rollback-dialog">
<header class="rollback-dialog__header">
<h2>
<i class="pi pi-undo"></i>
Rollback Deployment
</h2>
<button pButton icon="pi pi-times" class="p-button-text" (click)="cancel.emit()"></button>
</header>
<div class="rollback-dialog__content">
<p-message severity="warn" text="This will restore the previous version on selected targets."></p-message>
<div class="rollback-type">
<h4>Rollback Scope</h4>
<div class="rollback-type__options">
<div class="p-field-radiobutton">
<p-radioButton name="rollbackType" value="all" [ngModel]="rollbackType()"
(ngModelChange)="rollbackType.set($event)" inputId="rollbackAll">
</p-radioButton>
<label for="rollbackAll">All targets ({{ completedTargets.length }})</label>
</div>
<div class="p-field-radiobutton">
<p-radioButton name="rollbackType" value="selected" [ngModel]="rollbackType()"
(ngModelChange)="rollbackType.set($event)" inputId="rollbackSelected">
</p-radioButton>
<label for="rollbackSelected">Selected targets only</label>
</div>
</div>
</div>
<div class="target-selection" *ngIf="rollbackType() === 'selected'">
<div class="target-selection__header">
<span>Select targets to rollback ({{ selectedTargets().size }} selected)</span>
<button pButton label="Select All" class="p-button-text p-button-sm"
(click)="onSelectAll()"></button>
</div>
<div class="target-selection__list">
<div *ngFor="let target of completedTargets" class="target-checkbox">
<p-checkbox [binary]="true" [ngModel]="selectedTargets().has(target.id)"
(ngModelChange)="onToggleTarget(target.id)" [inputId]="target.id">
</p-checkbox>
<label [for]="target.id">{{ target.name }}</label>
</div>
</div>
</div>
<form [formGroup]="form">
<div class="field">
<label for="reason">Reason for rollback</label>
<textarea id="reason" pInputTextarea formControlName="reason"
rows="3" placeholder="Explain why this rollback is needed..."></textarea>
<small class="field-hint">Minimum 10 characters required</small>
</div>
</form>
</div>
<footer class="rollback-dialog__footer">
<button pButton label="Cancel" class="p-button-text" (click)="cancel.emit()"></button>
<button pButton label="Confirm Rollback" class="p-button-warning"
[disabled]="form.invalid || (rollbackType() === 'selected' && selectedTargets().size === 0)"
(click)="onConfirm()"></button>
</footer>
</div>
</div>
```
---
## Acceptance Criteria
- [ ] Deployment list shows all deployments
- [ ] Deployment monitor loads correctly
- [ ] Real-time progress updates via SignalR
- [ ] Target list shows all targets with status
- [ ] Target selection shows target-specific logs
- [ ] Log streaming works in real-time
- [ ] Log filtering by level works
- [ ] Log search works
- [ ] Pause deployment works
- [ ] Resume deployment works
- [ ] Cancel deployment with confirmation
- [ ] Rollback dialog opens
- [ ] Rollback scope selection works
- [ ] Rollback reason required
- [ ] Retry failed target works
- [ ] Timeline shows deployment events
- [ ] Metrics display correctly
- [ ] Unit test coverage >=80%
---
## Dependencies
| Dependency | Type | Status |
|------------|------|--------|
| 107_001 Platform API Gateway | Internal | TODO |
| Angular 17 | External | Available |
| NgRx 17 | External | Available |
| PrimeNG 17 | External | Available |
| SignalR Client | External | Available |
---
## Delivery Tracker
| Deliverable | Status | Notes |
|-------------|--------|-------|
| DeploymentListComponent | TODO | |
| DeploymentMonitorComponent | TODO | |
| TargetProgressListComponent | TODO | |
| LogStreamViewerComponent | TODO | |
| DeploymentTimelineComponent | TODO | |
| DeploymentMetricsComponent | TODO | |
| RollbackDialogComponent | TODO | |
| Deployment NgRx Store | TODO | |
| SignalR integration | TODO | |
| Unit tests | TODO | |
---
## Execution Log
| Date | Entry |
|------|-------|
| 10-Jan-2026 | Sprint created |

File diff suppressed because it is too large Load Diff