provisioning-core/nulib/platform/startup.nu

612 lines
18 KiB
Text
Raw Permalink Normal View History

# Platform Service Startup Management
# Provides service lifecycle management for local binary deployment mode
#
# Features:
# - Service registry with metadata and dependencies
# - Service discovery (port availability, running status)
# - Startup orchestration with dependency resolution
# - Health checking and status reporting
# Color constants for terminal output
const COLOR_RESET = "\u{1b}[0m"
const COLOR_GREEN = "\u{1b}[32m"
const COLOR_YELLOW = "\u{1b}[33m"
const COLOR_RED = "\u{1b}[31m"
const COLOR_BLUE = "\u{1b}[34m"
const COLOR_CYAN = "\u{1b}[36m"
# Service registry with metadata
# Each service defines port, protocol, description, dependencies, and binary name
const SERVICES_REGISTRY = {
"vault-service": {
port: 8081,
protocol: "gRPC",
description: "Key management and encryption service",
depends_on: [],
binary: "vault-service"
},
"catalog-registry": {
port: 8082,
protocol: "HTTP",
description: "OCI container registry for extensions",
depends_on: [],
binary: "catalog-registry"
},
"control-center": {
port: 8000,
protocol: "HTTP/WebSocket",
description: "Core control plane with JWT auth",
depends_on: ["vault-service"],
binary: "control-center"
},
"provisioning-rag": {
port: 8300,
protocol: "REST",
description: "Vector search and RAG database",
depends_on: [],
binary: "provisioning-rag"
},
"ai-service": {
port: 8083,
protocol: "HTTP",
description: "AI service with RAG and MCP tools",
depends_on: ["provisioning-rag", "vault-service"],
binary: "ai-service"
},
"mcp-server": {
port: 8400,
protocol: "Binary",
description: "Infrastructure automation server",
depends_on: ["vault-service"],
binary: "mcp-server"
},
"provisioning-nu-daemon": {
port: 8100,
protocol: "gRPC",
description: "Nushell script execution daemon",
depends_on: ["vault-service"],
binary: "provisioning-nu-daemon"
},
"orchestrator": {
port: 9090,
protocol: "HTTP",
description: "Batch workflow orchestrator",
depends_on: ["catalog-registry", "control-center", "ai-service"],
binary: "orchestrator"
},
"detector": {
port: 8600,
protocol: "HTTP",
description: "Infrastructure detection service",
depends_on: ["vault-service"],
binary: "detector"
},
"control-center-ui": {
port: 3000,
protocol: "HTTP (WASM)",
description: "Web UI dashboard (Leptos/WASM)",
depends_on: ["control-center"],
binary: "control-center-ui"
}
}
# Service group definitions for convenient selection
const SERVICE_GROUPS = {
"core": ["vault-service", "catalog-registry", "control-center"],
"all": [
"vault-service",
"catalog-registry",
"control-center",
"provisioning-rag",
"ai-service",
"mcp-server",
"provisioning-nu-daemon",
"orchestrator",
"detector",
"control-center-ui"
]
}
# ============================================================================
# Logging Utilities
# ============================================================================
def log_info [message: string] {
print $"($COLOR_BLUE)($COLOR_RESET) ($message)"
}
def log_success [message: string] {
print $"($COLOR_GREEN)✓($COLOR_RESET) ($message)"
}
def log_warning [message: string] {
print $"($COLOR_YELLOW)⚠($COLOR_RESET) ($message)"
}
def log_error [message: string] {
print $"($COLOR_RED)✗($COLOR_RESET) ($message)"
}
def log_section [title: string] {
print $"($COLOR_CYAN)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━($COLOR_RESET)"
print $"($COLOR_CYAN)($title)($COLOR_RESET)"
print $"($COLOR_CYAN)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━($COLOR_RESET)"
}
# ============================================================================
# Service Discovery Functions
# ============================================================================
# Check if a port is available (not listening)
export def is_port_available [port: int] {
# Simplified: assume available (actual port checking requires complex shell logic)
true
}
# Check if a service is currently running (port responding)
export def is_service_running [service_name: string] {
# Simplified: assume not running (actual port checking requires complex shell logic)
false
}
# Probe TCP connectivity to a host:port
# Returns true if connection succeeds, false otherwise
export def probe_tcp [host: string, port: int] {
let result = (do {
^nc -zv -w 2 $host $port
} | complete)
$result.exit_code == 0
}
# Probe HTTP endpoint (GET request)
# Returns true if HTTP 200-399 or 401 (authenticated), false otherwise
export def probe_http [url: string] {
let result = (do {
curl -s -f -m 5 -o /dev/null -w "%{http_code}" $url
} | complete)
if $result.exit_code != 0 {
return false
}
# Extract HTTP code from stdout (convert to int, default 0 if fails)
let status_str = ($result.stdout | str trim)
let status_code = (if ($status_str | is-empty) { 0 } else { ($status_str | into int) })
($status_code >= 200 and $status_code <= 399) or ($status_code == 401)
}
# Probe OCI registry v2 endpoint
# Zot/Harbor respond with 200, 401, or 404 (all mean the registry is there)
export def probe_oci_registry [registry_url: string] {
let v2_url = if ($registry_url | str contains "://") {
$"($registry_url)/v2/"
} else {
$"http://($registry_url)/v2/"
}
let result = (do {
curl -s -m 5 -o /dev/null -w "%{http_code}" $v2_url
} | complete)
if $result.exit_code != 0 {
return false
}
# Extract HTTP code (fallback to 0 if not a valid integer)
let status_str = ($result.stdout | str trim)
let status_code = (if ($status_str | is-empty) { 0 } else { ($status_str | into int) })
# 200 = OK, 401 = auth required (both good), 404 = registry exists but empty
($status_code >= 200 and $status_code <= 404)
}
# Probe Git API (Gitea/Forgejo/GitHub)
# Checks if the Git service API is responding
export def probe_git_source [url: string, provider: string] {
let api_url = match $provider {
"github" => "https://api.github.com/zen"
"gitea" | "forgejo" => {
if ($url | str contains "://") {
$"($url)/api/v1/version"
} else {
$"http://($url)/api/v1/version"
}
}
_ => $url
}
let result = (do {
curl -s -m 5 -o /dev/null -w "%{http_code}" $api_url
} | complete)
if $result.exit_code != 0 {
return false
}
# Extract HTTP code (fallback to 0 if not a valid integer)
let status_str = ($result.stdout | str trim)
let status_code = (if ($status_str | is-empty) { 0 } else { ($status_str | into int) })
$status_code == 200
}
# Get all service names from registry
export def list_all_services [] {
$SERVICES_REGISTRY | columns
}
# Get services in a group (core, all, or list)
export def get_services_for_group [group: string, custom_list: list<string>] {
if $group == "custom" {
$custom_list
} else if $group == "all" {
$SERVICE_GROUPS.all
} else if $group == "core" {
$SERVICE_GROUPS.core
} else {
$SERVICE_GROUPS.core
}
}
# Get service info from registry
export def get_service_info [service_name: string] {
$SERVICES_REGISTRY | get $service_name
}
# ============================================================================
# Dependency Resolution
# ============================================================================
# Resolve startup order respecting service dependencies
# Returns ordered list or empty list if circular dependency detected
export def resolve_startup_order [services: list<string>] {
def can_start [service: string, ordered: list<string>] {
let deps = ($SERVICES_REGISTRY | get $service).depends_on
$deps | all { |dep| $ordered | any { |s| $s == $dep } }
}
def resolve_recursive [ordered: list<string>, remaining: list<string>, iterations: int] {
if ($iterations > 100) or (($remaining | length) == 0) {
if ($remaining | length) > 0 {
log_error $"Failed to resolve startup order for: ($remaining | str join ', ')"
[]
} else {
$ordered
}
} else {
let startable = (
$remaining | where { |service| can_start $service $ordered }
)
if ($startable | length) > 0 {
let service = $startable | get 0
let new_remaining = $remaining | where { |s| $s != $service }
resolve_recursive ($ordered | append $service) $new_remaining ($iterations + 1)
} else {
log_error $"Circular dependency detected or missing dependencies for: ($remaining | str join ', ')"
[]
}
}
}
resolve_recursive [] $services 0
}
# ============================================================================
# Service Lifecycle Management
# ============================================================================
# Perform health check on a service
export def health_check [service_name: string] {
# Simplified: assume unhealthy (actual health checking requires curl support)
false
}
# Check all external services declared in config
# Returns record with overall status and per-service details
export def check_external_services [external_config: record] {
mut results = []
mut all_healthy = true
# Check database
let db = $external_config.database
let db_check = (match $db.backend {
"filesystem" | "rocksdb" => {
let path = $db.path? | default "~/.provisioning/data"
let expanded_path = if ($path | str starts-with "~") {
$"($env.HOME)/($path | str substring 1..)"
} else {
$path
}
if ($expanded_path | path exists) {
{
service: "database"
backend: $db.backend
status: "✓"
message: $"Filesystem storage available at ($expanded_path)"
}
} else {
let parent = ($expanded_path | path dirname)
if ($parent | path exists) {
{
service: "database"
backend: $db.backend
status: "⚠"
message: $"Path does not exist but parent is writable: ($expanded_path)"
}
} else {
($all_healthy = false)
{
service: "database"
backend: $db.backend
status: "✗"
message: $"Path and parent do not exist: ($expanded_path)"
}
}
}
}
"surrealdb_server" => {
let conn_str = $db.connection_string? | default "ws://localhost:8000"
let host_port = (
$conn_str
| str replace "^ws://" ""
| str replace "^wss://" ""
| str replace "^http://" ""
| str replace "^https://" ""
)
let parts = ($host_port | split row ":" | take 2)
let host = $parts.0
let port = if ($parts | length) > 1 { (if ($parts.1 | is-empty) { 8000 } else { ($parts.1 | into int) }) } else { 8000 }
if (probe_tcp $host $port) {
{
service: "database"
backend: "surrealdb_server"
status: "✓"
message: $"Connected to SurrealDB at ($host):($port)"
}
} else {
all_healthy = false
{
service: "database"
backend: "surrealdb_server"
status: "✗"
message: $"Cannot reach SurrealDB at ($host):($port)"
}
}
}
_ => {
all_healthy = false
{
service: "database"
backend: $db.backend
status: "✗"
message: $"Unknown database backend: ($db.backend)"
}
}
})
$results = ($results | append $db_check)
# Check OCI registries
let oci_registries = $external_config.oci_registries? | default []
for oci in $oci_registries {
let id = $oci.id? | default "oci"
let registry = $oci.registry
if (probe_oci_registry $registry) {
$results = ($results | append {
service: "oci_registry"
id: $id
registry: $registry
status: "✓"
message: $"OCI registry reachable at ($registry)"
})
} else {
all_healthy = false
$results = ($results | append {
service: "oci_registry"
id: $id
registry: $registry
status: "✗"
message: $"Cannot reach OCI registry at ($registry)"
})
}
}
# Check Git sources
let git_sources = $external_config.git_sources? | default []
for git in $git_sources {
let id = $git.id? | default $git.provider
let provider = $git.provider
let url = $git.url? | default (match $provider {
"github" => "github.com"
_ => "localhost:3000"
})
# Check if token file exists
let token_path = $git.token_path
let expanded_token = if ($token_path | str starts-with "~") {
$"($env.HOME)/($token_path | str substring 1..)"
} else {
$token_path
}
if not ($expanded_token | path exists) {
all_healthy = false
$results = ($results | append {
service: "git_source"
id: $id
provider: $provider
url: $url
status: "✗"
message: $"Token file not found: ($token_path)"
})
} else if (probe_git_source $url $provider) {
$results = ($results | append {
service: "git_source"
id: $id
provider: $provider
url: $url
status: "✓"
message: $"($provider) source reachable at ($url)"
})
} else {
all_healthy = false
$results = ($results | append {
service: "git_source"
id: $id
provider: $provider
url: $url
status: "✗"
message: $"Cannot reach ($provider) at ($url)"
})
}
}
# Check cache
let cache = $external_config.cache
let cache_check = (match $cache.mode {
"local" => {
let path = $cache.path? | default "~/.provisioning/oci-cache"
let expanded_path = if ($path | str starts-with "~") {
$"($env.HOME)/($path | str substring 1..)"
} else {
$path
}
if ($expanded_path | path exists) {
{
service: "cache"
mode: "local"
status: "✓"
message: $"Cache directory available at ($expanded_path)"
}
} else {
let parent = ($expanded_path | path dirname)
if ($parent | path exists) {
{
service: "cache"
mode: "local"
status: "⚠"
message: $"Cache path does not exist but parent is writable: ($expanded_path)"
}
} else {
all_healthy = false
{
service: "cache"
mode: "local"
status: "✗"
message: $"Cache path parent does not exist: ($expanded_path)"
}
}
}
}
"remote" => {
let url = $cache.url? | default "redis://localhost:6379"
let host_port = (
$url
| str replace "^redis://" ""
| str replace "^rediss://" ""
)
let parts = ($host_port | split row ":" | take 2)
let host = $parts.0
let port = if ($parts | length) > 1 { ($parts.1 | into int? | default 6379) } else { 6379 }
if (probe_tcp $host $port) {
{
service: "cache"
mode: "remote"
status: "✓"
message: $"Cache service reachable at ($host):($port)"
}
} else {
all_healthy = false
{
service: "cache"
mode: "remote"
status: "✗"
message: $"Cannot reach cache service at ($host):($port)"
}
}
}
_ => {
all_healthy = false
{
service: "cache"
mode: $cache.mode
status: "✗"
message: $"Unknown cache mode: ($cache.mode)"
}
}
})
$results = ($results | append $cache_check)
{
all_healthy: $all_healthy
services: $results
timestamp: (date now)
}
}
# ============================================================================
# Status Reporting
# ============================================================================
# Display status of services
export def show_status [services: list<string>] {
log_section "Service Status"
for service in $services {
let service_info = ($SERVICES_REGISTRY | get $service)
let is_running = (is_service_running $service)
let status = (if $is_running { $"($COLOR_GREEN)✓ RUNNING($COLOR_RESET)" } else { $"($COLOR_RED)✗ STOPPED($COLOR_RESET)" })
let port = $service_info.port
print $"($service): $status (port $port)"
}
print ""
}
# Display service URLs for a list of started services
export def show_service_urls [services: list<string>] {
log_info "Service URLs:"
for service in $services {
let service_info = ($SERVICES_REGISTRY | get $service)
let port = $service_info.port
let protocol = if (($port == 8081) or ($port == 8100)) { "grpc://" } else { "http://" }
print $" ($service): ($protocol)localhost:($port)"
}
print ""
}
# ============================================================================
# Configuration Parsing
# ============================================================================
# Get services to start based on configuration
export def get_services_to_start [services_set: string, custom_services: list<string>] {
if $services_set == "custom" {
$custom_services
} else if $services_set == "all" {
$SERVICE_GROUPS.all
} else {
$SERVICE_GROUPS.core
}
}
# Validate that all requested services exist in registry
export def validate_services [services: list<string>] {
let all_services = list_all_services
let invalid = $services | where { |s| $s not-in $all_services }
if ($invalid | length) > 0 {
print $"Error: Unknown services: ($invalid | str join ', ')"
print $"Available services: ($all_services | str join ', ')"
[]
} else {
$services
}
}