release orchestrator pivot, architecture and planning

This commit is contained in:
2026-01-10 22:37:22 +02:00
parent c84f421e2f
commit d509c44411
130 changed files with 70292 additions and 721 deletions

View File

@@ -0,0 +1,643 @@
# CI/CD Integration
## Overview
Release Orchestrator integrates with CI/CD systems to:
- Receive build completion notifications
- Trigger additional pipelines during deployment
- Create releases from CI artifacts
- Report deployment status back to CI systems
## Integration Patterns
### Pattern 1: CI Triggers Release
```
CI TRIGGERS RELEASE
┌────────────┐ ┌────────────┐ ┌────────────────────┐
│ CI/CD │ │ Container │ │ Release │
│ System │ │ Registry │ │ Orchestrator │
└─────┬──────┘ └─────┬──────┘ └─────────┬──────────┘
│ │ │
│ Build & Push │ │
│─────────────────►│ │
│ │ │
│ │ Webhook: image pushed
│ │─────────────────────►│
│ │ │
│ │ │ Create/Update
│ │ │ Version Map
│ │ │
│ │ │ Auto-create
│ │ │ Release (if configured)
│ │ │
│ API: Create Release (optional) │
│────────────────────────────────────────►│
│ │ │
│ │ │ Start Promotion
│ │ │ Workflow
│ │ │
```
### Pattern 2: Orchestrator Triggers CI
```
ORCHESTRATOR TRIGGERS CI
┌────────────────────┐ ┌────────────┐ ┌────────────┐
│ Release │ │ CI/CD │ │ Target │
│ Orchestrator │ │ System │ │ Systems │
└─────────┬──────────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
│ Pre-deploy: Trigger │ │
│ Integration Tests │ │
│─────────────────────►│ │
│ │ │
│ │ Run Tests │
│ │─────────────────►│
│ │ │
│ Wait for completion │ │
│◄─────────────────────│ │
│ │ │
│ If passed: Deploy │ │
│─────────────────────────────────────────►
│ │ │
```
### Pattern 3: Bidirectional Integration
```
BIDIRECTIONAL INTEGRATION
┌────────────┐ ┌────────────────────┐
│ CI/CD │◄───────────────────────►│ Release │
│ System │ │ Orchestrator │
└─────┬──────┘ └─────────┬──────────┘
│ │
│══════════════════════════════════════════│
│ Events (both directions) │
│══════════════════════════════════════════│
│ │
│ CI Events: │
│ - Pipeline completed │
│ - Tests passed/failed │
│ - Artifacts ready │
│ │
│ Orchestrator Events: │
│ - Deployment started │
│ - Deployment completed │
│ - Rollback initiated │
│ │
```
## CI/CD System Configuration
### GitLab CI Integration
```yaml
# .gitlab-ci.yml
stages:
- build
- push
- release
variables:
STELLA_API_URL: https://stella.example.com/api/v1
COMPONENT_NAME: myapp
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
push:
stage: push
script:
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
rules:
- if: $CI_COMMIT_TAG
release:
stage: release
image: curlimages/curl:latest
script:
- |
# Get image digest
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG | cut -d@ -f2)
# Create release in Stella
curl -X POST "$STELLA_API_URL/releases" \
-H "Authorization: Bearer $STELLA_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$COMPONENT_NAME-$CI_COMMIT_TAG\",
\"components\": [{
\"componentId\": \"$STELLA_COMPONENT_ID\",
\"digest\": \"$DIGEST\"
}],
\"sourceRef\": {
\"type\": \"git\",
\"repository\": \"$CI_PROJECT_URL\",
\"commit\": \"$CI_COMMIT_SHA\",
\"tag\": \"$CI_COMMIT_TAG\"
}
}"
rules:
- if: $CI_COMMIT_TAG
```
### GitHub Actions Integration
```yaml
# .github/workflows/release.yml
name: Release to Stella
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- name: Get image digest
id: digest
run: |
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/${{ github.repository }}:${{ github.ref_name }} | cut -d@ -f2)
echo "digest=$DIGEST" >> $GITHUB_OUTPUT
- name: Create Stella Release
uses: stella-ops/create-release-action@v1
with:
stella-url: ${{ vars.STELLA_API_URL }}
stella-token: ${{ secrets.STELLA_TOKEN }}
release-name: ${{ github.event.repository.name }}-${{ github.ref_name }}
components: |
- componentId: ${{ vars.STELLA_COMPONENT_ID }}
digest: ${{ steps.digest.outputs.digest }}
source-ref: |
type: git
repository: ${{ github.server_url }}/${{ github.repository }}
commit: ${{ github.sha }}
tag: ${{ github.ref_name }}
```
### Jenkins Integration
```groovy
// Jenkinsfile
pipeline {
agent any
environment {
STELLA_API_URL = 'https://stella.example.com/api/v1'
STELLA_TOKEN = credentials('stella-api-token')
REGISTRY = 'registry.example.com'
IMAGE_NAME = 'myorg/myapp'
}
stages {
stage('Build') {
steps {
script {
docker.build("${REGISTRY}/${IMAGE_NAME}:${env.BUILD_TAG}")
}
}
}
stage('Push') {
steps {
script {
docker.withRegistry("https://${REGISTRY}", 'registry-creds') {
docker.image("${REGISTRY}/${IMAGE_NAME}:${env.BUILD_TAG}").push()
}
}
}
}
stage('Create Release') {
when {
tag pattern: "v\\d+\\.\\d+\\.\\d+", comparator: "REGEXP"
}
steps {
script {
def digest = sh(
script: "docker inspect --format='{{index .RepoDigests 0}}' ${REGISTRY}/${IMAGE_NAME}:${env.TAG_NAME} | cut -d@ -f2",
returnStdout: true
).trim()
def response = httpRequest(
url: "${STELLA_API_URL}/releases",
httpMode: 'POST',
contentType: 'APPLICATION_JSON',
customHeaders: [[name: 'Authorization', value: "Bearer ${STELLA_TOKEN}"]],
requestBody: """
{
"name": "${IMAGE_NAME}-${env.TAG_NAME}",
"components": [{
"componentId": "${env.STELLA_COMPONENT_ID}",
"digest": "${digest}"
}],
"sourceRef": {
"type": "git",
"repository": "${env.GIT_URL}",
"commit": "${env.GIT_COMMIT}",
"tag": "${env.TAG_NAME}"
}
}
"""
)
echo "Release created: ${response.content}"
}
}
}
}
post {
success {
// Notify Stella of successful build
httpRequest(
url: "${STELLA_API_URL}/webhooks/ci-status",
httpMode: 'POST',
contentType: 'APPLICATION_JSON',
customHeaders: [[name: 'Authorization', value: "Bearer ${STELLA_TOKEN}"]],
requestBody: """
{
"buildId": "${env.BUILD_ID}",
"status": "success",
"commit": "${env.GIT_COMMIT}"
}
"""
)
}
}
}
```
## Workflow Step Integration
### Trigger CI Pipeline Step
```typescript
// Step type: trigger-ci
interface TriggerCIConfig {
integrationId: UUID; // CI integration reference
pipelineId: string; // Pipeline to trigger
ref?: string; // Branch/tag reference
variables?: Record<string, string>;
waitForCompletion: boolean;
timeout?: number;
}
class TriggerCIStep implements IStepExecutor {
async execute(
inputs: StepInputs,
config: TriggerCIConfig,
context: ExecutionContext
): Promise<StepOutputs> {
const connector = await this.getConnector(config.integrationId);
// Trigger pipeline
const run = await connector.triggerPipeline(
config.pipelineId,
{
ref: config.ref || context.release?.sourceRef?.tag,
variables: {
...config.variables,
STELLA_RELEASE_ID: context.release?.id,
STELLA_PROMOTION_ID: context.promotion?.id,
STELLA_ENVIRONMENT: context.environment?.name
}
}
);
if (!config.waitForCompletion) {
return {
pipelineRunId: run.id,
status: run.status,
webUrl: run.webUrl
};
}
// Wait for completion
const finalStatus = await this.waitForPipeline(
connector,
run.id,
config.timeout || 3600
);
if (finalStatus.status !== "success") {
throw new StepError(
`Pipeline failed with status: ${finalStatus.status}`,
{ pipelineRunId: run.id, status: finalStatus }
);
}
return {
pipelineRunId: run.id,
status: finalStatus.status,
webUrl: run.webUrl
};
}
private async waitForPipeline(
connector: ICICDConnector,
runId: string,
timeout: number
): Promise<PipelineRun> {
const deadline = Date.now() + timeout * 1000;
while (Date.now() < deadline) {
const run = await connector.getPipelineRun(runId);
if (run.status === "success" || run.status === "failed" || run.status === "cancelled") {
return run;
}
await sleep(10000); // Poll every 10 seconds
}
throw new TimeoutError(`Pipeline did not complete within ${timeout} seconds`);
}
}
```
### Wait for CI Step
```typescript
// Step type: wait-ci
interface WaitCIConfig {
integrationId: UUID;
runId?: string; // If known, or from input
runIdInput?: string; // Input name containing run ID
timeout: number;
failOnError: boolean;
}
class WaitCIStep implements IStepExecutor {
async execute(
inputs: StepInputs,
config: WaitCIConfig,
context: ExecutionContext
): Promise<StepOutputs> {
const runId = config.runId || inputs[config.runIdInput!];
if (!runId) {
throw new StepError("Pipeline run ID not provided");
}
const connector = await this.getConnector(config.integrationId);
const finalStatus = await this.waitForPipeline(
connector,
runId,
config.timeout
);
const success = finalStatus.status === "success";
if (!success && config.failOnError) {
throw new StepError(
`Pipeline failed with status: ${finalStatus.status}`,
{ pipelineRunId: runId, status: finalStatus }
);
}
return {
status: finalStatus.status,
success,
pipelineRun: finalStatus
};
}
}
```
## Deployment Status Reporting
### GitHub Deployment Status
```typescript
class GitHubStatusReporter {
async reportDeploymentStart(
integration: Integration,
deployment: DeploymentContext
): Promise<void> {
const client = await this.getClient(integration);
// Create deployment
const { data: ghDeployment } = await client.repos.createDeployment({
owner: deployment.repository.owner,
repo: deployment.repository.name,
ref: deployment.sourceRef.commit,
environment: deployment.environment.name,
auto_merge: false,
required_contexts: [],
payload: {
stellaReleaseId: deployment.release.id,
stellaPromotionId: deployment.promotion.id
}
});
// Set status to in_progress
await client.repos.createDeploymentStatus({
owner: deployment.repository.owner,
repo: deployment.repository.name,
deployment_id: ghDeployment.id,
state: "in_progress",
log_url: `${this.stellaUrl}/deployments/${deployment.jobId}`,
description: "Deployment in progress"
});
// Store deployment ID for later status update
await this.storeMapping(deployment.jobId, ghDeployment.id);
}
async reportDeploymentComplete(
integration: Integration,
deployment: DeploymentContext,
success: boolean
): Promise<void> {
const client = await this.getClient(integration);
const ghDeploymentId = await this.getMapping(deployment.jobId);
await client.repos.createDeploymentStatus({
owner: deployment.repository.owner,
repo: deployment.repository.name,
deployment_id: ghDeploymentId,
state: success ? "success" : "failure",
log_url: `${this.stellaUrl}/deployments/${deployment.jobId}`,
environment_url: deployment.environment.url,
description: success
? "Deployment completed successfully"
: "Deployment failed"
});
}
}
```
### GitLab Pipeline Status
```typescript
class GitLabStatusReporter {
async reportDeploymentStatus(
integration: Integration,
deployment: DeploymentContext,
state: "running" | "success" | "failed" | "canceled"
): Promise<void> {
const client = await this.getClient(integration);
await client.post(
`/projects/${integration.config.projectId}/statuses/${deployment.sourceRef.commit}`,
{
state,
ref: deployment.sourceRef.tag || deployment.sourceRef.branch,
name: `stella/${deployment.environment.name}`,
target_url: `${this.stellaUrl}/deployments/${deployment.jobId}`,
description: this.getDescription(state, deployment)
}
);
}
private getDescription(state: string, deployment: DeploymentContext): string {
switch (state) {
case "running":
return `Deploying to ${deployment.environment.name}`;
case "success":
return `Deployed to ${deployment.environment.name}`;
case "failed":
return `Deployment to ${deployment.environment.name} failed`;
case "canceled":
return `Deployment to ${deployment.environment.name} cancelled`;
default:
return "";
}
}
}
```
## API for CI Systems
### Create Release from CI
```http
POST /api/v1/releases
Authorization: Bearer <ci-token>
Content-Type: application/json
{
"name": "myapp-v1.2.0",
"components": [
{
"componentId": "component-uuid",
"digest": "sha256:abc123..."
}
],
"sourceRef": {
"type": "git",
"repository": "https://github.com/myorg/myapp",
"commit": "abc123def456",
"tag": "v1.2.0",
"branch": "main"
},
"metadata": {
"buildId": "12345",
"buildUrl": "https://ci.example.com/builds/12345",
"triggeredBy": "ci-pipeline"
}
}
```
### Report Build Status
```http
POST /api/v1/ci-events/build-complete
Authorization: Bearer <ci-token>
Content-Type: application/json
{
"integrationId": "integration-uuid",
"buildId": "12345",
"status": "success",
"commit": "abc123def456",
"artifacts": [
{
"name": "myapp",
"digest": "sha256:abc123...",
"repository": "registry.example.com/myorg/myapp"
}
],
"testResults": {
"passed": 150,
"failed": 0,
"skipped": 5
}
}
```
## Service Account for CI
### Creating CI Service Account
```http
POST /api/v1/service-accounts
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"name": "ci-pipeline",
"description": "Service account for CI/CD integration",
"roles": ["release-creator"],
"permissions": [
{ "resource": "release", "action": "create" },
{ "resource": "component", "action": "read" },
{ "resource": "version-map", "action": "read" }
],
"expiresIn": "365d"
}
```
Response:
```json
{
"success": true,
"data": {
"id": "sa-uuid",
"name": "ci-pipeline",
"token": "stella_sa_xxxxxxxxxxxxx",
"expiresAt": "2027-01-09T00:00:00Z"
}
}
```
## References
- [Integrations Overview](overview.md)
- [Connectors](connectors.md)
- [Webhooks](webhooks.md)
- [Workflow Templates](../workflow/templates.md)

