Apply Composer changes: comprehensive API updates, migrations, middleware, and infrastructure improvements

- Add comprehensive database migrations (001-024) for schema evolution
- Enhance API schema with expanded type definitions and resolvers
- Add new middleware: audit logging, rate limiting, MFA enforcement, security, tenant auth
- Implement new services: AI optimization, billing, blockchain, compliance, marketplace
- Add adapter layer for cloud integrations (Cloudflare, Kubernetes, Proxmox, storage)
- Update Crossplane provider with enhanced VM management capabilities
- Add comprehensive test suite for API endpoints and services
- Update frontend components with improved GraphQL subscriptions and real-time updates
- Enhance security configurations and headers (CSP, CORS, etc.)
- Update documentation and configuration files
- Add new CI/CD workflows and validation scripts
- Implement design system improvements and UI enhancements
This commit is contained in:
defiQUG
2025-12-12 18:01:35 -08:00
parent e01131efaf
commit 9daf1fd378
968 changed files with 160890 additions and 1092 deletions

View File

@@ -0,0 +1,70 @@
/**
* Ceph Adapter Tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { CephAdapter } from '../storage/ceph-adapter'
// Mock fetch globally
global.fetch = vi.fn()
describe('CephAdapter', () => {
let adapter: CephAdapter
beforeEach(() => {
adapter = new CephAdapter({
apiUrl: 'http://localhost:7480',
accessKey: 'test-key',
secretKey: 'test-secret',
})
vi.clearAllMocks()
})
describe('discoverResources', () => {
it('should discover Ceph buckets and pools', async () => {
const mockBuckets = {
buckets: [
{
name: 'test-bucket',
creation_date: new Date().toISOString(),
},
],
}
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockBuckets,
})
const resources = await adapter.discoverResources()
expect(resources.length).toBeGreaterThan(0)
expect(resources.some((r) => r.type === 'bucket')).toBe(true)
})
})
describe('healthCheck', () => {
it('should return healthy when Ceph is accessible', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ health: { status: 'HEALTH_OK' } }),
})
const health = await adapter.healthCheck()
expect(health.status).toBe('healthy')
})
it('should return unhealthy when Ceph is inaccessible', async () => {
;(global.fetch as any).mockResolvedValue({
ok: false,
status: 500,
})
const health = await adapter.healthCheck()
expect(health.status).toBe('unhealthy')
})
})
})

View File

@@ -0,0 +1,231 @@
/**
* Cloudflare Adapter Tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { CloudflareAdapter } from '../cloudflare/adapter'
// Mock fetch globally
global.fetch = vi.fn()
describe('CloudflareAdapter', () => {
let adapter: CloudflareAdapter
beforeEach(() => {
adapter = new CloudflareAdapter({
apiToken: 'test-token',
accountId: 'test-account-id',
})
vi.clearAllMocks()
})
describe('discoverResources', () => {
it('should discover tunnels and zones', async () => {
const mockTunnels = {
success: true,
result: [
{
id: 'tunnel-1',
name: 'test-tunnel',
status: 'active',
connections: 5,
created_at: new Date().toISOString(),
},
],
}
const mockZones = {
success: true,
result: [
{
id: 'zone-1',
name: 'example.com',
status: 'active',
account: { id: 'test-account-id' },
name_servers: ['ns1.example.com'],
plan: { name: 'Pro' },
created_on: new Date().toISOString(),
modified_on: new Date().toISOString(),
},
],
}
;(global.fetch as any)
.mockResolvedValueOnce({
ok: true,
json: async () => mockTunnels,
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockZones,
})
const resources = await adapter.discoverResources()
expect(resources).toHaveLength(2)
expect(resources[0].type).toBe('tunnel')
expect(resources[1].type).toBe('dns_zone')
})
it('should handle API errors gracefully', async () => {
;(global.fetch as any).mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
})
await expect(adapter.discoverResources()).rejects.toThrow()
})
})
describe('createResource', () => {
it('should create a tunnel', async () => {
const mockTunnel = {
success: true,
result: {
id: 'tunnel-1',
name: 'new-tunnel',
status: 'active',
created_at: new Date().toISOString(),
},
}
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockTunnel,
})
const resource = await adapter.createResource({
name: 'new-tunnel',
type: 'tunnel',
config: { config_src: 'local' },
})
expect(resource).toBeDefined()
expect(resource.name).toBe('new-tunnel')
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/cfd_tunnel'),
expect.objectContaining({
method: 'POST',
})
)
})
it('should create a DNS zone', async () => {
const mockZone = {
success: true,
result: {
id: 'zone-1',
name: 'example.com',
status: 'active',
account: { id: 'test-account-id' },
created_on: new Date().toISOString(),
modified_on: new Date().toISOString(),
},
}
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockZone,
})
const resource = await adapter.createResource({
name: 'example.com',
type: 'dns_zone',
config: {},
})
expect(resource).toBeDefined()
expect(resource.name).toBe('example.com')
})
})
describe('getMetrics', () => {
it('should fetch metrics for a zone', async () => {
const mockAnalytics = {
success: true,
result: {
totals: {
requests: { all: 1000, cached: 800 },
bandwidth: { all: 5000000 },
},
},
}
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockAnalytics,
})
const metrics = await adapter.getMetrics('zone-1', {
start: new Date(Date.now() - 3600000),
end: new Date(),
})
expect(metrics.length).toBeGreaterThan(0)
expect(metrics.some((m) => m.metricType === 'REQUEST_RATE')).toBe(true)
})
})
describe('getRelationships', () => {
it('should discover tunnel to zone relationships', async () => {
const mockTunnel = {
success: true,
result: {
id: 'tunnel-1',
name: 'test-tunnel',
},
}
const mockRoutes = {
success: true,
result: [
{
zone_id: 'zone-1',
hostname: 'example.com',
path: '/',
},
],
}
;(global.fetch as any)
.mockResolvedValueOnce({
ok: true,
json: async () => mockTunnel,
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockRoutes,
})
const relationships = await adapter.getRelationships('tunnel-1')
expect(relationships.length).toBeGreaterThan(0)
expect(relationships[0].type).toBe('routes_to')
})
})
describe('healthCheck', () => {
it('should return healthy when API is accessible', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
})
const health = await adapter.healthCheck()
expect(health.status).toBe('healthy')
})
it('should return unhealthy when API is inaccessible', async () => {
;(global.fetch as any).mockResolvedValue({
ok: false,
status: 401,
})
const health = await adapter.healthCheck()
expect(health.status).toBe('unhealthy')
})
})
})

View File

@@ -0,0 +1,193 @@
/**
* Kubernetes Adapter Tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { KubernetesAdapter } from '../kubernetes/adapter'
import * as k8s from '@kubernetes/client-node'
// Mock Kubernetes client
vi.mock('@kubernetes/client-node', () => ({
KubeConfig: vi.fn().mockImplementation(() => ({
loadFromDefault: vi.fn(),
loadFromFile: vi.fn(),
loadFromString: vi.fn(),
setCurrentContext: vi.fn(),
makeApiClient: vi.fn(),
})),
CoreV1Api: vi.fn(),
AppsV1Api: vi.fn(),
MetricsV1beta1Api: vi.fn(),
}))
describe('KubernetesAdapter', () => {
let adapter: KubernetesAdapter
let mockK8sApi: any
let mockK8sAppsApi: any
beforeEach(() => {
mockK8sApi = {
listPodForAllNamespaces: vi.fn(),
listServiceForAllNamespaces: vi.fn(),
readNamespacedPod: vi.fn(),
readNamespacedService: vi.fn(),
deleteNamespacedPod: vi.fn(),
getCode: vi.fn().mockResolvedValue(200),
}
mockK8sAppsApi = {
listDeploymentForAllNamespaces: vi.fn(),
readNamespacedDeployment: vi.fn(),
createNamespacedDeployment: vi.fn(),
replaceNamespacedDeployment: vi.fn(),
deleteNamespacedDeployment: vi.fn(),
}
const mockKc = {
loadFromDefault: vi.fn(),
setCurrentContext: vi.fn(),
makeApiClient: vi.fn((api: any) => {
if (api === k8s.CoreV1Api) return mockK8sApi
if (api === k8s.AppsV1Api) return mockK8sAppsApi
return mockK8sApi
}),
}
vi.mocked(k8s.KubeConfig).mockImplementation(() => mockKc as any)
adapter = new KubernetesAdapter({
prometheusUrl: 'http://localhost:9090',
})
})
describe('discoverResources', () => {
it('should discover pods, services, and deployments', async () => {
mockK8sApi.listPodForAllNamespaces.mockResolvedValue({
body: {
items: [
{
metadata: {
name: 'test-pod',
namespace: 'default',
uid: 'pod-uid-1',
labels: { app: 'test' },
creationTimestamp: new Date().toISOString(),
},
spec: {
nodeName: 'node-1',
containers: [{ name: 'container-1', image: 'nginx:latest' }],
},
status: { phase: 'Running' },
},
],
},
})
mockK8sApi.listServiceForAllNamespaces.mockResolvedValue({
body: {
items: [
{
metadata: {
name: 'test-service',
namespace: 'default',
labels: { app: 'test' },
creationTimestamp: new Date().toISOString(),
},
spec: {
type: 'ClusterIP',
ports: [{ port: 80, protocol: 'TCP' }],
},
},
],
},
})
mockK8sAppsApi.listDeploymentForAllNamespaces.mockResolvedValue({
body: {
items: [
{
metadata: {
name: 'test-deployment',
namespace: 'default',
labels: { app: 'test' },
creationTimestamp: new Date().toISOString(),
},
spec: { replicas: 3 },
status: { replicas: 3, readyReplicas: 3 },
},
],
},
})
const resources = await adapter.discoverResources()
expect(resources.length).toBe(3)
expect(resources.some((r) => r.type === 'pod')).toBe(true)
expect(resources.some((r) => r.type === 'service')).toBe(true)
expect(resources.some((r) => r.type === 'deployment')).toBe(true)
})
})
describe('getMetrics', () => {
it('should fetch metrics from Prometheus', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
status: 'success',
data: {
result: [
{
values: [
[Math.floor(Date.now() / 1000), '0.5'],
[Math.floor(Date.now() / 1000) + 15, '0.6'],
],
},
],
},
}),
})
const metrics = await adapter.getMetrics('default/test-pod', {
start: new Date(Date.now() - 3600000),
end: new Date(),
})
expect(metrics.length).toBeGreaterThan(0)
expect(metrics.some((m) => m.metricType === 'CPU_USAGE')).toBe(true)
})
})
describe('getRelationships', () => {
it('should discover pod to service relationships', async () => {
mockK8sApi.listNamespacedService.mockResolvedValue({
body: {
items: [
{
metadata: { name: 'test-service', namespace: 'default' },
spec: {
selector: { app: 'test' },
ports: [{ port: 80 }],
},
},
],
},
})
mockK8sApi.readNamespacedPod.mockResolvedValue({
body: {
metadata: {
name: 'test-pod',
namespace: 'default',
labels: { app: 'test' },
},
},
})
const relationships = await adapter.getRelationships('default/test-pod')
expect(relationships.length).toBeGreaterThan(0)
expect(relationships.some((r) => r.type === 'exposed_by')).toBe(true)
})
})
})

View File

@@ -0,0 +1,69 @@
/**
* MinIO Adapter Tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { MinIOAdapter } from '../storage/minio-adapter'
// Mock fetch globally
global.fetch = vi.fn()
describe('MinIOAdapter', () => {
let adapter: MinIOAdapter
beforeEach(() => {
adapter = new MinIOAdapter({
endpoint: 'http://localhost:9000',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
})
vi.clearAllMocks()
})
describe('discoverResources', () => {
it('should discover MinIO buckets', async () => {
const mockBuckets = [
{
name: 'test-bucket',
creationDate: new Date().toISOString(),
},
]
// MinIO uses S3-compatible API
;(global.fetch as any).mockResolvedValue({
ok: true,
text: async () => {
// Simulate XML response from S3 API
return `<?xml version="1.0"?>
<ListAllMyBucketsResult>
${mockBuckets.map((b) => `<Bucket><Name>${b.name}</Name></Bucket>`).join('')}
</ListAllMyBucketsResult>`
},
})
const resources = await adapter.discoverResources()
expect(resources.length).toBeGreaterThan(0)
expect(resources.some((r) => r.type === 'bucket')).toBe(true)
})
})
describe('createResource', () => {
it('should create a bucket', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
status: 200,
})
const resource = await adapter.createResource({
name: 'new-bucket',
type: 'bucket',
config: {},
})
expect(resource).toBeDefined()
expect(resource.name).toBe('new-bucket')
})
})
})

View File

@@ -0,0 +1,99 @@
/**
* Prometheus Adapter Tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { PrometheusAdapter } from '../monitoring/prometheus-adapter'
// Mock fetch globally
global.fetch = vi.fn()
describe('PrometheusAdapter', () => {
let adapter: PrometheusAdapter
beforeEach(() => {
adapter = new PrometheusAdapter({
url: 'http://localhost:9090',
})
vi.clearAllMocks()
})
describe('query', () => {
it('should execute Prometheus queries', async () => {
const mockResponse = {
status: 'success',
data: {
resultType: 'vector',
result: [
{
metric: { instance: 'localhost:9090' },
value: [Math.floor(Date.now() / 1000), '100'],
},
],
},
}
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockResponse,
})
const result = await adapter.query('up')
expect(result).toBeDefined()
expect(result.length).toBeGreaterThan(0)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/query'),
expect.any(Object)
)
})
it('should handle query errors', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
status: 'error',
error: 'Invalid query',
}),
})
await expect(adapter.query('invalid query')).rejects.toThrow()
})
})
describe('queryRange', () => {
it('should execute range queries', async () => {
const mockResponse = {
status: 'success',
data: {
resultType: 'matrix',
result: [
{
metric: { instance: 'localhost:9090' },
values: [
[Math.floor(Date.now() / 1000) - 60, '100'],
[Math.floor(Date.now() / 1000), '110'],
],
},
],
},
}
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockResponse,
})
const result = await adapter.queryRange(
'up',
new Date(Date.now() - 3600000),
new Date(),
'15s'
)
expect(result).toBeDefined()
expect(result.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,602 @@
/**
* Cloudflare Adapter
* Implements the InfrastructureAdapter interface for Cloudflare
*/
import { InfrastructureAdapter, NormalizedResource, ResourceSpec, NormalizedMetrics, TimeRange, HealthStatus, NormalizedRelationship } from '../types.js'
import { ResourceProvider } from '../../types/resource.js'
import { logger } from '../../lib/logger'
export class CloudflareAdapter implements InfrastructureAdapter {
readonly provider: ResourceProvider = 'CLOUDFLARE'
private apiToken: string
private accountId: string
constructor(config: { apiToken: string; accountId: string }) {
this.apiToken = config.apiToken
this.accountId = config.accountId
}
async discoverResources(): Promise<NormalizedResource[]> {
const resources: NormalizedResource[] = []
try {
// Discover Cloudflare Tunnels
const tunnels = await this.getTunnels()
for (const tunnel of tunnels) {
resources.push(this.normalizeTunnel(tunnel))
}
// Discover DNS zones
const zones = await this.getZones()
for (const zone of zones) {
resources.push(this.normalizeZone(zone))
}
} catch (error) {
logger.error('Error discovering Cloudflare resources', { error })
throw error
}
return resources
}
private async getTunnels(): Promise<CloudflareTunnel[]> {
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Cloudflare API error: ${response.status} ${response.statusText}`)
}
const data = (await response.json()) as CloudflareAPIResponse<CloudflareTunnel>
return data.result || []
}
interface CloudflareZone {
id: string
name: string
status: string
[key: string]: unknown
}
interface CloudflareTunnel {
id: string
name: string
status: string
[key: string]: unknown
}
interface CloudflareAPIResponse<T> {
result: T[]
success: boolean
errors?: unknown[]
[key: string]: unknown
}
private async getZones(): Promise<CloudflareZone[]> {
const response = await fetch('https://api.cloudflare.com/client/v4/zones', {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Cloudflare API error: ${response.status} ${response.statusText}`)
}
const data = (await response.json()) as CloudflareAPIResponse<CloudflareZone>
return data.result || []
}
private normalizeTunnel(tunnel: CloudflareTunnel): NormalizedResource {
return {
id: `cloudflare-tunnel-${tunnel.id}`,
name: tunnel.name || `Tunnel ${tunnel.id}`,
type: 'tunnel',
provider: 'CLOUDFLARE',
providerId: tunnel.id,
providerResourceId: `cloudflare://tunnels/${tunnel.id}`,
status: tunnel.status || 'active',
metadata: {
accountId: this.accountId,
createdAt: tunnel.created_at,
connections: tunnel.connections || [],
},
tags: [],
createdAt: tunnel.created_at ? new Date(tunnel.created_at) : new Date(),
updatedAt: new Date(),
}
}
private normalizeZone(zone: CloudflareZone): NormalizedResource {
return {
id: `cloudflare-zone-${zone.id}`,
name: zone.name,
type: 'dns_zone',
provider: 'CLOUDFLARE',
providerId: zone.id,
providerResourceId: `cloudflare://zones/${zone.id}`,
status: zone.status,
metadata: {
accountId: zone.account?.id,
nameServers: zone.name_servers || [],
plan: zone.plan?.name,
},
tags: [],
createdAt: zone.created_on ? new Date(zone.created_on) : new Date(),
updatedAt: zone.modified_on ? new Date(zone.modified_on) : new Date(),
}
}
async getResource(providerId: string): Promise<NormalizedResource | null> {
try {
// Try to get as tunnel first
try {
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel/${providerId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (response.ok) {
const data = await response.json()
if (data.result) {
return this.normalizeTunnel(data.result)
}
}
} catch (error) {
// Not a tunnel, try zone
try {
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${providerId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (response.ok) {
const data = await response.json()
if (data.result) {
return this.normalizeZone(data.result)
}
}
} catch (error) {
return null
}
}
return null
} catch (error) {
logger.error(`Error getting Cloudflare resource ${providerId}`, { error, providerId })
return null
}
}
async createResource(spec: ResourceSpec): Promise<NormalizedResource> {
try {
if (spec.type === 'tunnel') {
// Create Cloudflare Tunnel
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: spec.name,
config_src: spec.config.config_src || 'local',
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(`Failed to create tunnel: ${error.errors?.[0]?.message || response.statusText}`)
}
const data = await response.json()
return this.normalizeTunnel(data.result)
} else if (spec.type === 'dns_zone') {
// Create DNS Zone
const response = await fetch('https://api.cloudflare.com/client/v4/zones', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: spec.name,
account: {
id: this.accountId,
},
...spec.config,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(`Failed to create zone: ${error.errors?.[0]?.message || response.statusText}`)
}
const data = await response.json()
return this.normalizeZone(data.result)
} else {
throw new Error(`Unsupported resource type: ${spec.type}`)
}
} catch (error) {
logger.error('Error creating Cloudflare resource', { error })
throw error
}
}
async updateResource(providerId: string, spec: Partial<ResourceSpec>): Promise<NormalizedResource> {
try {
// Try to get the resource first to determine its type
const existing = await this.getResource(providerId)
if (!existing) {
throw new Error(`Resource ${providerId} not found`)
}
if (existing.type === 'tunnel') {
// Update Cloudflare Tunnel
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel/${providerId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: spec.name || existing.name,
...spec.config,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(`Failed to update tunnel: ${error.errors?.[0]?.message || response.statusText}`)
}
const data = await response.json()
return this.normalizeTunnel(data.result)
} else if (existing.type === 'dns_zone') {
// Update DNS Zone
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${providerId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
...spec.config,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(`Failed to update zone: ${error.errors?.[0]?.message || response.statusText}`)
}
const data = await response.json()
return this.normalizeZone(data.result)
} else {
throw new Error(`Unsupported resource type: ${existing.type}`)
}
} catch (error) {
logger.error(`Error updating Cloudflare resource ${providerId}`, { error, providerId })
throw error
}
}
async deleteResource(providerId: string): Promise<boolean> {
try {
// Try to get the resource first to determine its type
const existing = await this.getResource(providerId)
if (!existing) {
return false
}
if (existing.type === 'tunnel') {
// Delete Cloudflare Tunnel
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel/${providerId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
})
return response.ok
} else if (existing.type === 'dns_zone') {
// Delete DNS Zone
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${providerId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
})
return response.ok
} else {
return false
}
} catch (error) {
logger.error(`Error deleting Cloudflare resource ${providerId}`, { error, providerId })
return false
}
}
async getMetrics(providerId: string, timeRange: TimeRange): Promise<NormalizedMetrics[]> {
try {
const metrics: NormalizedMetrics[] = []
// Get analytics from Cloudflare Analytics API
// For zones, use zone analytics
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${providerId}/analytics/dashboard?since=${timeRange.start.toISOString()}&until=${timeRange.end.toISOString()}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
}
)
if (response.ok) {
const data = await response.json()
const result = data.result
// Network throughput
if (result?.totals?.requests?.all) {
metrics.push({
resourceId: providerId,
metricType: 'REQUEST_RATE',
value: result.totals.requests.all,
timestamp: new Date(),
labels: { type: 'all' },
})
}
// Bandwidth
if (result?.totals?.bandwidth?.all) {
metrics.push({
resourceId: providerId,
metricType: 'NETWORK_THROUGHPUT',
value: result.totals.bandwidth.all,
timestamp: new Date(),
labels: { type: 'all' },
})
}
// Error rate
if (result?.totals?.requests?.cached && result?.totals?.requests?.all) {
const errorRate = ((result.totals.requests.all - result.totals.requests.cached) / result.totals.requests.all) * 100
metrics.push({
resourceId: providerId,
metricType: 'ERROR_RATE',
value: errorRate,
timestamp: new Date(),
labels: { type: 'calculated' },
})
}
}
} catch (error) {
// Not a zone, try tunnel metrics
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel/${providerId}/connections`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
}
)
if (response.ok) {
const data = await response.json()
const connections = data.result || []
metrics.push({
resourceId: providerId,
metricType: 'NETWORK_THROUGHPUT',
value: connections.length,
timestamp: new Date(),
labels: { type: 'connections' },
})
}
} catch (tunnelError) {
// Metrics not available for this resource type
logger.warn(`Metrics not available for resource ${providerId}`, { providerId })
}
}
return metrics
} catch (error) {
logger.error(`Error getting Cloudflare metrics for ${providerId}`, { error, providerId })
return []
}
}
async getRelationships(providerId: string): Promise<NormalizedRelationship[]> {
try {
const relationships: NormalizedRelationship[] = []
// Try to get as tunnel first
try {
const tunnelResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel/${providerId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
}
)
if (tunnelResponse.ok) {
const tunnelData = await tunnelResponse.json()
const tunnel = tunnelData.result
// Get DNS routes for this tunnel
try {
const routesResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel/${providerId}/routes`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
}
)
if (routesResponse.ok) {
const routesData = await routesResponse.json()
const routes = routesData.result || []
for (const route of routes) {
if (route.zone_id) {
relationships.push({
sourceId: providerId,
targetId: route.zone_id,
type: 'routes_to',
metadata: {
hostname: route.hostname,
path: route.path,
},
})
}
}
}
} catch (error) {
// Routes not available
}
}
} catch (error) {
// Not a tunnel, try zone relationships
try {
// Get DNS records for this zone
const dnsResponse = await fetch(
`https://api.cloudflare.com/client/v4/zones/${providerId}/dns_records`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
}
)
if (dnsResponse.ok) {
const dnsData = await dnsResponse.json()
const records = dnsData.result || []
for (const record of records) {
relationships.push({
sourceId: providerId,
targetId: record.id,
type: 'contains',
metadata: {
type: record.type,
name: record.name,
content: record.content,
},
})
}
}
// Get tunnel connections to this zone
const tunnels = await this.getTunnels()
for (const tunnel of tunnels) {
try {
const routesResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/cfd_tunnel/${tunnel.id}/routes`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
}
)
if (routesResponse.ok) {
const routesData = await routesResponse.json()
const routes = routesData.result || []
for (const route of routes) {
if (route.zone_id === providerId) {
relationships.push({
sourceId: tunnel.id,
targetId: providerId,
type: 'routes_to',
metadata: {
hostname: route.hostname,
},
})
}
}
}
} catch (error) {
// Skip this tunnel
}
}
} catch (error) {
// Relationships not available
}
}
return relationships
} catch (error) {
logger.error(`Error getting Cloudflare relationships for ${providerId}`, { error, providerId })
return []
}
}
async healthCheck(): Promise<HealthStatus> {
try {
const response = await fetch('https://api.cloudflare.com/client/v4/user/tokens/verify', {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (response.ok) {
return {
status: 'healthy',
lastChecked: new Date(),
}
}
return {
status: 'unhealthy',
message: `API returned status ${response.status}`,
lastChecked: new Date(),
}
} catch (error) {
return {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
}
}
}
}

View File

@@ -0,0 +1,601 @@
/**
* Kubernetes Adapter
* Implements the InfrastructureAdapter interface for Kubernetes
*/
import { InfrastructureAdapter, NormalizedResource, ResourceSpec, NormalizedMetrics, TimeRange, HealthStatus, NormalizedRelationship } from '../types.js'
import { ResourceProvider } from '../../types/resource.js'
import { logger } from '../../lib/logger'
import * as k8s from '@kubernetes/client-node'
export class KubernetesAdapter implements InfrastructureAdapter {
readonly provider: ResourceProvider = 'KUBERNETES'
private k8sApi: k8s.CoreV1Api
private k8sAppsApi: k8s.AppsV1Api
private prometheusUrl?: string
constructor(config: { kubeconfig?: string; context?: string; prometheusUrl?: string }) {
const kc = new k8s.KubeConfig()
if (config.kubeconfig) {
kc.loadFromString(config.kubeconfig)
} else if (process.env.KUBECONFIG) {
kc.loadFromFile(process.env.KUBECONFIG)
} else {
kc.loadFromDefault()
}
if (config.context) {
kc.setCurrentContext(config.context)
}
this.k8sApi = kc.makeApiClient(k8s.CoreV1Api)
this.k8sAppsApi = kc.makeApiClient(k8s.AppsV1Api)
this.prometheusUrl = config.prometheusUrl || process.env.PROMETHEUS_URL
}
async discoverResources(): Promise<NormalizedResource[]> {
const resources: NormalizedResource[] = []
try {
// Discover pods
const podsResponse = await this.k8sApi.listPodForAllNamespaces()
if (podsResponse.body.items) {
for (const pod of podsResponse.body.items) {
resources.push(this.normalizePod(pod))
}
}
// Discover services
const servicesResponse = await this.k8sApi.listServiceForAllNamespaces()
if (servicesResponse.body.items) {
for (const service of servicesResponse.body.items) {
resources.push(this.normalizeService(service))
}
}
// Discover deployments
const deploymentsResponse = await this.k8sAppsApi.listDeploymentForAllNamespaces()
if (deploymentsResponse.body.items) {
for (const deployment of deploymentsResponse.body.items) {
resources.push(this.normalizeDeployment(deployment))
}
}
} catch (error) {
logger.error('Error discovering Kubernetes resources', { error })
throw error
}
return resources
}
private normalizePod(pod: k8s.V1Pod): NormalizedResource {
const namespace = pod.metadata?.namespace || 'default'
const name = pod.metadata?.name || ''
const uid = pod.metadata?.uid || ''
return {
id: `k8s-pod-${namespace}-${name}`,
name: name,
type: 'pod',
provider: 'KUBERNETES',
providerId: `${namespace}/${name}`,
providerResourceId: `k8s://${namespace}/pods/${name}`,
status: pod.status?.phase?.toLowerCase() || 'unknown',
metadata: {
namespace,
uid,
nodeName: pod.spec?.nodeName,
containers: pod.spec?.containers?.map(c => ({ name: c.name, image: c.image })) || [],
labels: pod.metadata?.labels || {},
},
tags: Object.keys(pod.metadata?.labels || {}),
createdAt: pod.metadata?.creationTimestamp ? new Date(pod.metadata.creationTimestamp) : new Date(),
updatedAt: new Date(),
}
}
private normalizeService(service: k8s.V1Service): NormalizedResource {
const namespace = service.metadata?.namespace || 'default'
const name = service.metadata?.name || ''
return {
id: `k8s-svc-${namespace}-${name}`,
name: name,
type: 'service',
provider: 'KUBERNETES',
providerId: `${namespace}/${name}`,
providerResourceId: `k8s://${namespace}/services/${name}`,
status: 'active',
metadata: {
namespace,
type: service.spec?.type,
ports: service.spec?.ports?.map(p => ({ port: p.port, protocol: p.protocol })) || [],
labels: service.metadata?.labels || {},
},
tags: Object.keys(service.metadata?.labels || {}),
createdAt: service.metadata?.creationTimestamp ? new Date(service.metadata.creationTimestamp) : new Date(),
updatedAt: new Date(),
}
}
private normalizeDeployment(deployment: k8s.V1Deployment): NormalizedResource {
const namespace = deployment.metadata?.namespace || 'default'
const name = deployment.metadata?.name || ''
return {
id: `k8s-deploy-${namespace}-${name}`,
name: name,
type: 'deployment',
provider: 'KUBERNETES',
providerId: `${namespace}/${name}`,
providerResourceId: `k8s://${namespace}/deployments/${name}`,
status: deployment.status?.replicas === deployment.status?.readyReplicas ? 'running' : 'pending',
metadata: {
namespace,
replicas: deployment.spec?.replicas || 0,
readyReplicas: deployment.status?.readyReplicas || 0,
labels: deployment.metadata?.labels || {},
},
tags: Object.keys(deployment.metadata?.labels || {}),
createdAt: deployment.metadata?.creationTimestamp ? new Date(deployment.metadata.creationTimestamp) : new Date(),
updatedAt: new Date(),
}
}
async getResource(providerId: string): Promise<NormalizedResource | null> {
try {
const [namespace, name] = providerId.split('/')
if (!namespace || !name) {
return null
}
// Try to get as pod first
try {
const pod = await this.k8sApi.readNamespacedPod(name, namespace)
if (pod.body) {
return this.normalizePod(pod.body)
}
} catch (error) {
// Not a pod, try service
try {
const service = await this.k8sApi.readNamespacedService(name, namespace)
if (service.body) {
return this.normalizeService(service.body)
}
} catch (error) {
// Not a service, try deployment
try {
const deployment = await this.k8sAppsApi.readNamespacedDeployment(name, namespace)
if (deployment.body) {
return this.normalizeDeployment(deployment.body)
}
} catch (error) {
return null
}
}
}
return null
} catch (error) {
logger.error(`Error getting Kubernetes resource ${providerId}`, { error, providerId })
return null
}
}
async createResource(spec: ResourceSpec): Promise<NormalizedResource> {
try {
if (spec.type === 'deployment') {
const deployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: spec.name,
namespace: spec.config.namespace || 'default',
labels: spec.tags?.reduce((acc, tag) => {
const [key, value] = tag.split('=')
acc[key] = value || 'true'
return acc
}, {} as Record<string, string>) || {},
},
spec: {
replicas: spec.config.replicas || 1,
selector: {
matchLabels: {
app: spec.name,
},
},
template: {
metadata: {
labels: {
app: spec.name,
},
},
spec: {
containers: [
{
name: spec.name,
image: spec.config.image || 'nginx:latest',
resources: {
requests: {
cpu: spec.config.cpu || '100m',
memory: spec.config.memory || '128Mi',
},
},
},
],
},
},
},
}
const result = await this.k8sAppsApi.createNamespacedDeployment(
deployment.metadata!.namespace!,
deployment
)
if (result.body.metadata) {
return this.normalizeDeployment(result.body)
}
}
throw new Error(`Unsupported resource type: ${spec.type}`)
} catch (error) {
logger.error('Error creating Kubernetes resource', { error })
throw error
}
}
async updateResource(providerId: string, spec: Partial<ResourceSpec>): Promise<NormalizedResource> {
try {
const [namespace, name] = providerId.split('/')
if (!namespace || !name) {
throw new Error('Invalid provider ID format')
}
// Try to update deployment
try {
const deployment = await this.k8sAppsApi.readNamespacedDeployment(name, namespace)
if (deployment.body.spec) {
if (spec.config?.replicas !== undefined) {
deployment.body.spec.replicas = spec.config.replicas
}
const updated = await this.k8sAppsApi.replaceNamespacedDeployment(
name,
namespace,
deployment.body
)
return this.normalizeDeployment(updated.body)
}
} catch (error) {
// Not a deployment, continue
}
throw new Error('Resource not found or cannot be updated')
} catch (error) {
logger.error(`Error updating Kubernetes resource ${providerId}`, { error, providerId })
throw error
}
}
async deleteResource(providerId: string): Promise<boolean> {
try {
const [namespace, name] = providerId.split('/')
if (!namespace || !name) {
return false
}
// Try to delete deployment
try {
await this.k8sAppsApi.deleteNamespacedDeployment(name, namespace)
return true
} catch (error) {
// Not a deployment, try pod
try {
await this.k8sApi.deleteNamespacedPod(name, namespace)
return true
} catch (error) {
return false
}
}
} catch (error) {
logger.error(`Error deleting Kubernetes resource ${providerId}`, { error, providerId })
return false
}
}
async getMetrics(providerId: string, timeRange: TimeRange): Promise<NormalizedMetrics[]> {
const metrics: NormalizedMetrics[] = []
try {
const [namespace, name] = providerId.split('/')
if (!namespace || !name) {
return []
}
// Try to get resource to determine type
const resource = await this.getResource(providerId)
if (!resource) {
return []
}
// If Prometheus is configured, query it
if (this.prometheusUrl) {
const startTime = Math.floor(timeRange.start.getTime() / 1000)
const endTime = Math.floor(timeRange.end.getTime() / 1000)
if (resource.type === 'pod') {
// CPU usage
try {
const cpuQuery = `rate(container_cpu_usage_seconds_total{pod="${name}",namespace="${namespace}"}[5m])`
const cpuResponse = await fetch(
`${this.prometheusUrl}/api/v1/query_range?query=${encodeURIComponent(cpuQuery)}&start=${startTime}&end=${endTime}&step=15s`
)
const cpuData = await cpuResponse.json()
if (cpuData.status === 'success' && cpuData.data?.result?.[0]?.values) {
for (const [timestamp, value] of cpuData.data.result[0].values) {
metrics.push({
resourceId: providerId,
metricType: 'CPU_USAGE',
value: parseFloat(value),
timestamp: new Date(parseInt(timestamp) * 1000),
labels: { namespace, pod: name },
})
}
}
} catch (error) {
logger.warn(`Failed to get CPU metrics for ${providerId}`, { error, providerId })
}
// Memory usage
try {
const memoryQuery = `container_memory_usage_bytes{pod="${name}",namespace="${namespace}"}`
const memoryResponse = await fetch(
`${this.prometheusUrl}/api/v1/query_range?query=${encodeURIComponent(memoryQuery)}&start=${startTime}&end=${endTime}&step=15s`
)
const memoryData = await memoryResponse.json()
if (memoryData.status === 'success' && memoryData.data?.result?.[0]?.values) {
for (const [timestamp, value] of memoryData.data.result[0].values) {
metrics.push({
resourceId: providerId,
metricType: 'MEMORY_USAGE',
value: parseFloat(value),
timestamp: new Date(parseInt(timestamp) * 1000),
labels: { namespace, pod: name },
})
}
}
} catch (error) {
logger.warn(`Failed to get memory metrics for ${providerId}`, { error, providerId })
}
} else if (resource.type === 'service') {
// Network throughput for services
try {
const networkQuery = `rate(istio_requests_total{destination_service="${name}.${namespace}.svc.cluster.local"}[5m])`
const networkResponse = await fetch(
`${this.prometheusUrl}/api/v1/query_range?query=${encodeURIComponent(networkQuery)}&start=${startTime}&end=${endTime}&step=15s`
)
const networkData = await networkResponse.json()
if (networkData.status === 'success' && networkData.data?.result?.[0]?.values) {
for (const [timestamp, value] of networkData.data.result[0].values) {
metrics.push({
resourceId: providerId,
metricType: 'NETWORK_THROUGHPUT',
value: parseFloat(value),
timestamp: new Date(parseInt(timestamp) * 1000),
labels: { namespace, service: name },
})
}
}
} catch (error) {
logger.warn(`Failed to get network metrics for ${providerId}`, { error, providerId })
}
}
} else {
// Fallback to Metrics Server API if available
try {
const kc = new k8s.KubeConfig()
kc.loadFromDefault()
const metricsApi = kc.makeApiClient(k8s.MetricsV1beta1Api)
if (resource.type === 'pod') {
try {
const podMetrics = await metricsApi.readNamespacedPodMetrics(name, namespace)
if (podMetrics.body.containers) {
for (const container of podMetrics.body.containers) {
if (container.usage?.cpu) {
const cpuValue = this.parseCpuValue(container.usage.cpu)
metrics.push({
resourceId: providerId,
metricType: 'CPU_USAGE',
value: cpuValue,
timestamp: new Date(),
labels: { container: container.name },
})
}
if (container.usage?.memory) {
const memoryValue = this.parseMemoryValue(container.usage.memory)
metrics.push({
resourceId: providerId,
metricType: 'MEMORY_USAGE',
value: memoryValue,
timestamp: new Date(),
labels: { container: container.name },
})
}
}
}
} catch (error) {
// Metrics Server not available
}
}
} catch (error) {
// Metrics API not available
}
}
} catch (error) {
logger.error(`Error getting Kubernetes metrics for ${providerId}`, { error, providerId })
}
return metrics
}
private parseCpuValue(cpu: string): number {
// Parse CPU values like "100m" or "1" to cores
if (cpu.endsWith('m')) {
return parseFloat(cpu) / 1000
}
return parseFloat(cpu)
}
private parseMemoryValue(memory: string): number {
// Parse memory values like "128Mi" or "1Gi" to bytes
const units: Record<string, number> = {
'Ki': 1024,
'Mi': 1024 * 1024,
'Gi': 1024 * 1024 * 1024,
'Ti': 1024 * 1024 * 1024 * 1024,
}
for (const [unit, multiplier] of Object.entries(units)) {
if (memory.endsWith(unit)) {
return parseFloat(memory) * multiplier
}
}
return parseFloat(memory)
}
async getRelationships(providerId: string): Promise<NormalizedRelationship[]> {
const relationships: NormalizedRelationship[] = []
try {
const [namespace, name] = providerId.split('/')
if (!namespace || !name) {
return []
}
// Try to get resource to determine type
const resource = await this.getResource(providerId)
if (!resource) {
return []
}
if (resource.type === 'pod') {
// Find services that select this pod
const servicesResponse = await this.k8sApi.listNamespacedService(namespace)
if (servicesResponse.body.items) {
for (const service of servicesResponse.body.items) {
const selector = service.spec?.selector || {}
const podLabels = (resource.metadata?.labels as Record<string, string>) || {}
// Check if service selector matches pod labels
const matches = Object.keys(selector).every(key => podLabels[key] === selector[key])
if (matches) {
relationships.push({
sourceId: providerId,
targetId: `${namespace}/${service.metadata?.name}`,
type: 'exposed_by',
metadata: {
serviceName: service.metadata?.name,
ports: service.spec?.ports?.map(p => p.port) || [],
},
})
}
}
}
// Find deployment that owns this pod
const deploymentsResponse = await this.k8sAppsApi.listNamespacedDeployment(namespace)
if (deploymentsResponse.body.items) {
for (const deployment of deploymentsResponse.body.items) {
const selector = deployment.spec?.selector?.matchLabels || {}
const podLabels = (resource.metadata?.labels as Record<string, string>) || {}
const matches = Object.keys(selector).every(key => podLabels[key] === selector[key])
if (matches) {
relationships.push({
sourceId: `${namespace}/${deployment.metadata?.name}`,
targetId: providerId,
type: 'manages',
metadata: {
deploymentName: deployment.metadata?.name,
},
})
}
}
}
} else if (resource.type === 'service') {
// Find pods that this service selects
const selector = (resource.metadata as any)?.selector || {}
const podsResponse = await this.k8sApi.listNamespacedPod(namespace)
if (podsResponse.body.items) {
for (const pod of podsResponse.body.items) {
const podLabels = pod.metadata?.labels || {}
const matches = Object.keys(selector).every(key => podLabels[key] === selector[key])
if (matches) {
relationships.push({
sourceId: `${namespace}/${pod.metadata?.name}`,
targetId: providerId,
type: 'exposed_by',
metadata: {
podName: pod.metadata?.name,
},
})
}
}
}
} else if (resource.type === 'deployment') {
// Find pods managed by this deployment
const selector = (resource.metadata as any)?.selector || {}
const podsResponse = await this.k8sApi.listNamespacedPod(namespace)
if (podsResponse.body.items) {
for (const pod of podsResponse.body.items) {
const podLabels = pod.metadata?.labels || {}
const matches = Object.keys(selector).every(key => podLabels[key] === selector[key])
if (matches) {
relationships.push({
sourceId: providerId,
targetId: `${namespace}/${pod.metadata?.name}`,
type: 'manages',
metadata: {
podName: pod.metadata?.name,
},
})
}
}
}
}
} catch (error) {
logger.error(`Error getting Kubernetes relationships for ${providerId}`, { error, providerId })
}
return relationships
}
async healthCheck(): Promise<HealthStatus> {
try {
const code = await this.k8sApi.getCode()
if (code === 200) {
return {
status: 'healthy',
lastChecked: new Date(),
}
}
return {
status: 'unhealthy',
message: `API returned status ${code}`,
lastChecked: new Date(),
}
} catch (error) {
return {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
}
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* Prometheus Monitoring Adapter
* Collects metrics from Prometheus and normalizes them
*/
export interface PrometheusConfig {
url: string
username?: string
password?: string
}
export interface PrometheusQueryResult {
metric: Record<string, string>
value: [number, string] // [timestamp, value]
}
export class PrometheusAdapter {
private config: PrometheusConfig
constructor(config: PrometheusConfig) {
this.config = config
}
async query(query: string, time?: Date): Promise<PrometheusQueryResult[]> {
const url = new URL(`${this.config.url}/api/v1/query`)
if (time) {
url.searchParams.set('time', (time.getTime() / 1000).toString())
}
url.searchParams.set('query', query)
const headers: Record<string, string> = {}
if (this.config.username && this.config.password) {
const auth = Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')
headers['Authorization'] = `Basic ${auth}`
}
const response = await fetch(url.toString(), { headers })
const data = await response.json()
if (data.status === 'success' && data.data?.result) {
return data.data.result
}
return []
}
async queryRange(
query: string,
start: Date,
end: Date,
step: string = '15s'
): Promise<PrometheusQueryResult[]> {
const url = new URL(`${this.config.url}/api/v1/query_range`)
url.searchParams.set('query', query)
url.searchParams.set('start', (start.getTime() / 1000).toString())
url.searchParams.set('end', (end.getTime() / 1000).toString())
url.searchParams.set('step', step)
const response = await fetch(url.toString())
const data = await response.json()
if (data.status === 'success' && data.data?.result) {
return data.data.result
}
return []
}
async getMetrics(resourceId: string, metricType: string, timeRange: { start: Date; end: Date }) {
// Query Prometheus for resource metrics
const query = `resource_${metricType.toLowerCase()}{resource_id="${resourceId}"}`
return this.queryRange(query, timeRange.start, timeRange.end)
}
}

View File

@@ -0,0 +1,108 @@
/**
* Resource Normalization Layer
* Converts provider-specific resources to unified format
*/
import { InfrastructureAdapter, NormalizedResource } from './types.js'
import { logger } from '../lib/logger'
import { ResourceProvider } from '../types/resource.js'
import { ProxmoxAdapter } from './proxmox/adapter.js'
import { KubernetesAdapter } from './kubernetes/adapter.js'
import { CloudflareAdapter } from './cloudflare/adapter.js'
export class ResourceNormalizer {
private adapters: Map<ResourceProvider, InfrastructureAdapter> = new Map()
constructor() {
// Initialize adapters based on configuration
// This will be configured via environment variables or config files
}
/**
* Register an adapter for a provider
*/
registerAdapter(provider: ResourceProvider, adapter: InfrastructureAdapter): void {
this.adapters.set(provider, adapter)
}
/**
* Get adapter for a provider
*/
getAdapter(provider: ResourceProvider): InfrastructureAdapter | null {
return this.adapters.get(provider) || null
}
/**
* Discover resources from all registered adapters
*/
async discoverAllResources(): Promise<NormalizedResource[]> {
const allResources: NormalizedResource[] = []
for (const [provider, adapter] of this.adapters.entries()) {
try {
const resources = await adapter.discoverResources()
allResources.push(...resources)
} catch (error) {
logger.error(`Error discovering resources from ${provider}`, { error, provider })
}
}
return allResources
}
/**
* Discover resources from a specific provider
*/
async discoverResources(provider: ResourceProvider): Promise<NormalizedResource[]> {
const adapter = this.getAdapter(provider)
if (!adapter) {
throw new Error(`No adapter registered for provider: ${provider}`)
}
return adapter.discoverResources()
}
/**
* Check health of all adapters
*/
async checkAllAdaptersHealth(): Promise<Record<ResourceProvider, any>> {
const health: Record<string, any> = {}
for (const [provider, adapter] of this.adapters.entries()) {
try {
health[provider] = await adapter.healthCheck()
} catch (error) {
health[provider] = {
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
}
}
}
return health
}
}
// Factory function to create adapters from configuration
export function createAdapters(config: {
proxmox?: { apiUrl: string; apiToken: string }
kubernetes?: { kubeconfig?: string; context?: string }
cloudflare?: { apiToken: string; accountId: string }
}): ResourceNormalizer {
const normalizer = new ResourceNormalizer()
if (config.proxmox) {
normalizer.registerAdapter('PROXMOX', new ProxmoxAdapter(config.proxmox))
}
if (config.kubernetes) {
normalizer.registerAdapter('KUBERNETES', new KubernetesAdapter(config.kubernetes))
}
if (config.cloudflare) {
normalizer.registerAdapter('CLOUDFLARE', new CloudflareAdapter(config.cloudflare))
}
return normalizer
}

View File

@@ -0,0 +1,347 @@
/**
* Proxmox VE Adapter
* Implements the InfrastructureAdapter interface for Proxmox
*/
import { InfrastructureAdapter, NormalizedResource, ResourceSpec, NormalizedMetrics, TimeRange, HealthStatus, NormalizedRelationship } from '../types.js'
import { ResourceProvider } from '../../types/resource.js'
import { logger } from '../../lib/logger.js'
import type { ProxmoxCluster, ProxmoxVM, ProxmoxVMConfig } from './types.js'
export class ProxmoxAdapter implements InfrastructureAdapter {
readonly provider: ResourceProvider = 'PROXMOX'
private apiUrl: string
private apiToken: string
constructor(config: { apiUrl: string; apiToken: string }) {
this.apiUrl = config.apiUrl
this.apiToken = config.apiToken
}
async discoverResources(): Promise<NormalizedResource[]> {
try {
const nodes = await this.getNodes()
const allResources: NormalizedResource[] = []
for (const node of nodes) {
try {
const vms = await this.getVMs(node.node)
for (const vm of vms) {
allResources.push(this.normalizeVM(vm, node.node))
}
} catch (error) {
logger.error(`Error discovering VMs on node ${node.node}`, { error, node: node.node })
}
}
return allResources
} catch (error) {
logger.error('Error discovering Proxmox resources', { error })
throw error
}
}
private async getNodes(): Promise<any[]> {
const response = await fetch(`${this.apiUrl}/api2/json/nodes`, {
method: 'GET',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Proxmox API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
return data.data || []
}
private async getVMs(node: string): Promise<any[]> {
const response = await fetch(`${this.apiUrl}/api2/json/nodes/${node}/qemu`, {
method: 'GET',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Proxmox API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
return data.data || []
}
async getResource(providerId: string): Promise<NormalizedResource | null> {
try {
const [node, vmid] = providerId.split(':')
if (!node || !vmid) {
return null
}
const response = await fetch(`${this.apiUrl}/api2/json/nodes/${node}/qemu/${vmid}`, {
method: 'GET',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Proxmox API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (!data.data) return null
return this.normalizeVM(data.data, node)
} catch (error) {
logger.error(`Error getting Proxmox resource ${providerId}`, { error, providerId })
return null
}
}
async createResource(spec: ResourceSpec): Promise<NormalizedResource> {
try {
const [node] = await this.getNodes()
if (!node) {
throw new Error('No Proxmox nodes available')
}
const config: any = {
vmid: spec.config.vmid || undefined, // Auto-assign if not specified
name: spec.name,
cores: spec.config.cores || 2,
memory: spec.config.memory || 2048,
net0: spec.config.net0 || 'virtio,bridge=vmbr0',
ostype: spec.config.ostype || 'l26',
}
// Create VM
const response = await fetch(`${this.apiUrl}/api2/json/nodes/${node.node}/qemu`, {
method: 'POST',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
if (!response.ok) {
throw new Error(`Failed to create VM: ${response.statusText}`)
}
const data = await response.json()
const vmid = data.data || config.vmid
// Get created VM
return this.getResource(`${node.node}:${vmid}`) as Promise<NormalizedResource>
} catch (error) {
logger.error('Error creating Proxmox resource', { error })
throw error
}
}
async updateResource(providerId: string, spec: Partial<ResourceSpec>): Promise<NormalizedResource> {
try {
const [node, vmid] = providerId.split(':')
if (!node || !vmid) {
throw new Error('Invalid provider ID format')
}
const updates: any = {}
if (spec.config?.cores) updates.cores = spec.config.cores
if (spec.config?.memory) updates.memory = spec.config.memory
if (Object.keys(updates).length === 0) {
return this.getResource(providerId) as Promise<NormalizedResource>
}
const response = await fetch(`${this.apiUrl}/api2/json/nodes/${node}/qemu/${vmid}/config`, {
method: 'PUT',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
throw new Error(`Failed to update VM: ${response.statusText}`)
}
return this.getResource(providerId) as Promise<NormalizedResource>
} catch (error) {
logger.error(`Error updating Proxmox resource ${providerId}`, { error, providerId })
throw error
}
}
async deleteResource(providerId: string): Promise<boolean> {
try {
const [node, vmid] = providerId.split(':')
if (!node || !vmid) {
throw new Error('Invalid provider ID format')
}
const response = await fetch(`${this.apiUrl}/api2/json/nodes/${node}/qemu/${vmid}`, {
method: 'DELETE',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
})
return response.ok
} catch (error) {
logger.error(`Error deleting Proxmox resource ${providerId}`, { error, providerId })
return false
}
}
async getMetrics(providerId: string, timeRange: TimeRange): Promise<NormalizedMetrics[]> {
try {
const [node, vmid] = providerId.split(':')
if (!node || !vmid) return []
const response = await fetch(
`${this.apiUrl}/api2/json/nodes/${node}/qemu/${vmid}/rrddata?timeframe=hour&cf=AVERAGE`,
{
method: 'GET',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) return []
const data = await response.json()
const metrics: NormalizedMetrics[] = []
if (data.data && Array.isArray(data.data)) {
for (const point of data.data) {
if (point.cpu) {
metrics.push({
resourceId: providerId,
metricType: 'CPU_USAGE',
value: parseFloat(point.cpu) * 100,
timestamp: new Date(point.time * 1000),
})
}
if (point.mem) {
metrics.push({
resourceId: providerId,
metricType: 'MEMORY_USAGE',
value: parseFloat(point.mem),
timestamp: new Date(point.time * 1000),
})
}
}
}
return metrics
} catch (error) {
logger.error(`Error getting Proxmox metrics for ${providerId}`, { error, providerId })
return []
}
}
async getRelationships(providerId: string): Promise<NormalizedRelationship[]> {
try {
const [node, vmid] = providerId.split(':')
if (!node || !vmid) return []
const vm = await this.getResource(providerId)
if (!vm) return []
const relationships: NormalizedRelationship[] = [
{
sourceId: providerId,
targetId: `proxmox-node-${node}`,
type: 'HOSTED_ON',
metadata: { node },
},
]
// Add storage relationships if available
if (vm.metadata?.storage) {
relationships.push({
sourceId: providerId,
targetId: `proxmox-storage-${vm.metadata.storage}`,
type: 'USES_STORAGE',
metadata: { storage: vm.metadata.storage },
})
}
return relationships
} catch (error) {
logger.error(`Error getting Proxmox relationships for ${providerId}`, { error, providerId })
return []
}
}
async healthCheck(): Promise<HealthStatus> {
try {
const response = await fetch(`${this.apiUrl}/api2/json/version`, {
method: 'GET',
headers: {
'Authorization': `PVEAPIToken=${this.apiToken}`,
'Content-Type': 'application/json',
},
})
if (response.ok) {
return {
status: 'healthy',
lastChecked: new Date(),
}
}
return {
status: 'unhealthy',
message: `API returned status ${response.status}`,
lastChecked: new Date(),
}
} catch (error) {
return {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
}
}
}
/**
* Helper method to normalize Proxmox VM to unified resource format
*/
private normalizeVM(vm: ProxmoxVM, node: string): NormalizedResource {
return {
id: `proxmox-${node}-${vm.vmid}`,
name: vm.name || `VM ${vm.vmid}`,
type: 'virtual_machine',
provider: 'PROXMOX',
providerId: `${node}:${vm.vmid}`,
providerResourceId: `proxmox://${node}/vm/${vm.vmid}`,
status: vm.status,
metadata: {
node,
vmid: vm.vmid,
cpu: vm.cpu,
memory: vm.mem,
disk: vm.disk,
uptime: vm.uptime,
},
tags: [],
createdAt: new Date(Date.now() - vm.uptime * 1000),
updatedAt: new Date(),
}
}
}

View File

@@ -0,0 +1,45 @@
/**
* Proxmox-specific types and interfaces
*/
export interface ProxmoxCluster {
id: string
name: string
nodes: ProxmoxNode[]
status: 'online' | 'offline' | 'degraded'
}
export interface ProxmoxNode {
node: string
status: 'online' | 'offline'
cpu: number
maxcpu: number
mem: number
maxmem: number
uptime: number
}
export interface ProxmoxVM {
vmid: number
name: string
status: 'running' | 'stopped' | 'paused'
node: string
cpu: number
mem: number
disk: number
netin: number
netout: number
diskread: number
diskwrite: number
uptime: number
}
export interface ProxmoxVMConfig {
name: string
cores: number
memory: number
disk: number
net0?: string
ostype?: string
}

View File

@@ -0,0 +1,284 @@
/**
* Ceph Storage Adapter
* Implements the InfrastructureAdapter interface for Ceph RadosGW (S3-compatible API)
*/
import { InfrastructureAdapter, NormalizedResource, ResourceSpec, NormalizedMetrics, TimeRange, HealthStatus, NormalizedRelationship } from '../types.js'
import { ResourceProvider } from '../../types/resource.js'
import { logger } from '../../lib/logger'
export class CephAdapter implements InfrastructureAdapter {
readonly provider: ResourceProvider = 'CEPH'
private config: {
apiUrl: string
accessKey: string
secretKey: string
}
constructor(config: { apiUrl: string; accessKey: string; secretKey: string }) {
this.config = config
}
private async makeS3Request(method: string, path: string, body?: string, headers: Record<string, string> = {}): Promise<Response> {
const url = `${this.config.apiUrl}${path}`
const auth = Buffer.from(`${this.config.accessKey}:${this.config.secretKey}`).toString('base64')
return fetch(url, {
method,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
...headers,
},
body,
})
}
async discoverResources(): Promise<NormalizedResource[]> {
const resources: NormalizedResource[] = []
try {
// List buckets using S3 API
const response = await this.makeS3Request('GET', '/')
if (response.ok) {
const xmlText = await response.text()
// Parse XML response (simplified - in production use proper XML parser)
const bucketMatches = xmlText.match(/<Name>([^<]+)<\/Name>/g) || []
for (const match of bucketMatches) {
const bucketName = match.replace(/<\/?Name>/g, '')
const bucketResource = await this.getResource(bucketName)
if (bucketResource) {
resources.push(bucketResource)
}
}
}
} catch (error) {
logger.error('Error discovering Ceph resources', { error })
throw error
}
return resources
}
async getResource(providerId: string): Promise<NormalizedResource | null> {
try {
// Get bucket metadata
const response = await this.makeS3Request('HEAD', `/${providerId}`)
if (response.ok) {
const metadata: Record<string, any> = {
bucketName: providerId,
creationDate: response.headers.get('x-amz-date') || new Date().toISOString(),
}
// Get bucket location
try {
const locationResponse = await this.makeS3Request('GET', `/${providerId}?location`)
if (locationResponse.ok) {
const locationText = await locationResponse.text()
const locationMatch = locationText.match(/<LocationConstraint>([^<]+)<\/LocationConstraint>/)
if (locationMatch) {
metadata.location = locationMatch[1]
}
}
} catch (error) {
// Location not available
}
// Get bucket versioning
try {
const versioningResponse = await this.makeS3Request('GET', `/${providerId}?versioning`)
if (versioningResponse.ok) {
metadata.versioning = versioningResponse.headers.get('x-amz-versioning') === 'Enabled'
}
} catch (error) {
// Versioning not available
}
return {
id: `ceph-bucket-${providerId}`,
name: providerId,
type: 'bucket',
provider: 'CEPH',
providerId: providerId,
providerResourceId: `ceph://buckets/${providerId}`,
status: 'active',
metadata,
tags: [],
createdAt: new Date(metadata.creationDate),
updatedAt: new Date(),
}
}
return null
} catch (error) {
logger.error(`Error getting Ceph resource ${providerId}`, { error, providerId })
return null
}
}
async createResource(spec: ResourceSpec): Promise<NormalizedResource> {
try {
if (spec.type !== 'bucket') {
throw new Error(`Unsupported resource type: ${spec.type}. Only 'bucket' is supported.`)
}
const bucketName = spec.name
// Create bucket using S3 API
const response = await this.makeS3Request('PUT', `/${bucketName}`)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to create bucket: ${errorText}`)
}
// Apply configuration if provided
if (spec.config.versioning) {
await this.makeS3Request('PUT', `/${bucketName}?versioning`,
'<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>',
{ 'Content-Type': 'application/xml' }
)
}
return await this.getResource(bucketName) || {
id: `ceph-bucket-${bucketName}`,
name: bucketName,
type: 'bucket',
provider: 'CEPH',
providerId: bucketName,
providerResourceId: `ceph://buckets/${bucketName}`,
status: 'active',
metadata: {},
tags: spec.tags || [],
createdAt: new Date(),
updatedAt: new Date(),
}
} catch (error) {
logger.error('Error creating Ceph resource', { error })
throw error
}
}
async updateResource(providerId: string, spec: Partial<ResourceSpec>): Promise<NormalizedResource> {
try {
const existing = await this.getResource(providerId)
if (!existing) {
throw new Error(`Resource ${providerId} not found`)
}
// Update versioning if specified
if (spec.config?.versioning !== undefined) {
const versioningStatus = spec.config.versioning ? 'Enabled' : 'Suspended'
await this.makeS3Request('PUT', `/${providerId}?versioning`,
`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>${versioningStatus}</Status></VersioningConfiguration>`,
{ 'Content-Type': 'application/xml' }
)
}
return await this.getResource(providerId) || existing
} catch (error) {
logger.error(`Error updating Ceph resource ${providerId}`, { error, providerId })
throw error
}
}
async deleteResource(providerId: string): Promise<boolean> {
try {
// Delete bucket (must be empty)
const response = await this.makeS3Request('DELETE', `/${providerId}`)
return response.ok || response.status === 204
} catch (error) {
logger.error(`Error deleting Ceph resource ${providerId}`, { error, providerId })
return false
}
}
async getMetrics(providerId: string, timeRange: TimeRange): Promise<NormalizedMetrics[]> {
const metrics: NormalizedMetrics[] = []
try {
// Get bucket stats (simplified - in production use Ceph admin API or Prometheus)
const response = await this.makeS3Request('GET', `/${providerId}?stats`)
if (response.ok) {
// Try to get object count and size from response headers or body
// This is a simplified implementation - real Ceph metrics would come from Prometheus or admin API
const size = parseInt(response.headers.get('x-amz-bucket-size') || '0')
const objectCount = parseInt(response.headers.get('x-amz-bucket-object-count') || '0')
if (size > 0) {
metrics.push({
resourceId: providerId,
metricType: 'STORAGE_IOPS',
value: size,
timestamp: new Date(),
labels: { type: 'bucket_size' },
})
}
if (objectCount > 0) {
metrics.push({
resourceId: providerId,
metricType: 'REQUEST_RATE',
value: objectCount,
timestamp: new Date(),
labels: { type: 'object_count' },
})
}
}
} catch (error) {
logger.error(`Error getting Ceph metrics for ${providerId}`, { error, providerId })
}
return metrics
}
async getRelationships(providerId: string): Promise<NormalizedRelationship[]> {
const relationships: NormalizedRelationship[] = []
try {
// List objects in bucket to find relationships
const response = await this.makeS3Request('GET', `/${providerId}`)
if (response.ok) {
const xmlText = await response.text()
// In a real implementation, you might track relationships between buckets and objects
// or between buckets based on replication policies, etc.
}
} catch (error) {
logger.error(`Error getting Ceph relationships for ${providerId}`, { error, providerId })
}
return relationships
}
async healthCheck(): Promise<HealthStatus> {
try {
// Check if we can list buckets (health check)
const response = await this.makeS3Request('GET', '/')
if (response.ok) {
return {
status: 'healthy',
lastChecked: new Date(),
}
}
return {
status: 'unhealthy',
message: `API returned status ${response.status}`,
lastChecked: new Date(),
}
} catch (error) {
return {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
}
}
}
}

View File

@@ -0,0 +1,327 @@
/**
* MinIO Storage Adapter
* Implements the InfrastructureAdapter interface for MinIO (S3-compatible API)
*/
import { InfrastructureAdapter, NormalizedResource, ResourceSpec, NormalizedMetrics, TimeRange, HealthStatus, NormalizedRelationship } from '../types.js'
import { ResourceProvider } from '../../types/resource.js'
import { logger } from '../../lib/logger'
export class MinIOAdapter implements InfrastructureAdapter {
readonly provider: ResourceProvider = 'MINIO'
private config: {
endpoint: string
accessKey: string
secretKey: string
adminEndpoint?: string
}
constructor(config: { endpoint: string; accessKey: string; secretKey: string; adminEndpoint?: string }) {
this.config = {
...config,
adminEndpoint: config.adminEndpoint || config.endpoint.replace('/api', '/minio/admin'),
}
}
private async makeS3Request(method: string, path: string, body?: string, headers: Record<string, string> = {}): Promise<Response> {
const url = `${this.config.endpoint}${path}`
const auth = Buffer.from(`${this.config.accessKey}:${this.config.secretKey}`).toString('base64')
return fetch(url, {
method,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
...headers,
},
body,
})
}
private async makeAdminRequest(method: string, path: string, body?: any): Promise<Response> {
const url = `${this.config.adminEndpoint}${path}`
const auth = Buffer.from(`${this.config.accessKey}:${this.config.secretKey}`).toString('base64')
return fetch(url, {
method,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
}
async discoverResources(): Promise<NormalizedResource[]> {
const resources: NormalizedResource[] = []
try {
// Use S3 API to list buckets
const response = await this.makeS3Request('GET', '/')
if (response.ok) {
const xmlText = await response.text()
// Parse XML response (simplified - in production use proper XML parser)
const bucketMatches = xmlText.match(/<Name>([^<]+)<\/Name>/g) || []
for (const match of bucketMatches) {
const bucketName = match.replace(/<\/?Name>/g, '')
const bucketResource = await this.getResource(bucketName)
if (bucketResource) {
resources.push(bucketResource)
}
}
}
// Also try MinIO Admin API for more detailed discovery
try {
const adminResponse = await this.makeAdminRequest('GET', '/v3/info')
if (adminResponse.ok) {
const adminData = await adminResponse.json()
// Admin API provides additional information about the MinIO instance
}
} catch (error) {
// Admin API not available, continue with S3 API
}
} catch (error) {
logger.error('Error discovering MinIO resources', { error })
throw error
}
return resources
}
async getResource(providerId: string): Promise<NormalizedResource | null> {
try {
// Get bucket metadata using S3 API
const response = await this.makeS3Request('HEAD', `/${providerId}`)
if (response.ok) {
const metadata: Record<string, any> = {
bucketName: providerId,
creationDate: response.headers.get('x-amz-date') || new Date().toISOString(),
}
// Try to get additional info from MinIO Admin API
try {
const adminResponse = await this.makeAdminRequest('GET', `/v3/info/bucket?bucket=${providerId}`)
if (adminResponse.ok) {
const adminData = await adminResponse.json()
metadata.size = adminData.size
metadata.objectCount = adminData.objects
}
} catch (error) {
// Admin API not available
}
return {
id: `minio-bucket-${providerId}`,
name: providerId,
type: 'bucket',
provider: 'MINIO',
providerId: providerId,
providerResourceId: `minio://buckets/${providerId}`,
status: 'active',
metadata,
tags: [],
createdAt: new Date(metadata.creationDate),
updatedAt: new Date(),
}
}
return null
} catch (error) {
logger.error(`Error getting MinIO resource ${providerId}`, { error, providerId })
return null
}
}
async createResource(spec: ResourceSpec): Promise<NormalizedResource> {
try {
if (spec.type !== 'bucket') {
throw new Error(`Unsupported resource type: ${spec.type}. Only 'bucket' is supported.`)
}
const bucketName = spec.name
// Create bucket using S3 API
const response = await this.makeS3Request('PUT', `/${bucketName}`)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to create bucket: ${errorText}`)
}
// Apply configuration if provided
if (spec.config.versioning) {
await this.makeS3Request('PUT', `/${bucketName}?versioning`,
'<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>',
{ 'Content-Type': 'application/xml' }
)
}
return await this.getResource(bucketName) || {
id: `minio-bucket-${bucketName}`,
name: bucketName,
type: 'bucket',
provider: 'MINIO',
providerId: bucketName,
providerResourceId: `minio://buckets/${bucketName}`,
status: 'active',
metadata: {},
tags: spec.tags || [],
createdAt: new Date(),
updatedAt: new Date(),
}
} catch (error) {
logger.error('Error creating MinIO resource', { error })
throw error
}
}
async updateResource(providerId: string, spec: Partial<ResourceSpec>): Promise<NormalizedResource> {
try {
const existing = await this.getResource(providerId)
if (!existing) {
throw new Error(`Resource ${providerId} not found`)
}
// Update versioning if specified
if (spec.config?.versioning !== undefined) {
const versioningStatus = spec.config.versioning ? 'Enabled' : 'Suspended'
await this.makeS3Request('PUT', `/${providerId}?versioning`,
`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>${versioningStatus}</Status></VersioningConfiguration>`,
{ 'Content-Type': 'application/xml' }
)
}
return await this.getResource(providerId) || existing
} catch (error) {
logger.error(`Error updating MinIO resource ${providerId}`, { error, providerId })
throw error
}
}
async deleteResource(providerId: string): Promise<boolean> {
try {
// Delete bucket (must be empty)
const response = await this.makeS3Request('DELETE', `/${providerId}`)
return response.ok || response.status === 204
} catch (error) {
logger.error(`Error deleting MinIO resource ${providerId}`, { error, providerId })
return false
}
}
async getMetrics(providerId: string, timeRange: TimeRange): Promise<NormalizedMetrics[]> {
const metrics: NormalizedMetrics[] = []
try {
// Try MinIO Admin API for metrics
try {
const adminResponse = await this.makeAdminRequest('GET', `/v3/info/bucket?bucket=${providerId}`)
if (adminResponse.ok) {
const adminData = await adminResponse.json()
if (adminData.size) {
metrics.push({
resourceId: providerId,
metricType: 'STORAGE_IOPS',
value: adminData.size,
timestamp: new Date(),
labels: { type: 'bucket_size' },
})
}
if (adminData.objects) {
metrics.push({
resourceId: providerId,
metricType: 'REQUEST_RATE',
value: adminData.objects,
timestamp: new Date(),
labels: { type: 'object_count' },
})
}
}
} catch (error) {
// Admin API not available, try S3 API
const response = await this.makeS3Request('GET', `/${providerId}?stats`)
if (response.ok) {
const size = parseInt(response.headers.get('x-amz-bucket-size') || '0')
const objectCount = parseInt(response.headers.get('x-amz-bucket-object-count') || '0')
if (size > 0) {
metrics.push({
resourceId: providerId,
metricType: 'STORAGE_IOPS',
value: size,
timestamp: new Date(),
labels: { type: 'bucket_size' },
})
}
if (objectCount > 0) {
metrics.push({
resourceId: providerId,
metricType: 'REQUEST_RATE',
value: objectCount,
timestamp: new Date(),
labels: { type: 'object_count' },
})
}
}
}
} catch (error) {
logger.error(`Error getting MinIO metrics for ${providerId}`, { error, providerId })
}
return metrics
}
async getRelationships(providerId: string): Promise<NormalizedRelationship[]> {
const relationships: NormalizedRelationship[] = []
try {
// List objects in bucket
const response = await this.makeS3Request('GET', `/${providerId}`)
if (response.ok) {
const xmlText = await response.text()
// In a real implementation, you might track relationships between buckets
// or replication relationships
}
} catch (error) {
logger.error(`Error getting MinIO relationships for ${providerId}`, { error, providerId })
}
return relationships
}
async healthCheck(): Promise<HealthStatus> {
try {
// Check if we can list buckets (health check)
const response = await this.makeS3Request('GET', '/')
if (response.ok) {
return {
status: 'healthy',
lastChecked: new Date(),
}
}
return {
status: 'unhealthy',
message: `API returned status ${response.status}`,
lastChecked: new Date(),
}
} catch (error) {
return {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
}
}
}
}

106
api/src/adapters/types.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Adapter interface contracts for infrastructure providers
* This defines the unified interface that all adapters must implement
*/
import { ResourceProvider } from '../types/resource.js'
export interface NormalizedResource {
id: string
name: string
type: string
provider: ResourceProvider
providerId: string
providerResourceId?: string
region?: string
status: string
metadata: Record<string, any>
tags: string[]
createdAt: Date
updatedAt: Date
}
export interface NormalizedMetrics {
resourceId: string
metricType: string
value: number
timestamp: Date
labels?: Record<string, string>
}
export interface NormalizedRelationship {
sourceId: string
targetId: string
type: string
metadata?: Record<string, any>
}
/**
* Base adapter interface that all provider adapters must implement
*/
export interface InfrastructureAdapter {
/**
* Unique identifier for the adapter
*/
readonly provider: ResourceProvider
/**
* Discover and return all resources from the provider
*/
discoverResources(): Promise<NormalizedResource[]>
/**
* Get a specific resource by provider ID
*/
getResource(providerId: string): Promise<NormalizedResource | null>
/**
* Create a new resource
*/
createResource(spec: ResourceSpec): Promise<NormalizedResource>
/**
* Update an existing resource
*/
updateResource(providerId: string, spec: Partial<ResourceSpec>): Promise<NormalizedResource>
/**
* Delete a resource
*/
deleteResource(providerId: string): Promise<boolean>
/**
* Get metrics for a resource
*/
getMetrics(providerId: string, timeRange: TimeRange): Promise<NormalizedMetrics[]>
/**
* Get resource relationships/dependencies
*/
getRelationships(providerId: string): Promise<NormalizedRelationship[]>
/**
* Health check for the adapter/provider connection
*/
healthCheck(): Promise<HealthStatus>
}
export interface ResourceSpec {
name: string
type: string
region?: string
config: Record<string, any>
tags?: string[]
}
export interface TimeRange {
start: Date
end: Date
}
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy'
message?: string
lastChecked: Date
}