release orchestrator pivot, architecture and planning
This commit is contained in:
643
docs/modules/release-orchestrator/integrations/ci-cd.md
Normal file
643
docs/modules/release-orchestrator/integrations/ci-cd.md
Normal 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)
|
||||
900
docs/modules/release-orchestrator/integrations/connectors.md
Normal file
900
docs/modules/release-orchestrator/integrations/connectors.md
Normal 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)
|
||||
412
docs/modules/release-orchestrator/integrations/overview.md
Normal file
412
docs/modules/release-orchestrator/integrations/overview.md
Normal 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)
|
||||
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