Files
Sankofa/api/src/services/resource.ts
defiQUG fe0365757a Update documentation structure and enhance .gitignore
- Added generated index files and report directories to .gitignore to prevent unnecessary tracking of transient files.
- Updated README links to reflect new documentation paths for better navigation.
- Improved documentation organization by ensuring all links point to the correct locations, enhancing user experience and accessibility.
2025-12-12 21:18:55 -08:00

475 lines
14 KiB
TypeScript

import { Context } from '../types/context'
import { AppErrors } from '../lib/errors'
export interface ResourceFilter {
type?: string
status?: string
siteId?: string
tenantId?: string
}
export interface CreateResourceInput {
name: string
type: string
siteId: string
metadata?: Record<string, unknown>
}
export interface UpdateResourceInput {
name?: string
metadata?: Record<string, unknown>
}
interface ResourceRow {
id: string
name: string
type: string
status: string
site_id: string | null
tenant_id: string | null
metadata: string | Record<string, unknown> | null
created_at: Date
updated_at: Date
[key: string]: unknown
}
interface SiteRow {
id: string
name: string
region: string
status: string
metadata: string | Record<string, unknown> | null
created_at: Date
updated_at: Date
[key: string]: unknown
}
/**
* Get all resources with optional filtering
*
* @param context - Request context with user and database connection
* @param filter - Optional filter criteria (type, status, siteId, tenantId)
* @returns Array of resources with site information
* @throws {UnauthenticatedError} If user is not authenticated
* @example
* ```typescript
* const resources = await getResources(context, { type: 'VM', status: 'RUNNING' });
* ```
*/
export async function getResources(context: Context, filter?: ResourceFilter) {
const db = context.db
// Use LEFT JOIN to fetch resources and sites in a single query (fixes N+1 problem)
let query = `
SELECT
r.id, r.name, r.type, r.status, r.site_id, r.tenant_id, r.metadata,
r.created_at, r.updated_at,
s.id as site_id_full, s.name as site_name, s.region as site_region,
s.status as site_status, s.metadata as site_metadata,
s.created_at as site_created_at, s.updated_at as site_updated_at
FROM resources r
LEFT JOIN sites s ON r.site_id = s.id
WHERE 1=1
`
const params: unknown[] = []
let paramCount = 1
// Tenant-aware filtering (superior to Azure)
if (context.tenantContext) {
if (context.tenantContext.isSystemAdmin) {
// System admins can see all resources
} else if (context.tenantContext.tenantId) {
// Filter by tenant
query += ` AND (r.tenant_id = $${paramCount} OR r.tenant_id IS NULL)`
params.push(context.tenantContext.tenantId)
paramCount++
} else {
// Non-tenant users only see system resources
query += ` AND r.tenant_id IS NULL`
}
} else {
// Unauthenticated users see nothing
query += ` AND 1=0`
}
if (filter?.type) {
query += ` AND r.type = $${paramCount}`
params.push(filter.type)
paramCount++
}
if (filter?.status) {
query += ` AND r.status = $${paramCount}`
params.push(filter.status)
paramCount++
}
if (filter?.siteId) {
query += ` AND r.site_id = $${paramCount}`
params.push(filter.siteId)
paramCount++
}
query += ' ORDER BY r.created_at DESC'
const result = await db.query(query, params)
// Map results using the joined data (no additional queries needed)
return result.rows.map((row) => mapResourceWithSite(row))
}
/**
* Get a single resource by ID
*
* @param context - Request context with user and database connection
* @param id - Resource ID
* @returns Resource with site information
* @throws {NotFoundError} If resource not found or user doesn't have access
* @throws {UnauthenticatedError} If user is not authenticated
* @example
* ```typescript
* const resource = await getResource(context, 'resource-123');
* ```
*/
export async function getResource(context: Context, id: string) {
const db = context.db
let query = 'SELECT * FROM resources WHERE id = $1'
const params: unknown[] = [id]
let paramCount = 2
// Tenant-aware filtering
if (context.tenantContext) {
if (context.tenantContext.isSystemAdmin) {
// System admins can see all resources
} else if (context.tenantContext.tenantId) {
query += ` AND (tenant_id = $${paramCount} OR tenant_id IS NULL)`
params.push(context.tenantContext.tenantId)
paramCount++
} else {
query += ` AND tenant_id IS NULL`
}
} else {
query += ` AND 1=0`
}
const result = await db.query(query, params)
if (result.rows.length === 0) {
throw AppErrors.notFound('Resource', id)
}
return mapResource(result.rows[0], context)
}
/**
* Create a new resource
*
* @param context - Request context with user and database connection
* @param input - Resource creation input (name, type, siteId, metadata)
* @returns Created resource with site information
* @throws {UnauthenticatedError} If user is not authenticated
* @throws {QuotaExceededError} If tenant quota limits are exceeded
* @example
* ```typescript
* const resource = await createResource(context, {
* name: 'My VM',
* type: 'VM',
* siteId: 'site-123'
* });
* ```
*/
export async function createResource(context: Context, input: CreateResourceInput) {
const db = context.db
// Set tenant_id from context if available
const tenantId = context.tenantContext?.tenantId || null
// Enforce tenant quotas if tenant context is available
if (tenantId) {
const { tenantService } = await import('./tenant.js')
// Calculate resource requirements based on input
const resourceRequest: {
compute?: { vcpu?: number; memory?: number; instances?: number }
storage?: { size?: number }
network?: { bandwidth?: number }
} = {}
// Extract compute requirements from metadata or input
const metadata = input.metadata || {}
if (metadata.cpu || metadata.vcpu) {
const cpu = typeof metadata.cpu === 'number' ? metadata.cpu : (typeof metadata.vcpu === 'number' ? metadata.vcpu : 1)
const memoryStr = typeof metadata.memory === 'string' ? metadata.memory : String(metadata.memory || '0')
resourceRequest.compute = {
vcpu: cpu,
memory: parseFloat(memoryStr.replace(/[^0-9.]/g, '')) || 0,
instances: 1,
}
}
// Extract storage requirements
if (metadata.storage || metadata.disk) {
const storageSize = typeof metadata.storage === 'string'
? metadata.storage
: (typeof metadata.disk === 'string' ? metadata.disk : String(metadata.storage || metadata.disk || '0'))
resourceRequest.storage = {
size: parseFloat(storageSize.replace(/[^0-9.]/g, '')) || 0,
}
}
// Enforce quota - will throw error if quota exceeded
await tenantService.enforceQuota(tenantId, resourceRequest)
}
const result = await db.query(
`INSERT INTO resources (name, type, status, site_id, tenant_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[input.name, input.type, 'PENDING', input.siteId, tenantId, JSON.stringify(input.metadata || {})]
)
const resource = await mapResource(result.rows[0], context)
// Record initial usage for billing (per-second granularity)
if (tenantId) {
try {
const { billingService } = await import('./billing.js')
const metadata = input.metadata || {}
// Calculate initial cost based on resource type and specs
let initialCost = 0
if (input.type === 'VM' || input.type === 'CONTAINER') {
const cpu = typeof metadata.cpu === 'number' ? metadata.cpu : (typeof metadata.vcpu === 'number' ? metadata.vcpu : 1)
const memory = typeof metadata.memory === 'string'
? parseFloat(metadata.memory.replace(/[^0-9.]/g, ''))
: (typeof metadata.memory === 'number' ? metadata.memory : 0)
// Simplified pricing: $0.01 per vCPU-hour, $0.005 per GB-hour
initialCost = (cpu * 0.01 + memory * 0.005) / 3600 // Per second
} else if (input.type === 'STORAGE') {
const storageSize = typeof metadata.storage === 'string'
? parseFloat(metadata.storage.replace(/[^0-9.]/g, ''))
: (typeof metadata.storage === 'number' ? metadata.storage : 0)
// $0.0001 per GB-hour = per second
initialCost = (storageSize * 0.0001) / 3600
}
if (initialCost > 0) {
await billingService.recordUsage({
tenantId,
resourceId: resource.id,
resourceType: input.type,
metricType: 'PROVISIONING',
quantity: 1,
unit: 'instance',
cost: initialCost,
currency: 'USD',
timestamp: new Date(),
labels: { action: 'create', resourceType: input.type },
})
}
} catch (error) {
// Log but don't fail resource creation if billing recording fails
const { logger } = await import('../lib/logger.js')
logger.warn('Failed to record usage for resource creation', { error, resourceId: resource.id })
}
}
// Record on blockchain if configured (future implementation)
// This would integrate with blockchain smart contracts for immutable resource tracking
if (process.env.BLOCKCHAIN_ENABLED === 'true') {
try {
const { blockchainService } = await import('./blockchain.js')
await blockchainService.initialize()
// Future: Record resource provisioning on blockchain
// await blockchainService.recordResourceProvisioning(...)
} catch (error) {
// Log but don't fail if blockchain is not configured
const { logger } = await import('../lib/logger.js')
logger.warn('Failed to record resource on blockchain', { error, resourceId: resource.id })
}
}
// Publish subscription event
const { publishResourceCreated } = await import('../schema/subscriptions')
publishResourceCreated(resource)
return resource
}
/**
* Update an existing resource
*
* @param context - Request context with user and database connection
* @param id - Resource ID
* @param input - Resource update input (name, metadata)
* @returns Updated resource
* @throws {NotFoundError} If resource not found or user doesn't have access
* @throws {UnauthenticatedError} If user is not authenticated
* @example
* ```typescript
* const resource = await updateResource(context, 'resource-123', {
* name: 'Updated Name'
* });
* ```
*/
export async function updateResource(context: Context, id: string, input: UpdateResourceInput) {
const db = context.db
const updates: string[] = []
const params: unknown[] = []
let paramCount = 1
if (input.name !== undefined) {
updates.push(`name = $${paramCount}`)
params.push(input.name)
paramCount++
}
if (input.metadata !== undefined) {
updates.push(`metadata = $${paramCount}`)
params.push(JSON.stringify(input.metadata))
paramCount++
}
if (updates.length === 0) {
return getResource(context, id)
}
params.push(id)
const result = await db.query(
`UPDATE resources SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
params
)
const resource = await mapResource(result.rows[0], context)
// Publish subscription event
const { publishResourceUpdated } = await import('../schema/subscriptions')
publishResourceUpdated(id, resource)
return resource
}
/**
* Delete a resource
*
* @param context - Request context with user and database connection
* @param id - Resource ID
* @returns true if deletion was successful
* @throws {NotFoundError} If resource not found or user doesn't have access
* @throws {UnauthenticatedError} If user is not authenticated
* @example
* ```typescript
* await deleteResource(context, 'resource-123');
* ```
*/
export async function deleteResource(context: Context, id: string) {
const db = context.db
await db.query('DELETE FROM resources WHERE id = $1', [id])
// Publish subscription event
const { publishResourceDeleted } = await import('../schema/subscriptions')
publishResourceDeleted(id)
return true
}
// Optimized mapping function for joined queries (no additional DB queries)
function mapResourceWithSite(row: {
id: string
name: string
type: string
status: string
site_id: string | null
tenant_id: string | null
metadata: string | Record<string, unknown> | null
created_at: Date
updated_at: Date
site_id_full: string | null
site_name: string | null
site_region: string | null
site_status: string | null
site_metadata: string | Record<string, unknown> | null
site_created_at: Date | null
site_updated_at: Date | null
}) {
const metadata = typeof row.metadata === 'string'
? (JSON.parse(row.metadata) as Record<string, unknown>)
: ((row.metadata as Record<string, unknown>) || {})
// Map site from joined data (if available)
let site = null
if (row.site_id_full) {
const siteMetadata = typeof row.site_metadata === 'string'
? (JSON.parse(row.site_metadata) as Record<string, unknown>)
: ((row.site_metadata as Record<string, unknown>) || {})
site = {
id: row.site_id_full,
name: row.site_name || 'Unknown',
region: row.site_region || '',
status: row.site_status || 'INACTIVE',
metadata: siteMetadata,
createdAt: row.site_created_at || new Date(),
updatedAt: row.site_updated_at || new Date(),
}
} else if (row.site_id) {
// Site ID exists but site not found (LEFT JOIN returned null)
site = {
id: row.site_id,
name: 'Unknown',
region: '',
status: 'INACTIVE',
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
}
}
return {
id: row.id,
name: row.name,
type: row.type,
status: row.status,
site: site || { id: row.site_id || '', name: 'Unknown', region: '', status: 'INACTIVE' },
metadata,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
async function mapResource(row: ResourceRow, context: Context) {
// Get site information
const siteResult = await context.db.query('SELECT * FROM sites WHERE id = $1', [row.site_id])
const site = siteResult.rows.length > 0 ? mapSite(siteResult.rows[0] as SiteRow) : null
const metadata = typeof row.metadata === 'string'
? (JSON.parse(row.metadata) as Record<string, unknown>)
: ((row.metadata as Record<string, unknown>) || {})
return {
id: row.id,
name: row.name,
type: row.type,
status: row.status,
site: site || { id: row.site_id || '', name: 'Unknown', region: '', status: 'INACTIVE' },
metadata,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function mapSite(row: SiteRow) {
const metadata = typeof row.metadata === 'string'
? (JSON.parse(row.metadata) as Record<string, unknown>)
: ((row.metadata as Record<string, unknown>) || {})
return {
id: row.id,
name: row.name,
region: row.region,
status: row.status,
metadata,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}