View File

@@ -0,0 +1,900 @@
# Connector Development
## Overview
Connectors are the integration layer between Release Orchestrator and external systems. Each connector implements a standard interface for its integration type.
## Connector Architecture
```
CONNECTOR ARCHITECTURE
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONNECTOR RUNTIME │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CONNECTOR INTERFACE │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ getCapabilities()│ │ ping() │ │ authenticate() │ │ │
│ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ discover() │ │ execute() │ │ healthCheck() │ │ │
│ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CONNECTOR IMPLEMENTATIONS │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Registry │ │ CI/CD │ │ Notification│ │ Secret │ │ │
│ │ │ Connectors │ │ Connectors │ │ Connectors │ │ Connectors │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ - Docker │ │ - GitLab │ │ - Slack │ │ - Vault │ │ │
│ │ │ - ECR │ │ - GitHub │ │ - Teams │ │ - AWS SM │ │ │
│ │ │ - ACR │ │ - Jenkins │ │ - Email │ │ - Azure KV │ │ │
│ │ │ - Harbor │ │ - Azure DO │ │ - PagerDuty │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Base Connector Interface
```typescript
interface IConnector {
// Metadata
readonly typeId: string;
readonly displayName: string;
readonly version: string;
readonly capabilities: ConnectorCapabilities;
// Lifecycle
initialize(config: IntegrationConfig): Promise<void>;
dispose(): Promise<void>;
// Health
ping(config: IntegrationConfig): Promise<void>;
healthCheck(config: IntegrationConfig, creds: Credential): Promise<HealthCheckResult>;
// Authentication
authenticate(config: IntegrationConfig, creds: Credential): Promise<AuthContext>;
// Discovery (optional)
discover?(
config: IntegrationConfig,
authContext: AuthContext,
resourceType: string,
filter?: DiscoveryFilter
): Promise<DiscoveredResource[]>;
}
interface ConnectorCapabilities {
discovery: boolean;
webhooks: boolean;
streaming: boolean;
batchOperations: boolean;
customActions: string[];
}
```
## Registry Connectors
### IRegistryConnector
```typescript
interface IRegistryConnector extends IConnector {
// Repository operations
listRepositories(authContext: AuthContext): Promise<Repository[]>;
// Tag operations
listTags(authContext: AuthContext, repository: string): Promise<Tag[]>;
getManifest(authContext: AuthContext, repository: string, reference: string): Promise<Manifest>;
getDigest(authContext: AuthContext, repository: string, tag: string): Promise<string>;
// Image operations
imageExists(authContext: AuthContext, repository: string, digest: string): Promise<boolean>;
getImageMetadata(authContext: AuthContext, repository: string, digest: string): Promise<ImageMetadata>;
}
interface Repository {
name: string;
fullName: string;
tagCount?: number;
lastUpdated?: DateTime;
}
interface Tag {
name: string;
digest: string;
createdAt?: DateTime;
size?: number;
}
interface ImageMetadata {
digest: string;
mediaType: string;
size: number;
architecture: string;
os: string;
created: DateTime;
labels: Record<string, string>;
layers: LayerInfo[];
}
```
### Docker Registry Connector
```typescript
class DockerRegistryConnector implements IRegistryConnector {
readonly typeId = "docker-registry";
readonly displayName = "Docker Registry";
readonly version = "1.0.0";
readonly capabilities: ConnectorCapabilities = {
discovery: true,
webhooks: true,
streaming: false,
batchOperations: false,
customActions: []
};
private httpClient: HttpClient;
async initialize(config: DockerRegistryConfig): Promise<void> {
this.httpClient = new HttpClient({
baseUrl: config.url,
timeout: config.timeout || 30000,
insecureSkipVerify: config.insecureSkipVerify
});
}
async ping(config: DockerRegistryConfig): Promise<void> {
const response = await this.httpClient.get("/v2/");
if (response.status !== 200 && response.status !== 401) {
throw new Error(`Registry unavailable: ${response.status}`);
}
}
async authenticate(
config: DockerRegistryConfig,
creds: BasicCredential
): Promise<AuthContext> {
// Get auth challenge from /v2/
const challenge = await this.getAuthChallenge();
if (challenge.type === "bearer") {
// OAuth2 token flow
const token = await this.getToken(challenge, creds);
return { type: "bearer", token };
} else {
// Basic auth
return {
type: "basic",
credentials: Buffer.from(`${creds.username}:${creds.password}`).toString("base64")
};
}
}
async getDigest(
authContext: AuthContext,
repository: string,
tag: string
): Promise<string> {
const response = await this.httpClient.head(
`/v2/${repository}/manifests/${tag}`,
{
headers: {
...this.authHeader(authContext),
Accept: "application/vnd.docker.distribution.manifest.v2+json"
}
}
);
const digest = response.headers.get("docker-content-digest");
if (!digest) {
throw new Error("No digest header in response");
}
return digest;
}
async getImageMetadata(
authContext: AuthContext,
repository: string,
digest: string
): Promise<ImageMetadata> {
// Fetch manifest
const manifest = await this.getManifest(authContext, repository, digest);
// Fetch config blob
const configDigest = manifest.config.digest;
const configResponse = await this.httpClient.get(
`/v2/${repository}/blobs/${configDigest}`,
{ headers: this.authHeader(authContext) }
);
const config = await configResponse.json();
return {
digest,
mediaType: manifest.mediaType,
size: manifest.config.size,
architecture: config.architecture,
os: config.os,
created: new Date(config.created),
labels: config.config?.Labels || {},
layers: manifest.layers.map(l => ({
digest: l.digest,
size: l.size,
mediaType: l.mediaType
}))
};
}
}
```
### ECR Connector
```typescript
class ECRConnector implements IRegistryConnector {
readonly typeId = "ecr";
readonly displayName = "AWS ECR";
readonly version = "1.0.0";
readonly capabilities: ConnectorCapabilities = {
discovery: true,
webhooks: false,
streaming: false,
batchOperations: true,
customActions: ["createRepository", "setLifecyclePolicy"]
};
private ecrClient: ECRClient;
async initialize(config: ECRConfig): Promise<void> {
this.ecrClient = new ECRClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey
}
});
}
async authenticate(
config: ECRConfig,
creds: AWSCredential
): Promise<AuthContext> {
const command = new GetAuthorizationTokenCommand({});
const response = await this.ecrClient.send(command);
const authData = response.authorizationData?.[0];
if (!authData?.authorizationToken) {
throw new Error("Failed to get ECR authorization token");
}
return {
type: "bearer",
token: authData.authorizationToken,
expiresAt: authData.expiresAt
};
}
async listRepositories(authContext: AuthContext): Promise<Repository[]> {
const repositories: Repository[] = [];
let nextToken: string | undefined;
do {
const command = new DescribeRepositoriesCommand({
nextToken
});
const response = await this.ecrClient.send(command);
for (const repo of response.repositories || []) {
repositories.push({
name: repo.repositoryName!,
fullName: repo.repositoryUri!,
lastUpdated: repo.createdAt
});
}
nextToken = response.nextToken;
} while (nextToken);
return repositories;
}
}
```
## CI/CD Connectors
### ICICDConnector
```typescript
interface ICICDConnector extends IConnector {
// Pipeline operations
listPipelines(authContext: AuthContext): Promise<Pipeline[]>;
getPipeline(authContext: AuthContext, pipelineId: string): Promise<Pipeline>;
// Trigger operations
triggerPipeline(
authContext: AuthContext,
pipelineId: string,
params: TriggerParams
): Promise<PipelineRun>;
// Run operations
getPipelineRun(authContext: AuthContext, runId: string): Promise<PipelineRun>;
cancelPipelineRun(authContext: AuthContext, runId: string): Promise<void>;
getPipelineRunLogs(authContext: AuthContext, runId: string): Promise<string>;
}
interface Pipeline {
id: string;
name: string;
ref?: string;
webUrl?: string;
}
interface TriggerParams {
ref?: string; // Branch/tag
variables?: Record<string, string>;
}
interface PipelineRun {
id: string;
pipelineId: string;
status: PipelineStatus;
ref?: string;
webUrl?: string;
startedAt?: DateTime;
finishedAt?: DateTime;
}
type PipelineStatus =
| "pending"
| "running"
| "success"
| "failed"
| "cancelled";
```
### GitLab CI Connector
```typescript
class GitLabCIConnector implements ICICDConnector {
readonly typeId = "gitlab-ci";
readonly displayName = "GitLab CI/CD";
readonly version = "1.0.0";
readonly capabilities: ConnectorCapabilities = {
discovery: true,
webhooks: true,
streaming: false,
batchOperations: false,
customActions: ["retryPipeline"]
};
private apiClient: GitLabClient;
async initialize(config: GitLabCIConfig): Promise<void> {
this.apiClient = new GitLabClient({
baseUrl: config.url,
projectId: config.projectId
});
}
async authenticate(
config: GitLabCIConfig,
creds: TokenCredential
): Promise<AuthContext> {
// Validate token with user endpoint
this.apiClient.setToken(creds.token);
await this.apiClient.get("/user");
return {
type: "bearer",
token: creds.token
};
}
async triggerPipeline(
authContext: AuthContext,
pipelineId: string,
params: TriggerParams
): Promise<PipelineRun> {
const response = await this.apiClient.post(
`/projects/${this.projectId}/pipeline`,
{
ref: params.ref || this.defaultBranch,
variables: Object.entries(params.variables || {}).map(([key, value]) => ({
key,
value,
variable_type: "env_var"
}))
},
{ headers: { Authorization: `Bearer ${authContext.token}` } }
);
return {
id: response.id.toString(),
pipelineId: pipelineId,
status: this.mapStatus(response.status),
ref: response.ref,
webUrl: response.web_url,
startedAt: response.started_at ? new Date(response.started_at) : undefined
};
}
async getPipelineRun(
authContext: AuthContext,
runId: string
): Promise<PipelineRun> {
const response = await this.apiClient.get(
`/projects/${this.projectId}/pipelines/${runId}`,
{ headers: { Authorization: `Bearer ${authContext.token}` } }
);
return {
id: response.id.toString(),
pipelineId: response.id.toString(),
status: this.mapStatus(response.status),
ref: response.ref,
webUrl: response.web_url,
startedAt: response.started_at ? new Date(response.started_at) : undefined,
finishedAt: response.finished_at ? new Date(response.finished_at) : undefined
};
}
private mapStatus(gitlabStatus: string): PipelineStatus {
const statusMap: Record<string, PipelineStatus> = {
created: "pending",
waiting_for_resource: "pending",
preparing: "pending",
pending: "pending",
running: "running",
success: "success",
failed: "failed",
canceled: "cancelled",
skipped: "cancelled",
manual: "pending"
};
return statusMap[gitlabStatus] || "pending";
}
}
```
## Notification Connectors
### INotificationConnector
```typescript
interface INotificationConnector extends IConnector {
// Channel operations
listChannels(authContext: AuthContext): Promise<Channel[]>;
// Send operations
sendMessage(
authContext: AuthContext,
channel: string,
message: NotificationMessage
): Promise<MessageResult>;
sendTemplate(
authContext: AuthContext,
channel: string,
templateId: string,
data: Record<string, any>
): Promise<MessageResult>;
}
interface Channel {
id: string;
name: string;
type: string;
}
interface NotificationMessage {
text: string;
title?: string;
color?: string;
fields?: MessageField[];
actions?: MessageAction[];
}
interface MessageField {
name: string;
value: string;
inline?: boolean;
}
interface MessageAction {
type: "button" | "link";
text: string;
url?: string;
style?: "primary" | "danger" | "default";
}
```
### Slack Connector
```typescript
class SlackConnector implements INotificationConnector {
readonly typeId = "slack";
readonly displayName = "Slack";
readonly version = "1.0.0";
readonly capabilities: ConnectorCapabilities = {
discovery: true,
webhooks: true,
streaming: false,
batchOperations: false,
customActions: ["addReaction", "updateMessage"]
};
private slackClient: WebClient;
async initialize(config: SlackConfig): Promise<void> {
// Client initialized in authenticate
}
async authenticate(
config: SlackConfig,
creds: TokenCredential
): Promise<AuthContext> {
this.slackClient = new WebClient(creds.token);
// Test authentication
const result = await this.slackClient.auth.test();
if (!result.ok) {
throw new Error("Slack authentication failed");
}
return {
type: "bearer",
token: creds.token,
teamId: result.team_id,
userId: result.user_id
};
}
async listChannels(authContext: AuthContext): Promise<Channel[]> {
const channels: Channel[] = [];
let cursor: string | undefined;
do {
const result = await this.slackClient.conversations.list({
types: "public_channel,private_channel",
cursor
});
for (const channel of result.channels || []) {
channels.push({
id: channel.id!,
name: channel.name!,
type: channel.is_private ? "private" : "public"
});
}
cursor = result.response_metadata?.next_cursor;
} while (cursor);
return channels;
}
async sendMessage(
authContext: AuthContext,
channel: string,
message: NotificationMessage
): Promise<MessageResult> {
const blocks = this.buildBlocks(message);
const result = await this.slackClient.chat.postMessage({
channel,
text: message.text,
blocks,
attachments: message.color ? [{
color: message.color,
blocks
}] : undefined
});
return {
messageId: result.ts!,
channel: result.channel!,
success: result.ok
};
}
private buildBlocks(message: NotificationMessage): KnownBlock[] {
const blocks: KnownBlock[] = [];
if (message.title) {
blocks.push({
type: "header",
text: {
type: "plain_text",
text: message.title
}
});
}
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: message.text
}
});
if (message.fields?.length) {
blocks.push({
type: "section",
fields: message.fields.map(f => ({
type: "mrkdwn",
text: `*${f.name}*\n${f.value}`
}))
});
}
if (message.actions?.length) {
blocks.push({
type: "actions",
elements: message.actions.map(a => ({
type: "button",
text: {
type: "plain_text",
text: a.text
},
url: a.url,
style: a.style === "danger" ? "danger" : "primary"
}))
});
}
return blocks;
}
}
```
## Secret Store Connectors
### ISecretConnector
```typescript
interface ISecretConnector extends IConnector {
// Secret operations
getSecret(
authContext: AuthContext,
path: string,
key?: string
): Promise<SecretValue>;
listSecrets(
authContext: AuthContext,
path: string
): Promise<string[]>;
}
interface SecretValue {
value: string;
version?: string;
createdAt?: DateTime;
expiresAt?: DateTime;
}
```
### HashiCorp Vault Connector
```typescript
class VaultConnector implements ISecretConnector {
readonly typeId = "hashicorp-vault";
readonly displayName = "HashiCorp Vault";
readonly version = "1.0.0";
readonly capabilities: ConnectorCapabilities = {
discovery: true,
webhooks: false,
streaming: false,
batchOperations: false,
customActions: ["renewToken"]
};
private vaultClient: VaultClient;
async initialize(config: VaultConfig): Promise<void> {
this.vaultClient = new VaultClient({
endpoint: config.url,
namespace: config.namespace
});
}
async authenticate(
config: VaultConfig,
creds: Credential
): Promise<AuthContext> {
let token: string;
switch (config.authMethod) {
case "token":
token = (creds as TokenCredential).token;
break;
case "approle":
const approle = creds as AppRoleCredential;
const result = await this.vaultClient.auth.approle.login({
role_id: approle.roleId,
secret_id: approle.secretId
});
token = result.auth.client_token;
break;
case "kubernetes":
const k8s = creds as KubernetesCredential;
const k8sResult = await this.vaultClient.auth.kubernetes.login({
role: k8s.role,
jwt: k8s.serviceAccountToken
});
token = k8sResult.auth.client_token;
break;
default:
throw new Error(`Unsupported auth method: ${config.authMethod}`);
}
this.vaultClient.token = token;
return {
type: "bearer",
token,
renewable: true
};
}
async getSecret(
authContext: AuthContext,
path: string,
key?: string
): Promise<SecretValue> {
const result = await this.vaultClient.kv.v2.read({
mount_path: this.mountPath,
path
});
const data = result.data.data;
const value = key ? data[key] : JSON.stringify(data);
return {
value,
version: result.data.metadata.version.toString(),
createdAt: new Date(result.data.metadata.created_time)
};
}
async listSecrets(
authContext: AuthContext,
path: string
): Promise<string[]> {
const result = await this.vaultClient.kv.v2.list({
mount_path: this.mountPath,
path
});
return result.data.keys;
}
}
```
## Custom Connector Development
### Plugin Structure
```
my-connector/
├── manifest.yaml
├── src/
│ ├── connector.ts
│ ├── config.ts
│ └── types.ts
└── package.json
```
### Manifest
```yaml
# manifest.yaml
id: my-custom-connector
version: 1.0.0
name: My Custom Connector
description: Custom connector for XYZ service
author: Your Name
connector:
typeId: my-service
displayName: My Service
entrypoint: ./src/connector.js
capabilities:
discovery: true
webhooks: false
streaming: false
batchOperations: false
config_schema:
type: object
properties:
url:
type: string
format: uri
description: Service URL
timeout:
type: integer
default: 30000
required:
- url
credential_types:
- api-key
- oauth2
```
### Implementation
```typescript
// connector.ts
import { IConnector, ConnectorCapabilities } from "@stella-ops/connector-sdk";
export class MyConnector implements IConnector {
readonly typeId = "my-service";
readonly displayName = "My Service";
readonly version = "1.0.0";
readonly capabilities: ConnectorCapabilities = {
discovery: true,
webhooks: false,
streaming: false,
batchOperations: false,
customActions: []
};
async initialize(config: MyConfig): Promise<void> {
// Initialize your connector
}
async dispose(): Promise<void> {
// Cleanup resources
}
async ping(config: MyConfig): Promise<void> {
// Check connectivity
}
async healthCheck(config: MyConfig, creds: Credential): Promise<HealthCheckResult> {
// Full health check
}
async authenticate(config: MyConfig, creds: Credential): Promise<AuthContext> {
// Authenticate and return context
}
async discover(
config: MyConfig,
authContext: AuthContext,
resourceType: string,
filter?: DiscoveryFilter
): Promise<DiscoveredResource[]> {
// Discover resources
}
}
// Export connector factory
export default function createConnector(): IConnector {
return new MyConnector();
}
```
## References
- [Integrations Overview](overview.md)
- [Webhooks](webhooks.md)
- [Plugin System](../modules/plugin-system.md)

View File

@@ -0,0 +1,412 @@
# Integrations Overview
## Purpose
The Integration Hub (INTHUB) provides a unified interface for connecting Release Orchestrator to external systems including container registries, CI/CD pipelines, notification services, secret stores, and metrics providers.
## Integration Architecture
```
INTEGRATION HUB ARCHITECTURE
┌─────────────────────────────────────────────────────────────────────────────┐
│ INTEGRATION HUB │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ INTEGRATION MANAGER │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Type │ │ Instance │ │ Health │ │ Discovery │ │ │
│ │ │ Registry │ │ Manager │ │ Monitor │ │ Service │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CONNECTOR RUNTIME │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ CONNECTOR POOL │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ Docker │ │ GitLab │ │ Slack │ │ Vault │ │ │ │
│ │ │ │ Registry │ │ CI │ │ │ │ │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────┬─────────────────┼─────────────────┬─────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Container│ │ CI/CD │ │ Notifi- │ │ Secret │ │ Metrics │
│Registry │ │ Systems │ │ cations │ │ Stores │ │ Systems │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
```
## Integration Types
### Container Registries
| Type ID | Description | Discovery Support |
|---------|-------------|-------------------|
| `docker-registry` | Docker Registry v2 API | Yes |
| `docker-hub` | Docker Hub | Yes |
| `gcr` | Google Container Registry | Yes |
| `ecr` | AWS Elastic Container Registry | Yes |
| `acr` | Azure Container Registry | Yes |
| `ghcr` | GitHub Container Registry | Yes |
| `harbor` | Harbor Registry | Yes |
| `jfrog` | JFrog Artifactory | Yes |
| `nexus` | Sonatype Nexus | Yes |
| `quay` | Quay.io | Yes |
### CI/CD Systems
| Type ID | Description | Trigger Support |
|---------|-------------|-----------------|
| `gitlab-ci` | GitLab CI/CD | Yes |
| `github-actions` | GitHub Actions | Yes |
| `jenkins` | Jenkins | Yes |
| `azure-devops` | Azure DevOps Pipelines | Yes |
| `circleci` | CircleCI | Yes |
| `teamcity` | TeamCity | Yes |
| `drone` | Drone CI | Yes |
### Notification Services
| Type ID | Description | Features |
|---------|-------------|----------|
| `slack` | Slack | Channels, threads, reactions |
| `teams` | Microsoft Teams | Channels, cards |
| `email` | Email (SMTP) | Templates, attachments |
| `webhook` | Generic Webhook | JSON payloads |
| `pagerduty` | PagerDuty | Incidents, alerts |
| `opsgenie` | OpsGenie | Alerts, on-call |
### Secret Stores
| Type ID | Description | Features |
|---------|-------------|----------|
| `hashicorp-vault` | HashiCorp Vault | KV, Transit, PKI |
| `aws-secrets-manager` | AWS Secrets Manager | Rotation, versioning |
| `azure-key-vault` | Azure Key Vault | Keys, secrets, certs |
| `gcp-secret-manager` | GCP Secret Manager | Versions, labels |
### Metrics & Monitoring
| Type ID | Description | Use Case |
|---------|-------------|----------|
| `prometheus` | Prometheus | Canary metrics |
| `datadog` | Datadog | APM, logs, metrics |
| `newrelic` | New Relic | APM, infra monitoring |
| `dynatrace` | Dynatrace | Full-stack monitoring |
## Integration Configuration
### Integration Entity
```typescript
interface Integration {
id: UUID;
tenantId: UUID;
typeId: string; // e.g., "docker-registry"
name: string; // Display name
description?: string;
// Connection configuration
config: IntegrationConfig;
// Credential reference (stored in vault)
credentialRef: string;
// Health tracking
healthStatus: "healthy" | "degraded" | "unhealthy" | "unknown";
lastHealthCheck?: DateTime;
// Metadata
labels: Record<string, string>;
createdAt: DateTime;
updatedAt: DateTime;
}
interface IntegrationConfig {
// Common fields
url?: string;
timeout?: number;
retries?: number;
// Type-specific fields
[key: string]: any;
}
```
### Type-Specific Configuration
```typescript
// Docker Registry
interface DockerRegistryConfig extends IntegrationConfig {
url: string; // https://registry.example.com
repository?: string; // Optional default repository
insecureSkipVerify?: boolean; // Skip TLS verification
}
// GitLab CI
interface GitLabCIConfig extends IntegrationConfig {
url: string; // https://gitlab.example.com
projectId: string; // Project ID or path
defaultBranch?: string; // Default ref for triggers
}
// Slack
interface SlackConfig extends IntegrationConfig {
workspace?: string; // Workspace identifier
defaultChannel?: string; // Default channel for notifications
iconEmoji?: string; // Bot icon
}
// HashiCorp Vault
interface VaultConfig extends IntegrationConfig {
url: string; // https://vault.example.com
namespace?: string; // Vault namespace
mountPath: string; // Secret mount path
authMethod: "token" | "approle" | "kubernetes";
}
```
## Credential Management
Credentials are never stored in the Release Orchestrator database. Instead, references to external secret stores are used.
### Credential Reference Format
```
vault://vault-integration-id/path/to/secret#key
└─────────┬────────┘ └─────┬─────┘ └┬┘
Vault ID Secret path Key
```
### Credential Types
```typescript
type CredentialType =
| "basic" // Username/password
| "token" // Bearer token
| "api-key" // API key
| "oauth2" // OAuth2 credentials
| "service-account" // GCP/K8s service account
| "certificate"; // Client certificate
interface CredentialReference {
type: CredentialType;
ref: string; // Vault reference
}
// Examples
const dockerCreds: CredentialReference = {
type: "basic",
ref: "vault://vault-1/docker/registry.example.com#credentials"
};
const gitlabToken: CredentialReference = {
type: "token",
ref: "vault://vault-1/ci/gitlab#access_token"
};
```
## Health Monitoring
### Health Check Types
| Check Type | Description | Frequency |
|------------|-------------|-----------|
| `connectivity` | TCP/HTTP connectivity | 1 min |
| `authentication` | Credential validity | 5 min |
| `functionality` | Full operation test | 15 min |
### Health Check Flow
```typescript
interface HealthCheckResult {
integrationId: UUID;
checkType: string;
status: "healthy" | "degraded" | "unhealthy";
latencyMs: number;
message?: string;
checkedAt: DateTime;
}
class IntegrationHealthMonitor {
async checkHealth(integration: Integration): Promise<HealthCheckResult> {
const connector = this.connectorPool.get(integration.typeId);
const startTime = Date.now();
try {
// Connectivity check
await connector.ping(integration.config);
// Authentication check
const creds = await this.fetchCredentials(integration.credentialRef);
await connector.authenticate(integration.config, creds);
return {
integrationId: integration.id,
checkType: "full",
status: "healthy",
latencyMs: Date.now() - startTime,
checkedAt: new Date()
};
} catch (error) {
return {
integrationId: integration.id,
checkType: "full",
status: this.classifyError(error),
latencyMs: Date.now() - startTime,
message: error.message,
checkedAt: new Date()
};
}
}
}
```
## Discovery Service
Integrations can discover resources from connected systems.
### Discovery Operations
```typescript
interface DiscoveryService {
// Discover available repositories
discoverRepositories(integrationId: UUID): Promise<Repository[]>;
// Discover tags/versions
discoverTags(integrationId: UUID, repository: string): Promise<Tag[]>;
// Discover pipelines
discoverPipelines(integrationId: UUID): Promise<Pipeline[]>;
// Discover notification channels
discoverChannels(integrationId: UUID): Promise<Channel[]>;
}
// Example: Discover Docker repositories
const repos = await discoveryService.discoverRepositories(dockerIntegrationId);
// Returns: [{ name: "myapp", tags: ["latest", "v1.0.0", ...] }, ...]
```
### Discovery Caching
```typescript
interface DiscoveryCache {
key: string; // integration_id:resource_type
data: any;
discoveredAt: DateTime;
ttlSeconds: number;
}
// Cache TTLs by resource type
const cacheTTLs = {
repositories: 3600, // 1 hour
tags: 300, // 5 minutes
pipelines: 3600, // 1 hour
channels: 86400 // 24 hours
};
```
## API Reference
### Create Integration
```http
POST /api/v1/integrations
Content-Type: application/json
{
"typeId": "docker-registry",
"name": "Production Registry",
"config": {
"url": "https://registry.example.com",
"repository": "myorg"
},
"credentialRef": "vault://vault-1/docker/prod-registry#credentials",
"labels": {
"environment": "production"
}
}
```
### Test Integration
```http
POST /api/v1/integrations/{id}/test
```
Response:
```json
{
"success": true,
"data": {
"connectivityTest": { "status": "passed", "latencyMs": 45 },
"authenticationTest": { "status": "passed", "latencyMs": 120 },
"functionalityTest": { "status": "passed", "latencyMs": 230 }
}
}
```
### Discover Resources
```http
POST /api/v1/integrations/{id}/discover
Content-Type: application/json
{
"resourceType": "repositories",
"filter": {
"namePattern": "myapp-*"
}
}
```
## Error Handling
### Integration Errors
| Error Code | Description | Retry Strategy |
|------------|-------------|----------------|
| `INTEGRATION_NOT_FOUND` | Integration ID not found | No retry |
| `INTEGRATION_UNHEALTHY` | Integration health check failing | Backoff retry |
| `CREDENTIAL_FETCH_FAILED` | Cannot fetch credentials | Retry with backoff |
| `CONNECTION_REFUSED` | Cannot connect to endpoint | Retry with backoff |
| `AUTHENTICATION_FAILED` | Invalid credentials | No retry |
| `RATE_LIMITED` | Too many requests | Retry after delay |
### Circuit Breaker
```typescript
interface CircuitBreakerConfig {
failureThreshold: number; // Failures before opening
successThreshold: number; // Successes to close
timeout: number; // Time in open state (ms)
}
// Default configuration
const defaultCircuitBreaker: CircuitBreakerConfig = {
failureThreshold: 5,
successThreshold: 3,
timeout: 60000
};
```
## References
- [Connectors](connectors.md)
- [Webhooks](webhooks.md)
- [CI/CD Integration](ci-cd.md)
- [Integration Hub Module](../modules/integration-hub.md)

View 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)