14 KiB
14 KiB
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
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
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
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
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
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
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
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
POST /api/v1/webhook-subscriptions/{id}/test
Content-Type: application/json
{
"event": "deployment.completed"
}
Response:
{
"success": true,
"data": {
"deliveryId": "d1234567-...",
"statusCode": 200,
"responseTime": 245,
"response": "OK"
}
}
List Deliveries
GET /api/v1/webhook-subscriptions/{id}/deliveries?page=1&pageSize=20
Event Payloads
deployment.completed
{
"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
{
"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:
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:
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