release orchestrator pivot, architecture and planning
This commit is contained in:
627
docs/modules/release-orchestrator/integrations/webhooks.md
Normal file
627
docs/modules/release-orchestrator/integrations/webhooks.md
Normal file
@@ -0,0 +1,627 @@
|
||||
# Webhooks
|
||||
|
||||
## Overview
|
||||
|
||||
Release Orchestrator supports both inbound webhooks (receiving events from external systems) and outbound webhooks (sending events to external systems).
|
||||
|
||||
## Inbound Webhooks
|
||||
|
||||
### Webhook Types
|
||||
|
||||
| Type | Source | Triggers |
|
||||
|------|--------|----------|
|
||||
| `registry-push` | Container registries | Image push events |
|
||||
| `ci-pipeline` | CI/CD systems | Pipeline completion |
|
||||
| `github-app` | GitHub | PR, push, workflow events |
|
||||
| `gitlab-webhook` | GitLab | Pipeline, push, MR events |
|
||||
| `generic` | Any system | Custom payloads |
|
||||
|
||||
### Registry Push Webhook
|
||||
|
||||
Receives events when new images are pushed to registries.
|
||||
|
||||
```
|
||||
POST /api/v1/webhooks/registry/{integrationId}
|
||||
Content-Type: application/json
|
||||
|
||||
# Docker Hub
|
||||
{
|
||||
"push_data": {
|
||||
"tag": "v1.2.0",
|
||||
"images": ["sha256:abc123..."],
|
||||
"pushed_at": 1704067200
|
||||
},
|
||||
"repository": {
|
||||
"name": "myapp",
|
||||
"namespace": "myorg",
|
||||
"repo_url": "https://hub.docker.com/r/myorg/myapp"
|
||||
}
|
||||
}
|
||||
|
||||
# Harbor
|
||||
{
|
||||
"type": "PUSH_ARTIFACT",
|
||||
"occur_at": 1704067200,
|
||||
"event_data": {
|
||||
"repository": {
|
||||
"name": "myapp",
|
||||
"repo_full_name": "myorg/myapp"
|
||||
},
|
||||
"resources": [{
|
||||
"digest": "sha256:abc123...",
|
||||
"tag": "v1.2.0"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Handler
|
||||
|
||||
```typescript
|
||||
interface WebhookHandler {
|
||||
handleRegistryPush(
|
||||
integrationId: UUID,
|
||||
payload: RegistryPushPayload
|
||||
): Promise<WebhookResponse>;
|
||||
|
||||
handleCIPipeline(
|
||||
integrationId: UUID,
|
||||
payload: CIPipelinePayload
|
||||
): Promise<WebhookResponse>;
|
||||
}
|
||||
|
||||
class RegistryWebhookHandler implements WebhookHandler {
|
||||
async handleRegistryPush(
|
||||
integrationId: UUID,
|
||||
payload: RegistryPushPayload
|
||||
): Promise<WebhookResponse> {
|
||||
// Normalize payload from different registries
|
||||
const normalized = this.normalizePayload(payload);
|
||||
|
||||
// Find matching component
|
||||
const component = await this.componentRegistry.findByRepository(
|
||||
normalized.repository
|
||||
);
|
||||
|
||||
if (!component) {
|
||||
return {
|
||||
success: true,
|
||||
action: "ignored",
|
||||
reason: "No matching component"
|
||||
};
|
||||
}
|
||||
|
||||
// Update version map
|
||||
await this.versionManager.addVersion({
|
||||
componentId: component.id,
|
||||
tag: normalized.tag,
|
||||
digest: normalized.digest,
|
||||
channel: this.determineChannel(normalized.tag)
|
||||
});
|
||||
|
||||
// Check for auto-release triggers
|
||||
const triggers = await this.getTriggers(component.id, normalized.tag);
|
||||
for (const trigger of triggers) {
|
||||
await this.triggerRelease(trigger, normalized);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "processed",
|
||||
componentId: component.id,
|
||||
versionsAdded: 1,
|
||||
triggersActivated: triggers.length
|
||||
};
|
||||
}
|
||||
|
||||
private normalizePayload(payload: any): NormalizedPushEvent {
|
||||
// Detect registry type and normalize
|
||||
if (payload.push_data) {
|
||||
// Docker Hub format
|
||||
return {
|
||||
repository: `${payload.repository.namespace}/${payload.repository.name}`,
|
||||
tag: payload.push_data.tag,
|
||||
digest: payload.push_data.images[0],
|
||||
pushedAt: new Date(payload.push_data.pushed_at * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === "PUSH_ARTIFACT") {
|
||||
// Harbor format
|
||||
return {
|
||||
repository: payload.event_data.repository.repo_full_name,
|
||||
tag: payload.event_data.resources[0].tag,
|
||||
digest: payload.event_data.resources[0].digest,
|
||||
pushedAt: new Date(payload.occur_at * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
// Generic format
|
||||
return payload as NormalizedPushEvent;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Authentication
|
||||
|
||||
```typescript
|
||||
interface WebhookAuth {
|
||||
// Signature validation
|
||||
validateSignature(
|
||||
payload: Buffer,
|
||||
signature: string,
|
||||
secret: string,
|
||||
algorithm: SignatureAlgorithm
|
||||
): boolean;
|
||||
|
||||
// Token validation
|
||||
validateToken(
|
||||
token: string,
|
||||
expectedToken: string
|
||||
): boolean;
|
||||
}
|
||||
|
||||
type SignatureAlgorithm = "hmac-sha256" | "hmac-sha1";
|
||||
|
||||
class WebhookAuthenticator implements WebhookAuth {
|
||||
validateSignature(
|
||||
payload: Buffer,
|
||||
signature: string,
|
||||
secret: string,
|
||||
algorithm: SignatureAlgorithm
|
||||
): boolean {
|
||||
const algo = algorithm === "hmac-sha256" ? "sha256" : "sha1";
|
||||
const expected = crypto
|
||||
.createHmac(algo, secret)
|
||||
.update(payload)
|
||||
.digest("hex");
|
||||
|
||||
// Constant-time comparison
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Configuration
|
||||
|
||||
```typescript
|
||||
interface WebhookConfig {
|
||||
id: UUID;
|
||||
integrationId: UUID;
|
||||
type: WebhookType;
|
||||
|
||||
// Security
|
||||
secretRef: string; // Vault reference for signature secret
|
||||
signatureHeader?: string; // Header containing signature
|
||||
signatureAlgorithm?: SignatureAlgorithm;
|
||||
|
||||
// Processing
|
||||
enabled: boolean;
|
||||
filters?: WebhookFilter[]; // Filter events
|
||||
|
||||
// Retry
|
||||
retryPolicy: RetryPolicy;
|
||||
}
|
||||
|
||||
interface WebhookFilter {
|
||||
field: string; // JSONPath to field
|
||||
operator: "equals" | "contains" | "matches";
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Example: Only process tags matching semver
|
||||
const semverFilter: WebhookFilter = {
|
||||
field: "$.tag",
|
||||
operator: "matches",
|
||||
value: "^v\\d+\\.\\d+\\.\\d+$"
|
||||
};
|
||||
```
|
||||
|
||||
## Outbound Webhooks
|
||||
|
||||
### Event Types
|
||||
|
||||
| Event | Description | Payload |
|
||||
|-------|-------------|---------|
|
||||
| `release.created` | New release created | Release details |
|
||||
| `promotion.requested` | Promotion requested | Promotion details |
|
||||
| `promotion.approved` | Promotion approved | Approval details |
|
||||
| `promotion.rejected` | Promotion rejected | Rejection details |
|
||||
| `deployment.started` | Deployment started | Job details |
|
||||
| `deployment.completed` | Deployment completed | Job details, results |
|
||||
| `deployment.failed` | Deployment failed | Job details, error |
|
||||
| `rollback.initiated` | Rollback initiated | Rollback details |
|
||||
|
||||
### Webhook Subscription
|
||||
|
||||
```typescript
|
||||
interface WebhookSubscription {
|
||||
id: UUID;
|
||||
tenantId: UUID;
|
||||
name: string;
|
||||
|
||||
// Target
|
||||
url: string;
|
||||
method: "POST" | "PUT";
|
||||
headers?: Record<string, string>;
|
||||
|
||||
// Authentication
|
||||
authType: "none" | "basic" | "bearer" | "signature";
|
||||
credentialRef?: string;
|
||||
signatureSecret?: string;
|
||||
|
||||
// Events
|
||||
events: string[]; // Event types to subscribe
|
||||
filters?: EventFilter[]; // Filter events
|
||||
|
||||
// Delivery
|
||||
retryPolicy: RetryPolicy;
|
||||
timeout: number;
|
||||
|
||||
// Status
|
||||
enabled: boolean;
|
||||
lastDelivery?: DateTime;
|
||||
lastStatus?: number;
|
||||
}
|
||||
|
||||
interface EventFilter {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: any;
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Delivery
|
||||
|
||||
```typescript
|
||||
interface WebhookPayload {
|
||||
id: string; // Delivery ID
|
||||
timestamp: string; // ISO-8601
|
||||
event: string; // Event type
|
||||
tenantId: string;
|
||||
data: Record<string, any>; // Event-specific data
|
||||
}
|
||||
|
||||
class WebhookDeliveryService {
|
||||
async deliver(
|
||||
subscription: WebhookSubscription,
|
||||
event: DomainEvent
|
||||
): Promise<DeliveryResult> {
|
||||
const payload: WebhookPayload = {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date().toISOString(),
|
||||
event: event.type,
|
||||
tenantId: subscription.tenantId,
|
||||
data: this.buildEventData(event)
|
||||
};
|
||||
|
||||
const headers = this.buildHeaders(subscription, payload);
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
// Attempt delivery with retries
|
||||
return this.deliverWithRetry(subscription, headers, body);
|
||||
}
|
||||
|
||||
private buildHeaders(
|
||||
subscription: WebhookSubscription,
|
||||
payload: WebhookPayload
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Stella-Event": payload.event,
|
||||
"X-Stella-Delivery": payload.id,
|
||||
"X-Stella-Timestamp": payload.timestamp,
|
||||
...subscription.headers
|
||||
};
|
||||
|
||||
// Add signature if configured
|
||||
if (subscription.authType === "signature") {
|
||||
const signature = this.computeSignature(
|
||||
JSON.stringify(payload),
|
||||
subscription.signatureSecret!
|
||||
);
|
||||
headers["X-Stella-Signature"] = signature;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async deliverWithRetry(
|
||||
subscription: WebhookSubscription,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<DeliveryResult> {
|
||||
const policy = subscription.retryPolicy;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(subscription.url, {
|
||||
method: subscription.method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(subscription.timeout)
|
||||
});
|
||||
|
||||
// Record delivery
|
||||
await this.recordDelivery(subscription.id, {
|
||||
attempt,
|
||||
statusCode: response.status,
|
||||
success: response.ok
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, statusCode: response.status, attempts: attempt + 1 };
|
||||
}
|
||||
|
||||
// Non-retryable status codes
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: response.status,
|
||||
attempts: attempt + 1,
|
||||
error: `Client error: ${response.status}`
|
||||
};
|
||||
}
|
||||
|
||||
lastError = new Error(`Server error: ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
}
|
||||
|
||||
// Wait before retry
|
||||
if (attempt < policy.maxRetries) {
|
||||
const delay = this.calculateDelay(policy, attempt);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
attempts: policy.maxRetries + 1,
|
||||
error: lastError?.message
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delivery Logging
|
||||
|
||||
```typescript
|
||||
interface WebhookDeliveryLog {
|
||||
id: UUID;
|
||||
subscriptionId: UUID;
|
||||
deliveryId: string;
|
||||
|
||||
// Request
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
|
||||
// Response
|
||||
statusCode?: number;
|
||||
responseBody?: string;
|
||||
responseTime: number;
|
||||
|
||||
// Result
|
||||
success: boolean;
|
||||
attempt: number;
|
||||
error?: string;
|
||||
|
||||
// Timing
|
||||
createdAt: DateTime;
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook API
|
||||
|
||||
### Register Subscription
|
||||
|
||||
```http
|
||||
POST /api/v1/webhook-subscriptions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Deployment Notifications",
|
||||
"url": "https://api.example.com/webhooks/stella",
|
||||
"method": "POST",
|
||||
"authType": "signature",
|
||||
"signatureSecret": "my-secret-key",
|
||||
"events": [
|
||||
"deployment.started",
|
||||
"deployment.completed",
|
||||
"deployment.failed"
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"field": "data.environment.name",
|
||||
"operator": "equals",
|
||||
"value": "production"
|
||||
}
|
||||
],
|
||||
"retryPolicy": {
|
||||
"maxRetries": 3,
|
||||
"backoffType": "exponential",
|
||||
"backoffSeconds": 10
|
||||
},
|
||||
"timeout": 30000
|
||||
}
|
||||
```
|
||||
|
||||
### Test Subscription
|
||||
|
||||
```http
|
||||
POST /api/v1/webhook-subscriptions/{id}/test
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"event": "deployment.completed"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deliveryId": "d1234567-...",
|
||||
"statusCode": 200,
|
||||
"responseTime": 245,
|
||||
"response": "OK"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### List Deliveries
|
||||
|
||||
```http
|
||||
GET /api/v1/webhook-subscriptions/{id}/deliveries?page=1&pageSize=20
|
||||
```
|
||||
|
||||
## Event Payloads
|
||||
|
||||
### deployment.completed
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "delivery-uuid",
|
||||
"timestamp": "2026-01-09T10:30:00Z",
|
||||
"event": "deployment.completed",
|
||||
"tenantId": "tenant-uuid",
|
||||
"data": {
|
||||
"deploymentJob": {
|
||||
"id": "job-uuid",
|
||||
"status": "completed"
|
||||
},
|
||||
"release": {
|
||||
"id": "release-uuid",
|
||||
"name": "myapp-v1.2.0",
|
||||
"components": [
|
||||
{
|
||||
"name": "api",
|
||||
"digest": "sha256:abc123..."
|
||||
}
|
||||
]
|
||||
},
|
||||
"environment": {
|
||||
"id": "env-uuid",
|
||||
"name": "production"
|
||||
},
|
||||
"promotion": {
|
||||
"id": "promo-uuid",
|
||||
"requestedBy": "user@example.com"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"id": "target-uuid",
|
||||
"name": "prod-host-1",
|
||||
"status": "succeeded"
|
||||
}
|
||||
],
|
||||
"timing": {
|
||||
"startedAt": "2026-01-09T10:25:00Z",
|
||||
"completedAt": "2026-01-09T10:30:00Z",
|
||||
"durationSeconds": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### promotion.requested
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "delivery-uuid",
|
||||
"timestamp": "2026-01-09T10:00:00Z",
|
||||
"event": "promotion.requested",
|
||||
"tenantId": "tenant-uuid",
|
||||
"data": {
|
||||
"promotion": {
|
||||
"id": "promo-uuid",
|
||||
"status": "pending_approval"
|
||||
},
|
||||
"release": {
|
||||
"id": "release-uuid",
|
||||
"name": "myapp-v1.2.0"
|
||||
},
|
||||
"sourceEnvironment": {
|
||||
"id": "staging-uuid",
|
||||
"name": "staging"
|
||||
},
|
||||
"targetEnvironment": {
|
||||
"id": "prod-uuid",
|
||||
"name": "production"
|
||||
},
|
||||
"requestedBy": {
|
||||
"id": "user-uuid",
|
||||
"email": "user@example.com",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"approvalRequired": {
|
||||
"count": 2,
|
||||
"currentApprovals": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Signature Verification
|
||||
|
||||
Receivers should verify webhook signatures:
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
expected = hmac.new(
|
||||
secret.encode(),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
# In webhook handler
|
||||
@app.route("/webhooks/stella", methods=["POST"])
|
||||
def handle_webhook():
|
||||
signature = request.headers.get("X-Stella-Signature")
|
||||
if not verify_signature(request.data, signature, WEBHOOK_SECRET):
|
||||
return "Invalid signature", 401
|
||||
|
||||
payload = request.json
|
||||
# Process event...
|
||||
```
|
||||
|
||||
### IP Allowlisting
|
||||
|
||||
Configure firewall rules to only accept webhooks from Stella IP ranges:
|
||||
- Document IP ranges in deployment configuration
|
||||
- Use VPN or private networking where possible
|
||||
|
||||
### Replay Protection
|
||||
|
||||
Check delivery timestamps to prevent replay attacks:
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
MAX_TIMESTAMP_AGE = timedelta(minutes=5)
|
||||
|
||||
def check_timestamp(timestamp_str: str) -> bool:
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
now = datetime.now(timestamp.tzinfo)
|
||||
return abs(now - timestamp) < MAX_TIMESTAMP_AGE
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Integrations Overview](overview.md)
|
||||
- [Connectors](connectors.md)
|
||||
- [CI/CD Integration](ci-cd.md)
|
||||
Reference in New Issue
Block a user