diff --git a/.env.example b/.env.example index 6befb5b..58f7dec 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,22 @@ LOG_LEVEL=info # Default: package (built-in) only # To add more tasks (e.g., custom modules), uncomment and customize: # PACKAGE_TASKS=[{"name":"package","label":"Package (built-in)","parameterMapping":{"packageName":"name","ensure":"action","version":"version"}},{"name":"mymodule::install","label":"Custom Installer","parameterMapping":{"packageName":"app","ensure":"ensure","version":"version","settings":"settings"}}] + +# PuppetDB Integration Configuration +# PUPPETDB_ENABLED=true +# PUPPETDB_SERVER_URL=https://puppetdb.example.com +# PUPPETDB_PORT=8081 + +# Prometheus Integration Configuration +# PROMETHEUS_ENABLED=true +# PROMETHEUS_URL=http://prometheus.example.com:9090 +# PROMETHEUS_TIMEOUT=30000 +# PROMETHEUS_GRAFANA_URL=http://grafana.example.com:3000 +# PROMETHEUS_NODE_EXPORTER_JOB=node + +# Ansible AWX/Tower Integration Configuration +# ANSIBLE_ENABLED=true +# ANSIBLE_URL=https://awx.example.com +# ANSIBLE_TOKEN=your-awx-api-token +# ANSIBLE_TIMEOUT=30000 +# ANSIBLE_ORGANIZATION_ID=1 diff --git a/.github/prompts/plan-integrationImplementation.prompt.md b/.github/prompts/plan-integrationImplementation.prompt.md new file mode 100644 index 0000000..16c9494 --- /dev/null +++ b/.github/prompts/plan-integrationImplementation.prompt.md @@ -0,0 +1,852 @@ +# Pabawi Integration Implementation Plan + +Based on Pabawi's current architecture and your multi-source inventory pattern, here's a detailed implementation plan for the top 3 integrations: + +--- + +## 1. Ansible Integration (Priority 1) + +### Architecture + +```typescript +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { Integration, IntegrationHealth, Node } from '../base/integration.js'; + +const execAsync = promisify(exec); + +export class AnsibleClient implements Integration { + name = 'ansible'; + enabled: boolean; + + private inventoryPath: string; + private configPath?: string; + + constructor(config: AnsibleConfig) { + this.enabled = config.enabled; + this.inventoryPath = config.inventoryPath; + this.configPath = config.configPath; + } + + async healthCheck(): Promise { + try { + const { stdout } = await execAsync('ansible --version'); + return { + status: 'connected', + message: `Ansible ${stdout.split('\n')[0]}`, + lastCheck: new Date() + }; + } catch (error) { + return { + status: 'disconnected', + message: error.message, + lastCheck: new Date() + }; + } + } + + async getInventory(): Promise { + const { stdout } = await execAsync( + `ansible-inventory -i ${this.inventoryPath} --list` + ); + const inventory = JSON.parse(stdout); + return this.parseInventory(inventory); + } + + async executePlaybook(playbook: string, params: PlaybookParams): Promise { + const cmd = this.buildPlaybookCommand(playbook, params); + return this.executeCommand(cmd); + } + + async executeAdHoc(pattern: string, module: string, args: string): Promise { + const cmd = `ansible ${pattern} -m ${module} -a "${args}" -i ${this.inventoryPath}`; + return this.executeCommand(cmd); + } + + private parseInventory(inventory: any): Node[] { + const nodes: Node[] = []; + + // Parse hosts from inventory + Object.entries(inventory._meta?.hostvars || {}).forEach(([host, vars]: [string, any]) => { + nodes.push({ + name: host, + uri: vars.ansible_host || host, + source: 'ansible', + metadata: { + groups: this.getHostGroups(inventory, host), + facts: vars + } + }); + }); + + return nodes; + } +} +``` + +### Database Schema Extension + +```sql +-- Add Ansible-specific execution types +ALTER TABLE executions ADD COLUMN playbook TEXT; +ALTER TABLE executions ADD COLUMN ansible_pattern TEXT; +ALTER TABLE executions ADD COLUMN ansible_module TEXT; + +-- Create playbook history table +CREATE TABLE IF NOT EXISTS ansible_playbooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id INTEGER NOT NULL, + playbook_path TEXT NOT NULL, + parameters TEXT, -- JSON + results TEXT, -- JSON per host + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (execution_id) REFERENCES executions(id) ON DELETE CASCADE +); + +CREATE INDEX idx_ansible_playbooks_execution ON ansible_playbooks(execution_id); +``` + +### API Routes + +```typescript +import express from 'express'; +import { AnsibleService } from '../services/ansible-service.js'; + +const router = express.Router(); +const ansibleService = new AnsibleService(); + +// Get Ansible inventory +router.get('/inventory', async (req, res) => { + const nodes = await ansibleService.getInventory(); + res.json(nodes); +}); + +// Execute playbook +router.post('/playbook', async (req, res) => { + const { playbook, targets, parameters, limit, tags } = req.body; + + const execution = await ansibleService.executePlaybook({ + playbook, + targets, + parameters, + limit, + tags + }); + + res.json(execution); +}); + +// Execute ad-hoc command +router.post('/adhoc', async (req, res) => { + const { pattern, module, args, targets } = req.body; + + const execution = await ansibleService.executeAdHoc({ + pattern, + module, + args, + targets + }); + + res.json(execution); +}); + +// Get playbook list +router.get('/playbooks', async (req, res) => { + const playbooks = await ansibleService.getAvailablePlaybooks(); + res.json(playbooks); +}); + +export default router; +``` + +### Frontend Components + +```svelte + + +
+

Ansible Playbooks

