Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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 |
|
||||
@@ -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 |
|
||||
1514
docs/implplan/SPRINT_20260110_100_001_PLUGIN_abstractions.md
Normal file
1514
docs/implplan/SPRINT_20260110_100_001_PLUGIN_abstractions.md
Normal file
File diff suppressed because it is too large
Load Diff
1173
docs/implplan/SPRINT_20260110_100_002_PLUGIN_host.md
Normal file
1173
docs/implplan/SPRINT_20260110_100_002_PLUGIN_host.md
Normal file
File diff suppressed because it is too large
Load Diff
762
docs/implplan/SPRINT_20260110_100_003_PLUGIN_registry.md
Normal file
762
docs/implplan/SPRINT_20260110_100_003_PLUGIN_registry.md
Normal 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 |
|
||||
1134
docs/implplan/SPRINT_20260110_100_004_PLUGIN_sandbox.md
Normal file
1134
docs/implplan/SPRINT_20260110_100_004_PLUGIN_sandbox.md
Normal file
File diff suppressed because it is too large
Load Diff
421
docs/implplan/SPRINT_20260110_100_005_PLUGIN_crypto_rework.md
Normal file
421
docs/implplan/SPRINT_20260110_100_005_PLUGIN_crypto_rework.md
Normal 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 |
|
||||
455
docs/implplan/SPRINT_20260110_100_006_PLUGIN_auth_rework.md
Normal file
455
docs/implplan/SPRINT_20260110_100_006_PLUGIN_auth_rework.md
Normal 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 |
|
||||
453
docs/implplan/SPRINT_20260110_100_007_PLUGIN_llm_rework.md
Normal file
453
docs/implplan/SPRINT_20260110_100_007_PLUGIN_llm_rework.md
Normal 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 |
|
||||
359
docs/implplan/SPRINT_20260110_100_008_PLUGIN_scm_rework.md
Normal file
359
docs/implplan/SPRINT_20260110_100_008_PLUGIN_scm_rework.md
Normal 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 |
|
||||
1156
docs/implplan/SPRINT_20260110_100_009_PLUGIN_scanner_rework.md
Normal file
1156
docs/implplan/SPRINT_20260110_100_009_PLUGIN_scanner_rework.md
Normal file
File diff suppressed because it is too large
Load Diff
1129
docs/implplan/SPRINT_20260110_100_010_PLUGIN_router_rework.md
Normal file
1129
docs/implplan/SPRINT_20260110_100_010_PLUGIN_router_rework.md
Normal file
File diff suppressed because it is too large
Load Diff
1209
docs/implplan/SPRINT_20260110_100_011_PLUGIN_concelier_rework.md
Normal file
1209
docs/implplan/SPRINT_20260110_100_011_PLUGIN_concelier_rework.md
Normal file
File diff suppressed because it is too large
Load Diff
1168
docs/implplan/SPRINT_20260110_100_012_PLUGIN_sdk.md
Normal file
1168
docs/implplan/SPRINT_20260110_100_012_PLUGIN_sdk.md
Normal file
File diff suppressed because it is too large
Load Diff
200
docs/implplan/SPRINT_20260110_101_000_INDEX_foundation.md
Normal file
200
docs/implplan/SPRINT_20260110_101_000_INDEX_foundation.md
Normal 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 |
|
||||
617
docs/implplan/SPRINT_20260110_101_001_DB_schema_core_tables.md
Normal file
617
docs/implplan/SPRINT_20260110_101_001_DB_schema_core_tables.md
Normal 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) |
|
||||
938
docs/implplan/SPRINT_20260110_101_002_PLUGIN_registry.md
Normal file
938
docs/implplan/SPRINT_20260110_101_002_PLUGIN_registry.md
Normal 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) |
|
||||
935
docs/implplan/SPRINT_20260110_101_003_PLUGIN_loader_sandbox.md
Normal file
935
docs/implplan/SPRINT_20260110_101_003_PLUGIN_loader_sandbox.md
Normal 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) |
|
||||
1120
docs/implplan/SPRINT_20260110_101_004_PLUGIN_sdk.md
Normal file
1120
docs/implplan/SPRINT_20260110_101_004_PLUGIN_sdk.md
Normal file
File diff suppressed because it is too large
Load Diff
201
docs/implplan/SPRINT_20260110_102_000_INDEX_integration_hub.md
Normal file
201
docs/implplan/SPRINT_20260110_102_000_INDEX_integration_hub.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
460
docs/implplan/SPRINT_20260110_102_003_INTHUB_scm_connectors.md
Normal file
460
docs/implplan/SPRINT_20260110_102_003_INTHUB_scm_connectors.md
Normal 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 |
|
||||
@@ -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 |
|
||||
503
docs/implplan/SPRINT_20260110_102_005_INTHUB_vault_connector.md
Normal file
503
docs/implplan/SPRINT_20260110_102_005_INTHUB_vault_connector.md
Normal 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 |
|
||||
605
docs/implplan/SPRINT_20260110_102_006_INTHUB_doctor_checks.md
Normal file
605
docs/implplan/SPRINT_20260110_102_006_INTHUB_doctor_checks.md
Normal 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 |
|
||||
@@ -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 |
|
||||
415
docs/implplan/SPRINT_20260110_103_001_ENVMGR_environment.md
Normal file
415
docs/implplan/SPRINT_20260110_103_001_ENVMGR_environment.md
Normal 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) |
|
||||
421
docs/implplan/SPRINT_20260110_103_002_ENVMGR_target_registry.md
Normal file
421
docs/implplan/SPRINT_20260110_103_002_ENVMGR_target_registry.md
Normal 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) |
|
||||
554
docs/implplan/SPRINT_20260110_103_003_ENVMGR_agent_manager.md
Normal file
554
docs/implplan/SPRINT_20260110_103_003_ENVMGR_agent_manager.md
Normal 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 |
|
||||
385
docs/implplan/SPRINT_20260110_103_004_ENVMGR_inventory_sync.md
Normal file
385
docs/implplan/SPRINT_20260110_103_004_ENVMGR_inventory_sync.md
Normal 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 |
|
||||
200
docs/implplan/SPRINT_20260110_104_000_INDEX_release_manager.md
Normal file
200
docs/implplan/SPRINT_20260110_104_000_INDEX_release_manager.md
Normal 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 |
|
||||
@@ -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) |
|
||||
541
docs/implplan/SPRINT_20260110_104_002_RELMAN_version_manager.md
Normal file
541
docs/implplan/SPRINT_20260110_104_002_RELMAN_version_manager.md
Normal 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) |
|
||||
643
docs/implplan/SPRINT_20260110_104_003_RELMAN_release_manager.md
Normal file
643
docs/implplan/SPRINT_20260110_104_003_RELMAN_release_manager.md
Normal 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) |
|
||||
623
docs/implplan/SPRINT_20260110_104_004_RELMAN_release_catalog.md
Normal file
623
docs/implplan/SPRINT_20260110_104_004_RELMAN_release_catalog.md
Normal 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 |
|
||||
263
docs/implplan/SPRINT_20260110_105_000_INDEX_workflow_engine.md
Normal file
263
docs/implplan/SPRINT_20260110_105_000_INDEX_workflow_engine.md
Normal 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 |
|
||||
@@ -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) |
|
||||
564
docs/implplan/SPRINT_20260110_105_002_WORKFL_step_registry.md
Normal file
564
docs/implplan/SPRINT_20260110_105_002_WORKFL_step_registry.md
Normal 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) |
|
||||
734
docs/implplan/SPRINT_20260110_105_003_WORKFL_dag_executor.md
Normal file
734
docs/implplan/SPRINT_20260110_105_003_WORKFL_dag_executor.md
Normal 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) |
|
||||
615
docs/implplan/SPRINT_20260110_105_004_WORKFL_step_executor.md
Normal file
615
docs/implplan/SPRINT_20260110_105_004_WORKFL_step_executor.md
Normal 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 |
|
||||
771
docs/implplan/SPRINT_20260110_105_005_WORKFL_builtin_steps.md
Normal file
771
docs/implplan/SPRINT_20260110_105_005_WORKFL_builtin_steps.md
Normal 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 |
|
||||
254
docs/implplan/SPRINT_20260110_106_000_INDEX_promotion_gates.md
Normal file
254
docs/implplan/SPRINT_20260110_106_000_INDEX_promotion_gates.md
Normal 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 |
|
||||
@@ -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) |
|
||||
648
docs/implplan/SPRINT_20260110_106_002_PROMOT_approval_gateway.md
Normal file
648
docs/implplan/SPRINT_20260110_106_002_PROMOT_approval_gateway.md
Normal 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) |
|
||||
727
docs/implplan/SPRINT_20260110_106_003_PROMOT_gate_registry.md
Normal file
727
docs/implplan/SPRINT_20260110_106_003_PROMOT_gate_registry.md
Normal 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 |
|
||||
576
docs/implplan/SPRINT_20260110_106_004_PROMOT_security_gate.md
Normal file
576
docs/implplan/SPRINT_20260110_106_004_PROMOT_security_gate.md
Normal 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 |
|
||||
626
docs/implplan/SPRINT_20260110_106_005_PROMOT_decision_engine.md
Normal file
626
docs/implplan/SPRINT_20260110_106_005_PROMOT_decision_engine.md
Normal 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 |
|
||||
@@ -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 |
|
||||
410
docs/implplan/SPRINT_20260110_107_001_DEPLOY_orchestrator.md
Normal file
410
docs/implplan/SPRINT_20260110_107_001_DEPLOY_orchestrator.md
Normal 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 |
|
||||
367
docs/implplan/SPRINT_20260110_107_002_DEPLOY_target_executor.md
Normal file
367
docs/implplan/SPRINT_20260110_107_002_DEPLOY_target_executor.md
Normal 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 |
|
||||
@@ -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 |
|
||||
461
docs/implplan/SPRINT_20260110_107_004_DEPLOY_rollback_manager.md
Normal file
461
docs/implplan/SPRINT_20260110_107_004_DEPLOY_rollback_manager.md
Normal 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 |
|
||||
460
docs/implplan/SPRINT_20260110_107_005_DEPLOY_strategies.md
Normal file
460
docs/implplan/SPRINT_20260110_107_005_DEPLOY_strategies.md
Normal 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 |
|
||||
291
docs/implplan/SPRINT_20260110_108_000_INDEX_agents.md
Normal file
291
docs/implplan/SPRINT_20260110_108_000_INDEX_agents.md
Normal 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 |
|
||||
776
docs/implplan/SPRINT_20260110_108_001_AGENTS_core_runtime.md
Normal file
776
docs/implplan/SPRINT_20260110_108_001_AGENTS_core_runtime.md
Normal 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 |
|
||||
936
docs/implplan/SPRINT_20260110_108_002_AGENTS_docker.md
Normal file
936
docs/implplan/SPRINT_20260110_108_002_AGENTS_docker.md
Normal 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) |
|
||||
976
docs/implplan/SPRINT_20260110_108_003_AGENTS_compose.md
Normal file
976
docs/implplan/SPRINT_20260110_108_003_AGENTS_compose.md
Normal 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) |
|
||||
813
docs/implplan/SPRINT_20260110_108_004_AGENTS_ssh.md
Normal file
813
docs/implplan/SPRINT_20260110_108_004_AGENTS_ssh.md
Normal 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) |
|
||||
915
docs/implplan/SPRINT_20260110_108_005_AGENTS_winrm.md
Normal file
915
docs/implplan/SPRINT_20260110_108_005_AGENTS_winrm.md
Normal 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 |
|
||||
961
docs/implplan/SPRINT_20260110_108_006_AGENTS_ecs.md
Normal file
961
docs/implplan/SPRINT_20260110_108_006_AGENTS_ecs.md
Normal 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 |
|
||||
900
docs/implplan/SPRINT_20260110_108_007_AGENTS_nomad.md
Normal file
900
docs/implplan/SPRINT_20260110_108_007_AGENTS_nomad.md
Normal 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 |
|
||||
243
docs/implplan/SPRINT_20260110_109_000_INDEX_evidence_audit.md
Normal file
243
docs/implplan/SPRINT_20260110_109_000_INDEX_evidence_audit.md
Normal 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 |
|
||||
@@ -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 |
|
||||
626
docs/implplan/SPRINT_20260110_109_002_RELEVI_evidence_signer.md
Normal file
626
docs/implplan/SPRINT_20260110_109_002_RELEVI_evidence_signer.md
Normal 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 |
|
||||
538
docs/implplan/SPRINT_20260110_109_003_RELEVI_version_sticker.md
Normal file
538
docs/implplan/SPRINT_20260110_109_003_RELEVI_version_sticker.md
Normal 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 |
|
||||
706
docs/implplan/SPRINT_20260110_109_004_RELEVI_audit_exporter.md
Normal file
706
docs/implplan/SPRINT_20260110_109_004_RELEVI_audit_exporter.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
520
docs/implplan/SPRINT_20260110_110_002_PROGDL_traffic_router.md
Normal file
520
docs/implplan/SPRINT_20260110_110_002_PROGDL_traffic_router.md
Normal 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 |
|
||||
@@ -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 |
|
||||
762
docs/implplan/SPRINT_20260110_110_004_PROGDL_nginx_router.md
Normal file
762
docs/implplan/SPRINT_20260110_110_004_PROGDL_nginx_router.md
Normal 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 |
|
||||
300
docs/implplan/SPRINT_20260110_111_000_INDEX_ui_implementation.md
Normal file
300
docs/implplan/SPRINT_20260110_111_000_INDEX_ui_implementation.md
Normal 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 |
|
||||
792
docs/implplan/SPRINT_20260110_111_001_FE_dashboard_overview.md
Normal file
792
docs/implplan/SPRINT_20260110_111_001_FE_dashboard_overview.md
Normal 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 |
|
||||
@@ -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) |
|
||||
@@ -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 |
|
||||
1207
docs/implplan/SPRINT_20260110_111_004_FE_workflow_editor.md
Normal file
1207
docs/implplan/SPRINT_20260110_111_004_FE_workflow_editor.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
@@ -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 |
|
||||
1109
docs/implplan/SPRINT_20260110_111_007_FE_evidence_viewer.md
Normal file
1109
docs/implplan/SPRINT_20260110_111_007_FE_evidence_viewer.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user