provisioning-core/nulib/platform/startup.nu

611 lines
18 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
}
}