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:
70
api/src/adapters/__tests__/ceph-adapter.test.ts
Normal file
70
api/src/adapters/__tests__/ceph-adapter.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
231
api/src/adapters/__tests__/cloudflare-adapter.test.ts
Normal file
231
api/src/adapters/__tests__/cloudflare-adapter.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
193
api/src/adapters/__tests__/kubernetes-adapter.test.ts
Normal file
193
api/src/adapters/__tests__/kubernetes-adapter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
69
api/src/adapters/__tests__/minio-adapter.test.ts
Normal file
69
api/src/adapters/__tests__/minio-adapter.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
99
api/src/adapters/__tests__/prometheus-adapter.test.ts
Normal file
99
api/src/adapters/__tests__/prometheus-adapter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
602
api/src/adapters/cloudflare/adapter.ts
Normal file
602
api/src/adapters/cloudflare/adapter.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
601
api/src/adapters/kubernetes/adapter.ts
Normal file
601
api/src/adapters/kubernetes/adapter.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
api/src/adapters/monitoring/prometheus-adapter.ts
Normal file
75
api/src/adapters/monitoring/prometheus-adapter.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
108
api/src/adapters/normalizer.ts
Normal file
108
api/src/adapters/normalizer.ts
Normal 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
|
||||
}
|
||||
|
||||
347
api/src/adapters/proxmox/adapter.ts
Normal file
347
api/src/adapters/proxmox/adapter.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
api/src/adapters/proxmox/types.ts
Normal file
45
api/src/adapters/proxmox/types.ts
Normal 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
|
||||
}
|
||||
|
||||
284
api/src/adapters/storage/ceph-adapter.ts
Normal file
284
api/src/adapters/storage/ceph-adapter.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
327
api/src/adapters/storage/minio-adapter.ts
Normal file
327
api/src/adapters/storage/minio-adapter.ts
Normal 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
106
api/src/adapters/types.ts
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user