prvng_core/nulib/main_provisioning/dag.nu
Jesús Pérez 894046ef5a
feat(core): three-layer DAG, unified component arch, commands-registry cache, Nushell 0.112.2 migration
- DAG architecture: `dag show/validate/export` (nulib/main_provisioning/dag.nu),
    config loader (lib_provisioning/config/loader/dag.nu), taskserv dag-executor.
    Backed by schemas/lib/dag/*.ncl; orchestrator emits NATS events via
    WorkspaceComposition::into_workflow. See ADR-020, ADR-021.
  - Unified Component Architecture: components/mod.nu, main_provisioning/
    {components,workflow,extensions,ontoref-queries}.nu. Full workflow engine with
    topological sort and NATS subject emission. Blocks A-H complete (libre-daoshi).
  - Commands-registry: nulib/commands-registry.ncl (Nickel source, 314 lines) +
    JSON cache at ~/.cache/provisioning/commands-registry.json rebuilt on source
    change. cli/provisioning fast-path alias expansion avoids cold Nu startup.
    ADDING_COMMANDS.md documents new-command workflow.
  - Platform service manager: service-manager.nu (+573), startup.nu (+611),
    service-check.nu (+255); autostart/bootstrap/health/target refactored.
  - Nushell 0.112.2 migration: removed all try/catch and bash redirections;
    external commands prefixed with ^; type signatures enforced. Driven by
    scripts/refactor-try-catch{,-simplified}.nu.
  - TTY stack: removed shlib/*-tty.sh; replaced by cli/tty-dispatch.sh,
    tty-filter.sh, tty-commands.conf.
  - New domain modules: images/ (golden image lifecycle), workspace/{state,sync}.nu,
    main_provisioning/{bootstrap,cluster-deploy,fip,state}.nu, commands/{state,
    build,integrations/auth,utilities/alias}.nu, platform.nu expanded (+874).
  - Config loader overhaul: loader/core.nu slimmed (-759), cache/core.nu
    refactored (-454), removed legacy loaders/file_loader.nu (-330).
  - Thirteen new provisioning-<domain>.nu top-level modules for bash dispatcher.
  - Tests: test_workspace_state.nu (+351); updates to test_oci_registry,
    test_services.
  - README + CHANGELOG updated.
2026-04-17 04:27:33 +01:00

231 lines
8.9 KiB
Text

use ../lib_provisioning/workspace *
use ../lib_provisioning/workspace/notation.nu *
use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval, ncl-eval-soft]
# Resolve the provisioning root for --import-path resolution.
def provisioning-root [] : nothing -> string {
$env.PROVISIONING? | default "/usr/local/provisioning"
}
# Export a Nickel file as parsed JSON, or error with stderr context.
def nickel-export [path: string] : nothing -> record {
ncl-eval $path [(provisioning-root)]
}
# Show the workspace DAG composition for a given infra.
#
# Renders each formula_id with its depends_on edges, conditions, health gates,
# and parallel flag. Marks root and terminal nodes.
export def "main dag show" [
--workspace (-w): string # Workspace name (default: active)
--infra (-i): string = "wuji" # Infra name
] : nothing -> nothing {
let ws_name = if ($workspace | is-not-empty) {
$workspace
} else {
let details = (get-active-workspace-details)
if ($details == null) {
error make { msg: "No active workspace. Pass --workspace or activate one first." }
}
$details.name
}
let ws_root = (get-workspace-path $ws_name)
if ($ws_root | is-empty) {
error make { msg: $"Workspace '($ws_name)' not found in registry." }
}
let dag_path = ($ws_root | path join "infra" $infra "dag.ncl")
if not ($dag_path | path exists) {
error make { msg: $"dag.ncl not found at ($dag_path)" }
}
let dag = (nickel-export $dag_path)
let formulas = $dag.composition.formulas
# Determine roots (no depends_on) and terminals (not depended upon by others).
let all_dep_targets = ($formulas | each { |e| $e.depends_on | each { |d| $d.formula_id } } | flatten)
let roots = ($formulas | where ($it.depends_on | length) == 0 | each { |e| $e.formula_id })
let terminals = ($formulas | where { |e| not ($all_dep_targets | any { |d| $d == $e.formula_id }) } | each { |e| $e.formula_id })
print $"DAG: ($dag.workspace) / ($dag.infra)"
print ""
for entry in $formulas {
let is_root = ($roots | any { |r| $r == $entry.formula_id })
let is_terminal = ($terminals | any { |t| $t == $entry.formula_id })
let tags = ([
(if $is_root { "[root]" } else { "" })
(if $is_terminal { "[terminal]" } else { "" })
(if $entry.parallel { "[parallel]" } else { "" })
] | where ($it | is-not-empty) | str join " ")
print $" ($entry.formula_id) ($tags)"
if ($entry.depends_on | length) > 0 {
for dep in $entry.depends_on {
print $" └─ depends_on: ($dep.formula_id) [($dep.condition)]"
}
}
if "health_gate" in $entry and ($entry.health_gate != null) {
let g = $entry.health_gate
print $" └─ health_gate: ($g.check_cmd) | expect=($g.expect) timeout=($g.timeout_ms)ms retries=($g.retries)"
}
print ""
}
}
# Validate dag.ncl against its Nickel schema and cross-check formula_ids against servers.ncl.
export def "main dag validate" [
--workspace (-w): string # Workspace name (default: active)
--infra (-i): string = "wuji" # Infra name
] : nothing -> nothing {
let ws_name = if ($workspace | is-not-empty) {
$workspace
} else {
let details = (get-active-workspace-details)
if ($details == null) {
error make { msg: "No active workspace. Pass --workspace or activate one first." }
}
$details.name
}
let ws_root = (get-workspace-path $ws_name)
if ($ws_root | is-empty) {
error make { msg: $"Workspace '($ws_name)' not found in registry." }
}
let dag_path = ($ws_root | path join "infra" $infra "dag.ncl")
let servers_path = ($ws_root | path join "infra" $infra "servers.ncl")
let prov_root = (provisioning-root)
mut passed = true
# Step 1: schema + contract validation via nickel export
print " [1/3] Nickel schema + WorkspaceComposition contract ..."
let dag_data = (ncl-eval-soft $dag_path [$prov_root] null)
if ($dag_data | is-not-empty) {
print " PASS"
} else {
print " FAIL: nickel export failed or empty"
$passed = false
}
# Step 2: load servers.ncl formula IDs
print " [2/3] Cross-check formula_ids against servers.ncl ..."
let servers_data = (ncl-eval-soft $servers_path [$prov_root] null)
if ($servers_data | is-empty) {
print " SKIP (servers.ncl export failed)"
} else if ($dag_data | is-not-empty) {
let dag_ids = ($dag_data | get composition.formulas | each { |e| $e.formula_id })
let server_ids = ($servers_data | get formulas | each { |f| $f.id })
let dangling = ($dag_ids | where { |id| not ($server_ids | any { |sid| $sid == $id }) })
if ($dangling | length) == 0 {
print " PASS"
} else {
print $" FAIL: dag.ncl references unknown formula_ids: ($dangling | str join ', ')"
$passed = false
}
}
# Step 3: check all formulas in servers.ncl are covered by dag.ncl
print " [3/3] Coverage — all servers.ncl formulas present in dag.ncl ..."
if ($servers_data | is-not-empty) and ($dag_data | is-not-empty) {
let dag_ids = ($dag_data | get composition.formulas | each { |e| $e.formula_id })
let server_ids = ($servers_data | get formulas | each { |f| $f.id })
let uncovered = ($server_ids | where { |id| not ($dag_ids | any { |did| $did == $id }) })
if ($uncovered | length) == 0 {
print " PASS"
} else {
print $" WARN: servers.ncl formulas not in dag.ncl (intentional?): ($uncovered | str join ', ')"
}
}
print ""
if $passed {
print "dag validate: OK"
} else {
print "dag validate: FAILED"
exit 1
}
}
# Export dag.ncl in various formats.
export def "main dag export" [
--workspace (-w): string # Workspace name (default: active)
--infra (-i): string = "wuji" # Infra name
--format (-f): string = "json" # Output format: json, dot, cytoscape-json
] : nothing -> nothing {
let ws_name = if ($workspace | is-not-empty) {
$workspace
} else {
let details = (get-active-workspace-details)
if ($details == null) {
error make { msg: "No active workspace. Pass --workspace or activate one first." }
}
$details.name
}
let ws_root = (get-workspace-path $ws_name)
if ($ws_root | is-empty) {
error make { msg: $"Workspace '($ws_name)' not found in registry." }
}
let dag_path = ($ws_root | path join "infra" $infra "dag.ncl")
let dag = (nickel-export $dag_path)
match $format {
"json" => {
print ($dag | to json)
}
"dot" => {
print "digraph dag {"
print " rankdir=LR;"
for entry in $dag.composition.formulas {
let shape = if ($entry.depends_on | length) == 0 { "shape=invhouse" } else { "shape=box" }
print $" \"($entry.formula_id)\" [($shape)];"
for dep in $entry.depends_on {
let label = $dep.condition
print $" \"($dep.formula_id)\" -> \"($entry.formula_id)\" [label=\"($label)\"];"
}
if "health_gate" in $entry and ($entry.health_gate != null) and (($entry.depends_on | length) > 0) {
let gate_id = $"health_gate__($entry.depends_on.0.formula_id)__($entry.formula_id)"
print $" \"($gate_id)\" [shape=hexagon label=\"health gate\"];"
print $" \"($entry.depends_on.0.formula_id)\" -> \"($gate_id)\" [style=dashed];"
print $" \"($gate_id)\" -> \"($entry.formula_id)\" [style=dashed];"
}
}
print "}"
}
"cytoscape-json" => {
let nodes = ($dag.composition.formulas | each { |e|
{
data: {
id: $e.formula_id,
label: $e.formula_id,
shape: "rectangle",
parallel: $e.parallel,
}
}
})
let edges = ($dag.composition.formulas | each { |e|
$e.depends_on | each { |dep|
{
data: {
id: $"($dep.formula_id)__($e.formula_id)",
source: $dep.formula_id,
target: $e.formula_id,
label: $dep.condition,
}
}
}
} | flatten)
print ({ elements: { nodes: $nodes, edges: $edges } } | to json)
}
_ => {
error make { msg: $"Unknown format '($format)'. Valid: json, dot, cytoscape-json" }
}
}
}