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
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
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
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
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
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
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
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
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
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
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":
throw new Error("Kubernetes auth is unsupported for Stella Ops deployment connectors");
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
# 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
// 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();
}