+ +
+ +
+ + {#if selectedPlaybook} +
+

Targets

+ + +

Parameters

+ + + +
+ {/if} +
+``` + +### Environment Configuration + +```bash +# ...existing code... + +# Ansible Integration (Optional) +ANSIBLE_ENABLED=true +ANSIBLE_INVENTORY_PATH=./ansible/inventory +ANSIBLE_PLAYBOOK_DIR=./ansible/playbooks +ANSIBLE_CONFIG_PATH=./ansible/ansible.cfg +ANSIBLE_TIMEOUT=600000 +``` + +--- + +## 2. Prometheus Integration (Priority 2) + +### Architecture + +```typescript +import fetch from 'node-fetch'; +import type { Integration, IntegrationHealth } from '../base/integration.js'; + +export class PrometheusClient implements Integration { + name = 'prometheus'; + enabled: boolean; + + private baseUrl: string; + private timeout: number; + + constructor(config: PrometheusConfig) { + this.enabled = config.enabled; + this.baseUrl = config.serverUrl; + this.timeout = config.timeout || 30000; + } + + async healthCheck(): Promise { + try { + const response = await fetch(`${this.baseUrl}/-/healthy`, { + timeout: this.timeout + }); + + if (response.ok) { + return { + status: 'connected', + message: 'Prometheus server healthy', + lastCheck: new Date() + }; + } + + return { + status: 'degraded', + message: `HTTP ${response.status}`, + lastCheck: new Date() + }; + } catch (error) { + return { + status: 'disconnected', + message: error.message, + lastCheck: new Date() + }; + } + } + + async getNodeMetrics(nodeName: string): Promise { + const queries = { + cpu: `100 - (avg by (instance) (irate(node_cpu_seconds_total{mode="idle",instance=~"${nodeName}.*"}[5m])) * 100)`, + memory: `(node_memory_MemTotal_bytes{instance=~"${nodeName}.*"} - node_memory_MemAvailable_bytes{instance=~"${nodeName}.*"}) / node_memory_MemTotal_bytes{instance=~"${nodeName}.*"} * 100`, + disk: `100 - ((node_filesystem_avail_bytes{instance=~"${nodeName}.*",mountpoint="/"} * 100) / node_filesystem_size_bytes{instance=~"${nodeName}.*",mountpoint="/"})` + }; + + const results = await Promise.all( + Object.entries(queries).map(([metric, query]) => + this.query(query).then(result => [metric, result]) + ) + ); + + return Object.fromEntries(results); + } + + async getAlerts(nodeName?: string): Promise { + const url = `${this.baseUrl}/api/v1/alerts`; + const response = await fetch(url, { timeout: this.timeout }); + const data = await response.json(); + + let alerts = data.data.alerts; + + if (nodeName) { + alerts = alerts.filter(alert => + alert.labels.instance?.includes(nodeName) + ); + } + + return alerts; + } + + private async query(promql: string): Promise { + const url = `${this.baseUrl}/api/v1/query?query=${encodeURIComponent(promql)}`; + const response = await fetch(url, { timeout: this.timeout }); + const data = await response.json(); + + if (data.status !== 'success') { + throw new Error(`Prometheus query failed: ${data.error}`); + } + + return data.data.result; + } +} +``` + +### API Routes + +```typescript +import express from 'express'; +import { PrometheusService } from '../services/prometheus-service.js'; + +const router = express.Router(); +const prometheusService = new PrometheusService(); + +// Get metrics for a node +router.get('/nodes/:name/metrics', async (req, res) => { + const { name } = req.params; + const metrics = await prometheusService.getNodeMetrics(name); + res.json(metrics); +}); + +// Get alerts for a node +router.get('/nodes/:name/alerts', async (req, res) => { + const { name } = req.params; + const alerts = await prometheusService.getAlerts(name); + res.json(alerts); +}); + +// Get all active alerts +router.get('/alerts', async (req, res) => { + const alerts = await prometheusService.getAlerts(); + res.json(alerts); +}); + +export default router; +``` + +### Frontend Components + +```svelte + + +{#if !loading && metrics} +
+ + CPU: {metrics.cpu.toFixed(1)}% + + + MEM: {metrics.memory.toFixed(1)}% + + + DISK: {metrics.disk.toFixed(1)}% + +
+{/if} + + +``` + +```svelte + + +
+
+

Active Alerts

+ {#if alerts.length === 0} +

No active alerts

+ {:else} +
+ {#each alerts as alert} +
+ {alert.labels.alertname} +

{alert.annotations.description || alert.annotations.summary}

+ Since: {new Date(alert.activeAt).toLocaleString()} +
+ {/each} +
+ {/if} +
+ + {#if grafanaUrl} +
+

Grafana Dashboard

+ + Open in Grafana → + +
+ {/if} +
+``` + +### Environment Configuration + +```bash +# ...existing code... + +# Prometheus Integration (Optional) +PROMETHEUS_ENABLED=true +PROMETHEUS_SERVER_URL=http://prometheus:9090 +PROMETHEUS_TIMEOUT=30000 +GRAFANA_URL=http://grafana:3000 +``` + +--- + +## 3. Terraform Integration (Priority 3) + +### Architecture + +```typescript +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { readFile } from 'fs/promises'; +import type { Integration, IntegrationHealth } from '../base/integration.js'; + +const execAsync = promisify(exec); + +export class TerraformClient implements Integration { + name = 'terraform'; + enabled: boolean; + + private workingDir: string; + private stateBackend: 'local' | 's3' | 'remote'; + + constructor(config: TerraformConfig) { + this.enabled = config.enabled; + this.workingDir = config.workingDir; + this.stateBackend = config.stateBackend || 'local'; + } + + async healthCheck(): Promise { + try { + const { stdout } = await execAsync('terraform version'); + return { + status: 'connected', + message: stdout.split('\n')[0], + lastCheck: new Date() + }; + } catch (error) { + return { + status: 'disconnected', + message: error.message, + lastCheck: new Date() + }; + } + } + + async getState(): Promise { + const { stdout } = await execAsync('terraform show -json', { + cwd: this.workingDir + }); + return JSON.parse(stdout); + } + + async getResources(): Promise { + const state = await this.getState(); + return state.values?.root_module?.resources || []; + } + + async getResourcesByNode(nodeName: string): Promise { + const resources = await this.getResources(); + return resources.filter(r => + r.values?.tags?.Name === nodeName || + r.values?.name === nodeName || + r.address.includes(nodeName) + ); + } + + async plan(targets?: string[]): Promise { + let cmd = 'terraform plan -json'; + if (targets) { + cmd += targets.map(t => ` -target=${t}`).join(''); + } + + const { stdout } = await execAsync(cmd, { + cwd: this.workingDir + }); + + return this.parsePlanOutput(stdout); + } + + async apply(targets?: string[]): Promise { + let cmd = 'terraform apply -auto-approve'; + if (targets) { + cmd += targets.map(t => ` -target=${t}`).join(''); + } + + const { stdout, stderr } = await execAsync(cmd, { + cwd: this.workingDir + }); + + return { + success: !stderr.includes('Error'), + stdout, + stderr, + timestamp: new Date() + }; + } +} +``` + +### API Routes + +```typescript +import express from 'express'; +import { TerraformService } from '../services/terraform-service.js'; + +const router = express.Router(); +const terraformService = new TerraformService(); + +// Get Terraform state +router.get('/state', async (req, res) => { + const state = await terraformService.getState(); + res.json(state); +}); + +// Get all resources +router.get('/resources', async (req, res) => { + const resources = await terraformService.getResources(); + res.json(resources); +}); + +// Get resources for a specific node +router.get('/nodes/:name/resources', async (req, res) => { + const { name } = req.params; + const resources = await terraformService.getResourcesByNode(name); + res.json(resources); +}); + +// Run terraform plan +router.post('/plan', async (req, res) => { + const { targets } = req.body; + const plan = await terraformService.plan(targets); + res.json(plan); +}); + +// Run terraform apply +router.post('/apply', async (req, res) => { + const { targets } = req.body; + const result = await terraformService.apply(targets); + res.json(result); +}); + +export default router; +``` + +### Frontend Components + +```svelte + + +
+ {#if loading} +

Loading Terraform resources...

+ {:else if resources.length === 0} +

No Terraform resources found for this node

+ {:else} +
+ {#each resources as resource} +
+
+ {getResourceIcon(resource.type)} +

{resource.name}

+ {resource.type} +
+ +
+
+ {#each Object.entries(resource.values || {}) as [key, value]} +
{key}
+
{JSON.stringify(value)}
+ {/each} +
+
+
+ {/each} +
+ {/if} +
+``` + +### Update Node Detail Page + +```typescript +// ...existing code... + +const tabs = [ + { id: 'overview', label: 'Overview', sources: ['all'] }, + { id: 'facts', label: 'Facts', sources: ['puppetdb'] }, + { id: 'reports', label: 'Puppet Reports', sources: ['puppetdb'] }, + { id: 'catalog', label: 'Catalog', sources: ['puppetdb'] }, + { id: 'events', label: 'Events', sources: ['puppetdb'] }, + { id: 'metrics', label: 'Metrics', sources: ['prometheus'] }, // NEW + { id: 'terraform', label: 'Terraform', sources: ['terraform'] }, // NEW + { id: 'ansible', label: 'Ansible', sources: ['ansible'] }, // NEW +]; + +// ...existing code... +``` + +--- + +## Integration Manager + +```typescript +import { PuppetDBClient } from './puppetdb/puppetdb-client.js'; +import { AnsibleClient } from './ansible/ansible-client.js'; +import { PrometheusClient } from './prometheus/prometheus-client.js'; +import { TerraformClient } from './terraform/terraform-client.js'; +import type { Integration, Node } from './base/integration.js'; + +export class IntegrationManager { + private integrations: Map = new Map(); + + constructor() { + this.initializeIntegrations(); + } + + private initializeIntegrations() { + // PuppetDB + if (process.env.PUPPETDB_ENABLED === 'true') { + this.integrations.set('puppetdb', new PuppetDBClient({ + enabled: true, + serverUrl: process.env.PUPPETDB_SERVER_URL!, + // ... other config + })); + } + + // Ansible + if (process.env.ANSIBLE_ENABLED === 'true') { + this.integrations.set('ansible', new AnsibleClient({ + enabled: true, + inventoryPath: process.env.ANSIBLE_INVENTORY_PATH!, + // ... other config + })); + } + + // Prometheus + if (process.env.PROMETHEUS_ENABLED === 'true') { + this.integrations.set('prometheus', new PrometheusClient({ + enabled: true, + serverUrl: process.env.PROMETHEUS_SERVER_URL!, + // ... other config + })); + } + + // Terraform + if (process.env.TERRAFORM_ENABLED === 'true') { + this.integrations.set('terraform', new TerraformClient({ + enabled: true, + workingDir: process.env.TERRAFORM_WORKING_DIR!, + // ... other config + })); + } + } + + async getMultiSourceInventory(): Promise { + const allNodes: Node[] = []; + + for (const [name, integration] of this.integrations) { + if (integration.enabled && integration.getInventory) { + try { + const nodes = await integration.getInventory(); + allNodes.push(...nodes); + } catch (error) { + console.error(`Failed to get inventory from ${name}:`, error); + } + } + } + + return this.deduplicateNodes(allNodes); + } + + async getIntegrationStatus() { + const statuses = []; + + for (const [name, integration] of this.integrations) { + const health = await integration.healthCheck(); + statuses.push({ + name, + enabled: integration.enabled, + ...health + }); + } + + return statuses; + } + + private deduplicateNodes(nodes: Node[]): Node[] { + const nodeMap = new Map(); + + for (const node of nodes) { + const existing = nodeMap.get(node.name); + if (!existing) { + nodeMap.set(node.name, node); + } else { + // Merge metadata from multiple sources + existing.metadata = { + ...existing.metadata, + ...node.metadata + }; + if (!existing.sources) existing.sources = []; + existing.sources.push(node.source); + } + } + + return Array.from(nodeMap.values()); + } +} +``` + +--- + +## Rollout Plan + +### Phase 1: Foundation (Week 1) +1. Create integration base interface +2. Implement IntegrationManager +3. Add integration status endpoint +4. Update home page with integration status cards + +### Phase 2: Prometheus (Week 2) +1. Implement PrometheusClient +2. Add metrics API routes +3. Create MetricsBadge component +4. Add Metrics tab to node detail page +5. Update inventory cards with metrics + +### Phase 3: Ansible (Weeks 3-4) +1. Implement AnsibleClient +2. Add Ansible API routes +3. Create playbook execution UI +4. Add Ansible tab to node detail +5. Integrate Ansible inventory into multi-source inventory + +### Phase 4: Terraform (Week 5) +1. Implement TerraformClient +2. Add Terraform API routes +3. Create resource visualization components +4. Add Terraform tab to node detail +5. Link nodes to Terraform resources + +This approach leverages Pabawi's existing multi-source inventory pattern and extends it seamlessly. Each integration adds independent value while contributing to a unified infrastructure view. diff --git a/backend/.env.example b/backend/.env.example index 24fae3b..52d4e1b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -37,3 +37,30 @@ MAX_QUEUE_SIZE=50 # PUPPETDB_SSL_CERT=/path/to/cert.pem # PUPPETDB_SSL_KEY=/path/to/key.pem # PUPPETDB_SSL_REJECT_UNAUTHORIZED=true + +# Prometheus integration configuration +# PROMETHEUS_ENABLED=true +# PROMETHEUS_URL=http://prometheus.example.com:9090 +# PROMETHEUS_TIMEOUT=30000 +# PROMETHEUS_BASIC_AUTH_USER= +# PROMETHEUS_BASIC_AUTH_PASSWORD= +# PROMETHEUS_BEARER_TOKEN= +# PROMETHEUS_GRAFANA_URL=http://grafana.example.com:3000 +# PROMETHEUS_NODE_EXPORTER_JOB=node + +# Ansible AWX/Tower integration configuration +# ANSIBLE_ENABLED=true +# ANSIBLE_URL=https://awx.example.com +# ANSIBLE_TOKEN=your-awx-api-token +# ANSIBLE_USERNAME=admin +# ANSIBLE_PASSWORD=password +# ANSIBLE_TIMEOUT=30000 +# ANSIBLE_VERIFY_SSL=true +# ANSIBLE_ORGANIZATION_ID=1 + +# Terraform Cloud/Enterprise integration configuration +# TERRAFORM_ENABLED=true +# TERRAFORM_URL=https://app.terraform.io +# TERRAFORM_TOKEN=your-terraform-api-token +# TERRAFORM_ORGANIZATION=your-organization +# TERRAFORM_TIMEOUT=30000 diff --git a/backend/src/config/ConfigService.ts b/backend/src/config/ConfigService.ts index d89dcd0..4ba24d0 100644 --- a/backend/src/config/ConfigService.ts +++ b/backend/src/config/ConfigService.ts @@ -43,6 +43,35 @@ export class ConfigService { retryAttempts?: number; retryDelay?: number; }; + prometheus?: { + enabled: boolean; + serverUrl: string; + timeout?: number; + basicAuth?: { + username: string; + password: string; + }; + bearerToken?: string; + grafanaUrl?: string; + nodeExporterJobName?: string; + }; + ansible?: { + enabled: boolean; + url: string; + token?: string; + username?: string; + password?: string; + timeout?: number; + verifySsl?: boolean; + organizationId?: number; + }; + terraform?: { + enabled: boolean; + url: string; + token: string; + organization?: string; + timeout?: number; + }; } { const integrations: ReturnType = {}; @@ -92,6 +121,92 @@ export class ConfigService { } } + // Parse Prometheus configuration + if (process.env.PROMETHEUS_ENABLED === "true") { + const serverUrl = process.env.PROMETHEUS_URL; + if (!serverUrl) { + throw new Error( + "PROMETHEUS_URL is required when PROMETHEUS_ENABLED is true", + ); + } + + integrations.prometheus = { + enabled: true, + serverUrl, + timeout: process.env.PROMETHEUS_TIMEOUT + ? parseInt(process.env.PROMETHEUS_TIMEOUT, 10) + : undefined, + grafanaUrl: process.env.PROMETHEUS_GRAFANA_URL, + nodeExporterJobName: process.env.PROMETHEUS_NODE_EXPORTER_JOB, + }; + + // Validate and parse basic auth if configured + const basicAuthUser = process.env.PROMETHEUS_BASIC_AUTH_USER; + const basicAuthPassword = process.env.PROMETHEUS_BASIC_AUTH_PASSWORD; + if ((basicAuthUser && !basicAuthPassword) || (!basicAuthUser && basicAuthPassword)) { + throw new Error( + "Both PROMETHEUS_BASIC_AUTH_USER and PROMETHEUS_BASIC_AUTH_PASSWORD must be set for Prometheus basic auth." + ); + } + if (basicAuthUser && basicAuthPassword) { + integrations.prometheus.basicAuth = { + username: basicAuthUser, + password: basicAuthPassword, + }; + } + + // Parse bearer token if configured + if (process.env.PROMETHEUS_BEARER_TOKEN) { + integrations.prometheus.bearerToken = process.env.PROMETHEUS_BEARER_TOKEN; + } + } + + // Parse Ansible AWX/Tower configuration + if (process.env.ANSIBLE_ENABLED === "true") { + const url = process.env.ANSIBLE_URL; + if (!url) { + throw new Error( + "ANSIBLE_URL is required when ANSIBLE_ENABLED is true", + ); + } + + integrations.ansible = { + enabled: true, + url, + token: process.env.ANSIBLE_TOKEN, + username: process.env.ANSIBLE_USERNAME, + password: process.env.ANSIBLE_PASSWORD, + timeout: process.env.ANSIBLE_TIMEOUT + ? parseInt(process.env.ANSIBLE_TIMEOUT, 10) + : undefined, + verifySsl: process.env.ANSIBLE_VERIFY_SSL !== "false", + organizationId: process.env.ANSIBLE_ORGANIZATION_ID + ? parseInt(process.env.ANSIBLE_ORGANIZATION_ID, 10) + : undefined, + }; + } + + // Parse Terraform Cloud/Enterprise configuration + if (process.env.TERRAFORM_ENABLED === "true") { + const url = process.env.TERRAFORM_URL || "https://app.terraform.io"; + const token = process.env.TERRAFORM_TOKEN; + if (!token) { + throw new Error( + "TERRAFORM_TOKEN is required when TERRAFORM_ENABLED is true", + ); + } + + integrations.terraform = { + enabled: true, + url, + token, + organization: process.env.TERRAFORM_ORGANIZATION, + timeout: process.env.TERRAFORM_TIMEOUT + ? parseInt(process.env.TERRAFORM_TIMEOUT, 10) + : undefined, + }; + } + return integrations; } diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts index ff0441f..5cd71ed 100644 --- a/backend/src/config/schema.ts +++ b/backend/src/config/schema.ts @@ -97,6 +97,49 @@ export const PuppetDBConfigSchema = z.object({ export type PuppetDBConfig = z.infer; +/** + * Prometheus integration configuration schema + */ +export const PrometheusConfigSchema = z.object({ + enabled: z.boolean().default(false), + serverUrl: z.string().url(), + timeout: z.number().int().positive().default(30000), // 30 seconds + username: z.string().optional(), + password: z.string().optional(), + grafanaUrl: z.string().url().optional(), +}); + +export type PrometheusConfig = z.infer; + +/** + * Ansible AWX/Tower integration configuration schema + */ +export const AnsibleConfigSchema = z.object({ + enabled: z.boolean().default(false), + url: z.string().url(), // AWX/Tower base URL + token: z.string().optional(), // API token + username: z.string().optional(), // Basic auth username + password: z.string().optional(), // Basic auth password + timeout: z.number().int().positive().default(30000), // 30 seconds + verifySsl: z.boolean().default(true), + organizationId: z.number().int().positive().optional(), // Default organization +}); + +export type AnsibleConfig = z.infer; + +/** + * Terraform Cloud/Enterprise integration configuration schema + */ +export const TerraformConfigSchema = z.object({ + enabled: z.boolean().default(false), + url: z.string().url().default('https://app.terraform.io'), // TFC/TFE URL + token: z.string(), // API token + organization: z.string().optional(), // Default organization + timeout: z.number().int().positive().default(30000), // 30 seconds +}); + +export type TerraformConfig = z.infer; + /** * Integration configuration schema */ @@ -115,7 +158,9 @@ export type IntegrationConfig = z.infer; */ export const IntegrationsConfigSchema = z.object({ puppetdb: PuppetDBConfigSchema.optional(), - // Future integrations: ansible, terraform, etc. + prometheus: PrometheusConfigSchema.optional(), + ansible: AnsibleConfigSchema.optional(), + terraform: TerraformConfigSchema.optional(), }); export type IntegrationsConfig = z.infer; diff --git a/backend/src/integrations/ansible/AnsibleClient.ts b/backend/src/integrations/ansible/AnsibleClient.ts new file mode 100644 index 0000000..91a2e7a --- /dev/null +++ b/backend/src/integrations/ansible/AnsibleClient.ts @@ -0,0 +1,456 @@ +/** + * Ansible AWX/Tower API Client + * + * HTTP client for communicating with Ansible AWX/Tower API + */ + +import type { + AnsibleConfig, + AnsibleInventory, + AnsibleHost, + AnsibleGroup, + AnsibleJobTemplate, + AnsibleJob, + AnsibleJobEvent, + AnsibleProject, + AnsibleCredential, + AnsibleOrganization, + AnsibleHostFacts, + AnsibleJobHostSummary, + AnsibleWorkflowJobTemplate, + AWXApiResponse, + LaunchJobRequest, +} from './types'; + +// Declare require for Node.js modules +declare const require: any; +// Node.js globals +declare const Buffer: any; +declare const URL: any; + +export class AnsibleClient { + private config: AnsibleConfig; + private baseUrl: string; + private headers: Record; + + constructor(config: AnsibleConfig) { + this.config = config; + this.baseUrl = config.url.replace(/\/$/, ''); + this.headers = this.buildHeaders(); + } + + private buildHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + if (this.config.token) { + headers['Authorization'] = `Bearer ${this.config.token}`; + } else if (this.config.username && this.config.password) { + const credentials = Buffer.from( + `${this.config.username}:${this.config.password}` + ).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } + + return headers; + } + + private async request( + method: string, + path: string, + body?: unknown + ): Promise { + const url = new URL(`/api/v2${path}`, this.baseUrl); + + const https = require('https'); + const http = require('http'); + const client = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method, + headers: this.headers, + timeout: this.config.timeout || 30000, + rejectUnauthorized: this.config.verifySsl !== false, + }; + + const req = client.request(options, (res: any) => { + let data = ''; + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(data ? JSON.parse(data) : ({} as T)); + } catch { + resolve(data as unknown as T); + } + } else { + reject( + new Error( + `AWX API error: ${res.statusCode} ${res.statusMessage} - ${data}` + ) + ); + } + }); + }); + + req.on('error', (err: Error) => { + reject(new Error(`AWX API request failed: ${err.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('AWX API request timed out')); + }); + + if (body) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); + } + + // Health check + async healthCheck(): Promise<{ status: 'ok' | 'error'; version?: string; error?: string }> { + try { + const response = await this.request<{ version: string }>('GET', '/ping/'); + return { status: 'ok', version: response.version }; + } catch (error) { + return { + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + // Organizations + async getOrganizations(): Promise { + const response = await this.request>( + 'GET', + '/organizations/' + ); + return response.results; + } + + async getOrganization(id: number): Promise { + return this.request('GET', `/organizations/${id}/`); + } + + // Inventories + async getInventories(organizationId?: number): Promise { + let path = '/inventories/'; + if (organizationId) { + path += `?organization=${organizationId}`; + } + const response = await this.request>( + 'GET', + path + ); + return response.results; + } + + async getInventory(id: number): Promise { + return this.request('GET', `/inventories/${id}/`); + } + + async getInventoryHosts(inventoryId: number): Promise { + const response = await this.request>( + 'GET', + `/inventories/${inventoryId}/hosts/` + ); + return response.results; + } + + async getInventoryGroups(inventoryId: number): Promise { + const response = await this.request>( + 'GET', + `/inventories/${inventoryId}/groups/` + ); + return response.results; + } + + // Hosts + async getHosts(inventoryId?: number): Promise { + let path = '/hosts/'; + if (inventoryId) { + path += `?inventory=${inventoryId}`; + } + const response = await this.request>('GET', path); + return response.results; + } + + async getHost(id: number): Promise { + return this.request('GET', `/hosts/${id}/`); + } + + async getHostByName(name: string, inventoryId?: number): Promise { + let path = `/hosts/?name=${encodeURIComponent(name)}`; + if (inventoryId) { + path += `&inventory=${inventoryId}`; + } + const response = await this.request>('GET', path); + return response.results[0] || null; + } + + async getHostGroups(hostId: number): Promise { + const response = await this.request>( + 'GET', + `/hosts/${hostId}/groups/` + ); + return response.results; + } + + async getHostFacts(hostId: number): Promise { + try { + return await this.request( + 'GET', + `/hosts/${hostId}/ansible_facts/` + ); + } catch { + return null; + } + } + + async getHostJobSummaries(hostId: number): Promise { + const response = await this.request>( + 'GET', + `/hosts/${hostId}/job_host_summaries/` + ); + return response.results; + } + + // Groups + async getGroups(inventoryId?: number): Promise { + let path = '/groups/'; + if (inventoryId) { + path += `?inventory=${inventoryId}`; + } + const response = await this.request>('GET', path); + return response.results; + } + + async getGroup(id: number): Promise { + return this.request('GET', `/groups/${id}/`); + } + + async getGroupHosts(groupId: number): Promise { + const response = await this.request>( + 'GET', + `/groups/${groupId}/hosts/` + ); + return response.results; + } + + // Projects + async getProjects(organizationId?: number): Promise { + let path = '/projects/'; + if (organizationId) { + path += `?organization=${organizationId}`; + } + const response = await this.request>( + 'GET', + path + ); + return response.results; + } + + async getProject(id: number): Promise { + return this.request('GET', `/projects/${id}/`); + } + + async syncProject(id: number): Promise<{ id: number }> { + return this.request<{ id: number }>('POST', `/projects/${id}/update/`); + } + + // Credentials + async getCredentials(organizationId?: number): Promise { + let path = '/credentials/'; + if (organizationId) { + path += `?organization=${organizationId}`; + } + const response = await this.request>( + 'GET', + path + ); + return response.results; + } + + async getCredential(id: number): Promise { + return this.request('GET', `/credentials/${id}/`); + } + + // Job Templates + async getJobTemplates(organizationId?: number): Promise { + let path = '/job_templates/'; + if (organizationId) { + path += `?organization=${organizationId}`; + } + const response = await this.request>( + 'GET', + path + ); + return response.results; + } + + async getJobTemplate(id: number): Promise { + return this.request('GET', `/job_templates/${id}/`); + } + + async launchJobTemplate( + id: number, + request?: LaunchJobRequest + ): Promise { + return this.request( + 'POST', + `/job_templates/${id}/launch/`, + request || {} + ); + } + + // Workflow Job Templates + async getWorkflowJobTemplates( + organizationId?: number + ): Promise { + let path = '/workflow_job_templates/'; + if (organizationId) { + path += `?organization=${organizationId}`; + } + const response = await this.request>( + 'GET', + path + ); + return response.results; + } + + async getWorkflowJobTemplate(id: number): Promise { + return this.request( + 'GET', + `/workflow_job_templates/${id}/` + ); + } + + async launchWorkflowJobTemplate( + id: number, + request?: LaunchJobRequest + ): Promise<{ id: number; workflow_job: number }> { + return this.request<{ id: number; workflow_job: number }>( + 'POST', + `/workflow_job_templates/${id}/launch/`, + request || {} + ); + } + + // Jobs + async getJobs( + templateId?: number, + status?: string, + limit?: number + ): Promise { + const params: string[] = []; + if (templateId) params.push(`unified_job_template=${templateId}`); + if (status) params.push(`status=${status}`); + if (limit) params.push(`page_size=${limit}`); + + const path = '/jobs/' + (params.length > 0 ? `?${params.join('&')}` : ''); + const response = await this.request>('GET', path); + return response.results; + } + + async getJob(id: number): Promise { + return this.request('GET', `/jobs/${id}/`); + } + + async getJobEvents(jobId: number): Promise { + const response = await this.request>( + 'GET', + `/jobs/${jobId}/job_events/` + ); + return response.results; + } + + async getJobStdout(jobId: number): Promise { + return this.request('GET', `/jobs/${jobId}/stdout/?format=txt`); + } + + async cancelJob(id: number): Promise { + await this.request('POST', `/jobs/${id}/cancel/`); + } + + async relaunchJob(id: number): Promise { + return this.request('POST', `/jobs/${id}/relaunch/`); + } + + // Ad-hoc commands + async runAdHocCommand( + inventoryId: number, + moduleArgs: string, + moduleName: string = 'shell', + limit?: string, + credentialId?: number + ): Promise { + const body: Record = { + inventory: inventoryId, + module_name: moduleName, + module_args: moduleArgs, + job_type: 'run', + }; + if (limit) body.limit = limit; + if (credentialId) body.credential = credentialId; + + return this.request('POST', '/ad_hoc_commands/', body); + } + + // Utility methods + parseHostVariables(variablesString: string): Record { + if (!variablesString || variablesString.trim() === '') { + return {}; + } + try { + // Try JSON first + return JSON.parse(variablesString); + } catch { + // Try YAML-like key: value format + const vars: Record = {}; + const lines = variablesString.split('\n'); + for (const line of lines) { + const match = line.match(/^(\w+):\s*(.+)$/); + if (match) { + vars[match[1]] = match[2].trim(); + } + } + return vars; + } + } + + // Get all hosts across all inventories with their groups + async getAllHostsWithGroups(): Promise< + Array + > { + const inventories = await this.getInventories(this.config.organizationId); + const hostsWithGroups: Array< + AnsibleHost & { groups: AnsibleGroup[]; inventory_name: string } + > = []; + + for (const inventory of inventories) { + const hosts = await this.getInventoryHosts(inventory.id); + for (const host of hosts) { + const groups = await this.getHostGroups(host.id); + hostsWithGroups.push({ + ...host, + groups, + inventory_name: inventory.name, + }); + } + } + + return hostsWithGroups; + } +} diff --git a/backend/src/integrations/ansible/AnsiblePlugin.ts b/backend/src/integrations/ansible/AnsiblePlugin.ts new file mode 100644 index 0000000..e67bdcb --- /dev/null +++ b/backend/src/integrations/ansible/AnsiblePlugin.ts @@ -0,0 +1,476 @@ +/** + * Ansible AWX/Tower Integration Plugin + * + * Provides inventory and execution capabilities through Ansible AWX/Tower + */ + +import { BasePlugin } from '../BasePlugin'; +import type { InformationSourcePlugin, HealthStatus } from '../types'; +import type { Node, Facts } from '../../bolt/types'; +import { AnsibleClient } from './AnsibleClient'; +import type { + AnsibleConfig, + AnsibleMappedNode, + AnsibleHost, + AnsibleJobTemplate, + AnsibleJob, + AnsibleInventory, + AnsibleGroup, + ParsedHostVariables, + LaunchJobRequest, +} from './types'; + +export class AnsiblePlugin + extends BasePlugin + implements InformationSourcePlugin +{ + type = 'information' as const; + private client?: AnsibleClient; + private ansibleConfig?: AnsibleConfig; + + constructor() { + super('ansible', 'information'); + } + + /** + * Perform plugin-specific initialization + */ + protected performInitialization(): Promise { + // Extract Ansible config from integration config + this.ansibleConfig = this.config.config as unknown as AnsibleConfig; + + if (!this.config.enabled) { + this.log('Ansible integration is disabled'); + return Promise.resolve(); + } + + if (!this.ansibleConfig.url) { + this.log('Ansible integration is not configured (missing url)'); + return Promise.resolve(); + } + + // Create Ansible client + this.client = new AnsibleClient(this.ansibleConfig); + + this.log('Ansible AWX/Tower service initialized successfully'); + return Promise.resolve(); + } + + /** + * Perform plugin-specific health check + */ + protected async performHealthCheck(): Promise> { + if (!this.client) { + return { + healthy: false, + message: 'Ansible client not initialized', + }; + } + + try { + const health = await this.client.healthCheck(); + if (health.status === 'ok') { + return { + healthy: true, + message: `AWX/Tower v${health.version} is accessible`, + details: { + version: health.version, + }, + }; + } + + return { + healthy: false, + message: health.error || 'Failed to connect to AWX/Tower', + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + healthy: false, + message: `AWX/Tower health check failed: ${errorMessage}`, + }; + } + } + + // InformationSourcePlugin implementation + + async getInventory(): Promise { + if (!this.isEnabled() || !this.client) return []; + + try { + const mappedNodes = await this.getMappedNodes(); + return mappedNodes.map((node) => ({ + id: node.id, + name: node.name, + uri: node.uri, + transport: node.transport, + config: node.config, + source: 'ansible' as const, + })); + } catch (error) { + this.logError('Failed to get inventory from Ansible', error); + return []; + } + } + + async getNodeFacts(nodeId: string): Promise { + const emptyFacts: Facts = { + nodeId, + gatheredAt: new Date().toISOString(), + source: 'ansible', + facts: { + os: { family: 'unknown', name: 'unknown', release: { full: 'unknown', major: 'unknown' } }, + processors: { count: 0, models: [] }, + memory: { system: { total: '0', available: '0' } }, + networking: { hostname: nodeId, interfaces: {} }, + }, + }; + + if (!this.isEnabled() || !this.client) { + return emptyFacts; + } + + try { + // Find host by name + const host = await this.client.getHostByName(nodeId); + if (!host) { + return emptyFacts; + } + + // Get Ansible facts if available + const ansibleFacts = await this.client.getHostFacts(host.id); + const hostVariables = this.client.parseHostVariables(host.variables); + + // Get host groups + const groups = await this.client.getHostGroups(host.id); + + // Extract OS info from ansible facts if available + const af = ansibleFacts?.ansible_facts || {}; + + return { + nodeId, + gatheredAt: new Date().toISOString(), + source: 'ansible', + facts: { + os: { + family: (af as any).ansible_os_family || 'unknown', + name: (af as any).ansible_distribution || 'unknown', + release: { + full: (af as any).ansible_distribution_version || 'unknown', + major: (af as any).ansible_distribution_major_version || 'unknown', + }, + }, + processors: { + count: (af as any).ansible_processor_vcpus || 0, + models: (af as any).ansible_processor || [], + }, + memory: { + system: { + total: `${(af as any).ansible_memtotal_mb || 0} MB`, + available: `${(af as any).ansible_memfree_mb || 0} MB`, + }, + }, + networking: { + hostname: (af as any).ansible_hostname || nodeId, + interfaces: (af as any).ansible_interfaces || {}, + }, + // Ansible-specific data + ansible_host_id: host.id, + ansible_host_enabled: host.enabled, + ansible_has_active_failures: host.has_active_failures, + ansible_inventory_id: host.inventory, + ansible_groups: groups.map((g) => g.name), + ansible_host_variables: hostVariables, + // Spread remaining ansible facts + ...af, + }, + }; + } catch (error) { + this.logError(`Error getting Ansible facts for ${nodeId}`, error); + return emptyFacts; + } + } + + async getNodeData(nodeId: string, dataType: string): Promise { + if (!this.isEnabled() || !this.client) return null; + + try { + switch (dataType) { + case 'host': + return await this.client.getHostByName(nodeId); + case 'jobs': + return await this.getHostJobHistory(nodeId); + case 'groups': { + const host = await this.client.getHostByName(nodeId); + if (!host) return []; + return await this.client.getHostGroups(host.id); + } + default: + return null; + } + } catch (error) { + this.logError(`Error getting Ansible data for ${nodeId}`, error); + return null; + } + } + + // Ansible-specific methods + + /** + * Get all inventories + */ + async getInventories(): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getInventories(this.ansibleConfig?.organizationId); + } + + /** + * Get hosts from a specific inventory + */ + async getInventoryHosts(inventoryId: number): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getInventoryHosts(inventoryId); + } + + /** + * Get groups from a specific inventory + */ + async getInventoryGroups(inventoryId: number): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getInventoryGroups(inventoryId); + } + + /** + * Map Ansible hosts to Pabawi nodes + */ + async getMappedNodes(inventoryId?: number): Promise { + if (!this.isEnabled() || !this.client) return []; + + try { + let hosts: AnsibleHost[]; + + if (inventoryId) { + hosts = await this.client.getInventoryHosts(inventoryId); + } else { + hosts = await this.client.getHosts(); + } + + const mappedNodes: AnsibleMappedNode[] = []; + + for (const host of hosts) { + const groups = await this.client.getHostGroups(host.id); + const variables = this.client.parseHostVariables( + host.variables + ) as ParsedHostVariables; + + // Determine transport type from ansible_connection or default to ssh + let transport: 'ssh' | 'winrm' | 'docker' | 'local' = 'ssh'; + if (variables.ansible_connection === 'winrm') { + transport = 'winrm'; + } else if (variables.ansible_connection === 'docker') { + transport = 'docker'; + } else if (variables.ansible_connection === 'local') { + transport = 'local'; + } + + // Build URI + const hostAddress = variables.ansible_host || host.name; + const port = variables.ansible_port; + const uri = + transport === 'local' + ? 'localhost' + : port + ? `${hostAddress}:${port}` + : hostAddress; + + mappedNodes.push({ + id: `ansible-${host.id}`, + name: host.name, + uri, + transport, + config: { + user: variables.ansible_user, + port: variables.ansible_port, + ...variables, + }, + source: 'ansible', + ansibleHostId: host.id, + ansibleInventoryId: host.inventory, + groups: groups.map((g) => g.name), + enabled: host.enabled, + hasActiveFailures: host.has_active_failures, + lastJobId: host.last_job || undefined, + }); + } + + return mappedNodes; + } catch (error) { + this.logError('Error mapping Ansible hosts', error); + return []; + } + } + + /** + * Get job templates + */ + async getJobTemplates(): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getJobTemplates(this.ansibleConfig?.organizationId); + } + + /** + * Get a specific job template + */ + async getJobTemplate(id: number): Promise { + if (!this.isEnabled() || !this.client) return null; + try { + return await this.client.getJobTemplate(id); + } catch { + return null; + } + } + + /** + * Launch a job template + */ + async launchJobTemplate( + templateId: number, + options?: LaunchJobRequest + ): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Ansible plugin is disabled or not initialized'); + } + return this.client.launchJobTemplate(templateId, options); + } + + /** + * Get jobs + */ + async getJobs( + templateId?: number, + status?: string, + limit?: number + ): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getJobs(templateId, status, limit); + } + + /** + * Get a specific job + */ + async getJob(id: number): Promise { + if (!this.isEnabled() || !this.client) return null; + try { + return await this.client.getJob(id); + } catch { + return null; + } + } + + /** + * Get job stdout + */ + async getJobStdout(jobId: number): Promise { + if (!this.isEnabled() || !this.client) return ''; + return this.client.getJobStdout(jobId); + } + + /** + * Cancel a running job + */ + async cancelJob(jobId: number): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Ansible plugin is disabled or not initialized'); + } + return this.client.cancelJob(jobId); + } + + /** + * Relaunch a job + */ + async relaunchJob(jobId: number): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Ansible plugin is disabled or not initialized'); + } + return this.client.relaunchJob(jobId); + } + + /** + * Run an ad-hoc command + */ + async runAdHocCommand( + inventoryId: number, + command: string, + limit?: string, + credentialId?: number + ): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Ansible plugin is disabled or not initialized'); + } + return this.client.runAdHocCommand( + inventoryId, + command, + 'shell', + limit, + credentialId + ); + } + + /** + * Get host's recent job history + */ + async getHostJobHistory( + hostName: string, + limit: number = 10 + ): Promise { + if (!this.isEnabled() || !this.client) return []; + + try { + const host = await this.client.getHostByName(hostName); + if (!host) return []; + + const jobSummaries = await this.client.getHostJobSummaries(host.id); + const jobs: AnsibleJob[] = []; + + for (const summary of jobSummaries.slice(0, limit)) { + try { + const job = await this.client.getJob(summary.job); + jobs.push(job); + } catch { + // Job may have been deleted + } + } + + return jobs; + } catch (error) { + this.logError(`Error getting job history for ${hostName}`, error); + return []; + } + } + + /** + * Sync a project + */ + async syncProject(projectId: number): Promise<{ id: number }> { + if (!this.isEnabled() || !this.client) { + throw new Error('Ansible plugin is disabled or not initialized'); + } + return this.client.syncProject(projectId); + } + + /** + * Get projects + */ + async getProjects(): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getProjects(this.ansibleConfig?.organizationId); + } + + /** + * Get credentials + */ + async getCredentials(): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getCredentials(this.ansibleConfig?.organizationId); + } +} diff --git a/backend/src/integrations/ansible/index.ts b/backend/src/integrations/ansible/index.ts new file mode 100644 index 0000000..ca78df7 --- /dev/null +++ b/backend/src/integrations/ansible/index.ts @@ -0,0 +1,9 @@ +/** + * Ansible AWX/Tower Integration Module + * + * Exports Ansible integration components for use in Pabawi + */ + +export * from './types'; +export { AnsibleClient } from './AnsibleClient'; +export { AnsiblePlugin } from './AnsiblePlugin'; diff --git a/backend/src/integrations/ansible/types.ts b/backend/src/integrations/ansible/types.ts new file mode 100644 index 0000000..e6ea1fb --- /dev/null +++ b/backend/src/integrations/ansible/types.ts @@ -0,0 +1,326 @@ +/** + * Ansible Integration Types + * + * Defines types for Ansible AWX/Tower API integration + */ + +// Configuration for Ansible AWX/Tower connection +export interface AnsibleConfig { + enabled: boolean; + url: string; // AWX/Tower base URL + token?: string; // API token for authentication + username?: string; // Basic auth username + password?: string; // Basic auth password + timeout?: number; // Request timeout in ms + verifySsl?: boolean; // Whether to verify SSL certificates + organizationId?: number; // Default organization ID +} + +// AWX/Tower Inventory +export interface AnsibleInventory { + id: number; + name: string; + description: string; + organization: number; + kind: string; + host_filter: string | null; + variables: string; + has_active_failures: boolean; + total_hosts: number; + hosts_with_active_failures: number; + total_groups: number; + has_inventory_sources: boolean; + total_inventory_sources: number; + inventory_sources_with_failures: number; + pending_deletion: boolean; + created: string; + modified: string; +} + +// AWX/Tower Host +export interface AnsibleHost { + id: number; + name: string; + description: string; + inventory: number; + enabled: boolean; + instance_id: string; + variables: string; + has_active_failures: boolean; + has_inventory_sources: boolean; + last_job: number | null; + last_job_host_summary: number | null; + created: string; + modified: string; +} + +// AWX/Tower Group +export interface AnsibleGroup { + id: number; + name: string; + description: string; + inventory: number; + variables: string; + has_active_failures: boolean; + total_hosts: number; + hosts_with_active_failures: number; + total_groups: number; + has_inventory_sources: boolean; + created: string; + modified: string; +} + +// AWX/Tower Job Template +export interface AnsibleJobTemplate { + id: number; + type: string; + name: string; + description: string; + job_type: 'run' | 'check'; + inventory: number | null; + project: number; + playbook: string; + scm_branch: string; + forks: number; + limit: string; + verbosity: number; + extra_vars: string; + job_tags: string; + skip_tags: string; + timeout: number; + ask_scm_branch_on_launch: boolean; + ask_diff_mode_on_launch: boolean; + ask_variables_on_launch: boolean; + ask_limit_on_launch: boolean; + ask_tags_on_launch: boolean; + ask_skip_tags_on_launch: boolean; + ask_job_type_on_launch: boolean; + ask_verbosity_on_launch: boolean; + ask_inventory_on_launch: boolean; + ask_credential_on_launch: boolean; + survey_enabled: boolean; + become_enabled: boolean; + diff_mode: boolean; + allow_simultaneous: boolean; + status: string; + last_job_run: string | null; + last_job_failed: boolean; + next_job_run: string | null; + created: string; + modified: string; +} + +// AWX/Tower Job +export interface AnsibleJob { + id: number; + type: string; + name: string; + description: string; + unified_job_template: number; + launch_type: string; + status: 'new' | 'pending' | 'waiting' | 'running' | 'successful' | 'failed' | 'error' | 'canceled'; + failed: boolean; + started: string | null; + finished: string | null; + canceled_on: string | null; + elapsed: number; + job_explanation: string; + execution_node: string; + controller_node: string; + job_type: string; + inventory: number | null; + project: number | null; + playbook: string; + scm_branch: string; + forks: number; + limit: string; + verbosity: number; + extra_vars: string; + job_tags: string; + skip_tags: string; + artifacts: Record; + scm_revision: string; + created: string; + modified: string; +} + +// AWX/Tower Job Event +export interface AnsibleJobEvent { + id: number; + type: string; + created: string; + modified: string; + job: number; + event: string; + counter: number; + event_display: string; + event_data: Record; + event_level: number; + failed: boolean; + changed: boolean; + uuid: string; + parent_uuid: string; + host: number | null; + host_name: string; + playbook: string; + play: string; + task: string; + role: string; + stdout: string; + start_line: number; + end_line: number; + verbosity: number; +} + +// AWX/Tower Project +export interface AnsibleProject { + id: number; + type: string; + name: string; + description: string; + organization: number; + scm_type: string; + scm_url: string; + scm_branch: string; + scm_refspec: string; + scm_clean: boolean; + scm_track_submodules: boolean; + scm_delete_on_update: boolean; + scm_update_on_launch: boolean; + scm_update_cache_timeout: number; + allow_override: boolean; + timeout: number; + status: string; + last_job_run: string | null; + last_job_failed: boolean; + next_job_run: string | null; + last_update_failed: boolean; + last_updated: string | null; + created: string; + modified: string; +} + +// AWX/Tower Credential +export interface AnsibleCredential { + id: number; + type: string; + name: string; + description: string; + organization: number | null; + credential_type: number; + managed: boolean; + created: string; + modified: string; +} + +// AWX/Tower Organization +export interface AnsibleOrganization { + id: number; + type: string; + name: string; + description: string; + max_hosts: number; + created: string; + modified: string; +} + +// Launch job request +export interface LaunchJobRequest { + inventory?: number; + credential?: number; + limit?: string; + job_tags?: string; + skip_tags?: string; + extra_vars?: Record; + verbosity?: number; + diff_mode?: boolean; + job_type?: 'run' | 'check'; + scm_branch?: string; +} + +// AWX API response wrapper +export interface AWXApiResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +// Host facts from AWX +export interface AnsibleHostFacts { + ansible_facts: Record; + module_setup: boolean; +} + +// Job summary for a host +export interface AnsibleJobHostSummary { + id: number; + created: string; + modified: string; + job: number; + host: number; + host_name: string; + changed: number; + dark: number; + failures: number; + ok: number; + processed: number; + skipped: number; + failed: boolean; + ignored: number; + rescued: number; +} + +// Workflow job template +export interface AnsibleWorkflowJobTemplate { + id: number; + type: string; + name: string; + description: string; + organization: number; + survey_enabled: boolean; + allow_simultaneous: boolean; + ask_variables_on_launch: boolean; + inventory: number | null; + limit: string; + scm_branch: string; + ask_inventory_on_launch: boolean; + ask_scm_branch_on_launch: boolean; + ask_limit_on_launch: boolean; + status: string; + last_job_run: string | null; + last_job_failed: boolean; + next_job_run: string | null; + created: string; + modified: string; +} + +// Parsed host variables +export interface ParsedHostVariables { + ansible_host?: string; + ansible_port?: number; + ansible_user?: string; + ansible_connection?: string; + ansible_ssh_private_key_file?: string; + [key: string]: unknown; +} + +// Mapped node from Ansible +export interface AnsibleMappedNode { + id: string; + name: string; + uri: string; + transport: 'ssh' | 'winrm' | 'docker' | 'local'; + config: { + user?: string; + port?: number; + [key: string]: unknown; + }; + source: 'ansible'; + ansibleHostId: number; + ansibleInventoryId: number; + groups: string[]; + enabled: boolean; + hasActiveFailures: boolean; + lastJobId?: number; +} diff --git a/backend/src/integrations/prometheus/PrometheusClient.ts b/backend/src/integrations/prometheus/PrometheusClient.ts new file mode 100644 index 0000000..40ab629 --- /dev/null +++ b/backend/src/integrations/prometheus/PrometheusClient.ts @@ -0,0 +1,296 @@ +/** + * Prometheus Client + * + * Client for interacting with Prometheus monitoring system. + * Provides methods for querying metrics and alerts. + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { + PrometheusConfig, + NodeMetrics, + PrometheusAlert, + PrometheusApiResponse, + PrometheusQueryData, + PrometheusAlertsData, +} from './types'; + +// Type declarations for Node.js globals +declare const require: any; +declare const Buffer: any; + +interface HttpResponse { + statusCode: number; + body: string; +} + +/** + * Client for Prometheus API + */ +export class PrometheusClient { + private baseUrl: string; + private timeout: number; + private username?: string; + private password?: string; + + constructor(config: PrometheusConfig) { + this.baseUrl = config.serverUrl.replace(/\/$/, ''); // Remove trailing slash + this.timeout = config.timeout ?? 30000; + this.username = config.username; + this.password = config.password; + } + + /** + * Check if Prometheus server is healthy + */ + async healthCheck(): Promise { + try { + const response = await this.request(`${this.baseUrl}/-/healthy`); + return response.statusCode === 200; + } catch (error) { + return false; + } + } + + /** + * Execute a PromQL query + */ + async query(promql: string, time?: Date): Promise { + const params: Record = { query: promql }; + if (time) { + params.time = (time.getTime() / 1000).toString(); + } + + const queryString = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const response = await this.request( + `${this.baseUrl}/api/v1/query?${queryString}` + ); + + if (response.statusCode !== 200) { + throw new Error(`Prometheus query failed: HTTP ${response.statusCode}`); + } + + const data: PrometheusApiResponse = JSON.parse(response.body); + + if (data.status !== 'success' || !data.data) { + throw new Error(`Prometheus query failed: ${data.error ?? 'Unknown error'}`); + } + + return data.data; + } + + /** + * Execute a range PromQL query + */ + async queryRange( + promql: string, + start: Date, + end: Date, + step: string + ): Promise { + const params: Record = { + query: promql, + start: (start.getTime() / 1000).toString(), + end: (end.getTime() / 1000).toString(), + step, + }; + + const queryString = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const response = await this.request( + `${this.baseUrl}/api/v1/query_range?${queryString}` + ); + + if (response.statusCode !== 200) { + throw new Error(`Prometheus range query failed: HTTP ${response.statusCode}`); + } + + const data: PrometheusApiResponse = JSON.parse(response.body); + + if (data.status !== 'success' || !data.data) { + throw new Error(`Prometheus range query failed: ${data.error ?? 'Unknown error'}`); + } + + return data.data; + } + + /** + * Get node metrics + */ + async getNodeMetrics(nodeName: string): Promise { + const metrics: NodeMetrics = {}; + + // Define metric queries + const queries: Record = { + cpu: `100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle",instance=~"${nodeName}.*"}[5m])) * 100)`, + memory: `(1 - (node_memory_MemAvailable_bytes{instance=~"${nodeName}.*"} / node_memory_MemTotal_bytes{instance=~"${nodeName}.*"})) * 100`, + disk: `100 - ((node_filesystem_avail_bytes{instance=~"${nodeName}.*",mountpoint="/"} * 100) / node_filesystem_size_bytes{instance=~"${nodeName}.*",mountpoint="/"})`, + load1: `node_load1{instance=~"${nodeName}.*"}`, + load5: `node_load5{instance=~"${nodeName}.*"}`, + load15: `node_load15{instance=~"${nodeName}.*"}`, + uptime: `node_time_seconds{instance=~"${nodeName}.*"} - node_boot_time_seconds{instance=~"${nodeName}.*"}`, + }; + + // Execute queries in parallel + const results = await Promise.allSettled( + Object.entries(queries).map(async ([key, queryStr]) => { + const result = await this.query(queryStr); + return { key, result }; + }) + ); + + // Process results + for (const result of results) { + if (result.status === 'fulfilled' && result.value.result.result.length > 0) { + const { key, result: queryResult } = result.value; + const value = parseFloat(queryResult.result[0].value?.[1] ?? '0'); + + switch (key) { + case 'cpu': + metrics.cpu = value; + break; + case 'memory': + metrics.memory = value; + break; + case 'disk': + metrics.disk = value; + break; + case 'load1': + if (!metrics.load) metrics.load = { load1: 0, load5: 0, load15: 0 }; + metrics.load.load1 = value; + break; + case 'load5': + if (!metrics.load) metrics.load = { load1: 0, load5: 0, load15: 0 }; + metrics.load.load5 = value; + break; + case 'load15': + if (!metrics.load) metrics.load = { load1: 0, load5: 0, load15: 0 }; + metrics.load.load15 = value; + break; + case 'uptime': + metrics.uptime = value; + break; + } + } + } + + return metrics; + } + + /** + * Get active alerts + */ + async getAlerts(nodeName?: string): Promise { + const response = await this.request(`${this.baseUrl}/api/v1/alerts`); + + if (response.statusCode !== 200) { + throw new Error(`Failed to get alerts: HTTP ${response.statusCode}`); + } + + const data: PrometheusApiResponse = JSON.parse(response.body); + + if (data.status !== 'success' || !data.data) { + throw new Error(`Failed to get alerts: ${data.error ?? 'Unknown error'}`); + } + + let alerts = data.data.alerts; + + // Filter by node name if provided + if (nodeName) { + alerts = alerts.filter( + (alert) => + alert.labels.instance?.includes(nodeName) || + alert.labels.node?.includes(nodeName) || + alert.labels.hostname?.includes(nodeName) + ); + } + + return alerts; + } + + /** + * Get metric series labels + */ + async getSeries(match: string[]): Promise>> { + const matchParams = match + .map((m) => `match[]=${encodeURIComponent(m)}`) + .join('&'); + + const response = await this.request( + `${this.baseUrl}/api/v1/series?${matchParams}` + ); + + if (response.statusCode !== 200) { + throw new Error(`Failed to get series: HTTP ${response.statusCode}`); + } + + const data: PrometheusApiResponse>> = JSON.parse(response.body); + + if (data.status !== 'success' || !data.data) { + throw new Error(`Failed to get series: ${data.error ?? 'Unknown error'}`); + } + + return data.data; + } + + /** + * Make HTTP request to Prometheus API + */ + private async request(url: string): Promise { + return new Promise((resolve, reject) => { + // Dynamically import to avoid TypeScript module issues + const urlModule = require('url'); + const parsedUrl = urlModule.parse(url); + const isHttps = parsedUrl.protocol === 'https:'; + const httpModule = isHttps ? require('https') : require('http'); + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.path, + method: 'GET', + headers: {} as Record, + timeout: this.timeout, + }; + + // Add basic auth if configured + if (this.username && this.password) { + const auth = Buffer.from(`${this.username}:${this.password}`).toString('base64'); + options.headers['Authorization'] = `Basic ${auth}`; + } + + const req = httpModule.request(options, (res: any) => { + let body = ''; + + res.on('data', (chunk: any) => { + body += chunk.toString(); + }); + + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body, + }); + }); + }); + + req.on('error', (error: Error) => { + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + } +} diff --git a/backend/src/integrations/prometheus/PrometheusPlugin.ts b/backend/src/integrations/prometheus/PrometheusPlugin.ts new file mode 100644 index 0000000..44f6c2a --- /dev/null +++ b/backend/src/integrations/prometheus/PrometheusPlugin.ts @@ -0,0 +1,257 @@ +/** + * Prometheus Integration Plugin + * + * Information source plugin for Prometheus monitoring system. + * Provides node metrics and alerts through Pabawi's plugin architecture. + */ + +import { BasePlugin } from '../BasePlugin'; +import { PrometheusClient } from './PrometheusClient'; +import type { InformationSourcePlugin, HealthStatus } from '../types'; +import type { Node, Facts } from '../../bolt/types'; +import type { PrometheusConfig, NodeMetrics, PrometheusAlert } from './types'; + +/** + * Prometheus integration plugin + */ +export class PrometheusPlugin extends BasePlugin implements InformationSourcePlugin { + type: 'information' = 'information'; + private client?: PrometheusClient; + private prometheusConfig?: PrometheusConfig; + private grafanaUrl?: string; + + constructor() { + super('prometheus', 'information'); + } + + /** + * Initialize the plugin with configuration + */ + protected async performInitialization(): Promise { + const config = this.config.config as unknown as PrometheusConfig; + + if (!config.serverUrl) { + throw new Error('Prometheus server URL is required'); + } + + this.prometheusConfig = config; + this.grafanaUrl = config.grafanaUrl; + this.client = new PrometheusClient(config); + + // Test connection + const healthy = await this.client.healthCheck(); + if (!healthy) { + throw new Error('Failed to connect to Prometheus server'); + } + + this.log('Successfully connected to Prometheus server'); + } + + /** + * Perform health check + */ + protected async performHealthCheck(): Promise> { + if (!this.client) { + return { + healthy: false, + message: 'Client not initialized', + }; + } + + try { + const healthy = await this.client.healthCheck(); + + if (healthy) { + return { + healthy: true, + message: 'Prometheus server is healthy', + details: { + serverUrl: this.prometheusConfig?.serverUrl, + grafanaUrl: this.grafanaUrl, + }, + }; + } + + return { + healthy: false, + message: 'Prometheus server is not responding', + }; + } catch (error) { + return { + healthy: false, + message: error instanceof Error ? error.message : 'Health check failed', + }; + } + } + + /** + * Get inventory - Prometheus doesn't provide inventory, returns empty array + */ + async getInventory(): Promise { + // Prometheus doesn't provide node inventory + // Nodes are discovered through other sources (Bolt, PuppetDB, etc.) + return []; + } + + /** + * Get node facts - returns metrics as facts + */ + async getNodeFacts(nodeId: string): Promise { + if (!this.client) { + throw new Error('Client not initialized'); + } + + try { + const metrics = await this.client.getNodeMetrics(nodeId); + const alerts = await this.client.getAlerts(nodeId); + + // Convert metrics to facts format + // Note: Prometheus doesn't provide standard OS facts, so we return a minimal Facts object + // with Prometheus-specific data in the extended facts + const facts: Facts = { + nodeId, + gatheredAt: new Date().toISOString(), + source: 'prometheus', + facts: { + os: { + family: 'unknown', + name: 'unknown', + release: { + full: 'unknown', + major: 'unknown', + }, + }, + processors: { + count: 0, + models: [], + }, + memory: { + system: { + total: 'unknown', + available: 'unknown', + }, + }, + networking: { + hostname: nodeId, + interfaces: {}, + }, + }, + // Add Prometheus-specific data as extended facts + ...({ + prometheus_metrics: { + cpu_usage_percent: metrics.cpu, + memory_usage_percent: metrics.memory, + disk_usage_percent: metrics.disk, + load_average: metrics.load, + uptime_seconds: metrics.uptime, + }, + prometheus_alerts: alerts.map((alert) => ({ + name: alert.labels.alertname, + severity: alert.labels.severity, + state: alert.state, + active_since: alert.activeAt, + description: alert.annotations.description || alert.annotations.summary, + })), + prometheus_server: this.prometheusConfig?.serverUrl, + } as any), + }; + + return facts; + } catch (error) { + this.logError('Failed to get node metrics', error); + throw error; + } + } + + /** + * Get node data - supports 'metrics' and 'alerts' data types + */ + async getNodeData(nodeId: string, dataType: string): Promise { + if (!this.client) { + throw new Error('Client not initialized'); + } + + try { + switch (dataType) { + case 'metrics': + return await this.client.getNodeMetrics(nodeId); + + case 'alerts': + return await this.client.getAlerts(nodeId); + + case 'grafana_url': + if (this.grafanaUrl) { + return { + dashboardUrl: `${this.grafanaUrl}/d/node-exporter?var-instance=${nodeId}`, + explorerUrl: `${this.grafanaUrl}/explore?left=${encodeURIComponent( + JSON.stringify({ + queries: [{ expr: `{instance=~"${nodeId}.*"}`, refId: 'A' }], + range: { from: 'now-1h', to: 'now' }, + }) + )}`, + }; + } + return null; + + default: + throw new Error(`Unknown data type: ${dataType}`); + } + } catch (error) { + this.logError(`Failed to get ${dataType} for node`, error); + throw error; + } + } + + /** + * Get metrics for a node + */ + async getNodeMetrics(nodeId: string): Promise { + return (await this.getNodeData(nodeId, 'metrics')) as NodeMetrics; + } + + /** + * Get alerts for a node + */ + async getNodeAlerts(nodeId: string): Promise { + return (await this.getNodeData(nodeId, 'alerts')) as PrometheusAlert[]; + } + + /** + * Get all active alerts + */ + async getAllAlerts(): Promise { + if (!this.client) { + throw new Error('Client not initialized'); + } + + try { + return await this.client.getAlerts(); + } catch (error) { + this.logError('Failed to get all alerts', error); + throw error; + } + } + + /** + * Execute a custom PromQL query + */ + async executeQuery(promql: string): Promise { + if (!this.client) { + throw new Error('Client not initialized'); + } + + try { + return await this.client.query(promql); + } catch (error) { + this.logError('Failed to execute query', error); + throw error; + } + } + + /** + * Get Grafana URL if configured + */ + getGrafanaUrl(): string | undefined { + return this.grafanaUrl; + } +} diff --git a/backend/src/integrations/prometheus/index.ts b/backend/src/integrations/prometheus/index.ts new file mode 100644 index 0000000..59b35a6 --- /dev/null +++ b/backend/src/integrations/prometheus/index.ts @@ -0,0 +1,17 @@ +/** + * Prometheus Integration Module + * + * Exports all Prometheus integration components. + */ + +export { PrometheusPlugin } from './PrometheusPlugin'; +export { PrometheusClient } from './PrometheusClient'; +export type { + PrometheusConfig, + NodeMetrics, + PrometheusAlert, + PrometheusQueryResult, + PrometheusApiResponse, + PrometheusQueryData, + PrometheusAlertsData, +} from './types'; diff --git a/backend/src/integrations/prometheus/types.ts b/backend/src/integrations/prometheus/types.ts new file mode 100644 index 0000000..8360558 --- /dev/null +++ b/backend/src/integrations/prometheus/types.ts @@ -0,0 +1,82 @@ +/** + * Prometheus Integration Types + * + * Type definitions for Prometheus monitoring integration. + */ + +/** + * Configuration for Prometheus integration + */ +export interface PrometheusConfig { + enabled: boolean; + serverUrl: string; + timeout?: number; + username?: string; + password?: string; + grafanaUrl?: string; +} + +/** + * Node metrics from Prometheus + */ +export interface NodeMetrics { + cpu?: number; + memory?: number; + disk?: number; + network?: { + rxBytes: number; + txBytes: number; + }; + load?: { + load1: number; + load5: number; + load15: number; + }; + uptime?: number; +} + +/** + * Prometheus alert + */ +export interface PrometheusAlert { + labels: Record; + annotations: Record; + state: 'inactive' | 'pending' | 'firing'; + activeAt: string; + value: string; +} + +/** + * Prometheus query result + */ +export interface PrometheusQueryResult { + metric: Record; + value?: [number, string]; + values?: Array<[number, string]>; +} + +/** + * Prometheus API response + */ +export interface PrometheusApiResponse { + status: 'success' | 'error'; + data?: T; + error?: string; + errorType?: string; + warnings?: string[]; +} + +/** + * Prometheus query response data + */ +export interface PrometheusQueryData { + resultType: 'matrix' | 'vector' | 'scalar' | 'string'; + result: PrometheusQueryResult[]; +} + +/** + * Prometheus alerts response data + */ +export interface PrometheusAlertsData { + alerts: PrometheusAlert[]; +} diff --git a/backend/src/integrations/terraform/TerraformClient.ts b/backend/src/integrations/terraform/TerraformClient.ts new file mode 100644 index 0000000..a5a2f91 --- /dev/null +++ b/backend/src/integrations/terraform/TerraformClient.ts @@ -0,0 +1,507 @@ +/** + * Terraform Cloud/Enterprise API Client + * + * HTTP client for communicating with Terraform Cloud/Enterprise API + */ + +import type { + TerraformConfig, + TerraformWorkspace, + TerraformRun, + TerraformPlan, + TerraformApply, + TerraformStateVersion, + TerraformStateResource, + TerraformOutput, + TerraformVariable, + TerraformOrganization, + TerraformApiResponse, + CreateRunRequest, +} from './types'; + +// Declare require for Node.js modules +declare const require: any; +// Node.js globals +declare const URL: any; +declare const URLSearchParams: any; + +export class TerraformClient { + private config: TerraformConfig; + private baseUrl: string; + private headers: Record; + + constructor(config: TerraformConfig) { + this.config = config; + // Default to Terraform Cloud if not specified + this.baseUrl = (config.url || 'https://app.terraform.io').replace(/\/$/, ''); + this.headers = { + 'Authorization': `Bearer ${config.token}`, + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }; + } + + private async request( + method: string, + path: string, + body?: unknown + ): Promise { + const url = new URL(`/api/v2${path}`, this.baseUrl); + + const https = require('https'); + const http = require('http'); + const client = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method, + headers: this.headers, + timeout: this.config.timeout || 30000, + }; + + const req = client.request(options, (res: any) => { + let data = ''; + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(data ? JSON.parse(data) : ({} as T)); + } catch { + resolve(data as unknown as T); + } + } else { + reject( + new Error( + `Terraform API error: ${res.statusCode} ${res.statusMessage} - ${data}` + ) + ); + } + }); + }); + + req.on('error', (err: Error) => { + reject(new Error(`Terraform API request failed: ${err.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Terraform API request timed out')); + }); + + if (body) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); + } + + // Health check + async healthCheck(): Promise<{ status: 'ok' | 'error'; error?: string }> { + try { + // Try to get account details to verify auth + await this.request('GET', '/account/details'); + return { status: 'ok' }; + } catch (error) { + return { + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + // Organizations + async getOrganizations(): Promise { + const response = await this.request>( + 'GET', + '/organizations' + ); + return response.data; + } + + async getOrganization(name: string): Promise { + const response = await this.request>( + 'GET', + `/organizations/${encodeURIComponent(name)}` + ); + return response.data; + } + + // Workspaces + async getWorkspaces( + organizationName?: string, + page?: number, + pageSize?: number + ): Promise<{ workspaces: TerraformWorkspace[]; totalCount: number }> { + const org = organizationName || this.config.organization; + if (!org) { + throw new Error('Organization name is required'); + } + + const params = new URLSearchParams(); + if (page) params.set('page[number]', String(page)); + if (pageSize) params.set('page[size]', String(pageSize)); + + const query = params.toString() ? `?${params.toString()}` : ''; + const response = await this.request>( + 'GET', + `/organizations/${encodeURIComponent(org)}/workspaces${query}` + ); + + return { + workspaces: response.data, + totalCount: response.meta?.pagination?.['total-count'] || response.data.length, + }; + } + + async getWorkspace(organizationName: string, workspaceName: string): Promise { + const response = await this.request>( + 'GET', + `/organizations/${encodeURIComponent(organizationName)}/workspaces/${encodeURIComponent(workspaceName)}` + ); + return response.data; + } + + async getWorkspaceById(workspaceId: string): Promise { + const response = await this.request>( + 'GET', + `/workspaces/${workspaceId}` + ); + return response.data; + } + + async lockWorkspace(workspaceId: string, reason?: string): Promise { + const response = await this.request>( + 'POST', + `/workspaces/${workspaceId}/actions/lock`, + { reason } + ); + return response.data; + } + + async unlockWorkspace(workspaceId: string): Promise { + const response = await this.request>( + 'POST', + `/workspaces/${workspaceId}/actions/unlock` + ); + return response.data; + } + + // Runs + async getRuns( + workspaceId: string, + page?: number, + pageSize?: number + ): Promise<{ runs: TerraformRun[]; totalCount: number }> { + const params = new URLSearchParams(); + if (page) params.set('page[number]', String(page)); + if (pageSize) params.set('page[size]', String(pageSize)); + + const query = params.toString() ? `?${params.toString()}` : ''; + const response = await this.request>( + 'GET', + `/workspaces/${workspaceId}/runs${query}` + ); + + return { + runs: response.data, + totalCount: response.meta?.pagination?.['total-count'] || response.data.length, + }; + } + + async getRun(runId: string): Promise { + const response = await this.request>( + 'GET', + `/runs/${runId}` + ); + return response.data; + } + + async createRun(workspaceId: string, options?: CreateRunRequest): Promise { + const body = { + data: { + type: 'runs', + attributes: { + message: options?.message || 'Triggered via Pabawi', + 'is-destroy': options?.['is-destroy'] || false, + refresh: options?.refresh ?? true, + 'refresh-only': options?.['refresh-only'] || false, + 'auto-apply': options?.['auto-apply'] || false, + 'plan-only': options?.['plan-only'] || false, + 'target-addrs': options?.['target-addrs'] || null, + 'replace-addrs': options?.['replace-addrs'] || null, + }, + relationships: { + workspace: { + data: { + type: 'workspaces', + id: workspaceId, + }, + }, + }, + }, + }; + + const response = await this.request>( + 'POST', + '/runs', + body + ); + return response.data; + } + + async applyRun(runId: string, comment?: string): Promise { + await this.request('POST', `/runs/${runId}/actions/apply`, { + comment: comment || 'Applied via Pabawi', + }); + } + + async discardRun(runId: string, comment?: string): Promise { + await this.request('POST', `/runs/${runId}/actions/discard`, { + comment: comment || 'Discarded via Pabawi', + }); + } + + async cancelRun(runId: string, comment?: string): Promise { + await this.request('POST', `/runs/${runId}/actions/cancel`, { + comment: comment || 'Cancelled via Pabawi', + }); + } + + async forceUnlockRun(runId: string): Promise { + await this.request('POST', `/runs/${runId}/actions/force-cancel`); + } + + // Plans + async getPlan(planId: string): Promise { + const response = await this.request>( + 'GET', + `/plans/${planId}` + ); + return response.data; + } + + async getPlanLogs(planId: string): Promise { + const plan = await this.getPlan(planId); + if (!plan.attributes['log-read-url']) { + return ''; + } + // Fetch logs from the log URL + return this.fetchExternalUrl(plan.attributes['log-read-url']); + } + + // Applies + async getApply(applyId: string): Promise { + const response = await this.request>( + 'GET', + `/applies/${applyId}` + ); + return response.data; + } + + async getApplyLogs(applyId: string): Promise { + const apply = await this.getApply(applyId); + if (!apply.attributes['log-read-url']) { + return ''; + } + return this.fetchExternalUrl(apply.attributes['log-read-url']); + } + + // State Versions + async getCurrentStateVersion(workspaceId: string): Promise { + try { + const response = await this.request>( + 'GET', + `/workspaces/${workspaceId}/current-state-version` + ); + return response.data; + } catch { + return null; + } + } + + async getStateVersions( + workspaceId: string, + page?: number, + pageSize?: number + ): Promise<{ stateVersions: TerraformStateVersion[]; totalCount: number }> { + const params = new URLSearchParams(); + if (page) params.set('page[number]', String(page)); + if (pageSize) params.set('page[size]', String(pageSize)); + + const query = params.toString() ? `?${params.toString()}` : ''; + const response = await this.request>( + 'GET', + `/workspaces/${workspaceId}/state-versions${query}` + ); + + return { + stateVersions: response.data, + totalCount: response.meta?.pagination?.['total-count'] || response.data.length, + }; + } + + async getStateVersion(stateVersionId: string): Promise { + const response = await this.request>( + 'GET', + `/state-versions/${stateVersionId}` + ); + return response.data; + } + + async getStateVersionResources( + stateVersionId: string, + page?: number, + pageSize?: number + ): Promise<{ resources: TerraformStateResource[]; totalCount: number }> { + const params = new URLSearchParams(); + if (page) params.set('page[number]', String(page)); + if (pageSize) params.set('page[size]', String(pageSize)); + + const query = params.toString() ? `?${params.toString()}` : ''; + const response = await this.request>( + 'GET', + `/state-versions/${stateVersionId}/resources${query}` + ); + + return { + resources: response.data, + totalCount: response.meta?.pagination?.['total-count'] || response.data.length, + }; + } + + async getStateVersionOutputs(stateVersionId: string): Promise { + const response = await this.request>( + 'GET', + `/state-versions/${stateVersionId}/outputs` + ); + return response.data; + } + + // Variables + async getWorkspaceVariables(workspaceId: string): Promise { + const response = await this.request>( + 'GET', + `/workspaces/${workspaceId}/vars` + ); + return response.data; + } + + async createVariable( + workspaceId: string, + key: string, + value: string, + options?: { + category?: 'terraform' | 'env'; + hcl?: boolean; + sensitive?: boolean; + description?: string; + } + ): Promise { + const body = { + data: { + type: 'vars', + attributes: { + key, + value, + category: options?.category || 'terraform', + hcl: options?.hcl || false, + sensitive: options?.sensitive || false, + description: options?.description || null, + }, + relationships: { + workspace: { + data: { + type: 'workspaces', + id: workspaceId, + }, + }, + }, + }, + }; + + const response = await this.request>( + 'POST', + '/vars', + body + ); + return response.data; + } + + async updateVariable( + variableId: string, + value: string, + options?: { + hcl?: boolean; + sensitive?: boolean; + description?: string; + } + ): Promise { + const body = { + data: { + type: 'vars', + id: variableId, + attributes: { + value, + hcl: options?.hcl, + sensitive: options?.sensitive, + description: options?.description, + }, + }, + }; + + const response = await this.request>( + 'PATCH', + `/vars/${variableId}`, + body + ); + return response.data; + } + + async deleteVariable(variableId: string): Promise { + await this.request('DELETE', `/vars/${variableId}`); + } + + // Helper to fetch external URLs (like log URLs) + private async fetchExternalUrl(url: string): Promise { + const https = require('https'); + const http = require('http'); + const parsedUrl = new URL(url); + const client = parsedUrl.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = client.request( + { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + }, + timeout: this.config.timeout || 30000, + }, + (res: any) => { + let data = ''; + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + resolve(data); + }); + } + ); + + req.on('error', (err: Error) => { + reject(err); + }); + + req.end(); + }); + } +} diff --git a/backend/src/integrations/terraform/TerraformPlugin.ts b/backend/src/integrations/terraform/TerraformPlugin.ts new file mode 100644 index 0000000..4e024e6 --- /dev/null +++ b/backend/src/integrations/terraform/TerraformPlugin.ts @@ -0,0 +1,452 @@ +/** + * Terraform Cloud/Enterprise Integration Plugin + * + * Provides infrastructure state visibility through Terraform Cloud/Enterprise + */ + +import { BasePlugin } from '../BasePlugin'; +import type { InformationSourcePlugin, HealthStatus } from '../types'; +import type { Node, Facts } from '../../bolt/types'; +import { TerraformClient } from './TerraformClient'; +import type { + TerraformConfig, + TerraformWorkspace, + TerraformRun, + TerraformStateVersion, + TerraformStateResource, + TerraformOutput, + TerraformVariable, + WorkspaceSummary, + TerraformMappedResource, + CreateRunRequest, +} from './types'; + +export class TerraformPlugin + extends BasePlugin + implements InformationSourcePlugin +{ + type = 'information' as const; + private client?: TerraformClient; + private terraformConfig?: TerraformConfig; + + constructor() { + super('terraform', 'information'); + } + + /** + * Perform plugin-specific initialization + */ + protected performInitialization(): Promise { + this.terraformConfig = this.config.config as unknown as TerraformConfig; + + if (!this.config.enabled) { + this.log('Terraform integration is disabled'); + return Promise.resolve(); + } + + if (!this.terraformConfig.token) { + this.log('Terraform integration is not configured (missing token)'); + return Promise.resolve(); + } + + this.client = new TerraformClient(this.terraformConfig); + this.log('Terraform Cloud/Enterprise service initialized successfully'); + return Promise.resolve(); + } + + /** + * Perform plugin-specific health check + */ + protected async performHealthCheck(): Promise> { + if (!this.client) { + return { + healthy: false, + message: 'Terraform client not initialized', + }; + } + + try { + const health = await this.client.healthCheck(); + if (health.status === 'ok') { + return { + healthy: true, + message: 'Terraform Cloud/Enterprise API is accessible', + }; + } + + return { + healthy: false, + message: health.error || 'Failed to connect to Terraform API', + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + healthy: false, + message: `Terraform health check failed: ${errorMessage}`, + }; + } + } + + // InformationSourcePlugin implementation + + async getInventory(): Promise { + // Terraform doesn't provide traditional node inventory + // Instead, we could map compute resources to nodes + // For now, return empty array + return []; + } + + async getNodeFacts(nodeId: string): Promise { + const emptyFacts: Facts = { + nodeId, + gatheredAt: new Date().toISOString(), + source: 'terraform', + facts: { + os: { family: 'unknown', name: 'unknown', release: { full: 'unknown', major: 'unknown' } }, + processors: { count: 0, models: [] }, + memory: { system: { total: '0', available: '0' } }, + networking: { hostname: nodeId, interfaces: {} }, + }, + }; + + if (!this.isEnabled() || !this.client) { + return emptyFacts; + } + + // Terraform doesn't have traditional node facts + // Could potentially extract from state resources + return emptyFacts; + } + + async getNodeData(_nodeId: string, _dataType: string): Promise { + if (!this.isEnabled() || !this.client) return null; + + // Could map resources to nodes + return null; + } + + // Terraform-specific methods + + /** + * Get all workspaces for the configured organization + */ + async getWorkspaces(page?: number, pageSize?: number): Promise<{ workspaces: TerraformWorkspace[]; totalCount: number }> { + if (!this.isEnabled() || !this.client) { + return { workspaces: [], totalCount: 0 }; + } + return this.client.getWorkspaces(this.terraformConfig?.organization, page, pageSize); + } + + /** + * Get workspace summaries for dashboard display + */ + async getWorkspaceSummaries(): Promise { + if (!this.isEnabled() || !this.client) return []; + + try { + const { workspaces } = await this.client.getWorkspaces(this.terraformConfig?.organization); + + const summaries: WorkspaceSummary[] = []; + for (const ws of workspaces) { + let lastRunStatus: string | null = null; + let lastRunAt: string | null = null; + let hasChanges = false; + + // Get latest run info if available + if (ws.relationships['latest-run']?.data?.id) { + try { + const run = await this.client.getRun(ws.relationships['latest-run'].data.id); + lastRunStatus = run.attributes.status; + lastRunAt = run.attributes['created-at']; + hasChanges = run.attributes['has-changes']; + } catch { + // Run may have been deleted + } + } + + summaries.push({ + id: ws.id, + name: ws.attributes.name, + organization: this.terraformConfig?.organization || '', + environment: ws.attributes.environment, + terraformVersion: ws.attributes['terraform-version'], + resourceCount: ws.attributes['resource-count'], + locked: ws.attributes.locked, + lastRunStatus, + lastRunAt, + hasChanges, + executionMode: ws.attributes['execution-mode'], + vcsRepo: ws.attributes['vcs-repo']?.identifier || null, + }); + } + + return summaries; + } catch (error) { + this.logError('Error getting workspace summaries', error); + return []; + } + } + + /** + * Get a specific workspace + */ + async getWorkspace(workspaceName: string): Promise { + if (!this.isEnabled() || !this.client || !this.terraformConfig?.organization) { + return null; + } + try { + return await this.client.getWorkspace(this.terraformConfig.organization, workspaceName); + } catch { + return null; + } + } + + /** + * Get workspace by ID + */ + async getWorkspaceById(workspaceId: string): Promise { + if (!this.isEnabled() || !this.client) return null; + try { + return await this.client.getWorkspaceById(workspaceId); + } catch { + return null; + } + } + + /** + * Lock a workspace + */ + async lockWorkspace(workspaceId: string, reason?: string): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.lockWorkspace(workspaceId, reason); + } + + /** + * Unlock a workspace + */ + async unlockWorkspace(workspaceId: string): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.unlockWorkspace(workspaceId); + } + + /** + * Get runs for a workspace + */ + async getRuns(workspaceId: string, page?: number, pageSize?: number): Promise<{ runs: TerraformRun[]; totalCount: number }> { + if (!this.isEnabled() || !this.client) { + return { runs: [], totalCount: 0 }; + } + return this.client.getRuns(workspaceId, page, pageSize); + } + + /** + * Get a specific run + */ + async getRun(runId: string): Promise { + if (!this.isEnabled() || !this.client) return null; + try { + return await this.client.getRun(runId); + } catch { + return null; + } + } + + /** + * Create a new run (plan) + */ + async createRun(workspaceId: string, options?: CreateRunRequest): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.createRun(workspaceId, options); + } + + /** + * Apply a run + */ + async applyRun(runId: string, comment?: string): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.applyRun(runId, comment); + } + + /** + * Discard a run + */ + async discardRun(runId: string, comment?: string): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.discardRun(runId, comment); + } + + /** + * Cancel a run + */ + async cancelRun(runId: string, comment?: string): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.cancelRun(runId, comment); + } + + /** + * Get plan logs + */ + async getPlanLogs(planId: string): Promise { + if (!this.isEnabled() || !this.client) return ''; + return this.client.getPlanLogs(planId); + } + + /** + * Get apply logs + */ + async getApplyLogs(applyId: string): Promise { + if (!this.isEnabled() || !this.client) return ''; + return this.client.getApplyLogs(applyId); + } + + /** + * Get current state version for a workspace + */ + async getCurrentState(workspaceId: string): Promise { + if (!this.isEnabled() || !this.client) return null; + return this.client.getCurrentStateVersion(workspaceId); + } + + /** + * Get state version history + */ + async getStateVersions(workspaceId: string, page?: number, pageSize?: number): Promise<{ stateVersions: TerraformStateVersion[]; totalCount: number }> { + if (!this.isEnabled() || !this.client) { + return { stateVersions: [], totalCount: 0 }; + } + return this.client.getStateVersions(workspaceId, page, pageSize); + } + + /** + * Get resources from state + */ + async getStateResources(stateVersionId: string, page?: number, pageSize?: number): Promise<{ resources: TerraformStateResource[]; totalCount: number }> { + if (!this.isEnabled() || !this.client) { + return { resources: [], totalCount: 0 }; + } + return this.client.getStateVersionResources(stateVersionId, page, pageSize); + } + + /** + * Get state outputs + */ + async getStateOutputs(stateVersionId: string): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getStateVersionOutputs(stateVersionId); + } + + /** + * Get workspace variables + */ + async getVariables(workspaceId: string): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getWorkspaceVariables(workspaceId); + } + + /** + * Create a variable + */ + async createVariable( + workspaceId: string, + key: string, + value: string, + options?: { + category?: 'terraform' | 'env'; + hcl?: boolean; + sensitive?: boolean; + description?: string; + } + ): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.createVariable(workspaceId, key, value, options); + } + + /** + * Update a variable + */ + async updateVariable( + variableId: string, + value: string, + options?: { + hcl?: boolean; + sensitive?: boolean; + description?: string; + } + ): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.updateVariable(variableId, value, options); + } + + /** + * Delete a variable + */ + async deleteVariable(variableId: string): Promise { + if (!this.isEnabled() || !this.client) { + throw new Error('Terraform plugin is disabled or not initialized'); + } + return this.client.deleteVariable(variableId); + } + + /** + * Get all resources across workspaces as mapped resources + */ + async getMappedResources(): Promise { + if (!this.isEnabled() || !this.client) return []; + + try { + const { workspaces } = await this.client.getWorkspaces(this.terraformConfig?.organization); + const mappedResources: TerraformMappedResource[] = []; + + for (const ws of workspaces) { + const currentState = await this.client.getCurrentStateVersion(ws.id); + if (!currentState) continue; + + const { resources } = await this.client.getStateVersionResources(currentState.id); + + for (const resource of resources) { + mappedResources.push({ + id: resource.id, + address: resource.attributes.address, + name: resource.attributes.name, + type: resource.attributes.type, + provider: resource.attributes['provider-name'], + module: resource.attributes.module, + mode: resource.attributes.mode, + workspaceId: ws.id, + workspaceName: ws.attributes.name, + organizationName: this.terraformConfig?.organization || '', + }); + } + } + + return mappedResources; + } catch (error) { + this.logError('Error getting mapped resources', error); + return []; + } + } + + /** + * Get organizations + */ + async getOrganizations(): Promise { + if (!this.isEnabled() || !this.client) return []; + return this.client.getOrganizations(); + } +} diff --git a/backend/src/integrations/terraform/index.ts b/backend/src/integrations/terraform/index.ts new file mode 100644 index 0000000..8b252a0 --- /dev/null +++ b/backend/src/integrations/terraform/index.ts @@ -0,0 +1,9 @@ +/** + * Terraform Cloud/Enterprise Integration Module + * + * Exports Terraform integration components for use in Pabawi + */ + +export * from './types'; +export { TerraformClient } from './TerraformClient'; +export { TerraformPlugin } from './TerraformPlugin'; diff --git a/backend/src/integrations/terraform/types.ts b/backend/src/integrations/terraform/types.ts new file mode 100644 index 0000000..f05a3cb --- /dev/null +++ b/backend/src/integrations/terraform/types.ts @@ -0,0 +1,304 @@ +/** + * Terraform Cloud/Enterprise Integration Types + * + * Defines types for Terraform Cloud/Enterprise API integration + */ + +// Configuration for Terraform Cloud/Enterprise connection +export interface TerraformConfig { + enabled: boolean; + url: string; // Terraform Cloud/Enterprise API URL (default: https://app.terraform.io) + token: string; // API token + organization?: string; // Default organization name + timeout?: number; // Request timeout in ms +} + +// Terraform Workspace +export interface TerraformWorkspace { + id: string; + type: 'workspaces'; + attributes: { + name: string; + description: string | null; + environment: string; + 'auto-apply': boolean; + 'allow-destroy-plan': boolean; + locked: boolean; + 'queue-all-runs': boolean; + 'speculative-enabled': boolean; + 'terraform-version': string; + 'working-directory': string | null; + 'global-remote-state': boolean; + 'created-at': string; + 'updated-at': string; + 'resource-count': number; + 'execution-mode': 'remote' | 'local' | 'agent'; + 'vcs-repo': { + identifier: string; + branch: string; + 'display-identifier': string; + 'ingress-submodules': boolean; + } | null; + 'latest-change-at': string | null; + }; + relationships: { + organization: { data: { id: string; type: string } }; + 'current-run': { data: { id: string; type: string } | null }; + 'latest-run': { data: { id: string; type: string } | null }; + 'current-state-version': { data: { id: string; type: string } | null }; + }; +} + +// Terraform Run +export interface TerraformRun { + id: string; + type: 'runs'; + attributes: { + status: + | 'pending' + | 'plan_queued' + | 'planning' + | 'planned' + | 'cost_estimating' + | 'cost_estimated' + | 'policy_checking' + | 'policy_override' + | 'policy_soft_failed' + | 'policy_checked' + | 'confirmed' + | 'planned_and_finished' + | 'apply_queued' + | 'applying' + | 'applied' + | 'discarded' + | 'errored' + | 'canceled' + | 'force_canceled'; + 'is-destroy': boolean; + message: string; + 'created-at': string; + 'has-changes': boolean; + 'auto-apply': boolean; + source: 'tfe-ui' | 'tfe-api' | 'tfe-configuration-version'; + 'trigger-reason': string; + 'plan-only': boolean; + 'status-timestamps': { + 'plan-queued-at'?: string; + 'planning-at'?: string; + 'planned-at'?: string; + 'apply-queued-at'?: string; + 'applying-at'?: string; + 'applied-at'?: string; + 'errored-at'?: string; + 'discarded-at'?: string; + 'canceled-at'?: string; + }; + }; + relationships: { + workspace: { data: { id: string; type: string } }; + plan: { data: { id: string; type: string } | null }; + apply: { data: { id: string; type: string } | null }; + 'configuration-version': { data: { id: string; type: string } | null }; + 'created-by': { data: { id: string; type: string } | null }; + }; +} + +// Terraform Plan +export interface TerraformPlan { + id: string; + type: 'plans'; + attributes: { + status: 'pending' | 'queued' | 'running' | 'finished' | 'errored' | 'canceled' | 'unreachable'; + 'has-changes': boolean; + 'resource-additions': number; + 'resource-changes': number; + 'resource-destructions': number; + 'log-read-url': string; + }; +} + +// Terraform Apply +export interface TerraformApply { + id: string; + type: 'applies'; + attributes: { + status: 'pending' | 'queued' | 'running' | 'finished' | 'errored' | 'canceled' | 'unreachable'; + 'resource-additions': number; + 'resource-changes': number; + 'resource-destructions': number; + 'log-read-url': string; + }; +} + +// Terraform State Version +export interface TerraformStateVersion { + id: string; + type: 'state-versions'; + attributes: { + 'created-at': string; + serial: number; + 'hosted-state-download-url': string; + 'hosted-json-state-download-url': string; + status: 'pending' | 'finalized' | 'discarded'; + size: number; + 'terraform-version': string; + 'resources-processed': boolean; + }; + relationships: { + run: { data: { id: string; type: string } | null }; + 'created-by': { data: { id: string; type: string } | null }; + outputs: { data: { id: string; type: string }[] }; + }; +} + +// Terraform State Resource +export interface TerraformStateResource { + id: string; + type: 'state-version-resources'; + attributes: { + address: string; + name: string; + type: string; + module: string | null; + mode: 'managed' | 'data'; + 'provider-name': string; + 'name-index': string | null; + }; +} + +// Terraform Output +export interface TerraformOutput { + id: string; + type: 'state-version-outputs'; + attributes: { + name: string; + sensitive: boolean; + type: string; + value: unknown; + 'detailed-type': unknown; + }; +} + +// Terraform Variable +export interface TerraformVariable { + id: string; + type: 'vars'; + attributes: { + key: string; + value: string | null; + description: string | null; + category: 'terraform' | 'env'; + hcl: boolean; + sensitive: boolean; + }; + relationships: { + workspace: { data: { id: string; type: string } }; + }; +} + +// Terraform Organization +export interface TerraformOrganization { + id: string; + type: 'organizations'; + attributes: { + name: string; + email: string; + 'created-at': string; + 'external-id': string; + 'two-factor-conformant': boolean; + 'workspace-limit': number | null; + 'workspace-count': number; + 'run-task-limit': number | null; + 'run-task-count': number; + 'is-disabled': boolean; + 'managed-resource-count': number; + cost_estimation_enabled: boolean; + send_passing_statuses_for_untriggered_speculative_plans: boolean; + }; +} + +// Terraform Team +export interface TerraformTeam { + id: string; + type: 'teams'; + attributes: { + name: string; + 'users-count': number; + visibility: 'secret' | 'organization'; + 'organization-access': { + 'manage-policies': boolean; + 'manage-workspaces': boolean; + 'manage-vcs-settings': boolean; + 'manage-policy-overrides': boolean; + }; + }; +} + +// Terraform API response wrapper +export interface TerraformApiResponse { + data: T; + included?: unknown[]; + links?: { + self: string; + first?: string; + prev?: string; + next?: string; + last?: string; + }; + meta?: { + pagination?: { + 'current-page': number; + 'page-size': number; + 'prev-page': number | null; + 'next-page': number | null; + 'total-pages': number; + 'total-count': number; + }; + }; +} + +// Create run request +export interface CreateRunRequest { + message?: string; + 'is-destroy'?: boolean; + 'refresh'?: boolean; + 'refresh-only'?: boolean; + 'auto-apply'?: boolean; + 'plan-only'?: boolean; + 'target-addrs'?: string[]; + 'replace-addrs'?: string[]; + variables?: Array<{ + key: string; + value: string; + }>; +} + +// Mapped infrastructure resource for Pabawi +export interface TerraformMappedResource { + id: string; + address: string; + name: string; + type: string; + provider: string; + module: string | null; + mode: 'managed' | 'data'; + workspaceId: string; + workspaceName: string; + organizationName: string; +} + +// Workspace summary for dashboard +export interface WorkspaceSummary { + id: string; + name: string; + organization: string; + environment: string; + terraformVersion: string; + resourceCount: number; + locked: boolean; + lastRunStatus: string | null; + lastRunAt: string | null; + hasChanges: boolean; + executionMode: string; + vcsRepo: string | null; +} diff --git a/backend/src/routes/ansible.ts b/backend/src/routes/ansible.ts new file mode 100644 index 0000000..b2f387f --- /dev/null +++ b/backend/src/routes/ansible.ts @@ -0,0 +1,429 @@ +/** + * Ansible AWX/Tower API Routes + * + * Provides REST endpoints for Ansible integration + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import type { IntegrationManager } from '../integrations/IntegrationManager'; +import { AnsiblePlugin } from '../integrations/ansible'; + +/** + * Create Ansible router with IntegrationManager dependency + */ +export function createAnsibleRouter(integrationManager: IntegrationManager): Router { + const router = Router(); + + // Helper to get Ansible plugin + function getAnsiblePlugin(): AnsiblePlugin | null { + const plugin = integrationManager.getInformationSource('ansible'); + if (plugin && plugin instanceof AnsiblePlugin) { + return plugin; + } + return null; + } + + // Health check + router.get('/health', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ + status: 'unavailable', + message: 'Ansible integration is not configured', + }); + return; + } + + const health = await plugin.healthCheck(); + res.json({ + status: health.healthy ? 'ok' : 'error', + ...health, + }); + } catch (error) { + next(error); + } + }); + + // Get inventories + router.get('/inventories', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const inventories = await plugin.getInventories(); + res.json({ inventories }); + } catch (error) { + next(error); + } + }); + + // Get hosts from an inventory + router.get('/inventories/:id/hosts', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const inventoryId = parseInt(req.params.id, 10); + if (isNaN(inventoryId)) { + res.status(400).json({ error: 'Invalid inventory ID' }); + return; + } + + const hosts = await plugin.getInventoryHosts(inventoryId); + res.json({ hosts }); + } catch (error) { + next(error); + } + }); + + // Get groups from an inventory + router.get('/inventories/:id/groups', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const inventoryId = parseInt(req.params.id, 10); + if (isNaN(inventoryId)) { + res.status(400).json({ error: 'Invalid inventory ID' }); + return; + } + + const groups = await plugin.getInventoryGroups(inventoryId); + res.json({ groups }); + } catch (error) { + next(error); + } + }); + + // Get mapped nodes (hosts transformed to Pabawi format) + router.get('/nodes', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const inventoryId = req.query.inventory + ? parseInt(req.query.inventory as string, 10) + : undefined; + + const nodes = await plugin.getMappedNodes(inventoryId); + res.json({ nodes }); + } catch (error) { + next(error); + } + }); + + // Get node facts + router.get('/nodes/:name/facts', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const facts = await plugin.getNodeFacts(req.params.name); + res.json({ facts }); + } catch (error) { + next(error); + } + }); + + // Get node job history + router.get('/nodes/:name/jobs', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; + const jobs = await plugin.getHostJobHistory(req.params.name, limit); + res.json({ jobs }); + } catch (error) { + next(error); + } + }); + + // Get job templates + router.get('/job-templates', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const templates = await plugin.getJobTemplates(); + res.json({ templates }); + } catch (error) { + next(error); + } + }); + + // Get a specific job template + router.get('/job-templates/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const templateId = parseInt(req.params.id, 10); + if (isNaN(templateId)) { + res.status(400).json({ error: 'Invalid template ID' }); + return; + } + + const template = await plugin.getJobTemplate(templateId); + if (!template) { + res.status(404).json({ error: 'Job template not found' }); + return; + } + + res.json({ template }); + } catch (error) { + next(error); + } + }); + + // Launch a job template + router.post('/job-templates/:id/launch', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const templateId = parseInt(req.params.id, 10); + if (isNaN(templateId)) { + res.status(400).json({ error: 'Invalid template ID' }); + return; + } + + const job = await plugin.launchJobTemplate(templateId, req.body); + res.status(201).json({ job }); + } catch (error) { + next(error); + } + }); + + // Get jobs + router.get('/jobs', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const templateId = req.query.template + ? parseInt(req.query.template as string, 10) + : undefined; + const status = req.query.status as string | undefined; + const limit = req.query.limit + ? parseInt(req.query.limit as string, 10) + : undefined; + + const jobs = await plugin.getJobs(templateId, status, limit); + res.json({ jobs }); + } catch (error) { + next(error); + } + }); + + // Get a specific job + router.get('/jobs/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const jobId = parseInt(req.params.id, 10); + if (isNaN(jobId)) { + res.status(400).json({ error: 'Invalid job ID' }); + return; + } + + const job = await plugin.getJob(jobId); + if (!job) { + res.status(404).json({ error: 'Job not found' }); + return; + } + + res.json({ job }); + } catch (error) { + next(error); + } + }); + + // Get job stdout + router.get('/jobs/:id/stdout', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const jobId = parseInt(req.params.id, 10); + if (isNaN(jobId)) { + res.status(400).json({ error: 'Invalid job ID' }); + return; + } + + const stdout = await plugin.getJobStdout(jobId); + res.type('text/plain').send(stdout); + } catch (error) { + next(error); + } + }); + + // Cancel a job + router.post('/jobs/:id/cancel', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const jobId = parseInt(req.params.id, 10); + if (isNaN(jobId)) { + res.status(400).json({ error: 'Invalid job ID' }); + return; + } + + await plugin.cancelJob(jobId); + res.json({ success: true, message: 'Job cancelled' }); + } catch (error) { + next(error); + } + }); + + // Relaunch a job + router.post('/jobs/:id/relaunch', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const jobId = parseInt(req.params.id, 10); + if (isNaN(jobId)) { + res.status(400).json({ error: 'Invalid job ID' }); + return; + } + + const job = await plugin.relaunchJob(jobId); + res.status(201).json({ job }); + } catch (error) { + next(error); + } + }); + + // Run ad-hoc command + router.post('/ad-hoc', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const { inventory, command, limit, credential } = req.body; + + if (!inventory || !command) { + res.status(400).json({ error: 'inventory and command are required' }); + return; + } + + const inventoryId = parseInt(inventory, 10); + if (isNaN(inventoryId)) { + res.status(400).json({ error: 'Invalid inventory ID' }); + return; + } + + const credentialId = credential ? parseInt(credential, 10) : undefined; + + const job = await plugin.runAdHocCommand(inventoryId, command, limit, credentialId); + res.status(201).json({ job }); + } catch (error) { + next(error); + } + }); + + // Get projects + router.get('/projects', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const projects = await plugin.getProjects(); + res.json({ projects }); + } catch (error) { + next(error); + } + }); + + // Sync a project + router.post('/projects/:id/sync', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const projectId = parseInt(req.params.id, 10); + if (isNaN(projectId)) { + res.status(400).json({ error: 'Invalid project ID' }); + return; + } + + const result = await plugin.syncProject(projectId); + res.json({ success: true, updateId: result.id }); + } catch (error) { + next(error); + } + }); + + // Get credentials + router.get('/credentials', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getAnsiblePlugin(); + if (!plugin) { + res.status(503).json({ error: 'Ansible integration is not available' }); + return; + } + + const credentials = await plugin.getCredentials(); + res.json({ credentials }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/backend/src/routes/prometheus.ts b/backend/src/routes/prometheus.ts new file mode 100644 index 0000000..6a78e34 --- /dev/null +++ b/backend/src/routes/prometheus.ts @@ -0,0 +1,224 @@ +/** + * Prometheus API Routes + * + * Express routes for Prometheus metrics and alerts. + */ + +import { Router, type Request, type Response } from 'express'; +import { asyncHandler } from './asyncHandler'; +import type { IntegrationManager } from '../integrations/IntegrationManager'; +import type { PrometheusPlugin } from '../integrations/prometheus'; + +/** + * Create Prometheus router + */ +export function createPrometheusRouter( + integrationManager: IntegrationManager +): Router { + const router = Router(); + + /** + * Get health status of Prometheus integration + */ + router.get( + '/health', + asyncHandler(async (_req: Request, res: Response): Promise => { + const plugin = integrationManager.getInformationSource('prometheus') as PrometheusPlugin | null; + + if (!plugin) { + res.status(404).json({ + error: 'Prometheus integration not found', + }); + return; + } + + const health = await plugin.healthCheck(); + + res.json({ + ...health, + grafanaUrl: plugin.getGrafanaUrl(), + }); + }) + ); + + /** + * Get metrics for a specific node + * GET /api/prometheus/nodes/:name/metrics + */ + router.get( + '/nodes/:name/metrics', + asyncHandler(async (req: Request, res: Response): Promise => { + const { name } = req.params; + + const plugin = integrationManager.getInformationSource('prometheus') as PrometheusPlugin | null; + + if (!plugin) { + res.status(404).json({ + error: 'Prometheus integration not found', + }); + return; + } + + if (!plugin.isInitialized()) { + res.status(503).json({ + error: 'Prometheus integration not initialized', + }); + return; + } + + const metrics = await plugin.getNodeMetrics(name); + + res.json(metrics); + }) + ); + + /** + * Get alerts for a specific node + * GET /api/prometheus/nodes/:name/alerts + */ + router.get( + '/nodes/:name/alerts', + asyncHandler(async (req: Request, res: Response): Promise => { + const { name } = req.params; + + const plugin = integrationManager.getInformationSource('prometheus') as PrometheusPlugin | null; + + if (!plugin) { + res.status(404).json({ + error: 'Prometheus integration not found', + }); + return; + } + + if (!plugin.isInitialized()) { + res.status(503).json({ + error: 'Prometheus integration not initialized', + }); + return; + } + + const alerts = await plugin.getNodeAlerts(name); + + res.json(alerts); + }) + ); + + /** + * Get all active alerts + * GET /api/prometheus/alerts + */ + router.get( + '/alerts', + asyncHandler(async (_req: Request, res: Response): Promise => { + const plugin = integrationManager.getInformationSource('prometheus') as PrometheusPlugin | null; + + if (!plugin) { + res.status(404).json({ + error: 'Prometheus integration not found', + }); + return; + } + + if (!plugin.isInitialized()) { + res.status(503).json({ + error: 'Prometheus integration not initialized', + }); + return; + } + + const alerts = await plugin.getAllAlerts(); + + res.json(alerts); + }) + ); + + /** + * Execute a custom PromQL query + * POST /api/prometheus/query + * Body: { query: string } + */ + router.post( + '/query', + asyncHandler(async (req: Request, res: Response): Promise => { + const { query } = req.body; + + // Validate query: must be a non-empty string and match allowed PromQL pattern + const promqlSafePattern = /^[a-zA-Z0-9_:{}\[\]\(\)\s\+\-\*\/\.,<>=!~"']+$/; + if ( + typeof query !== 'string' || + query.trim().length === 0 || + !promqlSafePattern.test(query) + ) { + res.status(400).json({ + error: + 'Invalid query. Only standard PromQL expressions are allowed (alphanumeric, _, :, {}, [], (), spaces, operators).', + }); + return; + } + + const plugin = integrationManager.getInformationSource('prometheus') as PrometheusPlugin | null; + + if (!plugin) { + res.status(404).json({ + error: 'Prometheus integration not found', + }); + return; + } + + if (!plugin.isInitialized()) { + res.status(503).json({ + error: 'Prometheus integration not initialized', + }); + return; + } + + const result = await plugin.executeQuery(query); + + res.json(result); + }) + ); + + /** + * Get Grafana dashboard URL for a node + * GET /api/prometheus/nodes/:name/grafana + */ + router.get( + '/nodes/:name/grafana', + asyncHandler(async (req: Request, res: Response): Promise => { + const { name } = req.params; + + const plugin = integrationManager.getInformationSource('prometheus') as PrometheusPlugin | null; + + if (!plugin) { + res.status(404).json({ + error: 'Prometheus integration not found', + }); + return; + } + + const grafanaUrl = plugin.getGrafanaUrl(); + + if (!grafanaUrl) { + res.status(404).json({ + error: 'Grafana URL not configured', + }); + return; + } + + res.json({ + dashboardUrl: `${grafanaUrl}/d/node-exporter?var-instance=${name}`, + explorerUrl: `${grafanaUrl}/explore?left=${encodeURIComponent( + JSON.stringify({ + queries: [{ expr: `{instance=~"${name}.*"}`, refId: 'A' }], + range: { from: 'now-1h', to: 'now' }, + }) + )}`, + }); + }) + ); + + return router; +} + +export default createPrometheusRouter; + diff --git a/backend/src/routes/terraform.ts b/backend/src/routes/terraform.ts new file mode 100644 index 0000000..5594b85 --- /dev/null +++ b/backend/src/routes/terraform.ts @@ -0,0 +1,487 @@ +/** + * Terraform Cloud/Enterprise API Routes + * + * Provides REST endpoints for Terraform integration + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import type { IntegrationManager } from '../integrations/IntegrationManager'; +import { TerraformPlugin } from '../integrations/terraform'; + +/** + * Create Terraform router with IntegrationManager dependency + */ +export function createTerraformRouter(integrationManager: IntegrationManager): Router { + const router = Router(); + + // Helper to get Terraform plugin + function getTerraformPlugin(): TerraformPlugin | null { + const plugin = integrationManager.getInformationSource('terraform'); + if (plugin && plugin instanceof TerraformPlugin) { + return plugin; + } + return null; + } + + // Health check + router.get('/health', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ + status: 'unavailable', + message: 'Terraform integration is not configured', + }); + return; + } + + const health = await plugin.healthCheck(); + res.json({ + status: health.healthy ? 'ok' : 'error', + ...health, + }); + } catch (error) { + next(error); + } + }); + + // Get organizations + router.get('/organizations', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const organizations = await plugin.getOrganizations(); + res.json({ organizations }); + } catch (error) { + next(error); + } + }); + + // Get workspaces + router.get('/workspaces', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : undefined; + + const result = await plugin.getWorkspaces(page, pageSize); + res.json(result); + } catch (error) { + next(error); + } + }); + + // Get workspace summaries (for dashboard) + router.get('/workspaces/summary', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const summaries = await plugin.getWorkspaceSummaries(); + res.json({ summaries }); + } catch (error) { + next(error); + } + }); + + // Get a specific workspace by name + router.get('/workspaces/by-name/:name', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const workspace = await plugin.getWorkspace(req.params.name); + if (!workspace) { + res.status(404).json({ error: 'Workspace not found' }); + return; + } + + res.json({ workspace }); + } catch (error) { + next(error); + } + }); + + // Get a specific workspace by ID + router.get('/workspaces/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const workspace = await plugin.getWorkspaceById(req.params.id); + if (!workspace) { + res.status(404).json({ error: 'Workspace not found' }); + return; + } + + res.json({ workspace }); + } catch (error) { + next(error); + } + }); + + // Lock workspace + router.post('/workspaces/:id/lock', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const workspace = await plugin.lockWorkspace(req.params.id, req.body.reason); + res.json({ workspace }); + } catch (error) { + next(error); + } + }); + + // Unlock workspace + router.post('/workspaces/:id/unlock', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const workspace = await plugin.unlockWorkspace(req.params.id); + res.json({ workspace }); + } catch (error) { + next(error); + } + }); + + // Get workspace variables + router.get('/workspaces/:id/variables', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const variables = await plugin.getVariables(req.params.id); + res.json({ variables }); + } catch (error) { + next(error); + } + }); + + // Create variable + router.post('/workspaces/:id/variables', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const { key, value, category, hcl, sensitive, description } = req.body; + if (!key) { + res.status(400).json({ error: 'key is required' }); + return; + } + // Validate key format: alphanumeric and underscores, 1-64 chars + const keyRegex = /^[A-Za-z0-9_]{1,64}$/; + if (typeof key !== 'string' || !keyRegex.test(key)) { + res.status(400).json({ error: 'key must be alphanumeric (letters, numbers, underscores) and 1-64 characters long' }); + return; + } + + const variable = await plugin.createVariable(req.params.id, key, value || '', { + category, + hcl, + sensitive, + description, + }); + res.status(201).json({ variable }); + } catch (error) { + next(error); + } + }); + + // Update variable + router.patch('/variables/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const { value, hcl, sensitive, description } = req.body; + const variable = await plugin.updateVariable(req.params.id, value, { + hcl, + sensitive, + description, + }); + res.json({ variable }); + } catch (error) { + next(error); + } + }); + + // Delete variable + router.delete('/variables/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + await plugin.deleteVariable(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } + }); + + // Get workspace runs + router.get('/workspaces/:id/runs', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : undefined; + + const result = await plugin.getRuns(req.params.id, page, pageSize); + res.json(result); + } catch (error) { + next(error); + } + }); + + // Create a run + router.post('/workspaces/:id/runs', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const run = await plugin.createRun(req.params.id, req.body); + res.status(201).json({ run }); + } catch (error) { + next(error); + } + }); + + // Get a specific run + router.get('/runs/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const run = await plugin.getRun(req.params.id); + if (!run) { + res.status(404).json({ error: 'Run not found' }); + return; + } + + res.json({ run }); + } catch (error) { + next(error); + } + }); + + // Apply a run + router.post('/runs/:id/apply', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + await plugin.applyRun(req.params.id, req.body.comment); + res.json({ success: true, message: 'Run applied' }); + } catch (error) { + next(error); + } + }); + + // Discard a run + router.post('/runs/:id/discard', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + await plugin.discardRun(req.params.id, req.body.comment); + res.json({ success: true, message: 'Run discarded' }); + } catch (error) { + next(error); + } + }); + + // Cancel a run + router.post('/runs/:id/cancel', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + await plugin.cancelRun(req.params.id, req.body.comment); + res.json({ success: true, message: 'Run cancelled' }); + } catch (error) { + next(error); + } + }); + + // Get plan logs + router.get('/plans/:id/logs', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const logs = await plugin.getPlanLogs(req.params.id); + res.type('text/plain').send(logs); + } catch (error) { + next(error); + } + }); + + // Get apply logs + router.get('/applies/:id/logs', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const logs = await plugin.getApplyLogs(req.params.id); + res.type('text/plain').send(logs); + } catch (error) { + next(error); + } + }); + + // Get current state + router.get('/workspaces/:id/current-state', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const state = await plugin.getCurrentState(req.params.id); + if (!state) { + res.status(404).json({ error: 'No state found' }); + return; + } + + res.json({ state }); + } catch (error) { + next(error); + } + }); + + // Get state versions + router.get('/workspaces/:id/state-versions', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : undefined; + + const result = await plugin.getStateVersions(req.params.id, page, pageSize); + res.json(result); + } catch (error) { + next(error); + } + }); + + // Get state resources + router.get('/state-versions/:id/resources', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : undefined; + + const result = await plugin.getStateResources(req.params.id, page, pageSize); + res.json(result); + } catch (error) { + next(error); + } + }); + + // Get state outputs + router.get('/state-versions/:id/outputs', async (req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const outputs = await plugin.getStateOutputs(req.params.id); + res.json({ outputs }); + } catch (error) { + next(error); + } + }); + + // Get all mapped resources + router.get('/resources', async (_req: Request, res: Response, next: NextFunction) => { + try { + const plugin = getTerraformPlugin(); + if (!plugin) { + res.status(503).json({ error: 'Terraform integration is not available' }); + return; + } + + const resources = await plugin.getMappedResources(); + res.json({ resources }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 78ea63e..610e633 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -22,6 +22,12 @@ import { errorHandler, requestIdMiddleware } from "./middleware"; import { IntegrationManager } from "./integrations/IntegrationManager"; import { PuppetDBService } from "./integrations/puppetdb/PuppetDBService"; import { BoltPlugin } from "./integrations/bolt"; +import { PrometheusPlugin } from "./integrations/prometheus"; +import { AnsiblePlugin } from "./integrations/ansible"; +import { TerraformPlugin } from "./integrations/terraform"; +import { createPrometheusRouter } from "./routes/prometheus"; +import { createAnsibleRouter } from "./routes/ansible"; +import { createTerraformRouter } from "./routes/terraform"; import type { IntegrationConfig } from "./integrations/types"; /** @@ -179,6 +185,102 @@ async function startServer(): Promise { console.warn("PuppetDB integration not configured - skipping registration"); } + // Initialize Prometheus integration if configured + const prometheusConfig = config.integrations.prometheus; + const prometheusConfigured = !!prometheusConfig?.serverUrl; + + if (prometheusConfigured) { + console.warn("Initializing Prometheus integration..."); + try { + const prometheusPlugin = new PrometheusPlugin(); + const prometheusIntegrationConfig: IntegrationConfig = { + enabled: prometheusConfig.enabled, + name: "prometheus", + type: "information", + config: prometheusConfig, + priority: 8, // Between Bolt and PuppetDB + }; + + integrationManager.registerPlugin(prometheusPlugin, prometheusIntegrationConfig); + + console.warn("Prometheus integration registered and enabled"); + console.warn(`- Server URL: ${prometheusConfig.serverUrl}`); + console.warn( + `- Grafana URL: ${prometheusConfig.grafanaUrl ?? "not configured"}`, + ); + } catch (error) { + console.warn( + `WARNING: Failed to initialize Prometheus integration: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } else { + console.warn("Prometheus integration not configured - skipping registration"); + } + + // Initialize Ansible AWX/Tower integration if configured + const ansibleConfig = config.integrations.ansible; + const ansibleConfigured = !!ansibleConfig?.url; + + if (ansibleConfigured) { + console.warn("Initializing Ansible AWX/Tower integration..."); + try { + const ansiblePlugin = new AnsiblePlugin(); + const ansibleIntegrationConfig: IntegrationConfig = { + enabled: ansibleConfig.enabled, + name: "ansible", + type: "both", // Can provide inventory and execute jobs + config: ansibleConfig, + priority: 7, // Between Bolt and Prometheus + }; + + integrationManager.registerPlugin(ansiblePlugin, ansibleIntegrationConfig); + + console.warn("Ansible integration registered and enabled"); + console.warn(`- AWX/Tower URL: ${ansibleConfig.url}`); + console.warn( + `- Authentication: ${ansibleConfig.token ? "token" : ansibleConfig.username ? "basic auth" : "not configured"}`, + ); + } catch (error) { + console.warn( + `WARNING: Failed to initialize Ansible integration: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } else { + console.warn("Ansible integration not configured - skipping registration"); + } + + // Initialize Terraform Cloud/Enterprise integration if configured + const terraformConfig = config.integrations.terraform; + const terraformConfigured = !!terraformConfig?.url && !!terraformConfig?.token; + + if (terraformConfigured) { + console.warn("Initializing Terraform Cloud/Enterprise integration..."); + try { + const terraformPlugin = new TerraformPlugin(); + const terraformIntegrationConfig: IntegrationConfig = { + enabled: terraformConfig.enabled, + name: "terraform", + type: "information", // Read-only infrastructure state + config: terraformConfig, + priority: 6, // Lower priority + }; + + integrationManager.registerPlugin(terraformPlugin, terraformIntegrationConfig); + + console.warn("Terraform integration registered and enabled"); + console.warn(`- Terraform URL: ${terraformConfig.url}`); + console.warn( + `- Organization: ${terraformConfig.organization ?? "not specified"}`, + ); + } catch (error) { + console.warn( + `WARNING: Failed to initialize Terraform integration: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } else { + console.warn("Terraform integration not configured - skipping registration"); + } + // Initialize all registered plugins console.warn("Initializing all integration plugins..."); const initErrors = await integrationManager.initializePlugins(); @@ -329,6 +431,18 @@ async function startServer(): Promise { "/api/integrations", createIntegrationsRouter(integrationManager, puppetDBService), ); + app.use( + "/api/prometheus", + createPrometheusRouter(integrationManager), + ); + app.use( + "/api/ansible", + createAnsibleRouter(integrationManager), + ); + app.use( + "/api/terraform", + createTerraformRouter(integrationManager), + ); // Serve static frontend files in production const publicPath = path.resolve(__dirname, "..", "public"); diff --git a/frontend/src/components/MetricsBadge.svelte b/frontend/src/components/MetricsBadge.svelte new file mode 100644 index 0000000..de0f64a --- /dev/null +++ b/frontend/src/components/MetricsBadge.svelte @@ -0,0 +1,129 @@ + + +{#if loading} +
+ + CPU: ... + + + MEM: ... + + {#if !compact} + + DISK: ... + + {/if} +
+{:else if metrics} +
+ + CPU: {formatValue(metrics.cpu)} + + + MEM: {formatValue(metrics.memory)} + + {#if !compact} + + DISK: {formatValue(metrics.disk)} + + {#if metrics.load} + + LOAD: {metrics.load.load1.toFixed(2)} + + {/if} + {#if metrics.uptime !== undefined} + + UP: {formatUptime(metrics.uptime)} + + {/if} + {/if} +
+{:else} + + No metrics + +{/if} diff --git a/frontend/src/components/MetricsTab.svelte b/frontend/src/components/MetricsTab.svelte new file mode 100644 index 0000000..7e7dcc7 --- /dev/null +++ b/frontend/src/components/MetricsTab.svelte @@ -0,0 +1,296 @@ + + +
+ {#if loading} +
+ +
+ {:else if error} + + {:else} + +
+
+

+ System Metrics +

+ +
+ + {#if metrics} +
+ +
+
CPU Usage
+
+ {metrics.cpu !== undefined ? `${metrics.cpu.toFixed(1)}%` : 'N/A'} +
+ {#if metrics.cpu !== undefined} +
+
+
+ {/if} +
+ + +
+
Memory Usage
+
+ {metrics.memory !== undefined ? `${metrics.memory.toFixed(1)}%` : 'N/A'} +
+ {#if metrics.memory !== undefined} +
+
+
+ {/if} +
+ + +
+
Disk Usage
+
+ {metrics.disk !== undefined ? `${metrics.disk.toFixed(1)}%` : 'N/A'} +
+ {#if metrics.disk !== undefined} +
+
+
+ {/if} +
+ + +
+
Load Average
+ {#if metrics.load} +
+ {metrics.load.load1.toFixed(2)} +
+
+ {metrics.load.load1.toFixed(2)} / {metrics.load.load5.toFixed(2)} / {metrics.load.load15.toFixed(2)} +
+ {:else} +
N/A
+ {/if} +
+
+ + + {#if metrics.uptime !== undefined} +
+
+ + + + Uptime: + {formatUptime(metrics.uptime)} +
+
+ {/if} + {:else} +
+ No metrics available for this node. Make sure Prometheus node_exporter is running. +
+ {/if} +
+ + +
+

+ Active Alerts +

+ + {#if alerts.length === 0} +
+ + + + No active alerts +
+ {:else} +
+ {#each alerts as alert} +
+
+
+ + + + {alert.labels.alertname || 'Unknown Alert'} +
+ + {alert.state} + +
+ {#if alert.annotations.description || alert.annotations.summary} +

+ {alert.annotations.description || alert.annotations.summary} +

+ {/if} +
+ Active since: {formatDate(alert.activeAt)} +
+
+ {/each} +
+ {/if} +
+ + + {#if grafanaUrls} +
+

+ Grafana Dashboards +

+
+ {#if grafanaUrls.dashboardUrl} + + + + + + Node Exporter Dashboard + + {/if} + {#if grafanaUrls.explorerUrl} + + + + + Explore Metrics + + {/if} +
+
+ {/if} + {/if} +
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 93dec43..c311c27 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -7,6 +7,8 @@ export { default as EventsViewer } from "./EventsViewer.svelte"; export { default as FactsViewer } from "./FactsViewer.svelte"; export { default as IntegrationStatus } from "./IntegrationStatus.svelte"; export { default as LoadingSpinner } from "./LoadingSpinner.svelte"; +export { default as MetricsBadge } from "./MetricsBadge.svelte"; +export { default as MetricsTab } from "./MetricsTab.svelte"; export { default as Navigation } from "./Navigation.svelte"; export { default as PuppetOutputViewer } from "./PuppetOutputViewer.svelte"; export { default as PuppetRunInterface } from "./PuppetRunInterface.svelte"; diff --git a/frontend/src/pages/NodeDetailPage.svelte b/frontend/src/pages/NodeDetailPage.svelte index 4126b28..c9dec43 100644 --- a/frontend/src/pages/NodeDetailPage.svelte +++ b/frontend/src/pages/NodeDetailPage.svelte @@ -15,6 +15,7 @@ import CatalogViewer from '../components/CatalogViewer.svelte'; import EventsViewer from '../components/EventsViewer.svelte'; import ReExecutionButton from '../components/ReExecutionButton.svelte'; + import MetricsTab from '../components/MetricsTab.svelte'; import { get, post } from '../lib/api'; import { showError, showSuccess, showInfo } from '../lib/toast.svelte'; import { expertMode } from '../lib/expertMode.svelte'; @@ -82,10 +83,13 @@ const nodeId = $derived(params?.id || ''); // Tab types - type TabId = 'overview' | 'facts' | 'execution-history' | 'puppet-reports' | 'catalog' | 'events'; + type TabId = 'overview' | 'facts' | 'execution-history' | 'puppet-reports' | 'catalog' | 'events' | 'metrics'; // State let node = $state(null); + + // Prometheus metrics state + let prometheusAvailable = $state(false); let loading = $state(true); let error = $state(null); @@ -444,7 +448,7 @@ const url = new URL(window.location.href); const tabParam = url.searchParams.get('tab') as TabId | null; - if (tabParam && ['overview', 'facts', 'execution-history', 'puppet-reports', 'catalog', 'events'].includes(tabParam)) { + if (tabParam && ['overview', 'facts', 'execution-history', 'puppet-reports', 'catalog', 'events', 'metrics'].includes(tabParam)) { activeTab = tabParam; // Load data for the tab if not already loaded @@ -524,11 +528,22 @@ } } + // Check if Prometheus integration is available + async function checkPrometheusAvailability(): Promise { + try { + const response = await fetch('/api/prometheus/health'); + prometheusAvailable = response.ok; + } catch (err) { + prometheusAvailable = false; + } + } + // On mount onMount(() => { fetchNode(); fetchExecutions(); fetchCommandWhitelist(); + checkPrometheusAvailability(); readTabFromURL(); checkReExecutionParams(); @@ -625,6 +640,20 @@ > Events + {#if prometheusAvailable} + + {/if} @@ -1074,6 +1103,11 @@ {/if} {/if} + + + {#if activeTab === 'metrics' && prometheusAvailable} + + {/if} {/if}